Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ on:
workflow_dispatch:

env:
GO_VERSION: "1.24.5"
GO_VERSION: "1.25.0"

permissions:
contents: read
Expand Down
16 changes: 7 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,37 +25,35 @@ 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
6. Serve gRPC + REST APIs concurrently

**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

**gRPC API** is defined in `github.com/k8shell-io/common` (external module). The four registered services are `SystemService`, `SshService`, `AppService`, and `CommandService`. All calls require a JWT in the `token` gRPC metadata key that must match the workspace token.

**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.

## Environment variables

| 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` |
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion cmd/kbox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
88 changes: 88 additions & 0 deletions cmd/kbox/passwd.go
Original file line number Diff line number Diff line change
@@ -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
}
34 changes: 10 additions & 24 deletions cmd/kbox/identity.go → cmd/kbox/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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)
},
}

Expand All @@ -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")
}
12 changes: 6 additions & 6 deletions cmd/kbox/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
Expand All @@ -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
}
Expand All @@ -37,18 +37,18 @@ 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
}
fmt.Println(strOr(data.Email, "n/a"))
},
}

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()
Expand Down
4 changes: 2 additions & 2 deletions docker/k8shelld/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
module github.com/k8shell-io/k8shelld

go 1.24.5
go 1.25.0

require (
github.com/creack/pty v1.1.24
github.com/docker/docker-credential-helpers v0.9.7
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
Expand All @@ -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
Expand All @@ -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
)
18 changes: 12 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down
Loading
Loading