Admin WebSSH/SFTP Implementation Plan
Admin WebSSH/SFTP Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add admin-managed remote servers, SSH public-key credentials, and browser-local SSH/SFTP access through the existing Agent relay.
Architecture: The browser owns SSH/SFTP protocol handling and authentication material; the Server creates one-time WebSocket sessions and brokers binary bytes to an existing Agent TCP relay stream. Management data follows Handler → Service → Repository, with an expand-only schema migration from v11 to v12.
Tech Stack: Go database/sql, golang.org/x/crypto/ssh, existing relay.NodeTransport, existing WSUpgrader/WSConn interfaces, Vue 3, TypeScript, Vite, Pinia, Vue Router, Element Plus, @xterm/xterm, and a browser-compatible SSH/SFTP package.
Spec: docs/superpowers/specs/2026-09-12-admin-webssh-sftp-design.md
Global Constraints
- Preserve Handler → Service → Repository layering; handlers never execute SQL.
- Preserve API prefix
/api/v1and response{ code, msg, data }. - Use cursor pagination, never offset pagination.
- Create and replace requests use
Idempotency-Key; PATCH, DELETE, restore, and close keep the existing management API convention and accept the header only for replay-safe token operations. SchemaVersionchanges from 11 to 12.- Maintain
migrations/ddl.sql, adjacent MySQL and SQLite migrations, and schema tests together. - Never store SSH passwords, private keys, terminal output, or SFTP file contents. The private-key extraction API may receive a private key once in memory, but must never persist, log, audit, or relay it.
- The Server is only a byte-stream broker; it does not terminate SSH.
- The Agent performs its existing target-address and port policy checks.
- Every task follows red-green TDD and runs its listed verification before completion.
- Do not commit unless the user explicitly authorizes it in the current task.
Task 1: Schema and Storage Models
Files:
- Modify:
internal/storage/models.go - Modify:
internal/storage/repository.go - Create:
internal/storage/credential_repository.go - Create:
internal/storage/credential_repository_test.go - Create:
internal/storage/remote_server_repository.go - Create:
internal/storage/remote_server_repository_test.go - Create:
internal/storage/webssh_session_repository.go - Create:
internal/storage/webssh_session_repository_test.go - Modify:
internal/storage/db.go - Modify:
migrations/ddl.sql - Create:
migrations/incremental/v0011_to_v0012/mysql.sql - Create:
migrations/incremental/v0011_to_v0012/sqlite.sql - Modify:
migrations/embed.go - Modify:
internal/storage/sqlite_test.go - Modify:
internal/storage/mysql_test.go
Interfaces:
- Produces:
type CredentialStatus string
const (
CredentialStatusActive CredentialStatus = "active"
CredentialStatusDeleted CredentialStatus = "deleted"
CredentialStatusAll CredentialStatus = "all"
)
type CredentialType string
const CredentialTypeSSHPublicKey CredentialType = "ssh_public_key"
type Credential struct {
ID string
OwnerUserID string
Name string
Type CredentialType
PublicKey string
Fingerprint string
Enabled bool
DeletedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type CredentialFilter struct {
OwnerUserID string
Type CredentialType
Status CredentialStatus
Keyword string
}
type CredentialRepository interface {
Create(context.Context, Credential) error
Get(context.Context, string) (Credential, error)
Update(context.Context, Credential) error
Delete(context.Context, string, time.Time) error
Restore(context.Context, string) error
List(context.Context, CredentialFilter, string, int) (Page[Credential], error)
}
type RemoteServerStatus string
const (
RemoteServerStatusEnabled RemoteServerStatus = "enabled"
RemoteServerStatusDisabled RemoteServerStatus = "disabled"
RemoteServerStatusDeleted RemoteServerStatus = "deleted"
)
type RemoteServer struct {
ID string
OwnerUserID string
Name string
Host string
Port int
DefaultUsername string
CredentialID string
AgentID string
Enabled bool
DeletedAt *time.Time
LastConnectedAt *time.Time
LastResult string
LastErrorClass string
CreatedAt time.Time
UpdatedAt time.Time
}
type RemoteServerFilter struct {
OwnerUserID string
AgentID string
Status RemoteServerStatus
Keyword string
}
type RemoteServerRepository interface {
Create(context.Context, RemoteServer) error
Get(context.Context, string) (RemoteServer, error)
Update(context.Context, RemoteServer) error
Delete(context.Context, string, time.Time) error
Restore(context.Context, string) error
List(context.Context, RemoteServerFilter, string, int) (Page[RemoteServer], error)
UpdateConnectionResult(context.Context, string, string, string, time.Time) error
}
type WebSSHSessionStatus string
const (
WebSSHSessionPending WebSSHSessionStatus = "pending"
WebSSHSessionActive WebSSHSessionStatus = "active"
WebSSHSessionClosed WebSSHSessionStatus = "closed"
WebSSHSessionExpired WebSSHSessionStatus = "expired"
)
type WebSSHSession struct {
ID string
OwnerUserID string
RemoteServerID string
AgentID string
OwnerNodeID string
TicketHash string
TicketExpiresAt time.Time
Status WebSSHSessionStatus
CreatedAt time.Time
ExpiresAt time.Time
ConnectedAt *time.Time
ClosedAt *time.Time
CloseReason string
}
type WebSSHSessionRepository interface {
Create(context.Context, WebSSHSession) error
Get(context.Context, string) (WebSSHSession, error)
ConsumeTicket(context.Context, string, string, time.Time) (WebSSHSession, error)
Close(context.Context, string, string, time.Time) error
CountActiveByOwner(context.Context, string, time.Time) (int, error)
ListActiveByOwner(context.Context, string, time.Time) ([]WebSSHSession, error)
ExpirePending(context.Context, time.Time) (int64, error)
CloseExpiredActive(context.Context, time.Time) (int64, error)
}
func (d *DB) Credentials() CredentialRepository
func (d *DB) RemoteServers() RemoteServerRepository
func (d *DB) WebSSHSessions() WebSSHSessionRepository
- Step 1: Write failing storage contract tests
Create internal/storage/credential_repository_test.go:
package storage
import (
"context"
"testing"
"time"
)
func TestCredentialRepositoryLifecycle(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
now := time.Now().UTC()
cred := Credential{
OwnerUserID: "user-a", Name: "team-key", Type: CredentialTypeSSHPublicKey,
PublicKey: "ssh-ed25519 AAAATEST", Fingerprint: "SHA256:test", Enabled: true,
}
if err := db.Credentials().Create(ctx, cred); err != nil { t.Fatal(err) }
page, err := db.Credentials().List(ctx, CredentialFilter{OwnerUserID: "user-a"}, "", 10)
if err != nil { t.Fatal(err) }
if len(page.Items) != 1 || page.Items[0].Name != "team-key" { t.Fatalf("page = %+v", page.Items) }
if err := db.Credentials().Delete(ctx, page.Items[0].ID, now); err != nil { t.Fatal(err) }
active, err := db.Credentials().List(ctx, CredentialFilter{OwnerUserID: "user-a"}, "", 10)
if err != nil { t.Fatal(err) }
if len(active.Items) != 0 { t.Fatalf("active items = %+v", active.Items) }
deleted, err := db.Credentials().List(ctx, CredentialFilter{OwnerUserID: "user-a", Status: CredentialStatusDeleted}, "", 10)
if err != nil { t.Fatal(err) }
if len(deleted.Items) != 1 { t.Fatalf("deleted items = %+v", deleted.Items) }
}
Create internal/storage/remote_server_repository_test.go:
func TestRemoteServerRepositoryOwnerFilterAndCursor(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
for _, name := range []string{"a", "b", "c"} {
server := RemoteServer{OwnerUserID: "user-a", Name: name, Host: "10.0.0.8", Port: 22, DefaultUsername: "deploy", AgentID: "agent-a", Enabled: true}
if err := db.RemoteServers().Create(ctx, server); err != nil { t.Fatal(err) }
}
page, err := db.RemoteServers().List(ctx, RemoteServerFilter{OwnerUserID: "user-a"}, "", 2)
if err != nil { t.Fatal(err) }
if len(page.Items) != 2 || !page.HasMore || page.NextCursor == "" { t.Fatalf("page = %+v", page) }
next, err := db.RemoteServers().List(ctx, RemoteServerFilter{OwnerUserID: "user-a"}, page.NextCursor, 2)
if err != nil { t.Fatal(err) }
if len(next.Items) != 1 || next.HasMore { t.Fatalf("next = %+v", next) }
other, err := db.RemoteServers().List(ctx, RemoteServerFilter{OwnerUserID: "user-b"}, "", 10)
if err != nil { t.Fatal(err) }
if len(other.Items) != 0 { t.Fatalf("other owner leaked data: %+v", other.Items) }
}
Create internal/storage/webssh_session_repository_test.go:
func TestWebSSHSessionTicketConsumedOnce(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
now := time.Now().UTC()
session := WebSSHSession{ID: "webssh-1", OwnerUserID: "user-a", RemoteServerID: "server-1", AgentID: "agent-a", OwnerNodeID: "server-node-a", TicketHash: "hash", TicketExpiresAt: now.Add(30*time.Second), Status: WebSSHSessionPending, CreatedAt: now, ExpiresAt: now.Add(8*time.Hour)}
if err := db.WebSSHSessions().Create(ctx, session); err != nil { t.Fatal(err) }
if _, err := db.WebSSHSessions().ConsumeTicket(ctx, "webssh-1", "hash", now); err != nil { t.Fatal(err) }
if _, err := db.WebSSHSessions().ConsumeTicket(ctx, "webssh-1", "hash", now); err == nil {
t.Fatal("expected second ticket use to fail")
}
}
Add migration assertions to SQLite and MySQL tests:
func TestV11ToV12WebSSHMigrationsAreAdjacent(t *testing.T) {
if SchemaVersion != 12 { t.Fatalf("SchemaVersion = %d, want 12", SchemaVersion) }
if !strings.Contains(migrations.V11ToV12SQLite, "CREATE TABLE IF NOT EXISTS credentials") { t.Fatal("SQLite migration lacks credentials") }
if !strings.Contains(migrations.V11ToV12MySQL, "CREATE TABLE IF NOT EXISTS remote_servers") { t.Fatal("MySQL migration lacks remote_servers") }
if !strings.Contains(migrations.V11ToV12MySQL, "CREATE TABLE IF NOT EXISTS webssh_sessions") { t.Fatal("MySQL migration lacks webssh_sessions") }
}
- Step 2: Run tests and verify red
Run:
go test ./internal/storage -run 'Test(Credential|RemoteServer|WebSSH|V11ToV12)' -count=1
Expected result: compilation fails because CredentialRepository, RemoteServerRepository, WebSSHSessionRepository, DB accessors, and migration constants do not exist.
- Step 3: Implement schema and repositories
- Add the three tables to
migrations/ddl.sql. - Create both
v0011_to_v0012scripts with onlyCREATE TABLE/CREATE INDEXstatements. - Bump
SchemaVersionto 12 and embed the new scripts. - Add models, interfaces, SQL implementations, and DB fields/accessors.
- Keep timestamps as RFC3339 strings in storage, converting at repository boundaries.
- Implement
ConsumeTicketas one condition update:
UPDATE webssh_sessions
SET status='active', connected_at=?
WHERE id=? AND ticket_hash=? AND status='pending' AND ticket_expires_at>?
If RowsAffected() == 0, return a stable ErrWebSSHTicketInvalid.
- Step 4: Run tests and verify green
Run:
go test ./internal/storage -count=1
go test ./internal/storage -run 'TestV11ToV12' -count=1
go vet ./internal/storage
Expected result: all tests pass and go vet reports no issues.
Task 2: Credential and Remote Server Services
Files:
- Create:
internal/server/credential_service.go - Create:
internal/server/credential_service_test.go - Create:
internal/server/remote_server_service.go - Create:
internal/server/remote_server_service_test.go - Modify:
internal/server/api.go
Interfaces:
- Consumes repositories from Task 1.
- Produces:
type CredentialService struct { /* repositories and policy dependencies */ }
func NewCredentialService(credentials storage.CredentialRepository, agents storage.AgentRepository, audits storage.AuditRepository) *CredentialService
func (s *CredentialService) Create(ctx context.Context, actor auth.Principal, input CredentialInput) (storage.Credential, error)
func (s *CredentialService) Update(ctx context.Context, actor auth.Principal, id string, input CredentialPatch) (storage.Credential, error)
func (s *CredentialService) Delete(ctx context.Context, actor auth.Principal, id string) error
func (s *CredentialService) Restore(ctx context.Context, actor auth.Principal, id string) (storage.Credential, error)
func (s *CredentialService) List(ctx context.Context, actor auth.Principal, filter CredentialListFilter, cursor string, limit int) (storage.Page[storage.Credential], error)
type CredentialListFilter = storage.CredentialFilter
// CredentialPatch contains pointer fields so PUT can distinguish an omitted
// field from a zero value. CredentialInput is the full-create/replace model.
type CredentialInput struct {
Name string
Type storage.CredentialType
PublicKey string
Enabled bool
}
type CredentialPatch struct {
Name *string
Type *storage.CredentialType
PublicKey *string
Enabled *bool
}
type RemoteServerService struct { /* repositories and policy dependencies */ }
func NewRemoteServerService(servers storage.RemoteServerRepository, credentials storage.CredentialRepository, agents storage.AgentRepository, policies storage.PolicyRepository, audits storage.AuditRepository) *RemoteServerService
func (s *RemoteServerService) Create(ctx context.Context, actor auth.Principal, input RemoteServerInput) (storage.RemoteServer, error)
func (s *RemoteServerService) Update(ctx context.Context, actor auth.Principal, id string, input RemoteServerPatch) (storage.RemoteServer, error)
func (s *RemoteServerService) Delete(ctx context.Context, actor auth.Principal, id string) error
func (s *RemoteServerService) Restore(ctx context.Context, actor auth.Principal, id string) (storage.RemoteServer, error)
func (s *RemoteServerService) List(ctx context.Context, actor auth.Principal, filter RemoteServerListFilter, cursor string, limit int) (storage.Page[storage.RemoteServer], error)
type RemoteServerListFilter = storage.RemoteServerFilter
// RemoteServerInput is the full-create/replace model; pointer fields in
// RemoteServerPatch distinguish omitted values from zero values.
type RemoteServerInput struct {
Name string
Host string
Port int
DefaultUsername string
CredentialID string
AgentID string
Enabled bool
}
type RemoteServerPatch struct {
Name *string
Host *string
Port *int
DefaultUsername *string
CredentialID *string
AgentID *string
Enabled *bool
}
Public key parsing must live in a helper so the same parser protects all create/update paths:
func ParseOpenSSHPublicKey(raw string) (normalized string, fingerprint string, err error)
Supported algorithms in this iteration are ssh-ed25519, ssh-rsa, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, and ecdsa-sha2-nistp521.
- Step 1: Write failing service tests
Cover these cases in Go table tests:
cases := []struct{
name string
key string
valid bool
}{
{name: "ed25519", key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... valid-base64", valid: true},
{name: "rsa", key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQ... valid-base64", valid: true},
{name: "wrong-algorithm", key: "ssh-dss AAAAB3NzaC1kc3MAAACBA...", valid: false},
{name: "invalid-base64", key: "ssh-ed25519 !!!!", valid: false},
{name: "comment-only", key: "hello", valid: false},
}
Also test:
- Normal users can only read/write their own credentials and remote servers.
- Administrators can access all resources.
- A remote server rejects a deleted, disabled, or other owner’s credential.
- A remote server rejects a deleted, disabled, or other owner’s Agent.
- Logical deletion prevents new session creation but preserves the record.
- Restore checks the active-name uniqueness before restoring.
-
Audit callbacks never receive public-key private material because the input model contains only public keys.
- Step 2: Run tests and verify red
go test ./internal/server -run 'Test(CredentialService|RemoteServerService|ParseOpenSSHPublicKey)' -count=1
Expected result: new service constructors and types are undefined.
- Step 3: Implement services
Implement input validation, owner filtering, authorization, logical delete/restore, audit emission, and stable errors. Keep the exact error strings in internal/server/errors.go or equivalent so tests can use errors.Is.
- Step 4: Run tests and verify green
go test ./internal/server -run 'Test(CredentialService|RemoteServerService|ParseOpenSSHPublicKey)' -count=1
go test ./internal/server -count=1
go vet ./internal/server
Task 3: Management REST API
Files:
- Create:
internal/server/credential_api.go - Create:
internal/server/credential_api_test.go - Create:
internal/server/remote_server_api.go - Create:
internal/server/remote_server_api_test.go - Modify:
internal/server/api.go - Modify:
docs/api/openapi.yaml
Interfaces:
- Consumes services from Task 2.
- Produces routes:
GET/POST /api/v1/credentials
GET/PUT/PATCH /api/v1/credentials/{id}
DELETE /api/v1/credentials/{id}
POST /api/v1/credentials/{id}/restore
GET/POST /api/v1/remote-servers
GET/PUT/PATCH /api/v1/remote-servers/{id}
DELETE /api/v1/remote-servers/{id}
POST /api/v1/remote-servers/{id}/restore
- Produces JSON view functions:
func publicCredential(v storage.Credential) credentialResponse
func publicRemoteServer(v storage.RemoteServer, credentialName, agentName string, agentOnline bool) remoteServerResponse
- Step 1: Write failing API tests
Use the existing in-memory SQLite API test setup. Cover:
- Unauthenticated request returns 401.
- Regular user receives an empty list rather than another owner’s rows.
- Regular user receives 403 for another owner’s ID.
- Create, update, patch, delete, restore, detail, and list all use the envelope.
- Cursor returns only the next page.
- Create and PUT honor
Idempotency-Key; PATCH, DELETE, and restore do not require it. - Invalid public key returns 400 without writing a row.
- Invalid port and username return 400.
-
OpenAPI contains every operation and response schema.
- Step 2: Run tests and verify red
go test ./internal/server -run 'Test(CredentialAPI|RemoteServerAPI)' -count=1
Expected result: route dispatch returns 404 and view functions are undefined.
- Step 3: Implement handlers and OpenAPI
Handlers decode requests, call services, and render views only. Query parsing and cursor limits follow the existing API patterns. PUT requires every editable field; PATCH updates only provided fields using pointer input fields.
- Step 4: Run tests and verify green
go test ./internal/server -run 'Test(CredentialAPI|RemoteServerAPI)' -count=1
go test ./internal/server -count=1
go vet ./internal/server
Task 4: WebSSH Session API and Origin/Ticket Handshake
Files:
- Create:
internal/server/webssh_session_service.go - Create:
internal/server/webssh_session_service_test.go - Create:
internal/server/webssh_api.go - Create:
internal/server/webssh_api_test.go - Create:
internal/server/webssh_middleware.go - Create:
internal/server/webssh_middleware_test.go - Modify:
internal/server/api.go
Interfaces:
- Consumes repositories and remote-server service.
- Produces:
type WebSSHSessionService struct { /* repositories, services, limits */ }
func NewWebSSHSessionService(
sessions storage.WebSSHSessionRepository,
servers storage.RemoteServerRepository,
credentials storage.CredentialRepository,
agents storage.AgentRepository,
leases storage.LeaseRepository,
audits storage.AuditRepository,
localNodeID string,
) *WebSSHSessionService
leases.ListActiveByAgent(ctx, agentID) is the online check. A non-empty
result with a non-expired lease makes the Agent eligible for session creation.
localNodeID is written to webssh_sessions.owner_node_id when the ticket is
created; an empty value is rejected so cluster routing can never be ambiguous.
type WebSSHSessionLimits struct {
TicketTTL time.Duration // default 30s
SessionTTL time.Duration // default 8h
MaxActivePerUser int // default 5
OpenTimeout time.Duration // default 10s
IdleTimeout time.Duration // default 5m
}
type CreateWebSSHSessionInput struct {
Username string
CredentialID string
}
type WebSSHSessionTicket struct {
SessionID string
Ticket string
WebSocketPath string
}
func (s *WebSSHSessionService) Create(ctx context.Context, actor auth.Principal, serverID string, input CreateWebSSHSessionInput) (WebSSHSessionTicket, error)
func (s *WebSSHSessionService) Get(ctx context.Context, actor auth.Principal, sessionID string) (storage.WebSSHSession, error)
func (s *WebSSHSessionService) Close(ctx context.Context, actor auth.Principal, sessionID, reason string) error
func (s *WebSSHSessionService) AuthenticateTicket(ctx context.Context, sessionID, ticket string, now time.Time) (storage.WebSSHSession, storage.RemoteServer, error)
func validateWebSSHOrigin(security config.SecurityConfig, r *http.Request) error
- Step 1: Write failing tests
Cover:
- Creating a session requires an enabled, non-deleted server, owner-matching Agent and credential, and a valid username.
- Pending ticket expiration rejects authentication.
- Wrong ticket hash rejects authentication.
- Second use of a valid ticket rejects authentication.
- User concurrency limit rejects creation.
- Empty local node identity rejects creation.
- Owner can close their session; another user gets 403; admin can close any session.
- Repeated close is idempotent.
- Origin allowlist rejects missing, malformed, and non-listed origins.
- API response includes only
sessionId,ticket, andwebsocketPath. -
Audit contains IDs and result class only.
- Step 2: Run tests and verify red
go test ./internal/server -run 'Test(WebSSHSession|WebSSHAPI|WebSSHOrigin)' -count=1
Expected result: service, middleware, and API types are undefined.
- Step 3: Implement service and API
Generate a 256-bit random ticket with crypto/rand, return only its base64url representation, and persist only sha256(ticket). Add routes for:
POST /api/v1/remote-servers/{id}/ssh-sessions
GET /api/v1/ssh-sessions/{id}
DELETE /api/v1/ssh-sessions/{id}
- Step 4: Run tests and verify green
go test ./internal/server -run 'Test(WebSSHSession|WebSSHAPI|WebSSHOrigin)' -count=1
go test ./internal/server -count=1
go vet ./internal/server
Task 5: WebSSH Byte-Stream Broker
Files:
- Create:
internal/server/webssh_broker.go - Create:
internal/server/webssh_broker_test.go - Create:
internal/server/webssh_runtime.go - Create:
internal/server/webssh_connection_close.go - Create:
internal/server/webssh_connection_close_test.go - Modify:
internal/relay/service.go - Modify:
internal/relay/transport.go - Modify:
internal/relay/server_node_auth_test.go - Modify:
internal/server/web.go - Modify:
internal/server/runtime.go - Modify:
internal/server/runtime_test.go - Modify:
internal/cli/root.go - Modify:
internal/config/config.go - Modify:
internal/config/config_test.go - Modify:
docs/operations/config-examples.md - Modify:
docs/operations/configuration.md
Interfaces:
- Consumes:
// xNetWebSSHUpgrader adapts the existing golang.org/x/net/websocket server
// to the small WSUpgrader contract already used by TCPBridgeHandler.
type xNetWebSSHUpgrader struct{}
func (xNetWebSSHUpgrader) Upgrade(w http.ResponseWriter, r *http.Request) (WSConn, error)
type WebSSHBrokerDeps struct {
Sessions *WebSSHSessionService
Opener relay.NodeTransport
Upgrade WSUpgrader
Security config.SecurityConfig
Limits WebSSHSessionLimits
Metrics *observability.Metrics
Audit func(context.Context, WebSSHAuditEvent)
}
func NewWebSSHBroker(deps WebSSHBrokerDeps) *WebSSHBroker
Add this optional capability beside the existing WSConn interface:
type deadlineWSConn interface {
WSConn
SetReadDeadline(time.Time) error
}
If the connection does not implement it, use the session TTL and SSH keepalive instead of forcing a transport-specific idle timeout. The x/net adapter must implement it.
For admin-initiated close, the broker also owns a process-local registry:
type WebSSHActiveSession struct {
Cancel context.CancelFunc
WS WSConn
}
func (h *WebSSHBroker) Register(sessionID string, session WebSSHActiveSession) error
func (h *WebSSHBroker) Unregister(sessionID string)
func (h *WebSSHBroker) CloseLocal(sessionID string) error
DELETE /api/v1/ssh-sessions/{id} marks the durable row closed and calls
CloseLocal when the local Server owns the session. Cluster mode routes the
request through a new, narrow authenticated Server-node control method:
type WebSSHConnectionCloseService interface {
Close(ctx context.Context, sessionID string) error
}
type WebSSHConnectionRelayClient interface {
CloseWebSSHConnection(context.Context, relay.CloseWebSSHConnectionRequest) error
Close() error
}
type ClusterWebSSHConnectionCloseService struct { /* dependencies */ }
func NewClusterWebSSHConnectionCloseService(
sessions storage.WebSSHSessionRepository,
nodes storage.NodeRepository,
local *WebSSHBroker,
localNodeID string,
dialRelayNode func(context.Context, string, int64) (WebSSHConnectionRelayClient, error),
) *ClusterWebSSHConnectionCloseService
The relay request carries session_id and requested_by_node_id, and the
receiving runtime verifies the authenticated node identity before calling
CloseLocal. If the owner node is unavailable, the service returns a stable
503 mapping and leaves the durable row’s final state to the sweeper; it does
not decrement schema_meta or mutate another node’s process registry.
- Produces:
func (h *WebSSHBroker) ServeHTTP(w http.ResponseWriter, r *http.Request)
func (h *WebSSHBroker) Handle(ctx context.Context, ws WSConn, sessionID, ticket string) error
Runtime exposes the broker under /ws/webssh/. NewWebHandlerWithManagedRoutes must route this prefix before SPA fallback.
Because TCPBridgeHandler currently requires a route resolver, this task
must add a reusable websocket.Server adapter rather than reusing the TCP
bridge’s route-specific implementation. The adapter owns Origin validation in
the x/net Handshake callback, then delegates the established *websocket.Conn
to WebSSHBroker.Handle.
- Step 1: Write failing broker tests
Use an in-memory WebSocket implementation and a fake relay opener. Cover:
- Binary WebSocket bytes reach the relay stream unchanged.
- Relay bytes reach WebSocket as binary messages.
- Non-binary WebSocket message closes the stream.
- Relay EOF closes the WebSocket.
- WebSocket close closes the relay stream.
- Context cancel closes both sides.
- Invalid/expired/reused ticket returns handshake error and never opens a relay.
- Agent offline returns 502 and marks the session closed.
- Active session close API terminates the local WebSocket broker.
- Cluster close routes a local session to
CloseLocaldirectly. - Cluster close routes a remote session through
CloseWebSSHConnection. - Cluster close maps an unavailable owner node to 503 without closing the row.
- Relay control authentication rejects a caller-node mismatch.
- Metrics count bytes, sessions, errors, and duration.
-
Panics in copy goroutines cannot terminate the Server process.
- Step 2: Run tests and verify red
go test ./internal/server -run 'TestWebSSHBroker' -count=1
Expected result: broker type is undefined.
- Step 3: Implement broker
Reuse the proven bidirectional copy and close-propagation approach from TCPBridgeHandler, but replace route resolution with authenticated session resolution. The relay request must be:
relay.StreamRequest{
AgentID: session.AgentID,
Protocol: "tcp",
TargetHost: server.Host,
TargetPort: server.Port,
StrictOpen: false,
}
Add default configuration:
server:
webssh:
enabled: true
ticket_ttl: 30s
session_ttl: 8h
idle_timeout: 5m
open_timeout: 10s
max_active_sessions_per_user: 5
max_message_bytes: 65536
- Step 4: Run tests and verify green
go test ./internal/server -run 'TestWebSSHBroker' -count=1
go test ./internal/config ./internal/server -count=1
go vet ./internal/server ./internal/config
Task 6: Session Sweeper and Observability
Files:
- Create:
internal/server/webssh_sweeper.go - Create:
internal/server/webssh_sweeper_test.go - Modify:
internal/server/webssh_runtime.go - Modify:
internal/server/runtime.go - Modify:
internal/observability/metrics.go - Modify:
internal/observability/metrics_test.go - Modify:
docs/operations/observability.md
Interfaces:
- Produces:
const DefaultWebSSHSweepInterval = time.Minute
type WebSSHSweeper struct { /* repository and interval */ }
func NewWebSSHSweeper(sessions storage.WebSSHSessionRepository, interval time.Duration) *WebSSHSweeper
func (s *WebSSHSweeper) Run(ctx context.Context) error
- Produces Prometheus collectors:
tunnelmesh_webssh_sessions_active
tunnelmesh_webssh_tickets_created_total
tunnelmesh_webssh_ticket_reuse_total
tunnelmesh_webssh_streams_errors_total
tunnelmesh_webssh_stream_duration_seconds
tunnelmesh_webssh_bytes_total
- Step 1: Write failing tests
Cover:
- Sweeper expires pending tickets after TTL.
- Sweeper closes expired active sessions.
- Sweeper runs repeatedly until context cancel.
- Cancel returns
context.Canceled. - All six metric names are registered once and expose expected labels.
-
Runtime Close stops the sweeper.
- Step 2: Run tests and verify red
go test ./internal/server ./internal/observability -run 'TestWebSSH(Sweeper|Metrics)' -count=1
- Step 3: Implement sweeper and metrics
Register metrics in the existing metrics registry and attach counters to the broker. Keep audit and logs metadata-only.
- Step 4: Run tests and verify green
go test ./internal/server ./internal/observability -run 'TestWebSSH(Sweeper|Metrics)' -count=1
go test ./internal/server ./internal/observability -count=1
go vet ./internal/server ./internal/observability
Task 7: Server-Side Public-Key Extraction and Credential Management
Files:
- Modify:
web/package.json - Modify:
web/package-lock.json - Modify:
web/src/api/client.ts - Create:
web/src/api/credentials.ts - Create:
web/src/api/remote-servers.ts - Create:
web/src/api/webssh.ts - Create:
web/src/tests/credentials-api.spec.ts - Create:
web/src/tests/remote-servers-api.spec.ts - Create:
web/src/tests/webssh-api.spec.ts - Modify:
web/src/router.ts - Modify:
web/src/layouts/AppShell.vue - Modify:
web/src/i18n/messages/zh-CN.ts - Modify:
web/src/i18n/messages/en-US.ts - Modify:
web/src/tests/routes.spec.ts - Modify:
web/src/tests/shell.spec.ts - Modify:
web/src/tests/i18n.spec.ts - Create:
web/src/views/Credentials.vue - Create:
web/src/tests/credentials-view.spec.ts - Create:
web/src/api/ssh-public-key.ts - Create:
web/src/tests/ssh-public-key-api.spec.ts - Modify:
internal/server/credential_service.go - Modify:
internal/server/credential_service_test.go - Modify:
internal/server/credential_api.go - Modify:
internal/server/credential_api_test.go - Modify:
docs/api/openapi.yaml
Interfaces:
- Produces API types:
export type CredentialType = 'ssh_public_key'
export type ResourceStatus = 'active' | 'deleted'
export type Credential = {
id: string
name: string
type: CredentialType
publicKey: string
fingerprint: string
enabled: boolean
status: ResourceStatus
deletedAt?: string | null
createdAt: string
updatedAt: string
}
- Produces extraction API client:
export async function extractSSHPublicKey(privateKey: string, passphrase?: string): Promise<{
publicKey: string
fingerprint: string
}>
The helper calls the one-time Server extraction API, then the caller immediately clears its local private-key and passphrase refs. The Server parses the private key in memory with Go golang.org/x/crypto/ssh, returns the normalized public key and fingerprint, and never persists or logs the private key.
Dependency selection rule:
- Add
@xterm/xtermand@xterm/addon-fit. - Add the selected browser-compatible SSH/SFTP package only after its Vite build and browser smoke test pass; record the exact version in
web/package-lock.json. - If the SSH/SFTP package is not browser-compatible under Vite without unsafe Node polyfills, stop before Task 9 and report the blocker.
-
Do not add
sshpk,ssh2, or Node polyfills for public-key extraction; extraction is now server-side. - Step 1: Write failing frontend tests
Cover:
- Credential API methods use correct paths, methods, bodies, and
Idempotency-Key. - The extraction API sends
privateKeyand optionalpassphraseto/api/v1/credentials/extract-ssh-public-keyand returns a no-store response. - Remote server API methods use correct paths and payloads.
- WebSSH create/get/close methods use correct paths.
- Routes expose
/remote-serversand/credentials. - Admin shell shows both menus.
- Empty, loading, error, active, and deleted states render.
- Paste-public-key form accepts a valid key.
- Private-key extraction fills the public-key field and clears the private-key textarea.
- Extraction errors are shown while private-key and passphrase fields remain cleared.
-
Both locales contain all new keys.
- Step 2: Run tests and verify red
cd web
npm test -- --run
Expected result: new modules and view imports fail.
- Step 3: Implement frontend foundation
Install the browser-compatible dependencies and configure Vite only if the chosen packages require it. Keep private-key text in component-local refs and clear it immediately after successful or failed extraction. The credentials page uses Element Plus dialogs, table pagination, and status filters.
- Step 4: Run tests and verify green
cd web
npm test -- --run
npm run build
Task 8: Remote Server Management UI
Files:
- Create:
web/src/views/RemoteServers.vue - Create:
web/src/tests/remote-servers-view.spec.ts - Modify:
web/src/i18n/messages/zh-CN.ts - Modify:
web/src/i18n/messages/en-US.ts - Modify:
web/src/tests/i18n.spec.ts
Interfaces:
- Consumes APIs from Task 7.
- Produces form and view model:
export type RemoteServer = {
id: string
name: string
host: string
port: number
defaultUsername: string
credentialId?: string | null
credentialName?: string
agentId: string
agentName?: string
enabled: boolean
status: 'enabled' | 'disabled' | 'deleted'
agentOnline: boolean
lastConnectedAt?: string | null
lastResult?: string | null
lastErrorClass?: string | null
deletedAt?: string | null
createdAt: string
updatedAt: string
}
- Step 1: Write failing tests
Cover:
- Filter by keyword, Agent, and status.
- Create/edit form validation for name, host, port, username, Agent, and enabled state.
- Credential select shows only active keys owned by the user.
- Agent select shows enabled Agents.
- Actions include edit, details, SSH, delete, and restore for deleted rows.
- SSH button opens an auth dialog and does not persist password.
-
Server-side errors are shown in the table or dialog.
- Step 2: Run tests and verify red
cd web
npm test -- --run web/src/tests/remote-servers-view.spec.ts
- Step 3: Implement view
Use the existing admin view conventions. Password input uses type="password" and is never copied into Pinia or localStorage. On SSH submit, navigate to the WebSSH terminal route with session data held in a memory-only Pinia store.
- Step 4: Run tests and verify green
cd web
npm test -- --run web/src/tests/remote-servers-view.spec.ts
npm test -- --run
npm run build
Task 9: Browser Byte Stream and SSH Terminal
Files:
- Create:
web/src/webssh/byte-stream.ts - Create:
web/src/webssh/byte-stream.spec.ts - Create:
web/src/webssh/ssh-client.ts - Create:
web/src/webssh/ssh-client.spec.ts - Create:
web/src/stores/webssh.ts - Create:
web/src/tests/webssh-store.spec.ts - Create:
web/src/views/WebSSHTerminal.vue - Create:
web/src/tests/webssh-terminal.spec.ts - Modify:
web/src/router.ts - Modify:
web/src/i18n/messages/zh-CN.ts - Modify:
web/src/i18n/messages/en-US.ts
Interfaces:
- Produces:
export interface DuplexByteStream {
read(onChunk: (chunk: Uint8Array) => void): () => void
write(chunk: Uint8Array): Promise<void>
close(): void
onClose(handler: () => void): () => void
}
export function createWebSocketByteStream(url: string): DuplexByteStream
export interface WebSSHClientOptions {
stream: DuplexByteStream
username: string
password?: string
privateKey?: string
hostKeyVerifier: (fingerprint: string) => boolean | Promise<boolean>
}
export async function connectSSH(options: WebSSHClientOptions): Promise<SSHConnection>
export interface SSHConnection {
openShell(): Promise<TerminalChannel>
openSFTP(): Promise<SFTPClient>
close(): Promise<void>
}
The internal ssh-client.ts adapter must pass a Node-compatible duplex socket to the browser SSH library. It must translate data, end, error, and close events to DuplexByteStream events without buffering unbounded data.
- Step 1: Write failing tests
Cover:
- WebSocket binary
ArrayBufferbecomesUint8Array. writesends binary data and rejects before WebSocket opens.closeis idempotent and emits close once.- Backpressure rejects or waits when
bufferedAmountexceeds 4 MiB. - SSH client rejects missing username and absent authentication material.
- SSH client closes WebSocket on SSH error.
- Terminal route leaves and window unload close the store session.
- Resize sends
colsandrows. -
Password and private key are cleared on close.
- Step 2: Run tests and verify red
cd web
npm test -- --run web/src/webssh/byte-stream.spec.ts web/src/webssh/ssh-client.spec.ts web/src/tests/webssh-terminal.spec.ts
- Step 3: Implement terminal
Use xterm with fit addon. Keep session credentials only in the memory-only Pinia store; never write them to localStorage. The route should show connection, connected, error, and closed states and expose a close button.
- Step 4: Run tests and verify green
cd web
npm test -- --run web/src/webssh/byte-stream.spec.ts web/src/webssh/ssh-client.spec.ts web/src/tests/webssh-terminal.spec.ts
npm test -- --run
npm run build
Task 10: SFTP UI
Files:
- Create:
web/src/webssh/sftp.ts - Create:
web/src/webssh/sftp.spec.ts - Create:
web/src/views/WebSFTP.vue - Create:
web/src/tests/web-sftp-view.spec.ts - Modify:
web/src/router.ts - Modify:
web/src/i18n/messages/zh-CN.ts - Modify:
web/src/i18n/messages/en-US.ts
Interfaces:
- Produces:
export interface SFTPEntry {
name: string
path: string
type: 'file' | 'directory' | 'symlink'
size: number
modifiedAt?: string
permissions?: string
}
export interface SFTPProgress {
path: string
transferred: number
total: number
}
export async function listDirectory(client: SFTPClient, path: string): Promise<SFTPEntry[]>
export async function uploadFile(client: SFTPClient, file: File, remotePath: string, onProgress?: (event: SFTPProgress) => void): Promise<void>
export async function downloadFile(client: SFTPClient, remotePath: string, size: number, onProgress?: (event: SFTPProgress) => void): Promise<Blob>
export async function deleteEntry(client: SFTPClient, entry: SFTPEntry): Promise<void>
export async function renameEntry(client: SFTPClient, from: SFTPEntry, toPath: string): Promise<void>
- Step 1: Write failing tests
Cover:
- Directory listing maps names, types, sizes, and timestamps.
- Upload reads in chunks and reports progress.
- Download reads in chunks and reports progress.
- Delete/rename require confirmation.
- Permission and not-found errors show stable UI messages.
- One-gigabyte default limit rejects larger files before transfer.
-
Closing the SSH connection stops transfers and resets progress.
- Step 2: Run tests and verify red
cd web
npm test -- --run web/src/webssh/sftp.spec.ts web/src/tests/web-sftp-view.spec.ts
- Step 3: Implement SFTP
Reuse the same authenticated SSH connection from the terminal store. If the browser SSH library does not support multiple channels on one socket, open a second one-time WebSSH session and document that behavior in the UI. All operations go through SFTP; no Server-side file API is introduced.
- Step 4: Run tests and verify green
cd web
npm test -- --run web/src/webssh/sftp.spec.ts web/src/tests/web-sftp-view.spec.ts
npm test -- --run
npm run build
Task 11: Runtime Wiring, Embedded Assets, and E2E Smoke
Files:
- Modify:
internal/server/runtime.go - Modify:
internal/server/runtime_test.go - Modify:
internal/cli/root.go - Modify:
Dockerfile - Create:
internal/server/webssh_e2e_test.go - Build and synchronize:
web/dist/* - Build and synchronize:
internal/server/web_dist/*
Interfaces:
- Produces runtime configuration wiring and a repeatable E2E smoke test:
func TestWebSSHBrowserBrokerE2E(t *testing.T)
The test uses a local TCP SSH banner server plus an in-memory Agent relay. It verifies:
- Session API returns a one-time ticket.
- WebSocket handshake consumes it.
- Browser-side bytes reach the TCP server.
- TCP bytes return through WebSocket.
- Close terminates both streams.
- Step 1: Write failing E2E test
Start a local TCP listener that expects SSH-2.0-test bytes and returns SSH-2.0-test\n. Use a fake relay opener first to prove byte flow, then add an integration test with the real Agent relay if the existing test harness supports it.
- Step 2: Run and verify red
The full byte-flow E2E passed immediately because earlier tasks had already
wired the production handler and broker. The runtime-shutdown requirement was
still untested; TestServerRuntimeCloseTerminatesWebSSHBridges failed first
with “runtime close did not terminate the Agent stream”, then passed after
WebSSHBroker.CloseAll was wired into ServerRuntime.Close.
go test ./internal/server -run TestWebSSHBrowserBrokerE2E -count=1
- Step 3: Implement wiring
Wire broker, sweeper, metrics, API, WebSocket route, runtime close, and embedded production assets. Keep /api/, /health/, and all /ws/ routes before SPA fallback.
- Step 4: Run E2E and builds
go test ./internal/server -run 'TestWebSSH' -count=1
go test ./... -count=1
cd web
npm test -- --run
npm run build
cd ..
rm -rf internal/server/web_dist
mkdir -p internal/server/web_dist
cp -a web/dist/. internal/server/web_dist/
./scripts/verify-web-embed.sh
web/dist/ and internal/server/web_dist/ are generated and ignored by Git;
do not attempt to stage them manually. The verification script proves the
Server binary will embed exactly the bundle produced by this change.
Task 12: Documentation and PR Package
Files:
- Modify:
docs/README.md - Modify:
docs/api/openapi.yaml - Modify:
docs/user-guide/server-admin.md - Modify:
docs/operations/schema-upgrades.md - Modify:
docs/operations/configuration.md - Modify:
docs/deployment/nginx.md - Create:
docs/pull-requests/2026-09-12-admin-webssh-sftp.md
Interfaces:
- Consumes final behavior from Tasks 1–11.
- Produces:
- Admin guide for remote servers, keys, SSH, and SFTP.
- Security warning that private keys and passwords never leave the browser.
- Nginx
/ws/webssh/proxy configuration. - v11 → v12 schema guide.
- Complete OpenAPI models and operations.
- PR description with test evidence.
- Step 1: Write documentation
Document:
- Menu structure.
- Server and key fields.
- Password-per-session behavior.
- Browser-only private-key extraction.
- Host-key confirmation.
- SFTP permissions and transfer limits.
- Ticket expiry and session limits.
- Nginx timeout and buffering settings.
-
Schema upgrade, rollback, and verification SQL.
- Step 2: Validate docs
rg -n "TB[D]|TO[D]O|后续补[充]" docs/superpowers/specs/2026-09-12-admin-webssh-sftp-design.md docs/superpowers/plans/2026-09-12-admin-webssh-sftp-implementation.md docs/pull-requests/2026-09-12-admin-webssh-sftp.md
Expected result: no matches.
- Step 3: Full verification
Run all mandatory checks:
go test ./... -count=1
go test -race ./...
go vet ./...
go build ./cmd/...
git diff --check
cd web
npm test -- --run
npm run build
cd ..
rm -rf internal/server/web_dist
mkdir -p internal/server/web_dist
cp -a web/dist/. internal/server/web_dist/
./scripts/verify-web-embed.sh
Rollback
- The schema change is expand-only. Roll back the application while retaining the v12 tables.
- Do not manually decrement
schema_meta.version. - If exact schema restoration is mandatory, restore the pre-upgrade database backup.
- Disable
server.webssh.enabledto turn off the WebSocket broker without removing management tables. - Browser authentication material is never persisted, so no secret cleanup is required after rollback.
Plan Completion Check
Before claiming completion, confirm every task checkbox is checked, full verification has passed, OpenAPI and docs are synchronized, embedded assets are current, and the user has explicitly authorized any commit operation.