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
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"editor.aiStats.enabled": true,
"go.lintTool": "golangci-lint",
"go.lintOnSave": "package"
"go.lintOnSave": "package",
"git.scanRepositories": [
"common"
]
}
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ image: vendor
coverage: ##@ Calculate test coverage percentage from coverage.out
@go tool cover -func=$(REPORTS_DIR)/coverage.out | grep total | awk '{print $$3}'

debug-setup: ##@ Set up local debug environment
##@ Generates go.work (Go version taken from go.mod) and symlinks the common module for local debugging
@GO_VERSION=$$(grep -m1 '^go ' go.mod | awk '{print $$2}') && \
printf 'go %s\n\nuse (\n\t.\n\t/opt/shared/common\n)\n' "$$GO_VERSION" > go.work
ln -sfn /opt/shared/common common

##@
##@ Misc commands
##@
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ The blueprint is a YAML document mounted at `/etc/k8shell/blueprint.yaml`. It is

On startup, `k8shelld` runs all executable files matching the pattern `__init_*` found in `/usr/local/k8shell/system`, sorted alphabetically. Scripts are executed sequentially in the background so they do not block the gRPC server from accepting connections. Each script runs as the workspace user inside its own process group.

A flag file is written to `~/.k8shell/flags/<script-name>` after a successful run, so scripts are skipped on subsequent container restarts unless the flag is removed.
A flag file is written to `~/.k8shell/flags/<script-name>` after a successful run, so scripts are skipped on subsequent container restarts unless the flag is removed. A blueprint init script with `always: true` skips this run-once guard and executes on every start; its flag file is still maintained so the script can tell the two cases apart.

Every init script is run with `K8SHELL_INIT_FIRST_RUN` in its environment — `true` when the script has not previously completed successfully in this workspace, `false` when it is being re-run (only `always` scripts are ever re-run).

Init-script progress is tracked in memory and streamed to the PTY display when a new shell session is opened before the scripts finish.

Expand Down
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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/common v0.40.0
github.com/k8shell-io/common v0.52.0
github.com/k8shell-io/k8shell-go v0.2.3
github.com/pkg/sftp v1.13.10
github.com/rs/zerolog v1.34.0
Expand All @@ -20,6 +20,8 @@ require (
)

require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/coreos/go-oidc/v3 v3.16.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
Expand All @@ -29,13 +31,16 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.14.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
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/spf13/pflag v1.0.6 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
Expand Down
28 changes: 16 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
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=
Expand Down Expand Up @@ -42,18 +46,14 @@ 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/common v0.36.0 h1:fkMH1XfYRLzDxqhIq5/luHusWWPGnCGJUXSTEIhEDzI=
github.com/k8shell-io/common v0.36.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY=
github.com/k8shell-io/common v0.37.0 h1:whq66WosIJECKErUKZF1RQep7tdpOfI6GtP4hXREpsQ=
github.com/k8shell-io/common v0.37.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY=
github.com/k8shell-io/common v0.39.0 h1:hfrKZYX2lBonornGrfK35rSY/+5o2sK+SakgUPKDL24=
github.com/k8shell-io/common v0.39.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY=
github.com/k8shell-io/common v0.40.0 h1:MhQPVI5oe+JSdRJhWrtvCTJf0MaJDjM58KR7Nq3+lsM=
github.com/k8shell-io/common v0.40.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY=
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/k8shell-io/k8shell-go v0.2.2 h1:rwLOeIfyq1+l2Jyv0ak/lXZS7x6xbA5ye0yMsRkTonw=
github.com/k8shell-io/k8shell-go v0.2.2/go.mod h1:ZShnaWs7zxUlNwAkIn4lJodFqaB+PB8O+gn2EIscxq8=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/k8shell-io/common v0.49.0 h1:z29iAirMd3GZdz+s/QHI8bSYEcNqmfN5Fq8dCKWMO0w=
github.com/k8shell-io/common v0.49.0/go.mod h1:TcMCP6YSmuVFHi7igxE0hK4kRewdNcfOQmhsx7mJAWY=
github.com/k8shell-io/common v0.50.0 h1:p0sLvpn54K/BrD0khLVt4+go1zKQbGM85+lcu4xuKno=
github.com/k8shell-io/common v0.50.0/go.mod h1:TcMCP6YSmuVFHi7igxE0hK4kRewdNcfOQmhsx7mJAWY=
github.com/k8shell-io/common v0.52.0 h1:DI/BKMJwv5TLV+Ubmbqwu3jxy9prwpTUwKAymIXBOFs=
github.com/k8shell-io/common v0.52.0/go.mod h1:TcMCP6YSmuVFHi7igxE0hK4kRewdNcfOQmhsx7mJAWY=
github.com/k8shell-io/k8shell-go v0.2.3 h1:gL7dXDYN4EhWdQvvnY4B2On9Tpb1sZ7G5lO8RtI/nr4=
github.com/k8shell-io/k8shell-go v0.2.3/go.mod h1:wWb5gq693qqb48/p5iYrosLG4uNeGOr5dQJiOClIbE8=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
Expand All @@ -73,6 +73,8 @@ 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/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
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=
Expand Down Expand Up @@ -104,6 +106,8 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ const (
BlueprintPath = "/etc/k8shell/blueprint.yaml"
ProfilePath = "/etc/k8shell/profile.yaml"
InitScriptsDir = "/usr/local/k8shell/system"

// EnvInitFirstRun is injected into every init script's environment. It is
// "true" when the script has not previously completed successfully in this
// workspace and "false" when it is being re-run (only InitScript.Always
// scripts are ever re-run).
EnvInitFirstRun = "K8SHELL_INIT_FIRST_RUN"
)

// Type aliases for types defined in common.
Expand Down
59 changes: 59 additions & 0 deletions internal/grpc/acquire.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,65 @@ func (s *ShellHandler) AcquireSession(ctx context.Context, req *k8shelldv1.Acqui
}, nil
}

// ListSessions implements SshServiceServer.ListSessions.
// It returns the set of live PTY sessions that AcquireSession would currently
// accept: no client attached and no unexpired lock held, along with the OS
// user each session runs as.
func (s *ShellHandler) ListSessions(_ context.Context, _ *k8shelldv1.ListSessionsRequest) (*k8shelldv1.ListSessionsResponse, error) {
if !s.grpcApi.allowSessionDetach {
return nil, status.Errorf(codes.PermissionDenied, "session attachment is not enabled on this server")
}

now := time.Now()
locked := make(map[string]bool)
s.grpcApi.SessionLockStore.Range(func(_, v any) bool {
lk := v.(*sessionLock)
if now.Before(lk.expiresAt) {
locked[lk.sessionId] = true
}
return true
})

resp := &k8shelldv1.ListSessionsResponse{}
s.grpcApi.SessionStore.Range(func(_, value any) bool {
session, ok := value.(*SessionData)
if !ok || session.ptyDone == nil || !session.Deleted.IsZero() {
return true
}
select {
case <-session.ptyDone:
return true
default:
}

session.mu.Lock()
attached := session.attachedSender != nil
detachedAt := session.DetachedAt
session.mu.Unlock()

if attached || locked[session.Id] {
return true
}

var detachedAtStr string
if !detachedAt.IsZero() {
detachedAtStr = detachedAt.Format(timeFormat)
}

resp.Sessions = append(resp.Sessions, &k8shelldv1.AcquirableSession{
SessionId: session.Id,
Owner: session.user.Username,
CmdShell: session.CmdShell,
Pid: int32(session.Pid),
Created: session.Created.Format(timeFormat),
DetachedAt: detachedAtStr,
})
return true
})

return resp, nil
}

// cleanupExpiredLocks removes session locks that have passed their TTL.
func (a *GRPCService) cleanupExpiredLocks() {
now := time.Now()
Expand Down
77 changes: 77 additions & 0 deletions internal/grpc/detachable.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,17 @@ func (a *GRPCService) runRESTAttachLoop(session *SessionData, conn net.Conn, det
// - OSC 10 ; ... ST/BEL — foreground colour report
// - OSC 11 ; ... ST/BEL — background colour report
// - DCS ... ST — device control string responses (e.g. XTGETTCAP)
//
// Also stripped: the shell's PROMPT_EOL_MARK ("%" or "#" wrapped in SGR
// styling, padded with spaces to the terminal width, then a bare CR — e.g.
// zsh's default "%B%S%#%s%b"). It is rendered by overwriting it with the
// next prompt line, which relies on the replaying terminal being exactly as
// wide as the terminal the shell originally sized its padding for. Replayed
// scrollback is frequently viewed in a differently-sized terminal (the
// session's PTY width at attach time lags the reattaching client's actual
// width — see cmd/kbox/attach.go), so the overwrite trick fails and the
// mark is left stranded above the prompt. Since it carries no information
// for a new client, it's dropped like the other noise above.
func stripTerminalQueryResponses(data []byte) []byte {
if len(data) == 0 {
return data
Expand All @@ -544,6 +555,9 @@ func stripTerminalQueryResponses(data []byte) []byte {
switch data[i+1] {
case '[': // CSI
end, ok := scanCSIResponse(data, i)
if !ok {
end, ok = scanEOLMark(data, i)
}
if ok {
i = end
} else {
Expand Down Expand Up @@ -611,6 +625,69 @@ func scanCSIResponse(data []byte, i int) (end int, ok bool) {
return i, false
}

// scanEOLMark matches a shell's PROMPT_EOL_MARK sequence: one or more SGR
// (colour/style) escapes, the marker character ('%' or '#'), one or more
// closing SGR escapes, a run of padding spaces, and a bare CR (not followed
// by LF — that would just be a normal line ending). Returns the index past
// the CR on success, or (i, false) if the pattern doesn't fully match,
// leaving the caller to emit data[i] unchanged.
//
// i must point at the ESC of what is expected to be the first SGR escape.
func scanEOLMark(data []byte, i int) (end int, ok bool) {
j := i
opened := 0
for {
next, sgrOK := scanSGR(data, j)
if !sgrOK {
break
}
j = next
opened++
}
if opened == 0 {
return i, false
}
if j >= len(data) || (data[j] != '%' && data[j] != '#') {
return i, false
}
j++ // consume the marker character

for {
next, sgrOK := scanSGR(data, j)
if !sgrOK {
break
}
j = next
}

for j < len(data) && data[j] == ' ' {
j++
}

if j >= len(data) || data[j] != '\r' {
return i, false
}
j++ // consume the CR; a following LF (if any) is left untouched

return j, true
}

// scanSGR returns the index past an SGR escape (ESC [ params m) starting at
// j, or (j, false) if there isn't one there.
func scanSGR(data []byte, j int) (end int, ok bool) {
if j+1 >= len(data) || data[j] != 0x1b || data[j+1] != '[' {
return j, false
}
k := j + 2
for k < len(data) && ((data[k] >= 0x30 && data[k] <= 0x3f) || data[k] == ';') {
k++
}
if k >= len(data) || data[k] != 'm' {
return j, false
}
return k + 1, true
}

// scanOSCResponse returns the index past the end of an OSC sequence that
// starts with 10; or 11; (colour query responses), terminated by BEL or ST.
func scanOSCResponse(data []byte, i int) (end int, ok bool) {
Expand Down
87 changes: 87 additions & 0 deletions internal/grpc/detachable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Use of this source code is governed by a AGPLv3
// license that can be found in the LICENSE file.

package grpc

import "testing"

func TestStripTerminalQueryResponses_EOLMark(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "zsh default PROMPT_EOL_MARK (bold+standout %, padded, bare CR)",
in: "hello\x1b[1m\x1b[7m%\x1b[27m\x1b[m \r[~]$ ",
want: "hello[~]$ ",
},
{
name: "single SGR wrap with # marker (root prompt)",
in: "out\x1b[7m#\x1b[27m \r$ ",
want: "out$ ",
},
{
name: "no padding spaces still strips",
in: "x\x1b[7m%\x1b[27m\r$ ",
want: "x$ ",
},
{
name: "CRLF is left untouched (normal line ending, not the EOL mark)",
in: "line one\r\nline two\r\n",
want: "line one\r\nline two\r\n",
},
{
name: "bare % without SGR wrapping is not stripped (e.g. a real progress indicator)",
in: "50% \rdone",
want: "50% \rdone",
},
{
name: "styled text without the marker char is left alone",
in: "\x1b[1mBOLD\x1b[0m text",
want: "\x1b[1mBOLD\x1b[0m text",
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := string(stripTerminalQueryResponses([]byte(c.in)))
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}

func TestStripTerminalQueryResponses_ExistingCSIFiltering(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "CPR response stripped",
in: "before\x1b[24;80Rafter",
want: "beforeafter",
},
{
name: "primary DA response stripped",
in: "before\x1b[?1;2cafter",
want: "beforeafter",
},
{
name: "OSC 11 colour response stripped (BEL terminated)",
in: "before\x1b]11;rgb:0000/0000/0000\x07after",
want: "beforeafter",
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := string(stripTerminalQueryResponses([]byte(c.in)))
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
4 changes: 4 additions & 0 deletions internal/grpc/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ func (s *SshServiceServer) AcquireSession(ctx context.Context, req *k8shelldv1.A
return s.shell.AcquireSession(ctx, req)
}

func (s *SshServiceServer) ListSessions(ctx context.Context, req *k8shelldv1.ListSessionsRequest) (*k8shelldv1.ListSessionsResponse, error) {
return s.shell.ListSessions(ctx, req)
}

func (s *SshServiceServer) Exec(stream grpc.BidiStreamingServer[k8shelldv1.ExecRequest, k8shelldv1.ExecResponse]) error {
return s.exec.Exec(stream)
}
Expand Down
Loading
Loading