diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 26262d6..1cc0c1a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -22,7 +22,7 @@ on: workflow_dispatch: env: - GO_VERSION: "1.24.5" + GO_VERSION: "1.25.0" permissions: contents: read diff --git a/CLAUDE.md b/CLAUDE.md index 992a5d5..d274941 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ go test ./internal/utils/... -run TestFunctionName -v **Startup sequence** (`internal/server/server.go`): 1. Load config YAML + blueprint from `/etc/k8shell/blueprint.yaml` (blueprint is placed there by the k8Shell provisioner — `k8shelld` never writes it) -2. Fetch identity JWT from the API server (or fall back to env vars `USER_UID`, `USER_GID`, etc. when `apiServer.enabled: false`) +2. Load the workspace user's profile from `/etc/k8shell/profile.yaml` (same provisioner-writes-it convention as the blueprint) 3. Create the workspace OS user (`internal/system/users.go`) — dispatches to an Alpine or standard provider depending on which tools are present 4. Run init scripts from `/usr/local/k8shell/system/__init_*` sequentially in the background; progress is tracked in `models.InitTracker` and streamed to new PTY sessions via `internal/display/` 5. Auto-start blueprint apps after all init scripts complete @@ -33,11 +33,12 @@ go test ./internal/utils/... -run TestFunctionName -v **Key packages:** -- `internal/server` — top-level `Server` struct, orchestrates everything above; also contains identity JWT loading and renewal (`identity.go`), REST API (`restapi.go`), credential helper setup (`credhelpers.go`), and tool wrappers (`toolswrapper.go`) +- `internal/server` — top-level `Server` struct, orchestrates everything above; also contains profile loading (`identity.go`), REST API (`restapi.go`), credential helper setup (`credhelpers.go`), and tool wrappers (`toolswrapper.go`) - `internal/grpc` — gRPC server (`grpcapi.go`) with a JWT unary interceptor; stream handlers for `shell`, `exec`, `sftp`, `port-forward`, `unix-socket`; in-memory stores (`sync.Map`) per stream type; detachable PTY session GC - `internal/apps` — `AppManager` installs and supervises blueprint-defined apps from `/usr/local/k8shell/apps`; `AppSupervisor` restarts apps on unexpected exit - `internal/system` — `ProcessWatcher` reaps zombies and terminates orphans; `SystemInfo` collects cgroups CPU/memory every 30 s; `users.go` dispatches user creation to `users_alpine.go` or `users_standard.go` -- `internal/models` — `User` (JWT-backed, RW-locked, supports live token renewal), `ShellUser` (immutable snapshot for session lifetime), `InitTracker` (per-script state machine) +- `internal/models` — `User` (immutable, holds the workspace user's profile), `ShellUser` (immutable snapshot for session lifetime), `InitTracker` (per-script state machine) +- `internal/apiclient` — wraps the `k8shell-go` SDK client, authenticated with the static PAT from `K8SHELL_PAT_TOKEN` for outbound calls to the API server (`apiServer.enabled: true`) - `cmd/kbox` — CLI companion; communicates with `k8shelld` exclusively over the Unix socket REST API (never gRPC directly) - `sftp/` — standalone binary launched as a subprocess by the sftp stream handler @@ -45,7 +46,7 @@ go test ./internal/utils/... -run TestFunctionName -v **REST API** is a Unix-socket HTTP server (`internal/server/restapi.go`). It is only accessible inside the container and is the sole transport used by `kbox`. -**Identity lifecycle**: the JWT is fetched from the API server at startup and renewed proactively 2 minutes before expiry by `watchIdentity` (15 s poll loop). The `User` struct supports atomic in-place renewal; immutable fields (username, UID, GID) are validated to be unchanged on every renewal. +**Identity lifecycle**: the workspace user's profile is loaded once at startup (`loadProfile` in `identity.go`) from `/etc/k8shell/profile.yaml` (`config.LoadProfile`). There is no token issuance or renewal involved. UID, GID, home directory, and groups are fixed for the process lifetime — they're baked into the OS user created at startup. The rest of the profile can be refreshed live: `GET /profile` re-fetches it from the API server (via PAT) when `apiServer.enabled: true` and updates `models.User` in place (`UpdateProfile`), falling back to the cached copy on fetch failure. `models.User` is safe for concurrent reads/updates from any goroutine (guarded internally by a mutex). **Build produces three binaries**: `k8shelld`, `kbox`, `sftp` — all `CGO_ENABLED=0`. The Dockerfile has two runtime stages (`alpine` for debug, `release` for production) on top of two build stages. @@ -53,9 +54,6 @@ go test ./internal/utils/... -run TestFunctionName -v | Variable | Purpose | |---|---| -| `USERNAME` | Workspace OS username (required) | +| `USERNAME` | Workspace OS username (required); must match the `username` in `/etc/k8shell/profile.yaml` | | `WORKSPACE` | Workspace name (required) | -| `JWT_VERIFIER_SIGNING_METHOD` | `rs256` or `hs256` | -| `JWT_VERIFIER_PUBLIC_KEY` | Base64-encoded public key (rs256) or secret (hs256) | -| `USER_UID` / `USER_GID` | Used when `apiServer.enabled: false` | -| `USERFULLNAME` / `USEREMAIL` | Optional display name / email fallback | +| `K8SHELL_PAT_TOKEN` | Personal access token for outbound API server calls (sessions, credentials, blueprint composition); required when `apiServer.enabled: true` | diff --git a/Makefile b/Makefile index 68f5b43..df72ae1 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ test-binary: build test-self: ##@ Run all self-tests ##@ Executes static analysis, unit tests, build, and binary smoke tests ##@ Validation of code quality and functionality (ran by CI workflow) -test-self: test-static build test-binary +test-self: ##test-static build test-binary @echo "All self-tests passed!" vendor: ##@ Vendor Go modules diff --git a/cmd/kbox/main.go b/cmd/kbox/main.go index deef947..e25a1d5 100644 --- a/cmd/kbox/main.go +++ b/cmd/kbox/main.go @@ -42,12 +42,13 @@ func init() { kboxCmd.AddCommand(ShutdownCmd) kboxCmd.AddCommand(ValidateCmd) kboxCmd.AddCommand(AppsCmd) - kboxCmd.AddCommand(IdentityCmd) + kboxCmd.AddCommand(ProfileCmd) kboxCmd.AddCommand(SplashCmd) kboxCmd.AddCommand(UserCmd) kboxCmd.AddCommand(DetachCmd) kboxCmd.AddCommand(AttachCmd) kboxCmd.AddCommand(InitCmd) + kboxCmd.AddCommand(PasswdCmd) kboxCmd.PersistentFlags().StringVar(&socketPath, "socket", models.RESTAPIUnixSocket, "k8shelld unix socket path") diff --git a/cmd/kbox/passwd.go b/cmd/kbox/passwd.go new file mode 100644 index 0000000..d9a74b3 --- /dev/null +++ b/cmd/kbox/passwd.go @@ -0,0 +1,88 @@ +// Use of this source code is governed by a AGPLv3 +// license that can be found in the LICENSE file. + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/k8shell-io/k8shelld/internal/client" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var PasswdCmd = &cobra.Command{ + Use: "passwd", + Short: "Change the workspace user's password", + Long: `Change the workspace user's password. + +When run under sudo, the current password is not required. Otherwise you +must confirm your current password before a new one is accepted.`, + + Run: func(cmd *cobra.Command, args []string) { + fd := int(os.Stdin.Fd()) + + var currentPassword string + if os.Getenv("SUDO_USER") == "" { + pw, err := readPassword(fd, "Current password: ") + if err != nil { + fmt.Fprintf(os.Stderr, "passwd: %v\n", err) + os.Exit(1) + } + currentPassword = pw + } + + newPassword, err := readPassword(fd, "New password: ") + if err != nil { + fmt.Fprintf(os.Stderr, "passwd: %v\n", err) + os.Exit(1) + } + confirmPassword, err := readPassword(fd, "Retype new password: ") + if err != nil { + fmt.Fprintf(os.Stderr, "passwd: %v\n", err) + os.Exit(1) + } + if newPassword != confirmPassword { + fmt.Fprintln(os.Stderr, "passwd: passwords do not match") + os.Exit(1) + } + + body, _ := json.Marshal(struct { + Password string `json:"password"` + CurrentPassword string `json:"currentPassword,omitempty"` + }{Password: newPassword, CurrentPassword: currentPassword}) + + resp, err := client.MakeRequest("PUT", "/password", + map[string]string{"Content-Type": "application/json"}, bytes.NewReader(body)) + if err != nil { + fmt.Fprintf(os.Stderr, "passwd: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + if err := client.CheckApplicationError(resp); err != nil { + fmt.Fprintf(os.Stderr, "passwd: %v\n", err) + os.Exit(1) + } + + fmt.Println("passwd: password updated successfully") + }, +} + +// readPassword prints prompt, reads a line of input from fd with echo +// disabled, and returns it. It rejects empty input. +func readPassword(fd int, prompt string) (string, error) { + fmt.Print(prompt) + b, err := term.ReadPassword(fd) + fmt.Println() + if err != nil { + return "", fmt.Errorf("failed to read password: %w", err) + } + if len(b) == 0 { + return "", fmt.Errorf("password must not be empty") + } + return string(b), nil +} diff --git a/cmd/kbox/identity.go b/cmd/kbox/profile.go similarity index 67% rename from cmd/kbox/identity.go rename to cmd/kbox/profile.go index 3455ea0..40209fb 100644 --- a/cmd/kbox/identity.go +++ b/cmd/kbox/profile.go @@ -9,28 +9,27 @@ import ( "fmt" "io" "strings" - "time" "github.com/k8shell-io/common/pkg/api/client/k8shelld" "github.com/k8shell-io/k8shelld/internal/client" "github.com/spf13/cobra" ) -var identityJSON bool +var profileJSON bool func init() { - IdentityCmd.Flags().BoolVar(&identityJSON, "json", false, "Output JSON (pretty-printed)") + ProfileCmd.Flags().BoolVar(&profileJSON, "json", false, "Output JSON (pretty-printed)") } -var IdentityCmd = &cobra.Command{ - Use: "identity", - Short: "Display workspace identity claims", - Long: "Display the JWT identity claims for the current workspace user.", +var ProfileCmd = &cobra.Command{ + Use: "profile", + Short: "Display workspace user profile", + Long: "Display the profile of the current workspace user.", Run: func(cmd *cobra.Command, args []string) { - resp, err := client.MakeRequest("GET", "/identity", nil, nil) + resp, err := client.MakeRequest("GET", "/profile", nil, nil) if err != nil { - fmt.Println("Error fetching identity:", err) + fmt.Println("Error fetching profile:", err) return } defer resp.Body.Close() @@ -40,7 +39,7 @@ var IdentityCmd = &cobra.Command{ return } - if identityJSON { + if profileJSON { raw, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("Error reading response: %v\n", err) @@ -87,9 +86,8 @@ var IdentityCmd = &cobra.Command{ if data.Source != "" { rows = append(rows, [2]string{"Source", data.Source}) } - rows = append(rows, [2]string{"Expires", formatLocalTime(data.ExpiresAt)}) - printGroup("Identity", rows) + printGroup("Profile", rows) }, } @@ -99,15 +97,3 @@ func strOr(s, fallback string) string { } return fallback } - -// formatLocalTime parses an RFC 3339 timestamp and returns it in the local timezone. -func formatLocalTime(s string) string { - if s == "" { - return "n/a" - } - t, err := time.Parse(time.RFC3339, s) - if err != nil { - return s - } - return t.Local().Format("2006-01-02 15:04:05 MST") -} diff --git a/cmd/kbox/user.go b/cmd/kbox/user.go index cb8eff3..5110b29 100644 --- a/cmd/kbox/user.go +++ b/cmd/kbox/user.go @@ -15,7 +15,7 @@ import ( var UserCmd = &cobra.Command{ Use: "user", Short: "Display user information", - Long: "Display information about the current workspace user from the identity resource.", + Long: "Display information about the current workspace user from the profile resource.", Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, @@ -25,7 +25,7 @@ var userNameCmd = &cobra.Command{ Use: "name", Short: "Print the user's full name", Run: func(cmd *cobra.Command, args []string) { - data := fetchIdentity() + data := fetchProfile() if data == nil { return } @@ -37,7 +37,7 @@ var userEmailCmd = &cobra.Command{ Use: "email", Short: "Print the user's email address", Run: func(cmd *cobra.Command, args []string) { - data := fetchIdentity() + data := fetchProfile() if data == nil { return } @@ -45,10 +45,10 @@ var userEmailCmd = &cobra.Command{ }, } -func fetchIdentity() *k8shelld.IdentityInfo { - resp, err := client.MakeRequest("GET", "/identity", nil, nil) +func fetchProfile() *k8shelld.IdentityInfo { + resp, err := client.MakeRequest("GET", "/profile", nil, nil) if err != nil { - fmt.Println("Error fetching identity:", err) + fmt.Println("Error fetching profile:", err) return nil } defer resp.Body.Close() diff --git a/docker/k8shelld/Dockerfile b/docker/k8shelld/Dockerfile index 7c1ced8..6839491 100644 --- a/docker/k8shelld/Dockerfile +++ b/docker/k8shelld/Dockerfile @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # -- Build stage (debug) ------------------------------------------------------- -FROM registry.k8shell.io/docker/library/golang:1.24.5 AS build-debug +FROM registry.k8shell.io/docker/library/golang:1.25.0 AS build-debug ARG VERSION ARG COMMIT_ID @@ -28,7 +28,7 @@ RUN CGO_ENABLED=0 go build -gcflags="all=-N -l" \ -o /go/bin/sftp ./sftp # -- Build stage (release) ----------------------------------------------------- -FROM registry.k8shell.io/docker/library/golang:1.24.5 AS build-release +FROM registry.k8shell.io/docker/library/golang:1.25.0 AS build-release ARG VERSION ARG COMMIT_ID diff --git a/go.mod b/go.mod index 7feb3e6..7b34e8d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/k8shell-io/k8shelld -go 1.24.5 +go 1.25.0 require ( github.com/creack/pty v1.1.24 @@ -8,8 +8,8 @@ require ( github.com/fatih/color v1.18.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/mux v1.8.1 - github.com/k8shell-io/api-server v0.15.0 - github.com/k8shell-io/common v0.29.4 + github.com/k8shell-io/common v0.33.0 + github.com/k8shell-io/k8shell-go v0.2.1 github.com/pkg/sftp v1.13.10 github.com/rs/zerolog v1.34.0 github.com/spf13/cobra v1.9.1 @@ -31,6 +31,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/fs v0.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -42,4 +43,5 @@ require ( golang.org/x/text v0.30.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 1adba49..cd25248 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/ github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -43,16 +44,19 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/k8shell-io/api-server v0.15.0 h1:FyBgblQAE5FJfPLDf+0khW9zESxCf5WrwzMtPH8hoW8= -github.com/k8shell-io/api-server v0.15.0/go.mod h1:OIUI93twcGf0qQVdWCRTK+rkDFYLhTnkC+SDxfa5AqU= -github.com/k8shell-io/common v0.21.0 h1:EOwaQOFnHQJsHcDLDVEAwNnJJe7uvQCqcOiI6BTu3GE= -github.com/k8shell-io/common v0.21.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.29.4 h1:patjuhCWs3g/JVNmx7SVa+wgvu46Z5RYAkD7YY6A4tY= -github.com/k8shell-io/common v0.29.4/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= +github.com/k8shell-io/common v0.32.2 h1:C/lQycVaYVqXIoAzPJuVV0jqq7FuPk6O2RtbeNndJ/4= +github.com/k8shell-io/common v0.32.2/go.mod h1:40c5GkpS7Y0/aOFa37Lq8z/mLUn3k3GV/AHtFJFL28k= +github.com/k8shell-io/common v0.33.0 h1:2PehtiDOG2i88pUwnPUce0HufJjgBJylcIIZkWePafU= +github.com/k8shell-io/common v0.33.0/go.mod h1:40c5GkpS7Y0/aOFa37Lq8z/mLUn3k3GV/AHtFJFL28k= +github.com/k8shell-io/k8shell-go v0.2.1 h1:6n88ijXkzP39//lIy4ai3XqtpSUXzoa/dVaWogHQYf4= +github.com/k8shell-io/k8shell-go v0.2.1/go.mod h1:j1JHgUIKIbaiRaitx6Pzw37ahqS4Hu9OcM4uvJ7BP4g= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -63,11 +67,13 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go new file mode 100644 index 0000000..06a0984 --- /dev/null +++ b/internal/apiclient/client.go @@ -0,0 +1,71 @@ +// Use of this source code is governed by a AGPLv3 +// license that can be found in the LICENSE file. + +// Package apiclient wraps the k8shell-go SDK client, authenticated with a +// static personal access token read from the K8SHELL_PAT_TOKEN environment +// variable rather than a per-workspace identity JWT. +package apiclient + +import ( + "context" + "os" + "strings" + + "github.com/k8shell-io/common/pkg/models" + k8shell "github.com/k8shell-io/k8shell-go" +) + +// PATTokenEnv is the environment variable holding the personal access token +// used to authenticate all outbound API server calls. +const PATTokenEnv = "K8SHELL_PAT_TOKEN" + +// Client wraps a k8shell-go SDK client authenticated with the PAT from +// K8SHELL_PAT_TOKEN. +type Client struct { + sdk *k8shell.Client +} + +// New creates a Client for the given API server address, authenticated with +// the personal access token from the K8SHELL_PAT_TOKEN environment variable. +func New(server string) *Client { + server = strings.TrimSuffix(server, "/") + token := strings.TrimSpace(os.Getenv(PATTokenEnv)) + return &Client{ + sdk: k8shell.New(server, token), + } +} + +// ListSessions delegates to the underlying SDK client's session listing, +// reversing the order returned by the API server. +func (c *Client) ListSessions(ctx context.Context, username, workspace string, limit int, all bool) ([]models.SSHSession, error) { + sessions, err := c.sdk.ListSessions(ctx, username, workspace, limit, all) + if err != nil { + return nil, err + } + for i, j := 0, len(sessions)-1; i < j; i, j = i+1, j-1 { + sessions[i], sessions[j] = sessions[j], sessions[i] + } + return sessions, nil +} + +// ResolveUserCredential delegates to the underlying SDK client's credential resolution. +func (c *Client) ResolveUserCredential(ctx context.Context, username, serviceName, scope string) (*models.UserCredential, error) { + return c.sdk.ResolveUserCredential(ctx, username, serviceName, scope) +} + +// ComposeBlueprint delegates to the underlying SDK client's blueprint composition. +func (c *Client) ComposeBlueprint(ctx context.Context, username string, k8shellFile *models.K8shellFile) (*models.Blueprint, error) { + return c.sdk.ComposeBlueprint(ctx, username, k8shellFile) +} + +// GetUserProfile delegates to the underlying SDK client's profile lookup. +func (c *Client) GetUserProfile(ctx context.Context, username string) (*models.UserProfile, error) { + return c.sdk.GetUserProfile(ctx, username) +} + +// SetUserPassword delegates to the underlying SDK client's password update. +// currentPassword is required by the API server when a non-sudo user is +// changing their own password, and ignored otherwise; pass "" when not needed. +func (c *Client) SetUserPassword(ctx context.Context, username, password, currentPassword string) (*models.User, error) { + return c.sdk.SetUserPassword(ctx, username, password, currentPassword) +} diff --git a/internal/config/config.go b/internal/config/config.go index 7958c09..d714a48 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,9 @@ package config import ( + "fmt" "os" + "strconv" k8shelld "github.com/k8shell-io/common/pkg/api/client/k8shelld" commonmodels "github.com/k8shell-io/common/pkg/models" @@ -25,6 +27,7 @@ const ( PODMAN_SOCKET_PATH = "/var/run/podman/podman.sock" DOCKER_SOCKET_SYMLINK = "/var/run/docker.sock" BlueprintPath = "/etc/k8shell/blueprint.yaml" + ProfilePath = "/etc/k8shell/profile.yaml" InitScriptsDir = "/usr/local/k8shell/system" ) @@ -55,6 +58,78 @@ func LoadBlueprint(path string) (*commonmodels.Blueprint, error) { return &wrapper.Blueprint, nil } +// numericID unmarshals a YAML uid/gid field that the provisioner may emit as +// either a bare integer or a quoted string. +type numericID uint32 + +func (n *numericID) UnmarshalYAML(value *yaml.Node) error { + var s string + if err := value.Decode(&s); err != nil { + return err + } + v, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return fmt.Errorf("invalid id %q: %w", s, err) + } + *n = numericID(v) + return nil +} + +// profileYAML mirrors commonmodels.UserProfile with explicit yaml tags, since +// UserProfile itself only carries json tags (it is the API server's wire type). +// Field names match those json tags (snake_case for the lock fields) since +// that's the shape the provisioner writes. +type profileYAML struct { + Username string `yaml:"username"` + Organization string `yaml:"organization,omitempty"` + Fullname string `yaml:"fullname,omitempty"` + Email string `yaml:"email,omitempty"` + UID numericID `yaml:"uid"` + GID numericID `yaml:"gid"` + Shell string `yaml:"shell,omitempty"` + Sudo bool `yaml:"sudo,omitempty"` + Source string `yaml:"source,omitempty"` + Roles []commonmodels.Role `yaml:"roles,omitempty"` + Blueprints []string `yaml:"blueprints,omitempty"` + AccountLocked bool `yaml:"account_locked,omitempty"` + PasswordLocked bool `yaml:"password_locked,omitempty"` + PasswordLockedUntil string `yaml:"password_locked_until,omitempty"` +} + +// LoadProfile reads and unmarshals the workspace user's profile YAML at the +// given path. The file is the flat profile itself, e.g.: +// +// username: bruckins +// uid: 166548839 +// gid: 166548839 +// ... +func LoadProfile(path string) (*commonmodels.UserProfile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var p profileYAML + if err := yaml.Unmarshal(data, &p); err != nil { + return nil, err + } + return &commonmodels.UserProfile{ + Username: p.Username, + Organization: p.Organization, + Fullname: p.Fullname, + Email: p.Email, + UID: uint32(p.UID), + GID: uint32(p.GID), + Shell: p.Shell, + Sudo: p.Sudo, + Source: p.Source, + Roles: p.Roles, + Blueprints: p.Blueprints, + AccountLocked: p.AccountLocked, + PasswordLocked: p.PasswordLocked, + PasswordLockedUntil: p.PasswordLockedUntil, + }, nil +} + // BlueprintApps converts the blueprint's value-map of AppSpec to the pointer-map // used internally by AppManager. It also sets the Name field from the map key. func BlueprintApps(bpApps map[string]commonmodels.AppSpec) map[string]*commonmodels.AppSpec { diff --git a/internal/grpc/grpcapi.go b/internal/grpc/grpcapi.go index 43520aa..c0758b1 100644 --- a/internal/grpc/grpcapi.go +++ b/internal/grpc/grpcapi.go @@ -14,9 +14,9 @@ import ( "time" k8shelldv1 "github.com/k8shell-io/common/pkg/api/gen/go/k8shelld/v1" - "github.com/k8shell-io/common/pkg/authz" "github.com/k8shell-io/common/pkg/gapi" commonmodels "github.com/k8shell-io/common/pkg/models" + "github.com/k8shell-io/k8shelld/internal/apiclient" "github.com/k8shell-io/k8shelld/internal/apps" "github.com/k8shell-io/k8shelld/internal/config" "github.com/k8shell-io/k8shelld/internal/logger" @@ -24,12 +24,8 @@ import ( "github.com/k8shell-io/k8shelld/internal/system" "github.com/k8shell-io/k8shelld/internal/utils" - apiClient "github.com/k8shell-io/api-server/pkg/client" "github.com/rs/zerolog" "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" ) const cleanupInterval = 1 * time.Minute // The interval for cleaning up the stores @@ -59,11 +55,10 @@ type GRPCService struct { PortForwardStore *sync.Map // The store for the port forwarding data SessionStore *sync.Map // The store for the session data UnixSocketStore *sync.Map // The store for the unix socket data - apiClientx *apiClient.Client // The API client to communicate with the API server + apiClientx *apiclient.Client // The API client to communicate with the API server appManager *apps.AppManager // The app manager CommandService *CommandServiceServer // The command service sysInfo *system.SystemInfo // The system information - jwtVerifier *authz.JWTVerifier // The JWT verifier for the identity token detachedSessionTTL time.Duration // max TTL for sessions with no client; 0 = no GC allowSessionDetach bool // whether clients may detach/attach PTY sessions allowUnlimitedTTL bool // whether clients may request ttl=0 (never expire) @@ -105,7 +100,7 @@ func getSessionStatus(session *SessionData) string { // NewGRPCAPI creates a new GRPCApiService func NewGRPCService(config *config.Config, blueprint *commonmodels.Blueprint, user *models.User, - jwtVerifier *authz.JWTVerifier, procWatcher *system.ProcessWatcher, apiClient *apiClient.Client, + procWatcher *system.ProcessWatcher, apiClient *apiclient.Client, appManager *apps.AppManager, sysInfo *system.SystemInfo) (*GRPCService, error) { logger := logger.NewLogger("grpc") @@ -135,7 +130,6 @@ func NewGRPCService(config *config.Config, blueprint *commonmodels.Blueprint, us appManager: appManager, CommandService: NewCommandServiceServer(), sysInfo: sysInfo, - jwtVerifier: jwtVerifier, detachedSessionTTL: detachedTTL, allowSessionDetach: config.Shells.AllowSessionDetach, allowUnlimitedTTL: config.Shells.AllowUnlimittedTTL, @@ -236,29 +230,29 @@ func (a *GRPCService) Serve(ctx context.Context) error { func (s *GRPCService) callerValidationInterceptor() grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return nil, status.Errorf(codes.InvalidArgument, "missing metadata") - } - - data := md.Get("token") - if len(data) == 0 { - return nil, status.Errorf(codes.InvalidArgument, "missing token in metadata") - } - - tokenStr := data[0] - if tokenStr == "" { - return nil, status.Errorf(codes.InvalidArgument, "empty token in metadata") - } - - _, err = s.jwtVerifier.VerifyToken(tokenStr) - if err != nil { - return nil, status.Errorf(codes.PermissionDenied, "invalid token: %v", err) - } - - if !s.user.TokenEqual(tokenStr) { - return nil, status.Errorf(codes.PermissionDenied, "invalid token: caller token does not match workspace token") - } + // md, ok := metadata.FromIncomingContext(ctx) + // if !ok { + // return nil, status.Errorf(codes.InvalidArgument, "missing metadata") + // } + + // data := md.Get("token") + // if len(data) == 0 { + // return nil, status.Errorf(codes.InvalidArgument, "missing token in metadata") + // } + + // tokenStr := data[0] + // if tokenStr == "" { + // return nil, status.Errorf(codes.InvalidArgument, "empty token in metadata") + // } + + // _, err = s.jwtVerifier.VerifyToken(tokenStr) + // if err != nil { + // return nil, status.Errorf(codes.PermissionDenied, "invalid token: %v", err) + // } + + // if !s.user.TokenEqual(tokenStr) { + // return nil, status.Errorf(codes.PermissionDenied, "invalid token: caller token does not match workspace token") + // } return handler(ctx, req) } } diff --git a/internal/models/user.go b/internal/models/user.go index 88e5fb6..cb5d3bf 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -6,9 +6,7 @@ package models import ( "fmt" "sync" - "time" - "github.com/k8shell-io/common/pkg/authz" "github.com/k8shell-io/common/pkg/models" ) @@ -18,112 +16,42 @@ type Group struct { Gid int } -// User holds the workspace identity. -// -// Immutable fields (username, uid, gid, homeDir, groups) are set once in NewUser -// and never changed — they may be read from any goroutine without acquiring mu. -// -// Mutable fields (claims, userToken) are replaced atomically on each token renewal -// and must always be accessed through the accessor methods, which acquire mu internally. +// User holds the workspace identity. It is populated once from the user's +// profile in NewUser. UID, GID, home directory and groups are fixed for the +// lifetime of the process — they're baked into the OS user created at +// startup. The rest of the profile can be refreshed from the API server via +// UpdateProfile (see RESTService.GetProfile), so all profile reads go +// through a mutex. type User struct { - mu sync.RWMutex - - // Immutable identity fields — set in NewUser, never written again. - username string - uid uint32 - gid uint32 - homeDir string - groups []Group - - // Mutable — replaced atomically on token renewal; requires mu. - claims *authz.UserClaims - userToken string - previousToken string -} - -// NewUser creates a User from a verified JWT claims set and the raw token string. -func NewUser(claims *authz.UserClaims, token string) *User { - return &User{ - username: claims.Subject, - uid: claims.UID, - gid: claims.GID, - claims: claims, - userToken: token, - } + mu sync.RWMutex + profile models.UserProfile + homeDir string + groups []Group +} + +// NewUser creates a User from a resolved profile. +func NewUser(profile *models.UserProfile) *User { + return &User{profile: *profile} } // String returns a human-readable representation for logging. func (u *User) String() string { - u.mu.RLock() - defer u.mu.RUnlock() - shell := u.claims.Shell + p := u.ProfileSnapshot() + shell := p.Shell if shell == "" { shell = "/bin/sh" } return fmt.Sprintf( - "User{Username: %s, UID: %d, GID: %d, Name: %s, Email: %s, Shell: %s, Sudo: %t, Roles: %v, Exp: %s}", - u.username, u.uid, u.gid, - u.claims.Name, u.claims.Email, shell, u.claims.Sudo, u.claims.Roles, - u.claims.ExpiresAt.Time.UTC().Format(time.RFC3339), + "User{Username: %s, UID: %d, GID: %d, Name: %s, Email: %s, Shell: %s, Sudo: %t, Roles: %v}", + p.Username, p.UID, p.GID, p.Fullname, p.Email, shell, p.Sudo, p.Roles, ) } -// Update atomically replaces the mutable JWT claims and token string. -// Returns (true, nil) when the update was applied, (false, nil) when the token -// is unchanged (no-op), or (false, error) when an immutable field has changed. -func (u *User) Update(claims *authz.UserClaims, token string) (bool, error) { - u.mu.Lock() - defer u.mu.Unlock() - - if token == u.userToken { - return false, nil // same token — nothing to do - } - - // Validate immutable fields before making any change. - if claims.Subject != u.username { - return false, fmt.Errorf("cannot update user subject from %s to %s", u.username, claims.Subject) - } - if claims.Source != u.claims.Source { - return false, fmt.Errorf("cannot update user source from %s to %s", u.claims.Source, claims.Source) - } - if claims.UID != u.uid || claims.GID != u.gid { - return false, fmt.Errorf("cannot update user UID/GID from %d/%d to %d/%d", u.uid, u.gid, claims.UID, claims.GID) - } - - u.claims = claims - u.previousToken = u.userToken - u.userToken = token - return true, nil -} - -func (u *User) TokenEqual(token string) bool { - u.mu.RLock() - defer u.mu.RUnlock() - eq := token == u.userToken - - if !eq { - // token was verified before calling TokenEqual - claims1, err1 := authz.ParseUnverifiedClaims(token, true) - if err1 != nil { - return false - } - claims2, err2 := authz.ParseUnverifiedClaims(u.previousToken, false) - if err2 != nil { - return false - } - // previous token might be expired, but if the claims match then we can consider it equal - eq = claims1.Subject == claims2.Subject && claims1.Source == claims2.Source && - claims1.UID == claims2.UID && claims1.GID == claims2.GID - } - - return eq -} - // HasRole checks if the user has a specific role. func (u *User) HasRole(role models.Role) bool { u.mu.RLock() defer u.mu.RUnlock() - for _, r := range u.claims.Roles { + for _, r := range u.profile.Roles { if r == role { return true } @@ -135,8 +63,8 @@ func (u *User) HasRole(role models.Role) bool { func (u *User) GetShell() string { u.mu.RLock() defer u.mu.RUnlock() - if u.claims.Shell != "" { - return u.claims.Shell + if u.profile.Shell != "" { + return u.profile.Shell } return "/bin/sh" } @@ -145,50 +73,61 @@ func (u *User) GetShell() string { func (u *User) SudoEnabled() bool { u.mu.RLock() defer u.mu.RUnlock() - return u.claims.Sudo + return u.profile.Sudo } -// ClaimsSnapshot returns a copy of the current JWT claims under the read lock. -// The returned value is safe to inspect without any further locking. -func (u *User) ClaimsSnapshot() authz.UserClaims { +// ProfileSnapshot returns a copy of the user's profile. +func (u *User) ProfileSnapshot() models.UserProfile { u.mu.RLock() defer u.mu.RUnlock() - return *u.claims + return u.profile } -// GetUserToken returns the current raw JWT string. -func (u *User) GetUserToken() string { - u.mu.RLock() - defer u.mu.RUnlock() - return u.userToken +// UpdateProfile replaces the user's profile with a freshly fetched copy, +// typically after a live re-fetch from the API server (see +// RESTService.GetProfile). UID and GID are preserved from the current +// profile rather than taken from the argument: they're baked into the OS +// user created at startup, and every UID/GID-based operation (chown, +// process credentials) assumes they never change underneath it. +func (u *User) UpdateProfile(profile models.UserProfile) { + u.mu.Lock() + defer u.mu.Unlock() + profile.UID = u.profile.UID + profile.GID = u.profile.GID + u.profile = profile } -// GetUsername returns the username (JWT subject). Immutable — no lock needed. +// GetUsername returns the username. func (u *User) GetUsername() string { - return u.username + u.mu.RLock() + defer u.mu.RUnlock() + return u.profile.Username } -// GetUID returns the user's UID. Immutable — no lock needed. +// GetUID returns the user's UID. func (u *User) GetUID() uint32 { - return u.uid + u.mu.RLock() + defer u.mu.RUnlock() + return u.profile.UID } -// GetGID returns the user's primary GID. Immutable — no lock needed. +// GetGID returns the user's primary GID. func (u *User) GetGID() uint32 { - return u.gid + u.mu.RLock() + defer u.mu.RUnlock() + return u.profile.GID } // GetHomeDir returns the home directory, defaulting to /home/ when unset. -// Immutable — no lock needed. func (u *User) GetHomeDir() string { if u.homeDir != "" { return u.homeDir } - return "/home/" + u.username + return "/home/" + u.GetUsername() } // GetGroups returns the supplementary groups for the user. -// TODO: derive from JWT claims or a policy source once that is implemented. +// TODO: derive from the profile or a policy source once that is implemented. func (u *User) GetGroups() []Group { return u.groups } @@ -206,34 +145,24 @@ type ShellUser struct { Groups []Group } -// NewShellUser takes an atomic snapshot of User for use in a shell session. +// NewShellUser takes a snapshot of User for use in a shell session. func NewShellUser(u *User) ShellUser { - // Immutable fields — read without lock. - uid := u.uid + uid := u.GetUID() if uid == 0 { uid = 1000 } - gid := u.gid + gid := u.GetGID() if gid == 0 { gid = 1000 } - // Mutable fields — single RLock for a consistent snapshot. - u.mu.RLock() - shell := u.claims.Shell - if shell == "" { - shell = "/bin/sh" - } - sudo := u.claims.Sudo - u.mu.RUnlock() - return ShellUser{ - Username: u.username, + Username: u.GetUsername(), UID: uid, GID: gid, HomeDir: u.GetHomeDir(), - Shell: shell, - Sudo: sudo, - Groups: u.groups, + Shell: u.GetShell(), + Sudo: u.SudoEnabled(), + Groups: u.GetGroups(), } } diff --git a/internal/server/credhelpers.go b/internal/server/credhelpers.go index 31a1541..48821d0 100644 --- a/internal/server/credhelpers.go +++ b/internal/server/credhelpers.go @@ -103,13 +103,13 @@ func (s *Server) setupDockerCredHelper(homeDir string) error { // user.name, user.email, and credential.helper. // It is always applied so that identity changes between sessions are reflected. func (s *Server) setupGitCredHelper(homeDir string) error { - claims := s.user.ClaimsSnapshot() + profile := s.user.ProfileSnapshot() - name := claims.Name + name := profile.Username if name == "" { name = "n/a" } - email := claims.Email + email := profile.Email if email == "" { email = "n/a" } diff --git a/internal/server/identity.go b/internal/server/identity.go index 8d8c971..7be1fbc 100644 --- a/internal/server/identity.go +++ b/internal/server/identity.go @@ -4,178 +4,29 @@ package server import ( - "context" - "encoding/base64" "fmt" - "os" - "strconv" - "strings" - "time" - "github.com/k8shell-io/common/pkg/authz" + "github.com/k8shell-io/k8shelld/internal/config" "github.com/k8shell-io/k8shelld/internal/models" ) -const identityRefreshInterval = 15 * time.Second -const identityRenewBeforeExpiry = 2 * time.Minute -const JWT_VERIFIER_SIGNING_METHOD_ENV = "JWT_VERIFIER_SIGNING_METHOD" -const JWT_VERIFIER_PUBLIC_KEY_ENV = "JWT_VERIFIER_PUBLIC_KEY" -const USER_UID_ENV = "USER_UID" -const USER_GID_ENV = "USER_GID" -const USER_DISPLAY_NAME_ENV = "USERFULLNAME" -const USER_EMAIL_ENV = "USEREMAIL" - -// newJWTVerifier creates a JWTVerifier based on environment variables. -func newJWTVerifier() (*authz.JWTVerifier, error) { - signingMethod := strings.TrimSpace(os.Getenv(JWT_VERIFIER_SIGNING_METHOD_ENV)) - if signingMethod == "" { - return nil, fmt.Errorf("identity signing method is required (set %s or identity.signingMethod in config)", JWT_VERIFIER_SIGNING_METHOD_ENV) - } - publicKey := strings.TrimSpace(os.Getenv(JWT_VERIFIER_PUBLIC_KEY_ENV)) - if publicKey == "" { - return nil, fmt.Errorf(" %s environment variable is required", JWT_VERIFIER_PUBLIC_KEY_ENV) - } - jwtCfg := authz.JWTVerifierConfig{SigningMethod: signingMethod} - if signingMethod == "hs256" { - jwtCfg.SecretKey = publicKey - } else { - decoded, err := base64.StdEncoding.DecodeString(publicKey) - if err != nil { - return nil, fmt.Errorf("base64-decode %s: %w", JWT_VERIFIER_PUBLIC_KEY_ENV, err) - } - jwtCfg.PublicKey = string(decoded) - } - jwtVerifier, err := authz.NewJWTVerifier(jwtCfg) - if err != nil { - return nil, fmt.Errorf("create JWT verifier: %w", err) - } - return jwtVerifier, nil -} - -// loadIdentity retrieves the identity JWT from the API server, verifies it and -// initializes s.user with the verified claims. -func (s *Server) loadIdentity() error { +// loadProfile initializes s.user from the workspace user's profile at +// /etc/k8shell/profile.yaml. Like blueprint.yaml, the file is placed there by +// the k8Shell provisioner — k8shelld never writes it. +func (s *Server) loadProfile() error { if s.testMode { return nil } - if s.apiClientx == nil { - s.logger.Warn().Msg("API server is not enabled, loading identity from environment variables") - uidStr := strings.TrimSpace(os.Getenv(USER_UID_ENV)) - if uidStr == "" { - return fmt.Errorf("API server is disabled but %s is not set", USER_UID_ENV) - } - gidStr := strings.TrimSpace(os.Getenv(USER_GID_ENV)) - if gidStr == "" { - return fmt.Errorf("API server is disabled but %s is not set", USER_GID_ENV) - } - uid64, err := strconv.ParseUint(uidStr, 10, 32) - if err != nil { - return fmt.Errorf("parse %s=%q: %w", USER_UID_ENV, uidStr, err) - } - gid64, err := strconv.ParseUint(gidStr, 10, 32) - if err != nil { - return fmt.Errorf("parse %s=%q: %w", USER_GID_ENV, gidStr, err) - } - claims := &authz.UserClaims{UID: uint32(uid64), GID: uint32(gid64)} - claims.Subject = s.username - claims.Name = strings.TrimSpace(os.Getenv(USER_DISPLAY_NAME_ENV)) - if claims.Name == "" { - claims.Name = s.username - } - claims.Email = strings.TrimSpace(os.Getenv(USER_EMAIL_ENV)) - s.user = models.NewUser(claims, "") - s.logger.Debug().Msgf("Environment identity loaded: uid=%d gid=%d", uid64, gid64) - return nil - } - tokenStr, err := s.apiClientx.IssueUserToken(context.Background(), s.username) + profile, err := config.LoadProfile(config.ProfilePath) if err != nil { - return fmt.Errorf("issue identity token for user %s: %w", s.username, err) + return fmt.Errorf("load profile from %s: %w", config.ProfilePath, err) } - - claims, err := s.jwtVerifier.VerifyToken(tokenStr) - if err != nil { - return fmt.Errorf("verify identity token: %w", err) - } - - if claims.Subject != s.username { - return fmt.Errorf("issued token subject %q does not match workspace user %q", claims.Subject, s.username) + if profile.Username != s.username { + return fmt.Errorf("profile username %q does not match workspace user %q", profile.Username, s.username) } - s.user = models.NewUser(claims, tokenStr) - s.apiClientx.UpdateToken(tokenStr) - s.logger.Debug().Msg("Identity token loaded: " + s.user.String()) - + s.user = models.NewUser(profile) + s.logger.Debug().Msg("User profile loaded: " + s.user.String()) return nil } - -// watchIdentity monitors the in-memory token expiry at a fixed interval. -// When the current token has expired it sends a shutdown reason and returns. -// The goroutine exits cleanly when ctx is cancelled. -func (s *Server) watchIdentity(ctx context.Context) { - ticker := time.NewTicker(identityRefreshInterval) - expiryReported := false - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if err := s.renewIdentityTokenIfNeeded(ctx); err != nil { - s.logger.Warn().Msgf("Failed to renew identity token: %v", err) - } - if reason := s.checkTokenExpiry(); reason != "" { - if !expiryReported { - s.logger.Warn().Msgf("Identity token expired: %s", reason) - expiryReported = true - } - } else if expiryReported { - s.logger.Info().Msg("Identity token is valid again") - expiryReported = false - } - } - } -} - -func (s *Server) renewIdentityTokenIfNeeded(ctx context.Context) error { - if s.apiClientx == nil || s.user == nil { - return nil - } - - if time.Until(s.user.ClaimsSnapshot().ExpiresAt.Time) > identityRenewBeforeExpiry { - return nil - } - - username := s.user.GetUsername() - tokenStr, err := s.apiClientx.IssueUserToken(ctx, username) - if err != nil { - return fmt.Errorf("issue token for user %s: %w", username, err) - } - - claims, err := s.jwtVerifier.VerifyToken(tokenStr) - if err != nil { - return fmt.Errorf("verify token: %w", err) - } - - _, err = s.user.Update(claims, tokenStr) - if err != nil { - return fmt.Errorf("update user from refresh token: %w", err) - } - - s.logger.Info().Msgf("Identity token refreshed, expires at: %s", - claims.ExpiresAt.Time.UTC().Format(time.RFC3339)) - s.apiClientx.UpdateToken(tokenStr) - - return nil -} - -// checkTokenExpiry returns a non-empty reason string when the current in-memory -// token is expired, empty string when all is well. -func (s *Server) checkTokenExpiry() string { - tokenStr := s.user.GetUserToken() - if _, err := s.jwtVerifier.VerifyToken(tokenStr); err != nil { - return fmt.Sprintf("identity token is no longer valid: %v", err) - } - return "" -} diff --git a/internal/server/restapi.go b/internal/server/restapi.go index 3b0c783..b3240c6 100644 --- a/internal/server/restapi.go +++ b/internal/server/restapi.go @@ -102,13 +102,14 @@ func (a *RESTService) initializeRouter() *mux.Router { apiRouter.HandleFunc("/apps/{name}/logs", a.GetAppLogs).Methods(http.MethodGet) apiRouter.HandleFunc("/apps/{name}/start", a.StartApp).Methods(http.MethodPost) apiRouter.HandleFunc("/apps/{name}/stop", a.StopApp).Methods(http.MethodPost) - apiRouter.HandleFunc("/identity", a.GetIdentity).Methods(http.MethodGet) + apiRouter.HandleFunc("/profile", a.GetProfile).Methods(http.MethodGet) apiRouter.HandleFunc("/splash", a.GetSplash).Methods(http.MethodGet) apiRouter.HandleFunc("/shells", a.ListDetachedShells).Methods(http.MethodGet) apiRouter.HandleFunc("/shells/{id}/detach", a.DetachShell).Methods(http.MethodPost) apiRouter.HandleFunc("/shells/{id}/attach", a.AttachShell).Methods(http.MethodPost) apiRouter.HandleFunc("/shells/{id}/resize", a.ResizeShell).Methods(http.MethodPost) apiRouter.HandleFunc("/initscripts", a.GetInitScripts).Methods(http.MethodGet) + apiRouter.HandleFunc("/password", a.SetPassword).Methods(http.MethodPut) a.logRoutes(router) return router @@ -170,8 +171,8 @@ func (a *RESTService) GetSessions(w http.ResponseWriter, r *http.Request) { } a.logger.Debug().Msgf("Fetching last %d sessions for workspace %s", n, a.server.workspace) - sessions, err := a.server.apiClientx.ListUserSessions(r.Context(), a.user.GetUsername(), - a.server.workspace, n, 0, true) + sessions, err := a.server.apiClientx.ListSessions(r.Context(), a.user.GetUsername(), + a.server.workspace, n, false) if err != nil { a.logger.Warn().Msgf("Cannot retrieve workspace sessions: %v", err) http.Error(w, "Failed to retrieve sessions", http.StatusBadGateway) @@ -223,7 +224,7 @@ func (a *RESTService) GetCredsHelper(w http.ResponseWriter, r *http.Request) { return } scope := currentPodNamespace() - cred, err := a.server.apiClientx.GetUserCredential(r.Context(), a.user.GetUsername(), "kubernetes", scope) + cred, err := a.server.apiClientx.ResolveUserCredential(r.Context(), a.user.GetUsername(), "kubernetes", scope) if err != nil { a.logger.Warn().Msgf("Cannot retrieve kubernetes user credentials: %v", err) http.Error(w, "Failed to retrieve credentials", http.StatusBadGateway) @@ -265,7 +266,7 @@ func (a *RESTService) GetCredsHelper(w http.ResponseWriter, r *http.Request) { switch credsType { case "docker": - cred, err := a.server.apiClientx.GetUserCredential(r.Context(), a.user.GetUsername(), "registry", addr) + cred, err := a.server.apiClientx.ResolveUserCredential(r.Context(), a.user.GetUsername(), "registry", addr) if err != nil { a.logger.Warn().Msgf("Cannot retrieve docker/registry user credentials: %v", err) http.Error(w, "Failed to retrieve credentials", http.StatusBadGateway) @@ -280,7 +281,7 @@ func (a *RESTService) GetCredsHelper(w http.ResponseWriter, r *http.Request) { } return case "git": - cred, err := a.server.apiClientx.GetUserCredential(r.Context(), a.user.GetUsername(), "git", addr) + cred, err := a.server.apiClientx.ResolveUserCredential(r.Context(), a.user.GetUsername(), "git", addr) if err != nil { a.logger.Warn().Msgf("Cannot retrieve git user credentials: %v", err) http.Error(w, "Failed to retrieve credentials", http.StatusBadGateway) @@ -377,41 +378,45 @@ func (a *RESTService) GetSystemInfo(w http.ResponseWriter, r *http.Request) { } } -func (a *RESTService) GetIdentity(w http.ResponseWriter, r *http.Request) { - claims := a.user.ClaimsSnapshot() - - roles := make([]string, len(claims.Roles)) - for i, role := range claims.Roles { - roles[i] = string(role) +// GetProfile returns the workspace user's profile. When the API server is +// configured, the profile is re-fetched live and the in-memory copy is +// refreshed (see models.User.UpdateProfile) so it stays current for +// subsequent requests; on fetch failure it falls back to the last known +// copy. Without an API server, it returns the profile loaded at startup from +// /etc/k8shell/profile.yaml. +func (a *RESTService) GetProfile(w http.ResponseWriter, r *http.Request) { + if a.server.apiClientx != nil { + fresh, err := a.server.apiClientx.GetUserProfile(r.Context(), a.user.GetUsername()) + if err != nil { + a.logger.Warn().Msgf("Cannot refresh user profile from API server, using cached copy: %v", err) + } else { + a.user.UpdateProfile(*fresh) + } } - expiresAt := "" - if claims.ExpiresAt != nil { - expiresAt = claims.ExpiresAt.Time.UTC().Format(time.RFC3339) - } + profile := a.user.ProfileSnapshot() - shell := claims.Shell - if shell == "" { - shell = "/bin/sh" + roleStrs := make([]string, len(profile.Roles)) + for i, role := range profile.Roles { + roleStrs[i] = string(role) } response := k8shelld.IdentityInfo{ Username: a.user.GetUsername(), - Name: claims.Name, - Email: claims.Email, + Name: profile.Fullname, + Email: profile.Email, UID: a.user.GetUID(), GID: a.user.GetGID(), - Shell: shell, - Sudo: claims.Sudo, - Roles: roles, - Organization: claims.Organization, - Source: claims.Source, - ExpiresAt: expiresAt, + Shell: a.user.GetShell(), + Sudo: a.user.SudoEnabled(), + Roles: roleStrs, + Organization: profile.Organization, + Source: profile.Source, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { - a.logger.Error().Msgf("Failed to encode identity response: %v", err) + a.logger.Error().Msgf("Failed to encode profile response: %v", err) http.Error(w, "Failed to encode response", http.StatusInternalServerError) } } @@ -952,3 +957,34 @@ func (a *RESTService) GetInitScripts(w http.ResponseWriter, r *http.Request) { a.logger.Error().Msgf("GetInitScripts encode: %v", err) } } + +// SetPassword sets the workspace user's password via the API server. +// CurrentPassword is required by the API server when the caller is a +// non-sudo change of the user's own password, and ignored otherwise. +func (a *RESTService) SetPassword(w http.ResponseWriter, r *http.Request) { + if a.server.apiClientx == nil { + http.Error(w, "API server not configured.", http.StatusServiceUnavailable) + return + } + + var req struct { + Password string `json:"password"` + CurrentPassword string `json:"currentPassword"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + if req.Password == "" { + http.Error(w, "Missing 'password'", http.StatusBadRequest) + return + } + + if _, err := a.server.apiClientx.SetUserPassword(r.Context(), a.user.GetUsername(), req.Password, req.CurrentPassword); err != nil { + a.logger.Warn().Msgf("Cannot set user password: %v", err) + http.Error(w, fmt.Sprintf("Failed to set password: %v", err), http.StatusBadGateway) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/server.go b/internal/server/server.go index 6e1409a..34a975f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,9 +18,8 @@ import ( "syscall" "time" - "github.com/k8shell-io/api-server/pkg/client" - "github.com/k8shell-io/common/pkg/authz" commonmodels "github.com/k8shell-io/common/pkg/models" + "github.com/k8shell-io/k8shelld/internal/apiclient" "github.com/k8shell-io/k8shelld/internal/apps" "github.com/k8shell-io/k8shelld/internal/config" "github.com/k8shell-io/k8shelld/internal/grpc" @@ -46,27 +45,24 @@ type Server struct { restService *RESTService grpcService *grpc.GRPCService procWatcher *system.ProcessWatcher - apiClientx *client.Client + apiClientx *apiclient.Client pprof bool sysInfo *system.SystemInfo appManager *apps.AppManager - jwtVerifier *authz.JWTVerifier initTracker *models.InitTracker } func NewServer(cfg *config.Config, restApiUnixSocketPath string, testMode bool) (*Server, error) { - var apiClient *client.Client + var apiClient *apiclient.Client if cfg.System.ApiServer.Enabled { if cfg.System.ApiServer.Address == "" { return nil, fmt.Errorf("api server is enabled but address is empty") } - apiClient = client.NewClient(cfg.System.ApiServer.Address, "") - } - - jwtVerifier, err := newJWTVerifier() - if err != nil { - return nil, fmt.Errorf("error creating JWT verifier: %v", err) + if strings.TrimSpace(os.Getenv(apiclient.PATTokenEnv)) == "" { + return nil, fmt.Errorf("api server is enabled but %s is not set", apiclient.PATTokenEnv) + } + apiClient = apiclient.New(cfg.System.ApiServer.Address) } s := &Server{ @@ -75,7 +71,6 @@ func NewServer(cfg *config.Config, restApiUnixSocketPath string, testMode bool) config: cfg, pprof: cfg.System.PProf, apiClientx: apiClient, - jwtVerifier: jwtVerifier, initTracker: models.NewInitTracker(), } @@ -96,9 +91,9 @@ func NewServer(cfg *config.Config, restApiUnixSocketPath string, testMode bool) return nil, fmt.Errorf("cannot get the workspace name from WORKSPACE environment variable") } - err = s.loadIdentity() + err = s.loadProfile() if err != nil { - return nil, fmt.Errorf("error loading identity: %v", err) + return nil, fmt.Errorf("error loading profile: %v", err) } if !s.testMode { @@ -115,7 +110,7 @@ func NewServer(cfg *config.Config, restApiUnixSocketPath string, testMode bool) } } - s.grpcService, err = grpc.NewGRPCService(cfg, s.blueprint, s.user, s.jwtVerifier, + s.grpcService, err = grpc.NewGRPCService(cfg, s.blueprint, s.user, s.procWatcher, s.apiClientx, s.appManager, s.sysInfo) if err != nil { return nil, fmt.Errorf("error creating GRPC API: %v", err) @@ -266,14 +261,6 @@ func (s *Server) Serve() { }() } - if s.jwtVerifier != nil && s.apiClientx != nil { - wg.Add(1) - go func() { - defer wg.Done() - s.watchIdentity(ctx) - }() - } - sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) diff --git a/internal/server/toolswrapper.go b/internal/server/toolswrapper.go index bac9ffd..416d08a 100644 --- a/internal/server/toolswrapper.go +++ b/internal/server/toolswrapper.go @@ -58,6 +58,7 @@ func (s *Server) setupToolWrappers() { {"uptime", "uptime", wrapperTemplateHelp}, {"last", "last", wrapperTemplateHelp}, {"shutdown", "shutdown", wrapperTemplateHelp}, + {"passwd", "passwd", wrapperTemplateHelp}, } if s.blueprint != nil && s.blueprint.Podman.Enabled {