diff --git a/Makefile b/Makefile index a2f8c896..10c06f95 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: goenv build build-cli build-server build-amikalog build-akfs clean test test-unit test-integration test-contract test-e2e test-e2e-api test-expensive test-all coverage vet fmt fmtcheck lint shellcheck ci setup +.PHONY: goenv build build-cli build-server build-amikad build-amikalog build-akfs clean test test-unit test-integration test-contract test-e2e test-e2e-api test-expensive test-all coverage vet fmt fmtcheck lint shellcheck ci setup GO_DIR = go UNIT_PACKAGES = $$(go -C $(GO_DIR) list ./... | grep -Ev '/test/(integration|contract)($$|/)') @@ -10,7 +10,7 @@ export GOTMPDIR := $(CURDIR)/.gotmp goenv: mkdir -p "$(GOCACHE)" "$(GOTMPDIR)" -build: build-cli build-server build-amikalog build-akfs +build: build-cli build-server build-amikad build-amikalog build-akfs build-cli: goenv mkdir -p dist @@ -20,6 +20,10 @@ build-server: goenv mkdir -p dist go -C $(GO_DIR) build -o $(CURDIR)/dist/amika-server ./cmd/amika-server +build-amikad: goenv + mkdir -p dist + go -C $(GO_DIR) build -o $(CURDIR)/dist/amikad ./cmd/amikad + build-amikalog: goenv mkdir -p dist go -C $(GO_DIR) build -o $(CURDIR)/dist/amikalog ./cmd/amikalog diff --git a/go/cmd/amika/plumbing.go b/go/cmd/amika/plumbing.go new file mode 100644 index 00000000..71155eae --- /dev/null +++ b/go/cmd/amika/plumbing.go @@ -0,0 +1,35 @@ +package main + +import ( + "github.com/gofixpoint/amika/go/internal/runmode" + "github.com/gofixpoint/amika/go/internal/ssh" + "github.com/spf13/cobra" +) + +var plumbingCmd = &cobra.Command{ + Use: "plumbing", + Short: "Internal machine-facing commands", + Hidden: true, +} + +var sshStdioProxyCmd = &cobra.Command{ + Use: "ssh-stdio-proxy ", + Short: "Proxy standard IO to one SSH transport", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return ssh.ProxySession( + cmd.Context(), + runmode.NewRemoteClient(), + ssh.WebSocketDialer{}, + args[0], + cmd.InOrStdin(), + cmd.OutOrStdout(), + ) + }, +} + +func init() { + rootCmd.AddCommand(plumbingCmd) + plumbingCmd.AddCommand(sshStdioProxyCmd) +} diff --git a/go/cmd/amika/sandbox/command.go b/go/cmd/amika/sandbox/command.go index d9b71f87..b92d8bf6 100644 --- a/go/cmd/amika/sandbox/command.go +++ b/go/cmd/amika/sandbox/command.go @@ -24,6 +24,7 @@ func New() *cobra.Command { sandboxCmd.AddCommand(sandboxListCmd) sandboxCmd.AddCommand(sandboxConnectCmd) sandboxCmd.AddCommand(sandboxSSHCmd) + sandboxCmd.AddCommand(sandboxSSHV2Cmd) sandboxCmd.AddCommand(sandboxCodeCmd) sandboxCmd.AddCommand(sandboxAgentSendCmd) @@ -65,6 +66,7 @@ func New() *cobra.Command { sandboxSSHCmd.Flags().BoolP("t", "t", false, "Force pseudo-terminal allocation (like ssh -t)") sandboxSSHCmd.Flags().Bool("revoke", false, "Revoke SSH access for the sandbox") sandboxSSHCmd.Flags().Bool("print", false, "Print the SSH connection string instead of connecting") + sandboxSSHV2Cmd.Flags().BoolP("t", "t", false, "Force pseudo-terminal allocation (like ssh -t)") sandboxCodeCmd.Flags().String("editor", "cursor", "Editor or agent to open: \"cursor\", \"claude\", or \"codex\"") sandboxCodeCmd.Flags().String("path", "", "Override the remote path to open (absolute, or relative to the sandbox workspace root)") sandboxAgentSendCmd.Flags().Bool("no-wait", false, "Send the instruction and return immediately without waiting for a response") diff --git a/go/cmd/amika/sandbox/sandbox_ssh_v2.go b/go/cmd/amika/sandbox/sandbox_ssh_v2.go new file mode 100644 index 00000000..d32def5e --- /dev/null +++ b/go/cmd/amika/sandbox/sandbox_ssh_v2.go @@ -0,0 +1,85 @@ +package sandboxcmd + +import ( + "fmt" + "os" + + "github.com/gofixpoint/amika/go/internal/basedir" + "github.com/gofixpoint/amika/go/internal/output" + "github.com/gofixpoint/amika/go/internal/runmode" + "github.com/gofixpoint/amika/go/internal/ssh" + "github.com/spf13/cobra" +) + +var sandboxSSHV2Cmd = &cobra.Command{ + Use: "sshv2 [flags] [-- ...]", + Short: "SSH through the beta direct WebSocket transport", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if runmode.Resolve(cmd) == runmode.Local { + return fmt.Errorf("direct WebSocket SSH requires a remote sandbox") + } + if err := runmode.RequireAuth(runmode.Remote, runmode.DefaultAuthChecker); err != nil { + return err + } + if err := output.RejectFlag(cmd); err != nil { + return err + } + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + client, err := getRemoteClient(target) + if err != nil { + return err + } + sandbox, err := client.GetSandbox(args[0]) + if err != nil { + return err + } + alias, err := ssh.BuildSessionAlias(sandbox.Name, sandbox.ID) + if err != nil { + return err + } + paths := basedir.New("") + state, err := ssh.LoadState(paths) + if err != nil { + return err + } + var sessionConfig ssh.SessionConfig + if state.SessionConfig != nil { + sessionConfig = *state.SessionConfig + } else { + identityFile, err := paths.SSHIdentityFile() + if err != nil { + return err + } + knownHostsFile, err := paths.SSHKnownHostsFile() + if err != nil { + return err + } + sessionConfig = ssh.SessionConfig{ + IdentityFile: identityFile, + KnownHostsFile: knownHostsFile, + ProxyCommand: "amika plumbing ssh-stdio-proxy %h", + } + } + identityInfo, err := os.Stat(sessionConfig.IdentityFile) + if err != nil || !identityInfo.Mode().IsRegular() || identityInfo.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("SSH identity is missing or unsafe; run \"amika secret ssh-keygen\"") + } + if err := ssh.ConfigureSession(paths, sessionConfig); err != nil { + return err + } + if _, err := ssh.PrepareSessionHost( + client, + ssh.FileHostKeyPinStore{Path: sessionConfig.KnownHostsFile}, + sandbox.ID, + alias, + ); err != nil { + return err + } + forcePTY, _ := cmd.Flags().GetBool("t") + return ssh.ExecSessionSSH(alias, forcePTY, args[1:]) + }, +} diff --git a/go/cmd/amika/secrets.go b/go/cmd/amika/secrets.go index 191dc182..b0f945ea 100644 --- a/go/cmd/amika/secrets.go +++ b/go/cmd/amika/secrets.go @@ -1011,12 +1011,16 @@ func init() { rootCmd.AddCommand(secretCmd) secretCmd.AddCommand(newSecretExtractCmd()) secretCmd.AddCommand(newSecretPushCmd()) + secretCmd.AddCommand(newSSHKeygenCmd()) addProviderCommands(secretCmd, claudeProvider, false) addProviderCommands(secretCmd, codexProvider, false) rootCmd.AddCommand(secretsAliasCmd) secretsAliasCmd.AddCommand(newSecretExtractCmd()) secretsAliasCmd.AddCommand(newSecretPushCmd()) + sshKeygenAlias := newSSHKeygenCmd() + sshKeygenAlias.Hidden = true + secretsAliasCmd.AddCommand(sshKeygenAlias) addProviderCommands(secretsAliasCmd, claudeProvider, true) addProviderCommands(secretsAliasCmd, codexProvider, true) } diff --git a/go/cmd/amika/ssh_keygen.go b/go/cmd/amika/ssh_keygen.go new file mode 100644 index 00000000..22a6a105 --- /dev/null +++ b/go/cmd/amika/ssh_keygen.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/gofixpoint/amika/go/internal/apiclient" + "github.com/gofixpoint/amika/go/internal/basedir" + "github.com/gofixpoint/amika/go/internal/output" + "github.com/gofixpoint/amika/go/internal/runmode" + "github.com/gofixpoint/amika/go/internal/ssh" + "github.com/spf13/cobra" +) + +func newSSHKeygenCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ssh-keygen", + Short: "Create or import a user-owned SSH key", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := runmode.RequireAuth(runmode.Remote, runmode.DefaultAuthChecker); err != nil { + return err + } + format, err := output.FormatFrom(cmd) + if err != nil { + return err + } + name, _ := cmd.Flags().GetString("name") + if strings.TrimSpace(name) == "" { + return fmt.Errorf("--name must not be empty") + } + paths := basedir.New("") + identityPath, err := paths.SSHIdentityFile() + if err != nil { + return err + } + importPath, _ := cmd.Flags().GetString("import") + var publicKey string + if importPath == "" { + publicKey, err = ssh.GenerateIdentity(identityPath) + } else { + identityPath, publicKey, err = ssh.ImportIdentity(importPath) + } + if err != nil { + return err + } + knownHostsPath, err := paths.SSHKnownHostsFile() + if err != nil { + return err + } + if err := ssh.ConfigureSession(paths, ssh.SessionConfig{ + IdentityFile: identityPath, + KnownHostsFile: knownHostsPath, + ProxyCommand: "amika plumbing ssh-stdio-proxy %h", + }); err != nil { + return err + } + summary, err := runmode.NewRemoteClient().CreateSSHPublicKey( + apiclient.CreateSSHPublicKeyRequest{Name: name, PublicKey: publicKey}, + ) + if err != nil { + return err + } + if format.IsJSON() { + return format.JSON(cmd.OutOrStdout(), summary) + } + fmt.Fprintf(cmd.OutOrStdout(), "SSH public key %q uploaded; private key remains at %s.\n", summary.Name, identityPath) + return nil + }, + } + cmd.Flags().String("import", "", "Import an existing .pub file instead of generating a key") + cmd.Flags().String("name", "default", "Name for the uploaded public key") + return cmd +} diff --git a/go/cmd/amikad/main.go b/go/cmd/amikad/main.go new file mode 100644 index 00000000..060428c4 --- /dev/null +++ b/go/cmd/amikad/main.go @@ -0,0 +1,27 @@ +// Package main runs the Amika sandbox daemon. +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/gofixpoint/amika/go/internal/amikad" +) + +func main() { + ctx, stop := signal.NotifyContext( + context.Background(), + os.Interrupt, + syscall.SIGTERM, + ) + defer stop() + cmd := amikad.NewCommand(amikad.NewProductionOperations()) + if err := cmd.ExecuteContext(ctx); err != nil && !errors.Is(err, context.Canceled) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go/go.mod b/go/go.mod index 9f7fb2e1..4236d609 100644 --- a/go/go.mod +++ b/go/go.mod @@ -4,14 +4,17 @@ go 1.25.3 require ( github.com/BurntSushi/toml v1.6.0 + github.com/coder/websocket v1.8.15 github.com/danielgtaylor/huma/v2 v2.37.2 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 + golang.org/x/crypto v0.53.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index 54563994..148e42dd 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danielgtaylor/huma/v2 v2.37.2 h1:Nf9vjy2sxBJFaupPlthXL/Hy2+LurfVbaKHmCMEI7xE= github.com/danielgtaylor/huma/v2 v2.37.2/go.mod h1:95S04G/lExFRYlBkKaBaZm9lVmxRmqX9f2CgoOZ11AM= @@ -28,8 +30,14 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go/internal/amikad/background_unix.go b/go/internal/amikad/background_unix.go new file mode 100644 index 00000000..85bf2504 --- /dev/null +++ b/go/internal/amikad/background_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package amikad + +import ( + "os/exec" + "syscall" +) + +func configureBackgroundProcess(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} diff --git a/go/internal/amikad/background_windows.go b/go/internal/amikad/background_windows.go new file mode 100644 index 00000000..70935c32 --- /dev/null +++ b/go/internal/amikad/background_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package amikad + +import "os/exec" + +func configureBackgroundProcess(_ *exec.Cmd) {} diff --git a/go/internal/amikad/command.go b/go/internal/amikad/command.go new file mode 100644 index 00000000..b939b372 --- /dev/null +++ b/go/internal/amikad/command.go @@ -0,0 +1,161 @@ +// Package amikad defines the sandbox daemon command surface. +package amikad + +import ( + "context" + "errors" + "io" + + "github.com/gofixpoint/amika/go/internal/buildmeta" + "github.com/spf13/cobra" +) + +// DefaultPort is the single reserved HTTP port used by amikad. +const DefaultPort = 60999 + +// ErrNotImplemented marks fail-closed daemon operations whose implementation +// has not landed yet. +var ErrNotImplemented = errors.New("amikad operation is not implemented") + +// ServeOptions selects the daemon transports and listen port. +type ServeOptions struct { + Port int + BetaNoRelay bool + Background bool +} + +// SetupSSHDOptions controls whether managed setup may replace an existing +// user-defined sshd configuration. +type SetupSSHDOptions struct { + ForceOverwrite bool +} + +// Operations is the command layer's sandbox-interior boundary. +type Operations interface { + SetupSSHD(context.Context, SetupSSHDOptions) error + ShowHostKey(context.Context, io.Writer) error + SetAuthorizedKeys(context.Context, io.Reader) error + SetConnectToken(context.Context, io.Reader) error + Serve(context.Context, ServeOptions) error +} + +// UnimplementedOperations is a fail-closed Operations implementation. It does +// not read command input, write output, mutate files, or open listeners. +type UnimplementedOperations struct{} + +// SetupSSHD returns ErrNotImplemented without changing sshd state. +func (UnimplementedOperations) SetupSSHD(context.Context, SetupSSHDOptions) error { + return ErrNotImplemented +} + +// ShowHostKey returns ErrNotImplemented without writing key material. +func (UnimplementedOperations) ShowHostKey(context.Context, io.Writer) error { + return ErrNotImplemented +} + +// SetAuthorizedKeys returns ErrNotImplemented without reading or writing keys. +func (UnimplementedOperations) SetAuthorizedKeys(context.Context, io.Reader) error { + return ErrNotImplemented +} + +// SetConnectToken returns ErrNotImplemented without reading or writing a token. +func (UnimplementedOperations) SetConnectToken(context.Context, io.Reader) error { + return ErrNotImplemented +} + +// Serve returns ErrNotImplemented without opening a listener. +func (UnimplementedOperations) Serve(context.Context, ServeOptions) error { + return ErrNotImplemented +} + +// NewCommand builds the amikad command tree around an injectable Operations +// boundary. +func NewCommand(operations Operations) *cobra.Command { + root := &cobra.Command{ + Use: "amikad", + Short: "Run the Amika sandbox daemon", + SilenceUsage: true, + SilenceErrors: true, + Version: buildmeta.New("amikad", buildmeta.AmikadVersion).String(), + } + + setup := &cobra.Command{Use: "setup", Short: "Configure sandbox services"} + setupSSHDOptions := SetupSSHDOptions{} + setupSSHD := &cobra.Command{ + Use: "sshd", + Short: "Configure loopback-only OpenSSH", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return operations.SetupSSHD(cmd.Context(), setupSSHDOptions) + }, + } + setupSSHD.Flags().BoolVar( + &setupSSHDOptions.ForceOverwrite, + "force-overwrite", + false, + "replace an existing user-defined sshd configuration", + ) + setup.AddCommand(setupSSHD) + + hostKey := &cobra.Command{Use: "host-key", Short: "Manage the SSH host key"} + hostKey.AddCommand(&cobra.Command{ + Use: "show", + Short: "Print the SSH host public key", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return operations.ShowHostKey(cmd.Context(), cmd.OutOrStdout()) + }, + }) + + authorizedKeys := &cobra.Command{ + Use: "authorized-keys", + Short: "Manage authorized SSH public keys", + } + authorizedKeys.AddCommand(&cobra.Command{ + Use: "set", + Short: "Replace authorized keys from standard input", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return operations.SetAuthorizedKeys(cmd.Context(), cmd.InOrStdin()) + }, + }) + + connectToken := &cobra.Command{ + Use: "connect-token", + Short: "Manage the no-relay connection token", + } + connectToken.AddCommand(&cobra.Command{ + Use: "set", + Short: "Replace the connection token from standard input", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return operations.SetConnectToken(cmd.Context(), cmd.InOrStdin()) + }, + }) + + serveOptions := ServeOptions{Port: DefaultPort} + serve := &cobra.Command{ + Use: "serve", + Short: "Serve the amikad HTTP API", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return operations.Serve(cmd.Context(), serveOptions) + }, + } + serve.Flags().IntVar(&serveOptions.Port, "port", DefaultPort, "HTTP listen port") + serve.Flags().BoolVar( + &serveOptions.BetaNoRelay, + "beta-no-relay", + false, + "enable the unstable no-relay WebSocket SSH transport", + ) + serve.Flags().BoolVar( + &serveOptions.Background, + "bg", + false, + "start in the background and wait until healthy", + ) + + root.AddCommand(setup, hostKey, authorizedKeys, connectToken, serve) + return root +} diff --git a/go/internal/amikad/command_test.go b/go/internal/amikad/command_test.go new file mode 100644 index 00000000..e64e05fd --- /dev/null +++ b/go/internal/amikad/command_test.go @@ -0,0 +1,92 @@ +package amikad + +import ( + "context" + "errors" + "io" + "testing" +) + +type panicReader struct{} + +func (panicReader) Read([]byte) (int, error) { + panic("fail-closed stub read secret input") +} + +type recordingOperations struct { + UnimplementedOperations + setupOptions SetupSSHDOptions + serveOptions ServeOptions +} + +func (o *recordingOperations) SetupSSHD(_ context.Context, options SetupSSHDOptions) error { + o.setupOptions = options + return nil +} + +func (o *recordingOperations) Serve(_ context.Context, options ServeOptions) error { + o.serveOptions = options + return nil +} + +func TestCommandTopology(t *testing.T) { + cmd := NewCommand(UnimplementedOperations{}) + for _, path := range [][]string{ + {"setup", "sshd"}, + {"host-key", "show"}, + {"authorized-keys", "set"}, + {"connect-token", "set"}, + {"serve"}, + } { + found, _, err := cmd.Find(path) + if err != nil { + t.Fatalf("find %v: %v", path, err) + } + if found == cmd { + t.Fatalf("path %v resolved to root command", path) + } + } +} + +func TestUnimplementedConnectTokenDoesNotReadSecret(t *testing.T) { + cmd := NewCommand(UnimplementedOperations{}) + cmd.SetArgs([]string{"connect-token", "set"}) + cmd.SetIn(panicReader{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + if !errors.Is(err, ErrNotImplemented) { + t.Fatalf("execute error = %v, want ErrNotImplemented", err) + } +} + +func TestSetupSSHDForceOverwriteFlag(t *testing.T) { + operations := &recordingOperations{} + cmd := NewCommand(operations) + cmd.SetArgs([]string{"setup", "sshd", "--force-overwrite"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !operations.setupOptions.ForceOverwrite { + t.Fatal("ForceOverwrite = false, want true") + } +} + +func TestServeBackgroundFlag(t *testing.T) { + operations := &recordingOperations{} + cmd := NewCommand(operations) + cmd.SetArgs([]string{"serve", "--beta-no-relay", "--bg", "--port", "61000"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !operations.serveOptions.Background || !operations.serveOptions.BetaNoRelay || operations.serveOptions.Port != 61000 { + t.Fatalf("serve options = %+v", operations.serveOptions) + } +} diff --git a/go/internal/amikad/norelay/handler.go b/go/internal/amikad/norelay/handler.go new file mode 100644 index 00000000..803b54c7 --- /dev/null +++ b/go/internal/amikad/norelay/handler.go @@ -0,0 +1,254 @@ +// Package norelay serves authenticated WebSocket streams to loopback sshd. +package norelay + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +// SSHSessionsPath is the no-relay WebSocket upgrade route. +const SSHSessionsPath = "/v1/ssh-sessions" + +const copyBufferBytes = 32 * 1024 + +// Stream is one bidirectional byte stream. +type Stream interface { + io.Reader + io.Writer + io.Closer +} + +// TokenVerifier authenticates a bearer token without exposing stored token +// material to the HTTP layer. +type TokenVerifier interface { + Verify(string) bool +} + +// Upgrader upgrades an authenticated HTTP request into a binary stream. +type Upgrader interface { + Upgrade(http.ResponseWriter, *http.Request) (Stream, error) +} + +// Dialer opens the loopback sshd stream after authentication and upgrade. +// The context carries the request cancellation and deadline, network must be +// "tcp", and address is the configured loopback sshd endpoint. A successful +// call returns the connected bidirectional stream; a failure returns an error +// and no usable stream. +type Dialer interface { + DialContext(ctx context.Context, network, address string) (Stream, error) +} + +// Config contains bounded bridge settings. +type Config struct { + MaxConnections int + SSHDAddress string +} + +// Dependencies are the handler's security-sensitive boundaries. +type Dependencies struct { + Verifier TokenVerifier + Upgrader Upgrader + Dialer Dialer + Logger *slog.Logger +} + +// Handler is a fail-closed placeholder for the no-relay route. +type Handler struct { + config Config + deps Dependencies + slots chan struct{} + active atomic.Int64 + streamsMu sync.Mutex + streams map[Stream]struct{} +} + +// NewHandler creates a no-relay handler without opening any listener. +func NewHandler(config Config, deps Dependencies) *Handler { + if config.MaxConnections <= 0 { + config.MaxConnections = 64 + } + if config.SSHDAddress == "" { + config.SSHDAddress = "127.0.0.1:22" + } + if deps.Logger == nil { + deps.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + return &Handler{ + config: config, + deps: deps, + slots: make(chan struct{}, config.MaxConnections), + streams: make(map[Stream]struct{}), + } +} + +// ActiveConnections returns the number of upgraded bridge sessions currently +// holding a capacity slot. +func (h *Handler) ActiveConnections() int64 { return h.active.Load() } + +// Close cancels all active bridge I/O during daemon shutdown. +func (h *Handler) Close() { + h.streamsMu.Lock() + streams := make([]Stream, 0, len(h.streams)) + for stream := range h.streams { + streams = append(streams, stream) + } + h.streamsMu.Unlock() + for _, stream := range streams { + _ = stream.Close() + } +} + +// ServeHTTP authenticates, enforces capacity, and bridges one opaque stream. +func (h *Handler) ServeHTTP(w http.ResponseWriter, request *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if request.URL.Path != SSHSessionsPath { + http.NotFound(w, request) + return + } + if request.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.deps.Verifier == nil || h.deps.Upgrader == nil || h.deps.Dialer == nil { + h.log("ssh_bridge_refused", slog.String("reason", "not_configured")) + http.Error(w, "service unavailable", http.StatusServiceUnavailable) + return + } + + token, ok := requestBearerToken(request) + if !ok || !h.deps.Verifier.Verify(token) { + h.log("ssh_bridge_refused", slog.String("reason", "unauthorized")) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + select { + case h.slots <- struct{}{}: + h.active.Add(1) + defer func() { + <-h.slots + h.active.Add(-1) + }() + default: + h.log("ssh_bridge_refused", slog.String("reason", "capacity")) + http.Error(w, "capacity exceeded", http.StatusTooManyRequests) + return + } + + websocketStream, err := h.deps.Upgrader.Upgrade(w, request) + if err != nil { + h.log("ssh_bridge_refused", slog.String("reason", "upgrade_failed")) + return + } + h.track(websocketStream) + defer h.untrack(websocketStream) + defer websocketStream.Close() + + sshdStream, err := h.deps.Dialer.DialContext( + request.Context(), + "tcp", + h.config.SSHDAddress, + ) + if err != nil { + h.log("ssh_bridge_refused", slog.String("reason", "sshd_unreachable")) + return + } + h.track(sshdStream) + defer h.untrack(sshdStream) + defer sshdStream.Close() + + sessionID := newSessionID() + startedAt := time.Now() + h.log("ssh_bridge_open", slog.String("session_id", sessionID)) + fromClient, fromSSHD, closeReason := bridge(websocketStream, sshdStream) + h.log( + "ssh_bridge_close", + slog.String("session_id", sessionID), + slog.String("reason", closeReason), + slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()), + slog.Int64("bytes_from_client", fromClient), + slog.Int64("bytes_from_sshd", fromSSHD), + ) +} + +func requestBearerToken(request *http.Request) (string, bool) { + values := request.Header.Values("Authorization") + if len(values) != 1 { + return "", false + } + scheme, token, found := strings.Cut(values[0], " ") + if !found || !strings.EqualFold(scheme, "Bearer") || token == "" || strings.ContainsAny(token, " \t\r\n") { + return "", false + } + return token, true +} + +type copyResult struct { + name string + bytes int64 + err error +} + +func bridge(websocketStream, sshdStream Stream) (fromClient, fromSSHD int64, closeReason string) { + results := make(chan copyResult, 2) + go copyStream(results, "client", sshdStream, websocketStream) + go copyStream(results, "sshd", websocketStream, sshdStream) + + first := <-results + _ = websocketStream.Close() + _ = sshdStream.Close() + second := <-results + + for _, result := range []copyResult{first, second} { + switch result.name { + case "client": + fromClient = result.bytes + case "sshd": + fromSSHD = result.bytes + } + } + if first.err != nil && !errors.Is(first.err, io.EOF) { + return fromClient, fromSSHD, first.name + "_copy_error" + } + return fromClient, fromSSHD, first.name + "_closed" +} + +func copyStream(results chan<- copyResult, name string, destination io.Writer, source io.Reader) { + buffer := make([]byte, copyBufferBytes) + written, err := io.CopyBuffer(destination, source, buffer) + results <- copyResult{name: name, bytes: written, err: err} +} + +func newSessionID() string { + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return "unavailable" + } + return "sshs_" + hex.EncodeToString(random[:]) +} + +func (h *Handler) log(message string, attributes ...any) { + h.deps.Logger.Info(message, attributes...) +} + +func (h *Handler) track(stream Stream) { + h.streamsMu.Lock() + h.streams[stream] = struct{}{} + h.streamsMu.Unlock() +} + +func (h *Handler) untrack(stream Stream) { + h.streamsMu.Lock() + delete(h.streams, stream) + h.streamsMu.Unlock() +} diff --git a/go/internal/amikad/norelay/handler_test.go b/go/internal/amikad/norelay/handler_test.go new file mode 100644 index 00000000..dc118a28 --- /dev/null +++ b/go/internal/amikad/norelay/handler_test.go @@ -0,0 +1,218 @@ +package norelay + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +type fakeVerifier struct { + want string + calls int +} + +func (v *fakeVerifier) Verify(token string) bool { + v.calls++ + return token == v.want +} + +type fakeStream struct { + read *bytes.Reader + writes bytes.Buffer +} + +func newFakeStream(input string) *fakeStream { + return &fakeStream{read: bytes.NewReader([]byte(input))} +} + +func (s *fakeStream) Read(p []byte) (int, error) { return s.read.Read(p) } +func (s *fakeStream) Write(p []byte) (int, error) { return s.writes.Write(p) } +func (s *fakeStream) Close() error { return nil } + +type fakeUpgrader struct { + stream Stream + calls int +} + +func (u *fakeUpgrader) Upgrade(http.ResponseWriter, *http.Request) (Stream, error) { + u.calls++ + return u.stream, nil +} + +type fakeDialer struct { + stream Stream + calls int + network string + address string +} + +func (d *fakeDialer) DialContext(_ context.Context, network, address string) (Stream, error) { + d.calls++ + d.network = network + d.address = address + return d.stream, nil +} + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestHandlerRejectsMissingTokenBeforeUpgradeOrDial(t *testing.T) { + verifier := &fakeVerifier{want: "secret-token"} + upgrader := &fakeUpgrader{stream: newFakeStream("")} + dialer := &fakeDialer{stream: newFakeStream("")} + handler := NewHandler(Config{MaxConnections: 64, SSHDAddress: "127.0.0.1:22"}, Dependencies{ + Verifier: verifier, + Upgrader: upgrader, + Dialer: dialer, + Logger: testLogger(), + }) + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + handler.ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized) + } + if upgrader.calls != 0 || dialer.calls != 0 { + t.Fatalf("unauthorized request reached upgrader=%d dialer=%d", upgrader.calls, dialer.calls) + } +} + +func TestHandlerBridgesAuthenticatedBytesToLoopbackSSHD(t *testing.T) { + websocket := newFakeStream("ssh-from-client") + loopback := newFakeStream("ssh-from-server") + verifier := &fakeVerifier{want: "secret-token"} + upgrader := &fakeUpgrader{stream: websocket} + dialer := &fakeDialer{stream: loopback} + handler := NewHandler(Config{MaxConnections: 64, SSHDAddress: "127.0.0.1:22"}, Dependencies{ + Verifier: verifier, + Upgrader: upgrader, + Dialer: dialer, + Logger: testLogger(), + }) + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + request.Header.Set("Authorization", "Bearer secret-token") + handler.ServeHTTP(response, request) + + if verifier.calls != 1 { + t.Fatalf("verifier calls = %d, want 1", verifier.calls) + } + if upgrader.calls != 1 || dialer.calls != 1 { + t.Fatalf("authenticated request reached upgrader=%d dialer=%d, want 1 each", upgrader.calls, dialer.calls) + } + if dialer.network != "tcp" || dialer.address != "127.0.0.1:22" { + t.Fatalf("dial = %s %s, want tcp 127.0.0.1:22", dialer.network, dialer.address) + } + if got := loopback.writes.String(); got != "ssh-from-client" { + t.Fatalf("loopback received %q, want client bytes", got) + } + if got := websocket.writes.String(); got != "ssh-from-server" { + t.Fatalf("websocket received %q, want server bytes", got) + } +} + +type blockingStream struct { + closed chan struct{} + once sync.Once +} + +func newBlockingStream() *blockingStream { return &blockingStream{closed: make(chan struct{})} } +func (s *blockingStream) Read([]byte) (int, error) { + <-s.closed + return 0, io.EOF +} +func (s *blockingStream) Write(buffer []byte) (int, error) { return len(buffer), nil } +func (s *blockingStream) Close() error { + s.once.Do(func() { close(s.closed) }) + return nil +} + +func TestHandlerEnforcesCapacityBeforeUpgrade(t *testing.T) { + websocket := newBlockingStream() + loopback := newBlockingStream() + handler := NewHandler(Config{MaxConnections: 1}, Dependencies{ + Verifier: &fakeVerifier{want: "secret-token"}, + Upgrader: &fakeUpgrader{stream: websocket}, + Dialer: &fakeDialer{stream: loopback}, + Logger: testLogger(), + }) + firstDone := make(chan struct{}) + go func() { + defer close(firstDone) + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + request.Header.Set("Authorization", "Bearer secret-token") + handler.ServeHTTP(httptest.NewRecorder(), request) + }() + deadline := time.Now().Add(time.Second) + for handler.ActiveConnections() != 1 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if handler.ActiveConnections() != 1 { + t.Fatal("first connection did not acquire capacity") + } + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + request.Header.Set("Authorization", "Bearer secret-token") + handler.ServeHTTP(response, request) + if response.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want %d", response.Code, http.StatusTooManyRequests) + } + websocket.Close() + loopback.Close() + <-firstDone +} + +func TestHandlerNeverLogsAuthorizationMaterial(t *testing.T) { + secret := "secret-authorization-material" + var logs bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&logs, nil)) + handler := NewHandler(Config{}, Dependencies{ + Verifier: &fakeVerifier{want: "different"}, + Upgrader: &fakeUpgrader{stream: newFakeStream("")}, + Dialer: &fakeDialer{stream: newFakeStream("")}, + Logger: logger, + }) + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + request.Header.Set("Authorization", "Bearer "+secret) + handler.ServeHTTP(httptest.NewRecorder(), request) + if strings.Contains(logs.String(), secret) || strings.Contains(logs.String(), "Authorization") { + t.Fatalf("logs exposed credentials: %s", logs.String()) + } +} + +func TestHandlerRejectsMalformedAuthorizationBeforeUpgrade(t *testing.T) { + for _, header := range []string{ + "secret-token", + "Bearer", + "Bearer secret-token trailing", + "Basic secret-token", + } { + upgrader := &fakeUpgrader{stream: newFakeStream("")} + dialer := &fakeDialer{stream: newFakeStream("")} + handler := NewHandler(Config{}, Dependencies{ + Verifier: &fakeVerifier{want: "secret-token"}, + Upgrader: upgrader, + Dialer: dialer, + Logger: testLogger(), + }) + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + request.Header.Set("Authorization", header) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized || upgrader.calls != 0 || dialer.calls != 0 { + t.Fatalf("header %q reached status=%d upgrade=%d dial=%d", header, response.Code, upgrader.calls, dialer.calls) + } + } +} diff --git a/go/internal/amikad/norelay/net_dialer.go b/go/internal/amikad/norelay/net_dialer.go new file mode 100644 index 00000000..3db6cf34 --- /dev/null +++ b/go/internal/amikad/norelay/net_dialer.go @@ -0,0 +1,17 @@ +package norelay + +import ( + "context" + "net" +) + +// NetDialer opens the configured loopback TCP stream with the standard +// library network dialer. +type NetDialer struct { + dialer net.Dialer +} + +// DialContext opens one cancellable network stream. +func (d *NetDialer) DialContext(ctx context.Context, network, address string) (Stream, error) { + return d.dialer.DialContext(ctx, network, address) +} diff --git a/go/internal/amikad/norelay/token.go b/go/internal/amikad/norelay/token.go new file mode 100644 index 00000000..06c06533 --- /dev/null +++ b/go/internal/amikad/norelay/token.go @@ -0,0 +1,70 @@ +package norelay + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "io/fs" +) + +// TokenBytes is the decoded size of a no-relay connect token. +const TokenBytes = 32 + +const encodedTokenBytes = 43 + +// TokenFileReader reads the current token for every upgrade so rotation takes +// effect without restarting the long-running server process. +type TokenFileReader interface { + ReadFile(string) ([]byte, error) + Lstat(string) (fs.FileInfo, error) +} + +// FileTokenVerifier compares a canonical base64url token from disk in constant +// time. It fails closed when the file is absent, linked, malformed, or not +// owner-only. +type FileTokenVerifier struct { + path string + files TokenFileReader +} + +// NewFileTokenVerifier creates a verifier for one explicit token file. +func NewFileTokenVerifier(path string, files TokenFileReader) *FileTokenVerifier { + return &FileTokenVerifier{path: path, files: files} +} + +// Verify authenticates candidate against the current token file. +func (v *FileTokenVerifier) Verify(candidate string) bool { + stored, ok := v.currentToken() + if !ok || !IsCanonicalToken(candidate) { + return false + } + storedDigest := sha256.Sum256(stored) + candidateDigest := sha256.Sum256([]byte(candidate)) + return subtle.ConstantTimeCompare(storedDigest[:], candidateDigest[:]) == 1 +} + +// Ready reports whether the current token file is safe and canonical without +// returning its contents. +func (v *FileTokenVerifier) Ready() bool { + _, ok := v.currentToken() + return ok +} + +func (v *FileTokenVerifier) currentToken() ([]byte, bool) { + info, err := v.files.Lstat(v.path) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 || info.Size() != encodedTokenBytes { + return nil, false + } + stored, err := v.files.ReadFile(v.path) + if err != nil || !IsCanonicalToken(string(stored)) { + return nil, false + } + return stored, true +} + +// IsCanonicalToken reports whether token is exactly 32 random bytes encoded +// with unpadded URL-safe base64. +func IsCanonicalToken(token string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(token) + return err == nil && len(decoded) == TokenBytes && base64.RawURLEncoding.EncodeToString(decoded) == token +} diff --git a/go/internal/amikad/norelay/token_test.go b/go/internal/amikad/norelay/token_test.go new file mode 100644 index 00000000..8c602d51 --- /dev/null +++ b/go/internal/amikad/norelay/token_test.go @@ -0,0 +1,87 @@ +package norelay + +import ( + "encoding/base64" + "io/fs" + "testing" + "time" +) + +type tokenFiles struct { + data map[string][]byte + mode fs.FileMode + reads int +} + +func (f *tokenFiles) ReadFile(path string) ([]byte, error) { + f.reads++ + data, ok := f.data[path] + if !ok { + return nil, fs.ErrNotExist + } + return append([]byte(nil), data...), nil +} + +func TestFileTokenVerifierRejectsWrongSizeBeforeReading(t *testing.T) { + path := "/var/lib/amikad/connect-token" + files := &tokenFiles{data: map[string][]byte{path: make([]byte, 1<<20)}, mode: 0o600} + verifier := NewFileTokenVerifier(path, files) + if verifier.Ready() { + t.Fatal("oversized token file was ready") + } + if files.reads != 0 { + t.Fatalf("oversized token file was read %d times", files.reads) + } +} + +func (f *tokenFiles) Lstat(path string) (fs.FileInfo, error) { + data, ok := f.data[path] + if !ok { + return nil, fs.ErrNotExist + } + return tokenFileInfo{size: int64(len(data)), mode: f.mode}, nil +} + +type tokenFileInfo struct { + size int64 + mode fs.FileMode +} + +func (tokenFileInfo) Name() string { return "connect-token" } +func (i tokenFileInfo) Size() int64 { return i.size } +func (i tokenFileInfo) Mode() fs.FileMode { return i.mode } +func (tokenFileInfo) ModTime() time.Time { return time.Time{} } +func (tokenFileInfo) IsDir() bool { return false } +func (tokenFileInfo) Sys() any { return nil } + +func TestFileTokenVerifierRejectsUnsafeTokensAndObservesRotation(t *testing.T) { + path := "/var/lib/amikad/connect-token" + first := base64.RawURLEncoding.EncodeToString(make([]byte, TokenBytes)) + secondBytes := make([]byte, TokenBytes) + secondBytes[0] = 1 + second := base64.RawURLEncoding.EncodeToString(secondBytes) + files := &tokenFiles{data: map[string][]byte{path: []byte(first)}, mode: 0o600} + verifier := NewFileTokenVerifier(path, files) + + if !verifier.Verify(first) { + t.Fatal("current token was rejected") + } + for _, candidate := range []string{"", first + "=", "not-base64", second} { + if verifier.Verify(candidate) { + t.Fatalf("unsafe candidate %q was accepted", candidate) + } + } + + files.data[path] = []byte(second) + if verifier.Verify(first) || !verifier.Verify(second) { + t.Fatal("verifier did not observe token rotation") + } + files.mode = 0o644 + if verifier.Verify(second) { + t.Fatal("world-readable token file was accepted") + } + files.mode = fs.ModeSymlink | 0o777 + if verifier.Verify(second) { + t.Fatal("symlink token file was accepted") + } +} diff --git a/go/internal/amikad/norelay/websocket.go b/go/internal/amikad/norelay/websocket.go new file mode 100644 index 00000000..e0b5eb58 --- /dev/null +++ b/go/internal/amikad/norelay/websocket.go @@ -0,0 +1,25 @@ +package norelay + +import ( + "net/http" + + "github.com/coder/websocket" + "github.com/gofixpoint/amika/go/internal/wsstream" +) + +const maxWebSocketMessageBytes = 64 * 1024 + +// WebSocketUpgrader accepts binary, uncompressed WebSocket streams with a +// bounded per-message read limit. +type WebSocketUpgrader struct{} + +// Upgrade performs the RFC 6455 handshake and returns a byte-stream adapter. +func (WebSocketUpgrader) Upgrade(w http.ResponseWriter, request *http.Request) (Stream, error) { + connection, err := websocket.Accept(w, request, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + return nil, err + } + return wsstream.New(request.Context(), connection, maxWebSocketMessageBytes), nil +} diff --git a/go/internal/amikad/norelay/websocket_test.go b/go/internal/amikad/norelay/websocket_test.go new file mode 100644 index 00000000..54796b37 --- /dev/null +++ b/go/internal/amikad/norelay/websocket_test.go @@ -0,0 +1,82 @@ +package norelay + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestWebSocketUpgraderCarriesBinaryBytesWithoutCompression(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + stream, err := (WebSocketUpgrader{}).Upgrade(w, request) + if err != nil { + return + } + defer stream.Close() + buffer := make([]byte, 32) + read, err := stream.Read(buffer) + if err != nil { + return + } + _, _ = stream.Write(buffer[:read]) + })) + defer server.Close() + + connectURL := "ws" + strings.TrimPrefix(server.URL, "http") + connection, response, err := websocket.Dial(context.Background(), connectURL, &websocket.DialOptions{ + CompressionMode: websocket.CompressionNoContextTakeover, + }) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer connection.CloseNow() + if extension := response.Header.Get("Sec-WebSocket-Extensions"); extension != "" { + t.Fatalf("compression extension negotiated: %q", extension) + } + + want := []byte("opaque-ssh-bytes") + if err := connection.Write(context.Background(), websocket.MessageBinary, want); err != nil { + t.Fatalf("write: %v", err) + } + messageType, got, err := connection.Read(context.Background()) + if err != nil { + t.Fatalf("read: %v", err) + } + if messageType != websocket.MessageBinary || string(got) != string(want) { + t.Fatalf("message = type %v body %q, want binary %q", messageType, got, want) + } +} + +func TestWebSocketUpgraderRejectsOversizedMessages(t *testing.T) { + result := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + stream, err := (WebSocketUpgrader{}).Upgrade(w, request) + if err != nil { + result <- err + return + } + defer stream.Close() + _, err = io.Copy(io.Discard, stream) + result <- err + })) + defer server.Close() + + connectURL := "ws" + strings.TrimPrefix(server.URL, "http") + connection, _, err := websocket.Dial(context.Background(), connectURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + payload := make([]byte, maxWebSocketMessageBytes+1) + if err := connection.Write(context.Background(), websocket.MessageBinary, payload); err != nil { + t.Fatalf("write oversized message: %v", err) + } + connection.CloseNow() + if err := <-result; !strings.Contains(err.Error(), "message too big") { + t.Fatalf("server read error = %v, want message too big", err) + } +} diff --git a/go/internal/amikad/operations.go b/go/internal/amikad/operations.go new file mode 100644 index 00000000..8370decb --- /dev/null +++ b/go/internal/amikad/operations.go @@ -0,0 +1,276 @@ +package amikad + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/gofixpoint/amika/go/internal/amikad/norelay" + "github.com/gofixpoint/amika/go/internal/amikad/sshd" + "github.com/gofixpoint/amika/go/internal/amikad/state" +) + +const ( + defaultManifestPath = "/var/lib/amikad/injected-paths.json" + defaultTokenPath = "/var/lib/amikad/connect-token" + defaultServeLogPath = "/var/log/amikad/no-relay.log" +) + +// ErrNoServeMode marks a serve invocation with no explicitly enabled +// transport. +var ErrNoServeMode = errors.New("no amikad serve mode enabled") + +// ErrUnsafeConnectToken marks missing, malformed, or unsafe token state. +var ErrUnsafeConnectToken = errors.New("connect token is missing or unsafe") + +// SSHDManager is the daemon operation boundary implemented by package sshd. +type SSHDManager interface { + Setup(context.Context, sshd.SetupOptions) error + ShowHostKey(context.Context, io.Writer) error + SetAuthorizedKeys(context.Context, io.Reader) error + Serve(context.Context) error +} + +// DaemonOperations implements the amikad command surface. +type DaemonOperations struct { + sshd SSHDManager + store state.SensitiveStore + verifier *norelay.FileTokenVerifier + tokenPath string + logger *slog.Logger +} + +// NewDaemonOperations creates a command implementation around explicit +// security-sensitive dependencies. +func NewDaemonOperations( + sshdManager SSHDManager, + store state.SensitiveStore, + verifier *norelay.FileTokenVerifier, + tokenPath string, + logger *slog.Logger, +) *DaemonOperations { + return &DaemonOperations{ + sshd: sshdManager, store: store, verifier: verifier, tokenPath: tokenPath, logger: logger, + } +} + +// NewProductionOperations wires amikad to the host filesystem and image +// OpenSSH binaries. +func NewProductionOperations() *DaemonOperations { + files := state.OSFiles{} + store := state.NewStore(defaultManifestPath, files) + manager := sshd.NewManager( + sshd.DefaultPaths(), + store, + files, + sshd.Ed25519KeyGenerator{}, + sshd.ExecProcessRunner{}, + ) + logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) + return NewDaemonOperations( + manager, + store, + norelay.NewFileTokenVerifier(defaultTokenPath, files), + defaultTokenPath, + logger, + ) +} + +// SetupSSHD installs the managed OpenSSH policy. +func (o *DaemonOperations) SetupSSHD(ctx context.Context, options SetupSSHDOptions) error { + return o.sshd.Setup(ctx, sshd.SetupOptions{ForceOverwrite: options.ForceOverwrite}) +} + +// ShowHostKey prints only the canonical public host key. +func (o *DaemonOperations) ShowHostKey(ctx context.Context, output io.Writer) error { + return o.sshd.ShowHostKey(ctx, output) +} + +// SetAuthorizedKeys validates and replaces the complete authorized key set. +func (o *DaemonOperations) SetAuthorizedKeys(ctx context.Context, input io.Reader) error { + return o.sshd.SetAuthorizedKeys(ctx, input) +} + +// SetConnectToken validates canonical base64url input and stores it without a +// trailing newline through the scrub-registered sensitive store. +func (o *DaemonOperations) SetConnectToken(ctx context.Context, input io.Reader) error { + contents, err := io.ReadAll(io.LimitReader(input, 129)) + if err != nil { + return err + } + if len(contents) > 128 { + return ErrUnsafeConnectToken + } + token := strings.TrimSuffix(string(contents), "\n") + token = strings.TrimSuffix(token, "\r") + if !norelay.IsCanonicalToken(token) { + return ErrUnsafeConnectToken + } + return o.store.WriteAndRegister(ctx, o.tokenPath, strings.NewReader(token), 0o600) +} + +// Serve runs the no-relay HTTP bridge and the loopback OpenSSH child until +// either fails or ctx is cancelled. +func (o *DaemonOperations) Serve(ctx context.Context, options ServeOptions) error { + if !options.BetaNoRelay { + return ErrNoServeMode + } + if options.Port < 1 || options.Port > 65535 { + return fmt.Errorf("invalid HTTP listen port %d", options.Port) + } + if !o.verifier.Ready() { + return ErrUnsafeConnectToken + } + if options.Background { + return o.serveBackground(ctx, options) + } + + handler := norelay.NewHandler( + norelay.Config{MaxConnections: 64, SSHDAddress: "127.0.0.1:22"}, + norelay.Dependencies{ + Verifier: o.verifier, + Upgrader: norelay.WebSocketUpgrader{}, + Dialer: &norelay.NetDialer{}, + Logger: o.logger, + }, + ) + mux := http.NewServeMux() + mux.Handle(norelay.SSHSessionsPath, handler) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + mux.HandleFunc("/v1/status", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + Mode string `json:"mode"` + Port int `json:"port"` + ActiveConnections int64 `json:"active_connections"` + }{Mode: "beta_no_relay", Port: options.Port, ActiveConnections: handler.ActiveConnections()}) + }) + + server := &http.Server{ + Addr: ":" + strconv.Itoa(options.Port), + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 2 * time.Minute, + MaxHeaderBytes: 16 * 1024, + } + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + type componentResult struct { + component string + err error + } + results := make(chan componentResult, 2) + go func() { results <- componentResult{component: "sshd", err: o.sshd.Serve(runCtx)} }() + go func() { results <- componentResult{component: "http", err: server.ListenAndServe()} }() + + first := <-results + cancel() + handler.Close() + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = server.Shutdown(shutdownCtx) + shutdownCancel() + select { + case <-results: + case <-time.After(5 * time.Second): + } + if ctx.Err() != nil { + return ctx.Err() + } + if first.err == nil || errors.Is(first.err, http.ErrServerClosed) { + return fmt.Errorf("%s stopped unexpectedly", first.component) + } + return fmt.Errorf("%s failed: %w", first.component, first.err) +} + +func (o *DaemonOperations) serveBackground(ctx context.Context, options ServeOptions) error { + if serveReady(ctx, options.Port) { + return nil + } + if err := os.MkdirAll("/var/log/amikad", 0o755); err != nil { + return fmt.Errorf("prepare daemon log directory: %w", err) + } + logFile, err := os.OpenFile(defaultServeLogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open daemon log: %w", err) + } + defer logFile.Close() + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("locate amikad executable: %w", err) + } + args := []string{"serve", "--port", strconv.Itoa(options.Port)} + if options.BetaNoRelay { + args = append(args, "--beta-no-relay") + } + command := exec.Command(executable, args...) + command.Stdout = logFile + command.Stderr = logFile + configureBackgroundProcess(command) + if err := command.Start(); err != nil { + return fmt.Errorf("start background amikad: %w", err) + } + if err := command.Process.Release(); err != nil { + return fmt.Errorf("release background amikad: %w", err) + } + + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return errors.New("background amikad did not become healthy") + case <-ticker.C: + if serveReady(ctx, options.Port) { + return nil + } + } + } +} + +func serveReady(ctx context.Context, port int) bool { + requestCtx, cancel := context.WithTimeout(ctx, 250*time.Millisecond) + defer cancel() + request, err := http.NewRequestWithContext( + requestCtx, + http.MethodGet, + "http://127.0.0.1:"+strconv.Itoa(port)+"/v1/status", + nil, + ) + if err != nil { + return false + } + response, err := http.DefaultClient.Do(request) + if err != nil { + return false + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return false + } + var status struct { + Mode string `json:"mode"` + Port int `json:"port"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&status); err != nil { + return false + } + return status.Mode == "beta_no_relay" && status.Port == port +} diff --git a/go/internal/amikad/operations_test.go b/go/internal/amikad/operations_test.go new file mode 100644 index 00000000..024b8bf7 --- /dev/null +++ b/go/internal/amikad/operations_test.go @@ -0,0 +1,139 @@ +package amikad + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gofixpoint/amika/go/internal/amikad/norelay" + "github.com/gofixpoint/amika/go/internal/amikad/sshd" + "github.com/gofixpoint/amika/go/internal/amikad/state" +) + +type fakeSSHDManager struct { + setupOptions sshd.SetupOptions + serveCalls int +} + +func (m *fakeSSHDManager) Setup(_ context.Context, options sshd.SetupOptions) error { + m.setupOptions = options + return nil +} +func (*fakeSSHDManager) ShowHostKey(context.Context, io.Writer) error { return nil } +func (*fakeSSHDManager) SetAuthorizedKeys(context.Context, io.Reader) error { return nil } +func (m *fakeSSHDManager) Serve(context.Context) error { + m.serveCalls++ + return nil +} + +func testOperations(t *testing.T) (*DaemonOperations, string, *fakeSSHDManager) { + t.Helper() + directory := t.TempDir() + files := state.OSFiles{} + manifestPath := filepath.Join(directory, "injected-paths.json") + tokenPath := filepath.Join(directory, "connect-token") + store := state.NewStore(manifestPath, files) + manager := &fakeSSHDManager{} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewDaemonOperations( + manager, + store, + norelay.NewFileTokenVerifier(tokenPath, files), + tokenPath, + logger, + ), tokenPath, manager +} + +func TestSetConnectTokenStoresCanonicalOwnerOnlyValue(t *testing.T) { + operations, tokenPath, _ := testOperations(t) + token := base64.RawURLEncoding.EncodeToString(make([]byte, norelay.TokenBytes)) + if err := operations.SetConnectToken(context.Background(), strings.NewReader(token+"\n")); err != nil { + t.Fatalf("SetConnectToken: %v", err) + } + contents, err := os.ReadFile(tokenPath) + if err != nil { + t.Fatalf("read token: %v", err) + } + if string(contents) != token { + t.Fatalf("stored token = %q, want canonical token", contents) + } + info, err := os.Stat(tokenPath) + if err != nil { + t.Fatalf("stat token: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("token mode = %o, want 600", info.Mode().Perm()) + } + if !operations.verifier.Verify(token) { + t.Fatal("stored token did not authenticate") + } +} + +func TestSetConnectTokenRejectsMalformedInputWithoutLeakingIt(t *testing.T) { + operations, tokenPath, _ := testOperations(t) + secret := "secret-that-must-not-appear-in-errors" + err := operations.SetConnectToken(context.Background(), strings.NewReader(secret)) + if !errors.Is(err, ErrUnsafeConnectToken) { + t.Fatalf("error = %v, want ErrUnsafeConnectToken", err) + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("error leaked token: %v", err) + } + if _, err := os.Stat(tokenPath); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("invalid token created file: %v", err) + } +} + +func TestServeFailsClosedBeforeStartingSSHD(t *testing.T) { + operations, _, manager := testOperations(t) + if err := operations.Serve(context.Background(), ServeOptions{Port: DefaultPort}); !errors.Is(err, ErrNoServeMode) { + t.Fatalf("serve without mode error = %v", err) + } + if err := operations.Serve(context.Background(), ServeOptions{Port: DefaultPort, BetaNoRelay: true}); !errors.Is(err, ErrUnsafeConnectToken) { + t.Fatalf("serve without token error = %v", err) + } + if manager.serveCalls != 0 { + t.Fatalf("unsafe serve started sshd %d times", manager.serveCalls) + } +} + +func TestSetupSSHDForwardsOverwriteAuthorization(t *testing.T) { + operations, _, manager := testOperations(t) + if err := operations.SetupSSHD(context.Background(), SetupSSHDOptions{ForceOverwrite: true}); err != nil { + t.Fatalf("SetupSSHD: %v", err) + } + if !manager.setupOptions.ForceOverwrite { + t.Fatal("force-overwrite authorization was not forwarded") + } +} + +func TestServeReadyRequiresNoRelayStatusOnExactPort(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/status" { + http.NotFound(w, request) + return + } + _, _ = io.WriteString(w, fmt.Sprintf(`{"mode":"beta_no_relay","port":%d}`, port)) + })} + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { _ = server.Close() }) + + if !serveReady(context.Background(), port) { + t.Fatal("no-relay status was not considered ready") + } +} diff --git a/go/internal/amikad/sshd/manager.go b/go/internal/amikad/sshd/manager.go new file mode 100644 index 00000000..0998248e --- /dev/null +++ b/go/internal/amikad/sshd/manager.go @@ -0,0 +1,414 @@ +// Package sshd owns loopback-only OpenSSH configuration and supervision. +package sshd + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + + "github.com/gofixpoint/amika/go/internal/amikad/state" + "golang.org/x/crypto/ssh" +) + +// ErrExistingConfiguration indicates setup would replace user-defined SSH +// state without explicit authorization. +var ErrExistingConfiguration = errors.New("existing sshd configuration requires --force-overwrite") + +// ErrInvalidPublicKey indicates malformed or option-bearing authorized-key +// input. +var ErrInvalidPublicKey = errors.New("invalid SSH public key") + +// Paths contains every filesystem location owned by the managed sshd. +type Paths struct { + Config string + HostPrivateKey string + HostPublicKey string + AuthorizedKeys string + PID string + RuntimeDirectory string + AuthorizedKeysUID int + AuthorizedKeysGID int +} + +// DefaultPaths returns the production paths owned by amikad. +func DefaultPaths() Paths { + uid, gid := -1, -1 + if account, err := user.Lookup("amika"); err == nil { + uid, _ = strconv.Atoi(account.Uid) + gid, _ = strconv.Atoi(account.Gid) + } + return Paths{ + Config: "/var/lib/amikad/sshd_config", + HostPrivateKey: "/var/lib/amikad/ssh_host_ed25519_key", + HostPublicKey: "/var/lib/amikad/ssh_host_ed25519_key.pub", + AuthorizedKeys: "/home/amika/.ssh/authorized_keys", + PID: "/var/lib/amikad/sshd.pid", + RuntimeDirectory: "/run/sshd", + AuthorizedKeysUID: uid, + AuthorizedKeysGID: gid, + } +} + +// SetupOptions controls replacement of existing non-managed state. +type SetupOptions struct { + ForceOverwrite bool +} + +// GeneratedHostKey holds one in-memory Ed25519 OpenSSH host keypair. +type GeneratedHostKey struct { + Private []byte + Public []byte +} + +// KeyGenerator creates an Ed25519 OpenSSH host keypair without staging it on +// disk. Only the final scrub-registered paths ever contain private material. +type KeyGenerator interface { + Generate(context.Context) (GeneratedHostKey, error) +} + +// ProcessRunner runs the foreground OpenSSH daemon until ctx is cancelled or +// the process exits. +type ProcessRunner interface { + Run(ctx context.Context, name string, args ...string) error +} + +// AtomicFiles supplies the filesystem operations needed by Manager. +type AtomicFiles interface { + ReadFile(string) ([]byte, error) + Lstat(string) (fs.FileInfo, error) + WriteFileAtomic(string, []byte, fs.FileMode) error +} + +// Manager configures and runs one loopback-only OpenSSH daemon. +type Manager struct { + paths Paths + store state.SensitiveStore + files AtomicFiles + keygen KeyGenerator + processes ProcessRunner +} + +// NewManager creates a manager with explicit, testable boundaries. +func NewManager( + paths Paths, + store state.SensitiveStore, + files AtomicFiles, + keygen KeyGenerator, + processes ProcessRunner, +) *Manager { + return &Manager{paths: paths, store: store, files: files, keygen: keygen, processes: processes} +} + +// Setup installs the managed policy and creates a host key only when absent. +func (m *Manager) Setup(ctx context.Context, options SetupOptions) error { + if err := validatePaths(m.paths); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(m.paths.Config), 0o700); err != nil { + return err + } + if err := m.prepareAuthorizedKeysDirectory(); err != nil { + return err + } + if err := os.MkdirAll(m.paths.RuntimeDirectory, 0o755); err != nil { + return err + } + if err := os.Chmod(m.paths.RuntimeDirectory, 0o755); err != nil { + return err + } + + desiredConfig := []byte(RenderConfig(m.paths)) + existingConfig, err := m.files.ReadFile(m.paths.Config) + switch { + case err == nil && bytes.Equal(existingConfig, desiredConfig): + case err == nil && !options.ForceOverwrite: + return ErrExistingConfiguration + case err != nil && !errors.Is(err, fs.ErrNotExist): + return err + default: + if err := m.files.WriteFileAtomic(m.paths.Config, desiredConfig, 0o600); err != nil { + return err + } + } + + privateExists, err := pathExists(m.files, m.paths.HostPrivateKey) + if err != nil { + return err + } + publicExists, err := pathExists(m.files, m.paths.HostPublicKey) + if err != nil { + return err + } + if privateExists && publicExists && validateHostKeyPair(m.files, m.paths) { + return nil + } + if (privateExists || publicExists) && !options.ForceOverwrite { + return ErrExistingConfiguration + } + for _, path := range []string{m.paths.HostPrivateKey, m.paths.HostPublicKey} { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + return m.generateHostKey(ctx) +} + +// ShowHostKey writes the canonical Ed25519 public host key and no private +// material. +func (m *Manager) ShowHostKey(ctx context.Context, output io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + contents, err := m.files.ReadFile(m.paths.HostPublicKey) + if err != nil { + return err + } + key, err := canonicalPublicKey(string(contents), map[string]bool{"ssh-ed25519": true}) + if err != nil { + return err + } + _, err = io.WriteString(output, key+"\n") + return err +} + +// SetAuthorizedKeys validates and atomically replaces the complete authorized +// key set through the scrub-registered sensitive store. +func (m *Manager) SetAuthorizedKeys(ctx context.Context, input io.Reader) error { + if err := m.prepareAuthorizedKeysDirectory(); err != nil { + return err + } + contents, err := io.ReadAll(io.LimitReader(input, (1<<20)+1)) + if err != nil { + return err + } + if len(contents) > 1<<20 { + return state.ErrSensitiveFileTooLarge + } + + allowedTypes := map[string]bool{ + "ecdsa-sha2-nistp256": true, + "sk-ecdsa-sha2-nistp256@openssh.com": true, + "sk-ssh-ed25519@openssh.com": true, + "ssh-ed25519": true, + "ssh-rsa": true, + } + keys := make([]string, 0) + seen := make(map[string]struct{}) + for _, line := range strings.Split(string(contents), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + key, err := canonicalPublicKey(line, allowedTypes) + if err != nil { + return err + } + if _, duplicate := seen[key]; !duplicate { + seen[key] = struct{}{} + keys = append(keys, key) + } + } + if len(keys) == 0 { + return ErrInvalidPublicKey + } + return m.store.WriteAndRegisterOwned( + ctx, + m.paths.AuthorizedKeys, + strings.NewReader(strings.Join(keys, "\n")+"\n"), + 0o600, + state.Ownership{ + UID: m.paths.AuthorizedKeysUID, + GID: m.paths.AuthorizedKeysGID, + }, + ) +} + +func (m *Manager) prepareAuthorizedKeysDirectory() error { + directory := filepath.Dir(m.paths.AuthorizedKeys) + info, err := m.files.Lstat(directory) + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("authorized-keys directory missing: %w", state.ErrInvalidPath) + } + if err != nil { + return err + } + if info.Mode()&fs.ModeSymlink != 0 { + return state.ErrSymlinkPath + } + if !info.IsDir() || info.Mode().Perm() != 0o700 { + return fmt.Errorf("unsafe authorized-keys directory: %w", state.ErrInvalidPath) + } + // Lchown cannot follow a final-component symlink if the sandbox user + // races this check. A replacement real directory can only be assigned to + // that same unprivileged user and is never chmodded by root here. + return os.Lchown( + directory, + m.paths.AuthorizedKeysUID, + m.paths.AuthorizedKeysGID, + ) +} + +// Serve runs sshd in the foreground with only the managed configuration. +func (m *Manager) Serve(ctx context.Context) error { + return m.processes.Run(ctx, "sshd", "-D", "-e", "-f", m.paths.Config) +} + +func (m *Manager) generateHostKey(ctx context.Context) error { + generated, err := m.keygen.Generate(ctx) + if err != nil { + return err + } + if _, err := canonicalPublicKey(string(generated.Public), map[string]bool{"ssh-ed25519": true}); err != nil { + return err + } + if err := m.store.WriteAndRegister(ctx, m.paths.HostPrivateKey, bytes.NewReader(generated.Private), 0o600); err != nil { + return err + } + return m.store.WriteAndRegister(ctx, m.paths.HostPublicKey, bytes.NewReader(generated.Public), 0o644) +} + +func canonicalPublicKey(line string, allowedTypes map[string]bool) (string, error) { + key, _, options, rest, err := ssh.ParseAuthorizedKey([]byte(line)) + if err != nil || len(options) != 0 || strings.TrimSpace(string(rest)) != "" || !allowedTypes[key.Type()] { + return "", ErrInvalidPublicKey + } + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))), nil +} + +func pathExists(files AtomicFiles, path string) (bool, error) { + _, err := files.Lstat(path) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func validateHostKeyPair(files AtomicFiles, paths Paths) bool { + privateInfo, err := files.Lstat(paths.HostPrivateKey) + if err != nil || !privateInfo.Mode().IsRegular() || privateInfo.Mode().Perm() != 0o600 { + return false + } + publicInfo, err := files.Lstat(paths.HostPublicKey) + if err != nil || !publicInfo.Mode().IsRegular() || publicInfo.Mode().Perm()&0o022 != 0 { + return false + } + privateBytes, err := files.ReadFile(paths.HostPrivateKey) + if err != nil { + return false + } + signer, err := ssh.ParsePrivateKey(privateBytes) + if err != nil || signer.PublicKey().Type() != ssh.KeyAlgoED25519 { + return false + } + publicBytes, err := files.ReadFile(paths.HostPublicKey) + if err != nil { + return false + } + publicKey, _, options, rest, err := ssh.ParseAuthorizedKey(publicBytes) + if err != nil || len(options) != 0 || strings.TrimSpace(string(rest)) != "" || publicKey.Type() != ssh.KeyAlgoED25519 { + return false + } + return bytes.Equal(signer.PublicKey().Marshal(), publicKey.Marshal()) +} + +func validatePaths(paths Paths) error { + for _, path := range []string{ + paths.Config, + paths.HostPrivateKey, + paths.HostPublicKey, + paths.AuthorizedKeys, + paths.PID, + paths.RuntimeDirectory, + } { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("invalid sshd path: %w", state.ErrInvalidPath) + } + } + if paths.AuthorizedKeysUID < 0 || paths.AuthorizedKeysGID < 0 { + return fmt.Errorf("invalid authorized-keys owner: %w", state.ErrInvalidPath) + } + return nil +} + +// RenderConfig returns the complete loopback-only sshd policy. +func RenderConfig(paths Paths) string { + return fmt.Sprintf(`Port 22 +ListenAddress 127.0.0.1 +Protocol 2 +HostKey %s +PidFile %s +AuthorizedKeysFile %s +AllowUsers amika +AuthenticationMethods publickey +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +PermitRootLogin no +PermitEmptyPasswords no +AllowAgentForwarding no +AllowTcpForwarding local +GatewayPorts no +X11Forwarding no +PermitTunnel no +PermitUserEnvironment no +UsePAM no +Subsystem sftp internal-sftp +`, paths.HostPrivateKey, paths.PID, paths.AuthorizedKeys) +} + +// Ed25519KeyGenerator creates a passwordless OpenSSH keypair in memory. +type Ed25519KeyGenerator struct{} + +// Generate creates a passwordless Ed25519 keypair. +func (Ed25519KeyGenerator) Generate(ctx context.Context) (GeneratedHostKey, error) { + if err := ctx.Err(); err != nil { + return GeneratedHostKey{}, err + } + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return GeneratedHostKey{}, err + } + privateBlock, err := ssh.MarshalPrivateKey(private, "amikad SSH host key") + if err != nil { + return GeneratedHostKey{}, err + } + publicKey, err := ssh.NewPublicKey(public) + if err != nil { + return GeneratedHostKey{}, err + } + return GeneratedHostKey{ + Private: pem.EncodeToMemory(privateBlock), + Public: ssh.MarshalAuthorizedKey(publicKey), + }, nil +} + +// ExecProcessRunner runs foreground processes with daemon output on stderr. +type ExecProcessRunner struct{} + +// Run executes one process without a shell or environment interpolation. +func (ExecProcessRunner) Run(ctx context.Context, name string, args ...string) error { + resolved, err := exec.LookPath(name) + if err != nil { + return err + } + command := exec.CommandContext(ctx, resolved, args...) + command.Stdout = os.Stderr + command.Stderr = os.Stderr + return command.Run() +} diff --git a/go/internal/amikad/sshd/manager_test.go b/go/internal/amikad/sshd/manager_test.go new file mode 100644 index 00000000..e3239516 --- /dev/null +++ b/go/internal/amikad/sshd/manager_test.go @@ -0,0 +1,262 @@ +package sshd + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "encoding/pem" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gofixpoint/amika/go/internal/amikad/state" + "golang.org/x/crypto/ssh" +) + +type fakeKeyGenerator struct{ calls int } + +func (g *fakeKeyGenerator) Generate(_ context.Context) (GeneratedHostKey, error) { + g.calls++ + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return GeneratedHostKey{}, err + } + privateBlock, err := ssh.MarshalPrivateKey(private, "amikad test host key") + if err != nil { + return GeneratedHostKey{}, err + } + publicKey, err := ssh.NewPublicKey(public) + if err != nil { + return GeneratedHostKey{}, err + } + return GeneratedHostKey{ + Private: pem.EncodeToMemory(privateBlock), + Public: ssh.MarshalAuthorizedKey(publicKey), + }, nil +} + +type fakeProcessRunner struct { + name string + args []string +} + +func (r *fakeProcessRunner) Run(_ context.Context, name string, args ...string) error { + r.name = name + r.args = append([]string(nil), args...) + return nil +} + +func testManager(t *testing.T) (*Manager, Paths, *fakeKeyGenerator, *fakeProcessRunner) { + t.Helper() + directory := t.TempDir() + paths := Paths{ + Config: filepath.Join(directory, "state", "sshd_config"), + HostPrivateKey: filepath.Join(directory, "state", "ssh_host_ed25519_key"), + HostPublicKey: filepath.Join(directory, "state", "ssh_host_ed25519_key.pub"), + AuthorizedKeys: filepath.Join(directory, "home", ".ssh", "authorized_keys"), + PID: filepath.Join(directory, "state", "sshd.pid"), + RuntimeDirectory: filepath.Join(directory, "run", "sshd"), + AuthorizedKeysUID: os.Getuid(), + AuthorizedKeysGID: os.Getgid(), + } + if err := os.MkdirAll(filepath.Dir(paths.AuthorizedKeys), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Dir(paths.AuthorizedKeys), 0o700); err != nil { + t.Fatal(err) + } + files := state.OSFiles{} + store := state.NewStore(filepath.Join(directory, "injected-paths.json"), files) + keygen := &fakeKeyGenerator{} + processes := &fakeProcessRunner{} + return NewManager(paths, store, files, keygen, processes), paths, keygen, processes +} + +func TestSetupRejectsSymlinkedAuthorizedKeysDirectoryWithoutChangingTarget(t *testing.T) { + manager, paths, _, _ := testManager(t) + directory := filepath.Dir(paths.AuthorizedKeys) + if err := os.Remove(directory); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "target") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, directory); err != nil { + t.Fatal(err) + } + + if err := manager.Setup(context.Background(), SetupOptions{}); !errors.Is(err, state.ErrSymlinkPath) { + t.Fatalf("Setup error = %v, want ErrSymlinkPath", err) + } + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("symlink target mode = %o, want unchanged 755", info.Mode().Perm()) + } +} + +func TestSetupCreatesLoopbackPolicyAndIsIdempotent(t *testing.T) { + manager, paths, keygen, _ := testManager(t) + if err := manager.Setup(context.Background(), SetupOptions{}); err != nil { + t.Fatalf("Setup: %v", err) + } + if err := manager.Setup(context.Background(), SetupOptions{}); err != nil { + t.Fatalf("idempotent Setup: %v", err) + } + if keygen.calls != 1 { + t.Fatalf("keygen calls = %d, want 1", keygen.calls) + } + config, err := os.ReadFile(paths.Config) + if err != nil { + t.Fatalf("read config: %v", err) + } + for _, directive := range []string{ + "ListenAddress 127.0.0.1", + "AuthenticationMethods publickey", + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + "PermitRootLogin no", + "AllowAgentForwarding no", + "AllowTcpForwarding local", + "GatewayPorts no", + "X11Forwarding no", + "PermitTunnel no", + } { + if !strings.Contains(string(config), directive+"\n") { + t.Fatalf("config missing %q:\n%s", directive, config) + } + } + privateInfo, err := os.Stat(paths.HostPrivateKey) + if err != nil { + t.Fatalf("stat private key: %v", err) + } + if privateInfo.Mode().Perm() != 0o600 { + t.Fatalf("private key mode = %o, want 600", privateInfo.Mode().Perm()) + } + runtimeInfo, err := os.Stat(paths.RuntimeDirectory) + if err != nil { + t.Fatalf("stat runtime directory: %v", err) + } + if runtimeInfo.Mode().Perm() != 0o755 { + t.Fatalf("runtime directory mode = %o, want 755", runtimeInfo.Mode().Perm()) + } + manifestPath := filepath.Join(filepath.Dir(filepath.Dir(paths.Config)), "injected-paths.json") + manifestBytes, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + var manifest []string + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + t.Fatal(err) + } + for _, path := range manifest { + if strings.Contains(path, ".amikad-keygen-") { + t.Fatalf("temporary host key escaped into scrub manifest: %q", path) + } + } +} + +func TestSetupRefusesExistingUserConfigurationUnlessForced(t *testing.T) { + manager, paths, _, _ := testManager(t) + if err := os.MkdirAll(filepath.Dir(paths.Config), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.Config, []byte("user-defined\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := manager.Setup(context.Background(), SetupOptions{}); !errors.Is(err, ErrExistingConfiguration) { + t.Fatalf("Setup error = %v, want ErrExistingConfiguration", err) + } + if err := manager.Setup(context.Background(), SetupOptions{ForceOverwrite: true}); err != nil { + t.Fatalf("forced Setup: %v", err) + } + config, _ := os.ReadFile(paths.Config) + if string(config) != RenderConfig(paths) { + t.Fatalf("forced config = %q, want managed policy", config) + } +} + +func TestSetupRefusesInvalidExistingHostKeyUnlessForced(t *testing.T) { + manager, paths, keygen, _ := testManager(t) + if err := os.MkdirAll(filepath.Dir(paths.HostPrivateKey), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.Config, []byte(RenderConfig(paths)), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.HostPrivateKey, []byte("invalid"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.HostPublicKey, []byte("invalid"), 0o644); err != nil { + t.Fatal(err) + } + if err := manager.Setup(context.Background(), SetupOptions{}); !errors.Is(err, ErrExistingConfiguration) { + t.Fatalf("Setup error = %v, want ErrExistingConfiguration", err) + } + if err := manager.Setup(context.Background(), SetupOptions{ForceOverwrite: true}); err != nil { + t.Fatalf("forced Setup: %v", err) + } + if keygen.calls != 1 { + t.Fatalf("keygen calls = %d, want 1", keygen.calls) + } +} + +func TestAuthorizedKeysRejectsOptionsAndWritesCanonicalKeys(t *testing.T) { + manager, paths, _, _ := testManager(t) + key := newTestAuthorizedKey(t) + optionBearing := "command=\"touch /tmp/pwned\" " + key + if err := manager.SetAuthorizedKeys(context.Background(), strings.NewReader(optionBearing)); !errors.Is(err, ErrInvalidPublicKey) { + t.Fatalf("option-bearing key error = %v, want ErrInvalidPublicKey", err) + } + input := key + " comment\n" + key + " duplicate\n" + if err := manager.SetAuthorizedKeys(context.Background(), strings.NewReader(input)); err != nil { + t.Fatalf("SetAuthorizedKeys: %v", err) + } + got, err := os.ReadFile(paths.AuthorizedKeys) + if err != nil { + t.Fatalf("read authorized keys: %v", err) + } + want := key + "\n" + if string(got) != want { + t.Fatalf("authorized keys = %q, want %q", got, want) + } +} + +func newTestAuthorizedKey(t *testing.T) string { + t.Helper() + public, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + key, err := ssh.NewPublicKey(public) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))) +} + +func TestShowHostKeyAndServeUseOnlyPublicManagedState(t *testing.T) { + manager, _, _, processes := testManager(t) + if err := manager.Setup(context.Background(), SetupOptions{}); err != nil { + t.Fatalf("Setup: %v", err) + } + var output strings.Builder + if err := manager.ShowHostKey(context.Background(), &output); err != nil { + t.Fatalf("ShowHostKey: %v", err) + } + if !strings.HasPrefix(output.String(), "ssh-ed25519 ") || strings.Contains(output.String(), "PRIVATE") { + t.Fatalf("unsafe host-key output %q", output.String()) + } + if err := manager.Serve(context.Background()); err != nil { + t.Fatalf("Serve: %v", err) + } + if processes.name != "sshd" || strings.Join(processes.args, " ") != "-D -e -f "+manager.paths.Config { + t.Fatalf("process = %q %#v", processes.name, processes.args) + } +} diff --git a/go/internal/amikad/state/contracts.go b/go/internal/amikad/state/contracts.go new file mode 100644 index 00000000..a075fb51 --- /dev/null +++ b/go/internal/amikad/state/contracts.go @@ -0,0 +1,215 @@ +// Package state owns amikad's sensitive files and scrub manifest. +package state + +import ( + "context" + "encoding/json" + "errors" + "io" + "io/fs" + "path/filepath" + "slices" + "sync" +) + +// ErrInvalidPath marks a sensitive target that is not an absolute clean path. +var ErrInvalidPath = errors.New("invalid sensitive file path") + +// ErrSymlinkPath marks a sensitive target that resolves through a symlink. +var ErrSymlinkPath = errors.New("sensitive file path contains a symlink") + +// ErrInvalidManifest marks a scrub manifest that cannot be safely extended. +var ErrInvalidManifest = errors.New("invalid injected-paths manifest") + +// ErrSensitiveFileTooLarge marks input that exceeds the bounded in-memory +// secret size accepted by the atomic writer. +var ErrSensitiveFileTooLarge = errors.New("sensitive file exceeds size limit") + +const maxSensitiveFileBytes = 1 << 20 + +// SensitiveStore first registers an absolute path by atomically replacing the +// scrub manifest, then atomically replaces the sensitive file. A stale manifest +// entry is safe; an unregistered sensitive file is not. +type SensitiveStore interface { + // WriteAndRegister uses ctx for cancellation, registers absolutePath in the + // scrub manifest, reads the replacement contents from contents, and installs + // the sensitive file with mode after the registration is durable. + WriteAndRegister(ctx context.Context, absolutePath string, contents io.Reader, mode fs.FileMode) error + // WriteAndRegisterOwned performs the same ordered replacement while assigning + // ownership through the temporary file descriptor before the final rename. + WriteAndRegisterOwned( + ctx context.Context, + absolutePath string, + contents io.Reader, + mode fs.FileMode, + ownership Ownership, + ) error +} + +// Ownership is the numeric owner applied to a sensitive file before install. +type Ownership struct { + UID int + GID int +} + +// FileSystem is the minimum filesystem boundary needed for ordered, atomic +// manifest and sensitive-file replacement. +type FileSystem interface { + ReadFile(string) ([]byte, error) + Lstat(string) (fs.FileInfo, error) + WriteFileAtomic(string, []byte, fs.FileMode) error + WriteFileAtomicOwned(string, []byte, fs.FileMode, Ownership) error + WithLock(context.Context, string, func() error) error +} + +// Store is the manifest-backed sensitive writer. Its implementation remains +// fail-closed until atomic file and manifest replacement land. +type Store struct { + manifestPath string + files FileSystem + mu sync.Mutex +} + +// NewStore creates a sensitive writer for an explicit manifest path. +func NewStore(manifestPath string, files FileSystem) *Store { + return &Store{manifestPath: manifestPath, files: files} +} + +// WriteAndRegister registers the target before atomically replacing it. +func (s *Store) WriteAndRegister( + ctx context.Context, + absolutePath string, + contents io.Reader, + mode fs.FileMode, +) error { + return s.writeAndRegister(ctx, absolutePath, contents, mode, nil) +} + +// WriteAndRegisterOwned registers and replaces a sensitive file while setting +// ownership on the still-private temporary file descriptor. +func (s *Store) WriteAndRegisterOwned( + ctx context.Context, + absolutePath string, + contents io.Reader, + mode fs.FileMode, + ownership Ownership, +) error { + if ownership.UID < -1 || ownership.GID < -1 { + return ErrInvalidPath + } + return s.writeAndRegister(ctx, absolutePath, contents, mode, &ownership) +} + +func (s *Store) writeAndRegister( + ctx context.Context, + absolutePath string, + contents io.Reader, + mode fs.FileMode, + ownership *Ownership, +) error { + if err := ctx.Err(); err != nil { + return err + } + if !isCleanAbsolutePath(absolutePath) || absolutePath == s.manifestPath { + return ErrInvalidPath + } + if mode != mode.Perm() { + return ErrInvalidPath + } + + data, err := io.ReadAll(io.LimitReader(contents, maxSensitiveFileBytes+1)) + if err != nil { + return err + } + if len(data) > maxSensitiveFileBytes { + return ErrSensitiveFileTooLarge + } + + s.mu.Lock() + defer s.mu.Unlock() + + if err := ctx.Err(); err != nil { + return err + } + if err := rejectSymlinkComponents(s.files, s.manifestPath); err != nil { + return err + } + if err := rejectSymlinkComponents(s.files, absolutePath); err != nil { + return err + } + + return s.files.WithLock(ctx, s.manifestPath+".lock", func() error { + paths, err := readManifest(s.files, s.manifestPath) + if err != nil { + return err + } + if !slices.Contains(paths, absolutePath) { + paths = append(paths, absolutePath) + } + manifest, err := json.Marshal(paths) + if err != nil { + return errors.Join(ErrInvalidManifest, err) + } + manifest = append(manifest, '\n') + + if err := s.files.WriteFileAtomic(s.manifestPath, manifest, 0o600); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if ownership != nil { + return s.files.WriteFileAtomicOwned(absolutePath, data, mode, *ownership) + } + return s.files.WriteFileAtomic(absolutePath, data, mode) + }) +} + +func readManifest(files FileSystem, manifestPath string) ([]string, error) { + data, err := files.ReadFile(manifestPath) + if errors.Is(err, fs.ErrNotExist) { + return []string{}, nil + } + if err != nil { + return nil, err + } + + var paths []string + if err := json.Unmarshal(data, &paths); err != nil || paths == nil { + return nil, errors.Join(ErrInvalidManifest, err) + } + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + if !isCleanAbsolutePath(path) || path == manifestPath { + return nil, ErrInvalidManifest + } + if _, duplicate := seen[path]; duplicate { + return nil, ErrInvalidManifest + } + seen[path] = struct{}{} + } + return paths, nil +} + +func isCleanAbsolutePath(path string) bool { + return filepath.IsAbs(path) && filepath.Clean(path) == path +} + +func rejectSymlinkComponents(files FileSystem, path string) error { + if !isCleanAbsolutePath(path) { + return ErrInvalidPath + } + for current := path; ; current = filepath.Dir(current) { + info, err := files.Lstat(current) + if err == nil && info.Mode()&fs.ModeSymlink != 0 { + return ErrSymlinkPath + } + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + parent := filepath.Dir(current) + if parent == current { + return nil + } + } +} diff --git a/go/internal/amikad/state/contracts_test.go b/go/internal/amikad/state/contracts_test.go new file mode 100644 index 00000000..2c07d0c3 --- /dev/null +++ b/go/internal/amikad/state/contracts_test.go @@ -0,0 +1,355 @@ +package state + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +type writeCall struct { + path string + data []byte + mode fs.FileMode + ownership *Ownership +} + +type memoryFiles struct { + mu sync.Mutex + contents map[string][]byte + modes map[string]fs.FileMode + symlinks map[string]bool + failWrites map[string]error + writes []writeCall +} + +func newMemoryFiles() *memoryFiles { + return &memoryFiles{ + contents: make(map[string][]byte), + modes: make(map[string]fs.FileMode), + symlinks: make(map[string]bool), + failWrites: make(map[string]error), + } +} + +func (f *memoryFiles) ReadFile(path string) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + data, ok := f.contents[path] + if !ok { + return nil, fs.ErrNotExist + } + return append([]byte(nil), data...), nil +} + +func (f *memoryFiles) Lstat(path string) (fs.FileInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.symlinks[path] { + return fakeFileInfo{mode: fs.ModeSymlink}, nil + } + data, ok := f.contents[path] + if !ok { + return nil, fs.ErrNotExist + } + return fakeFileInfo{size: int64(len(data)), mode: f.modes[path]}, nil +} + +func (f *memoryFiles) WriteFileAtomic(path string, data []byte, mode fs.FileMode) error { + return f.writeFileAtomic(path, data, mode, nil) +} + +func (f *memoryFiles) WriteFileAtomicOwned( + path string, + data []byte, + mode fs.FileMode, + ownership Ownership, +) error { + return f.writeFileAtomic(path, data, mode, &ownership) +} + +func (f *memoryFiles) writeFileAtomic( + path string, + data []byte, + mode fs.FileMode, + ownership *Ownership, +) error { + f.mu.Lock() + defer f.mu.Unlock() + f.writes = append(f.writes, writeCall{ + path: path, + data: append([]byte(nil), data...), + mode: mode, + ownership: ownership, + }) + if err := f.failWrites[path]; err != nil { + return err + } + f.contents[path] = append([]byte(nil), data...) + f.modes[path] = mode + return nil +} + +func TestStoreAppliesOwnershipAsPartOfSensitiveReplacement(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + tokenPath := filepath.Join(dir, "authorized_keys") + files := newMemoryFiles() + store := NewStore(manifestPath, files) + ownership := Ownership{UID: 123, GID: 456} + + if err := store.WriteAndRegisterOwned( + context.Background(), + tokenPath, + strings.NewReader("ssh-ed25519 key\n"), + 0o600, + ownership, + ); err != nil { + t.Fatalf("WriteAndRegisterOwned: %v", err) + } + if len(files.writes) != 2 || files.writes[0].ownership != nil { + t.Fatalf("writes = %#v, want unowned manifest then owned sensitive file", files.writes) + } + if files.writes[1].ownership == nil || *files.writes[1].ownership != ownership { + t.Fatalf("sensitive ownership = %#v, want %#v", files.writes[1].ownership, ownership) + } +} + +func (f *memoryFiles) WithLock(_ context.Context, _ string, operation func() error) error { + return operation() +} + +type fakeFileInfo struct { + size int64 + mode fs.FileMode +} + +func (fakeFileInfo) Name() string { return "test" } +func (i fakeFileInfo) Size() int64 { return i.size } +func (i fakeFileInfo) Mode() fs.FileMode { return i.mode } +func (fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (fakeFileInfo) IsDir() bool { return false } +func (fakeFileInfo) Sys() any { return nil } + +func TestStoreRegistersBeforeWritingSensitiveFile(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + tokenPath := filepath.Join(dir, "connect-token") + files := newMemoryFiles() + store := NewStore(manifestPath, files) + + if err := store.WriteAndRegister( + context.Background(), + tokenPath, + strings.NewReader("connect-token"), + 0o600, + ); err != nil { + t.Fatalf("WriteAndRegister: %v", err) + } + if len(files.writes) != 2 { + t.Fatalf("writes = %#v, want manifest then sensitive file", files.writes) + } + if files.writes[0].path != manifestPath || files.writes[1].path != tokenPath { + t.Fatalf("write order = %q then %q", files.writes[0].path, files.writes[1].path) + } + if files.writes[1].mode != 0o600 { + t.Fatalf("token mode = %o, want 600", files.writes[1].mode) + } + assertManifestPaths(t, files.writes[0].data, []string{tokenPath}) +} + +func TestStoreDoesNotWriteSecretWhenManifestWriteFails(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + tokenPath := filepath.Join(dir, "connect-token") + manifestErr := errors.New("manifest write failed") + files := newMemoryFiles() + files.failWrites[manifestPath] = manifestErr + store := NewStore(manifestPath, files) + + err := store.WriteAndRegister(context.Background(), tokenPath, strings.NewReader("token"), 0o600) + if !errors.Is(err, manifestErr) { + t.Fatalf("error = %v, want manifest failure", err) + } + for _, call := range files.writes { + if call.path == tokenPath { + t.Fatalf("secret write occurred after manifest failure") + } + } +} + +func TestStoreLeavesRegistrationWhenSensitiveWriteFails(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + tokenPath := filepath.Join(dir, "connect-token") + fileErr := errors.New("file write failed") + files := newMemoryFiles() + files.failWrites[tokenPath] = fileErr + store := NewStore(manifestPath, files) + + err := store.WriteAndRegister(context.Background(), tokenPath, strings.NewReader("token"), 0o600) + if !errors.Is(err, fileErr) { + t.Fatalf("error = %v, want file failure", err) + } + assertManifestPaths(t, files.contents[manifestPath], []string{tokenPath}) +} + +func TestStorePreservesExistingManifestEntries(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + oldPath := filepath.Join(dir, "old-secret") + newPath := filepath.Join(dir, "new-secret") + files := newMemoryFiles() + files.contents[manifestPath], _ = json.Marshal([]string{oldPath}) + store := NewStore(manifestPath, files) + + if err := store.WriteAndRegister(context.Background(), newPath, strings.NewReader("new"), 0o600); err != nil { + t.Fatalf("WriteAndRegister: %v", err) + } + assertManifestPaths(t, files.contents[manifestPath], []string{oldPath, newPath}) +} + +func TestStoreRejectsUnsafePaths(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + + t.Run("manifest itself", func(t *testing.T) { + files := newMemoryFiles() + store := NewStore(manifestPath, files) + err := store.WriteAndRegister(context.Background(), manifestPath, strings.NewReader("replacement"), 0o600) + if !errors.Is(err, ErrInvalidPath) { + t.Fatalf("error = %v, want ErrInvalidPath", err) + } + if len(files.writes) != 0 { + t.Fatalf("manifest overwrite caused writes: %#v", files.writes) + } + }) + + t.Run("relative", func(t *testing.T) { + files := newMemoryFiles() + store := NewStore(manifestPath, files) + err := store.WriteAndRegister(context.Background(), "relative/token", strings.NewReader("token"), 0o600) + if !errors.Is(err, ErrInvalidPath) { + t.Fatalf("error = %v, want ErrInvalidPath", err) + } + if len(files.writes) != 0 { + t.Fatalf("unsafe path caused writes: %#v", files.writes) + } + }) + + t.Run("symlink", func(t *testing.T) { + files := newMemoryFiles() + tokenPath := filepath.Join(dir, "token-link") + files.symlinks[tokenPath] = true + store := NewStore(manifestPath, files) + err := store.WriteAndRegister(context.Background(), tokenPath, strings.NewReader("token"), 0o600) + if !errors.Is(err, ErrSymlinkPath) { + t.Fatalf("error = %v, want ErrSymlinkPath", err) + } + if len(files.writes) != 0 { + t.Fatalf("symlink path caused writes: %#v", files.writes) + } + }) +} + +func TestStoreSerializesConcurrentManifestUpdates(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + files := newMemoryFiles() + store := NewStore(manifestPath, files) + paths := []string{ + filepath.Join(dir, "secret-a"), + filepath.Join(dir, "secret-b"), + filepath.Join(dir, "secret-c"), + } + + var wg sync.WaitGroup + errs := make(chan error, len(paths)) + for _, path := range paths { + wg.Add(1) + go func() { + defer wg.Done() + errs <- store.WriteAndRegister(context.Background(), path, strings.NewReader("secret"), 0o600) + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("concurrent WriteAndRegister: %v", err) + } + } + assertManifestPathSet(t, files.contents[manifestPath], paths) +} + +func TestOSStoresSerializeManifestAcrossInstances(t *testing.T) { + directory := t.TempDir() + manifestPath := filepath.Join(directory, "injected-paths.json") + files := OSFiles{} + stores := []*Store{NewStore(manifestPath, files), NewStore(manifestPath, files)} + paths := make([]string, 40) + var wg sync.WaitGroup + errs := make(chan error, len(paths)) + for index := range paths { + paths[index] = filepath.Join(directory, fmt.Sprintf("secret-%d", index)) + wg.Add(1) + go func(index int) { + defer wg.Done() + errs <- stores[index%len(stores)].WriteAndRegister( + context.Background(), paths[index], strings.NewReader("secret"), 0o600, + ) + }(index) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("WriteAndRegister: %v", err) + } + } + manifest, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + assertManifestPathSet(t, manifest, paths) +} + +func assertManifestPaths(t *testing.T, data []byte, want []string) { + t.Helper() + var got []string + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("decode manifest: %v", err) + } + if len(got) != len(want) { + t.Fatalf("manifest = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("manifest = %#v, want %#v", got, want) + } + } +} + +func assertManifestPathSet(t *testing.T, data []byte, want []string) { + t.Helper() + var got []string + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("decode manifest: %v", err) + } + seen := make(map[string]bool, len(got)) + for _, path := range got { + seen[path] = true + } + for _, path := range want { + if !seen[path] { + t.Fatalf("manifest %v missing %q", got, path) + } + } +} diff --git a/go/internal/amikad/state/os_files.go b/go/internal/amikad/state/os_files.go new file mode 100644 index 00000000..44349050 --- /dev/null +++ b/go/internal/amikad/state/os_files.go @@ -0,0 +1,15 @@ +package state + +import ( + "io/fs" + "os" +) + +// OSFiles atomically replaces files on the host filesystem. +type OSFiles struct{} + +// ReadFile reads a complete file. +func (OSFiles) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) } + +// Lstat reads metadata without following the final symlink. +func (OSFiles) Lstat(path string) (fs.FileInfo, error) { return os.Lstat(path) } diff --git a/go/internal/amikad/state/os_files_linux.go b/go/internal/amikad/state/os_files_linux.go new file mode 100644 index 00000000..9c4abba1 --- /dev/null +++ b/go/internal/amikad/state/os_files_linux.go @@ -0,0 +1,134 @@ +//go:build linux + +package state + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "time" +) + +// WriteFileAtomic writes and renames relative to a no-symlink directory file +// descriptor, preventing path-component swaps between validation and commit. +func (OSFiles) WriteFileAtomic(path string, data []byte, mode fs.FileMode) (returnErr error) { + return writeFileAtomic(path, data, mode, nil) +} + +// WriteFileAtomicOwned assigns ownership through the temporary file descriptor +// before installing it, so no attacker-controlled pathname is chowned. +func (OSFiles) WriteFileAtomicOwned( + path string, + data []byte, + mode fs.FileMode, + ownership Ownership, +) error { + return writeFileAtomic(path, data, mode, &ownership) +} + +func writeFileAtomic( + path string, + data []byte, + mode fs.FileMode, + ownership *Ownership, +) (returnErr error) { + directoryFD, err := openDirectoryNoSymlinks(filepath.Dir(path)) + if err != nil { + return err + } + defer syscall.Close(directoryFD) + + var random [12]byte + if _, err := rand.Read(random[:]); err != nil { + return err + } + temporaryName := ".amikad-atomic-" + hex.EncodeToString(random[:]) + temporaryFD, err := syscall.Openat( + directoryFD, + temporaryName, + syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW, + uint32(mode.Perm()), + ) + if err != nil { + return err + } + temporary := os.NewFile(uintptr(temporaryFD), temporaryName) + defer func() { + _ = syscall.Unlinkat(directoryFD, temporaryName) + }() + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Chmod(mode); err != nil { + _ = temporary.Close() + return err + } + if ownership != nil { + if err := temporary.Chown(ownership.UID, ownership.GID); err != nil { + _ = temporary.Close() + return err + } + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := syscall.Renameat(directoryFD, temporaryName, directoryFD, filepath.Base(path)); err != nil { + return err + } + return syscall.Fsync(directoryFD) +} + +// WithLock serializes a complete manifest transaction across amikad processes. +func (OSFiles) WithLock(ctx context.Context, path string, operation func() error) error { + lock, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lock.Close() + for { + err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + break + } + if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + return operation() +} + +func openDirectoryNoSymlinks(path string) (int, error) { + current, err := syscall.Open("/", syscall.O_RDONLY|syscall.O_DIRECTORY, 0) + if err != nil { + return -1, err + } + for _, component := range strings.Split(strings.TrimPrefix(filepath.Clean(path), "/"), "/") { + if component == "" { + continue + } + next, err := syscall.Openat(current, component, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) + _ = syscall.Close(current) + if err != nil { + return -1, err + } + current = next + } + return current, nil +} diff --git a/go/internal/amikad/state/os_files_other.go b/go/internal/amikad/state/os_files_other.go new file mode 100644 index 00000000..0e438c54 --- /dev/null +++ b/go/internal/amikad/state/os_files_other.go @@ -0,0 +1,28 @@ +//go:build !linux + +package state + +import ( + "context" + "errors" + "io/fs" +) + +var errSecureAtomicWritesUnsupported = errors.New("amikad sensitive writes require Linux") + +// WriteFileAtomic fails closed where fd-relative no-symlink writes are not +// implemented. +func (OSFiles) WriteFileAtomic(string, []byte, fs.FileMode) error { + return errSecureAtomicWritesUnsupported +} + +// WriteFileAtomicOwned fails closed where secure fd-relative writes are not +// implemented. +func (OSFiles) WriteFileAtomicOwned(string, []byte, fs.FileMode, Ownership) error { + return errSecureAtomicWritesUnsupported +} + +// WithLock fails closed where interprocess manifest locking is not implemented. +func (OSFiles) WithLock(context.Context, string, func() error) error { + return errSecureAtomicWritesUnsupported +} diff --git a/go/internal/apiclient/ssh_session.go b/go/internal/apiclient/ssh_session.go new file mode 100644 index 00000000..613cf24e --- /dev/null +++ b/go/internal/apiclient/ssh_session.go @@ -0,0 +1,101 @@ +package apiclient + +import ( + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + + cryptossh "golang.org/x/crypto/ssh" +) + +// ErrInvalidSSHSession marks an unsafe or internally inconsistent descriptor. +var ErrInvalidSSHSession = errors.New("invalid SSH session descriptor") + +// SSHSessionTransport selects how the stdio proxy reaches the sandbox. +type SSHSessionTransport string + +// SSHSessionTransportDirectWS is the provider-exposed no-relay transport. +const SSHSessionTransportDirectWS SSHSessionTransport = "direct_ws" + +// SSHSession is the transport descriptor returned for one SSH dial. +type SSHSession struct { + SessionID string `json:"session_id"` + Transport SSHSessionTransport `json:"transport"` + ConnectURL string `json:"connect_url"` + ConnectCredential string `json:"connect_credential"` + SandboxID string `json:"sandbox_id"` + SSHUser string `json:"ssh_user"` + HostPublicKey string `json:"host_public_key"` +} + +// CreateSSHPublicKeyRequest uploads one user-owned public key. +type CreateSSHPublicKeyRequest struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` +} + +// SSHPublicKeySummary is the non-secret key metadata returned by the API. +type SSHPublicKeySummary struct { + ID string `json:"id"` + Name string `json:"name"` + PublicKey string `json:"public_key"` + Scope string `json:"scope"` +} + +// Validate checks every field used by OpenSSH or the WebSocket dialer. +func (s *SSHSession) Validate(expectedSandboxID string) error { + if s.SessionID == "" || len(s.SessionID) > 128 || !strings.HasPrefix(s.SessionID, "sshs_") { + return ErrInvalidSSHSession + } + if s.Transport != SSHSessionTransportDirectWS || s.SandboxID != expectedSandboxID || s.SSHUser != "amika" { + return ErrInvalidSSHSession + } + if !isCanonicalConnectToken(s.ConnectCredential) || canonicalEd25519Key(s.HostPublicKey) == "" { + return ErrInvalidSSHSession + } + connectURL, err := url.Parse(s.ConnectURL) + if err != nil || connectURL.Scheme != "wss" || connectURL.Host == "" || connectURL.User != nil || connectURL.RawQuery != "" || connectURL.Fragment != "" { + return ErrInvalidSSHSession + } + if !strings.HasSuffix(connectURL.EscapedPath(), "/v1/ssh-sessions") { + return ErrInvalidSSHSession + } + return nil +} + +// CreateSSHSession creates and validates a fresh descriptor for one dial. +func (c *Client) CreateSSHSession(sandboxID string) (*SSHSession, error) { + var result SSHSession + path := apiBasePath + "/sandboxes/" + url.PathEscape(sandboxID) + "/ssh-sessions" + if err := c.doJSON("POST", path, nil, &result); err != nil { + return nil, fmt.Errorf("remote create SSH session: %w", err) + } + if err := result.Validate(sandboxID); err != nil { + return nil, err + } + return &result, nil +} + +// CreateSSHPublicKey stores a user-scoped SSH public key. +func (c *Client) CreateSSHPublicKey(request CreateSSHPublicKeyRequest) (*SSHPublicKeySummary, error) { + var result SSHPublicKeySummary + if err := c.doJSON("POST", apiBasePath+"/secrets/ssh-public-keys", request, &result); err != nil { + return nil, fmt.Errorf("remote create SSH public key: %w", err) + } + return &result, nil +} + +func isCanonicalConnectToken(value string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(value) + return err == nil && len(decoded) == 32 && base64.RawURLEncoding.EncodeToString(decoded) == value +} + +func canonicalEd25519Key(value string) string { + key, _, options, rest, err := cryptossh.ParseAuthorizedKey([]byte(value)) + if err != nil || len(options) != 0 || strings.TrimSpace(string(rest)) != "" || key.Type() != cryptossh.KeyAlgoED25519 { + return "" + } + return strings.TrimSpace(string(cryptossh.MarshalAuthorizedKey(key))) +} diff --git a/go/internal/apiclient/ssh_session_test.go b/go/internal/apiclient/ssh_session_test.go new file mode 100644 index 00000000..7c03ac14 --- /dev/null +++ b/go/internal/apiclient/ssh_session_test.go @@ -0,0 +1,108 @@ +package apiclient + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + cryptossh "golang.org/x/crypto/ssh" +) + +func testEd25519PublicKey(t *testing.T) string { + t.Helper() + privateKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) + publicKey, err := cryptossh.NewPublicKey(privateKey.Public()) + if err != nil { + t.Fatal(err) + } + return string(cryptossh.MarshalAuthorizedKey(publicKey)) +} + +func TestCreateSSHSessionPostsForEveryDial(t *testing.T) { + token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + key := testEd25519PublicKey(t) + var calls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/api/v0beta1/sandboxes/sbx_123/ssh-sessions" { + t.Errorf("path = %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer api-token" { + t.Errorf("Authorization = %q", got) + } + _ = json.NewEncoder(w).Encode(SSHSession{ + SessionID: "sshs_1", + Transport: SSHSessionTransportDirectWS, + ConnectURL: "wss://sandbox.example/v1/ssh-sessions", + ConnectCredential: token, + SandboxID: "sbx_123", + SSHUser: "amika", + HostPublicKey: key, + }) + })) + defer server.Close() + + client := NewClient(server.URL, "api-token") + for range 2 { + session, err := client.CreateSSHSession("sbx_123") + if err != nil { + t.Fatalf("CreateSSHSession: %v", err) + } + if session.SessionID != "sshs_1" || session.Transport != "direct_ws" { + t.Fatalf("session = %#v", session) + } + } + if calls != 2 { + t.Fatalf("API calls = %d, want 2", calls) + } +} + +func TestSSHSessionValidateAcceptsOnlyMatchingDirectWSSession(t *testing.T) { + token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + key := testEd25519PublicKey(t) + valid := SSHSession{ + SessionID: "sshs_1", + Transport: SSHSessionTransportDirectWS, + ConnectURL: "wss://sandbox.example/v1/ssh-sessions", + ConnectCredential: token, + SandboxID: "sbx_123", + SSHUser: "amika", + HostPublicKey: key, + } + if err := valid.Validate("sbx_123"); err != nil { + t.Fatalf("Validate valid session: %v", err) + } + + tests := map[string]SSHSession{ + "unknown transport": func() SSHSession { s := valid; s.Transport = "other"; return s }(), + "wrong sandbox": func() SSHSession { s := valid; s.SandboxID = "sbx_other"; return s }(), + "non-wss URL": func() SSHSession { s := valid; s.ConnectURL = "https://sandbox.example/v1/ssh-sessions"; return s }(), + "URL credential": func() SSHSession { + s := valid + s.ConnectURL = "wss://user:secret@sandbox.example/v1/ssh-sessions" + return s + }(), + "URL query": func() SSHSession { + s := valid + s.ConnectURL = "wss://sandbox.example/v1/ssh-sessions?token=secret" + return s + }(), + "wrong URL path": func() SSHSession { s := valid; s.ConnectURL = "wss://sandbox.example/other"; return s }(), + "empty credential": func() SSHSession { s := valid; s.ConnectCredential = ""; return s }(), + "invalid host key": func() SSHSession { s := valid; s.HostPublicKey = "ssh-rsa AAAA"; return s }(), + "host key with newline": func() SSHSession { s := valid; s.HostPublicKey = "ssh-ed25519 AAAA\nInjected"; return s }(), + } + for name, session := range tests { + t.Run(name, func(t *testing.T) { + if err := session.Validate("sbx_123"); err == nil { + t.Fatal("Validate returned nil") + } + }) + } +} diff --git a/go/internal/basedir/basedir.go b/go/internal/basedir/basedir.go index 1d60034c..8ac9dc0d 100644 --- a/go/internal/basedir/basedir.go +++ b/go/internal/basedir/basedir.go @@ -23,6 +23,8 @@ const ( sshDirName = ".ssh" sshConfigFile = "config" sshAmikaConfigFile = "amika.conf" + sshKnownHostsFile = "amika_known_hosts" + sshIdentityFile = "amika_id_ed25519" claudeDirName = ".claude" claudeSettingsFile = "settings.json" codexDirName = ".codex" @@ -61,6 +63,8 @@ type Paths interface { SSHDir() (string, error) SSHConfigFile() (string, error) SSHAmikaConfigFile() (string, error) + SSHKnownHostsFile() (string, error) + SSHIdentityFile() (string, error) ClaudeSettingsFile() (string, error) CodexConfigFile() (string, error) @@ -281,6 +285,24 @@ func (p *xdgPaths) SSHAmikaConfigFile() (string, error) { return filepath.Join(dir, sshAmikaConfigFile), nil } +// SSHKnownHostsFile returns the dedicated strict host-key pin file. +func (p *xdgPaths) SSHKnownHostsFile() (string, error) { + dir, err := p.SSHDir() + if err != nil { + return "", err + } + return filepath.Join(dir, sshKnownHostsFile), nil +} + +// SSHIdentityFile returns the default user-owned Ed25519 private key path. +func (p *xdgPaths) SSHIdentityFile() (string, error) { + dir, err := p.SSHDir() + if err != nil { + return "", err + } + return filepath.Join(dir, sshIdentityFile), nil +} + // ClaudeSettingsFile returns the Claude Desktop / Claude Code settings file at // ~/.claude/settings.json, where SSH environments live under `sshConfigs`. func (p *xdgPaths) ClaudeSettingsFile() (string, error) { diff --git a/go/internal/basedir/basedir_test.go b/go/internal/basedir/basedir_test.go index 02e14832..52766c30 100644 --- a/go/internal/basedir/basedir_test.go +++ b/go/internal/basedir/basedir_test.go @@ -96,6 +96,12 @@ func TestPaths_SSHPaths(t *testing.T) { if got, _ := p.SSHAmikaConfigFile(); got != filepath.Join(home, ".ssh", "amika.conf") { t.Fatalf("SSHAmikaConfigFile = %q", got) } + if got, _ := p.SSHKnownHostsFile(); got != filepath.Join(home, ".ssh", "amika_known_hosts") { + t.Fatalf("SSHKnownHostsFile = %q", got) + } + if got, _ := p.SSHIdentityFile(); got != filepath.Join(home, ".ssh", "amika_id_ed25519") { + t.Fatalf("SSHIdentityFile = %q", got) + } } func TestSSHAmikaConfigName(t *testing.T) { diff --git a/go/internal/buildmeta/buildmeta.go b/go/internal/buildmeta/buildmeta.go index 2cc22e81..910c010b 100644 --- a/go/internal/buildmeta/buildmeta.go +++ b/go/internal/buildmeta/buildmeta.go @@ -10,12 +10,15 @@ import ( var ( amikaVersionValue = "dev" amikaServerVersionValue = "dev" + amikadVersionValue = "dev" amikalogVersionValue = "dev" // AmikaVersion is the parsed semantic version for the amika CLI. AmikaVersion = MustParseSemVer(amikaVersionValue) // AmikaServerVersion is the parsed semantic version for the amika-server binary. AmikaServerVersion = MustParseSemVer(amikaServerVersionValue) + // AmikadVersion is the parsed semantic version for the sandbox daemon. + AmikadVersion = MustParseSemVer(amikadVersionValue) // AmikalogVersion is the parsed semantic version for the amikalog CLI. AmikalogVersion = MustParseSemVer(amikalogVersionValue) // Commit is the full git SHA for the build. diff --git a/go/internal/filelock/filelock.go b/go/internal/filelock/filelock.go new file mode 100644 index 00000000..0e0ea7cc --- /dev/null +++ b/go/internal/filelock/filelock.go @@ -0,0 +1,43 @@ +// Package filelock provides cancellable cross-process advisory file locks. +package filelock + +import ( + "context" + "errors" + "os" + "time" +) + +// Lock is one held exclusive advisory lock. +type Lock struct { + file *os.File +} + +// Acquire waits until path can be locked or ctx is cancelled. +func Acquire(ctx context.Context, path string) (*Lock, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + for { + locked, err := tryLock(file) + if err != nil { + _ = file.Close() + return nil, err + } + if locked { + return &Lock{file: file}, nil + } + select { + case <-ctx.Done(): + _ = file.Close() + return nil, ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } +} + +// Close unlocks and closes the lock file. +func (l *Lock) Close() error { + return errors.Join(unlock(l.file), l.file.Close()) +} diff --git a/go/internal/filelock/lock_unix.go b/go/internal/filelock/lock_unix.go new file mode 100644 index 00000000..f9443416 --- /dev/null +++ b/go/internal/filelock/lock_unix.go @@ -0,0 +1,21 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package filelock + +import ( + "errors" + "os" + "syscall" +) + +func tryLock(file *os.File) (bool, error) { + err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return false, nil + } + return err == nil, err +} + +func unlock(file *os.File) error { + return syscall.Flock(int(file.Fd()), syscall.LOCK_UN) +} diff --git a/go/internal/filelock/lock_windows.go b/go/internal/filelock/lock_windows.go new file mode 100644 index 00000000..9ed6d73a --- /dev/null +++ b/go/internal/filelock/lock_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package filelock + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func tryLock(file *os.File) (bool, error) { + overlapped := &windows.Overlapped{} + err := windows.LockFileEx( + windows.Handle(file.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, + 0, + overlapped, + ) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return err == nil, err +} + +func unlock(file *os.File) error { + return windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &windows.Overlapped{}) +} diff --git a/go/internal/sandbox/presets/base/Dockerfile b/go/internal/sandbox/presets/base/Dockerfile index d0c50c9f..caa10420 100644 --- a/go/internal/sandbox/presets/base/Dockerfile +++ b/go/internal/sandbox/presets/base/Dockerfile @@ -1,7 +1,17 @@ # Locally, this image is called "amika/base" +FROM golang:1.25.3-bookworm AS amikad-builder + +# Build from an immutable reviewed commit until the first standalone amikad +# release exists. The final image receives only the binary, not the toolchain. +ARG AMIKAD_SOURCE_REF=3fde13c92ff8c3e43a393138459945f7b641cb09 +ENV GOBIN=/out +RUN go install "github.com/gofixpoint/amika/go/cmd/amikad@${AMIKAD_SOURCE_REF}" + FROM ubuntu:24.04 +COPY --from=amikad-builder /out/amikad /usr/local/bin/amikad + ENV DEBIAN_FRONTEND=noninteractive ENV LANG=C.UTF-8 @@ -19,6 +29,7 @@ RUN apt-get update && apt-get install -y \ nano \ tmux \ ncurses-term \ + openssh-server \ && rm -rf /var/lib/apt/lists/* # Install Ghostty terminfo so SSH sessions from Ghostty render correctly. @@ -99,6 +110,12 @@ RUN chmod +x /usr/lib/amikad/pre-setup.sh /usr/lib/amikad/post-setup.sh /usr/lib # Create user with home directory. RUN useradd -m -s /usr/bin/zsh amika +# OpenSSH refuses every login, including public-key auth, for a shadow-locked +# account. Give the account a discarded random password hash so it is unlocked +# without creating a usable password; the managed sshd also disables all +# password and keyboard-interactive authentication. +RUN usermod --password '$6$pdjtCb0QP/guq5xn$iwYNhGlawvdfJqqAeGINjgtq.2yFgcFCvRItN.ly3astbptigtMioqI/opqrgmEdHdDQ.sg0/LOWW5vyp9tHz0' amika +RUN install -d -m 0700 -o amika -g amika /home/amika/.ssh RUN usermod -aG sudo amika # Let amika user run sudo without a password. RUN echo "amika ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers diff --git a/go/internal/sandbox/presets_test.go b/go/internal/sandbox/presets_test.go index be493305..7f2c97cc 100644 --- a/go/internal/sandbox/presets_test.go +++ b/go/internal/sandbox/presets_test.go @@ -3,6 +3,7 @@ package sandbox import ( "os" "path/filepath" + "regexp" "strings" "testing" ) @@ -88,6 +89,26 @@ func TestGetPresetDockerfile_BaseCreatesAmikaAndAmikadDirectories(t *testing.T) } } +func TestGetPresetDockerfile_BaseBuildsPinnedAmikad(t *testing.T) { + data, err := GetPresetDockerfile("base") + if err != nil { + t.Fatal(err) + } + contents := string(data) + for _, want := range []string{ + "FROM golang:1.25.3-bookworm AS amikad-builder", + "go install \"github.com/gofixpoint/amika/go/cmd/amikad@${AMIKAD_SOURCE_REF}\"", + "COPY --from=amikad-builder /out/amikad /usr/local/bin/amikad", + } { + if !strings.Contains(contents, want) { + t.Fatalf("base Dockerfile missing pinned amikad build %q", want) + } + } + if !regexp.MustCompile(`(?m)^ARG AMIKAD_SOURCE_REF=[0-9a-f]{40}$`).MatchString(contents) { + t.Fatal("base Dockerfile must pin amikad to a full source commit") + } +} + func TestGetPresetDockerfile_BaseChownsUserManagedAmikaDirectories(t *testing.T) { data, err := GetPresetDockerfile("base") if err != nil { diff --git a/go/internal/ssh/config.go b/go/internal/ssh/config.go index 6cf320b1..c0661f10 100644 --- a/go/internal/ssh/config.go +++ b/go/internal/ssh/config.go @@ -35,7 +35,8 @@ type HostEntry struct { // HostsState is the source of truth from which ~/.ssh/amika.conf is rendered. type HostsState struct { - Hosts []HostEntry `json:"hosts"` + Hosts []HostEntry `json:"hosts"` + SessionConfig *SessionConfig `json:"session_config,omitempty"` } // Alias returns the stable SSH host alias for a sandbox id. Cursor keys its @@ -218,6 +219,13 @@ func Render(state HostsState) string { } b.WriteString(" StrictHostKeyChecking accept-new\n") } + if state.SessionConfig != nil { + block, err := RenderSessionConfig(*state.SessionConfig) + if err == nil { + b.WriteString("\n# No-relay WebSocket SSH aliases.\n") + b.WriteString(block) + } + } return b.String() } @@ -247,6 +255,11 @@ func LoadState(paths basedir.Paths) (HostsState, error) { // SaveState writes the SSH hosts state atomically with owner-only permissions. func SaveState(paths basedir.Paths, state HostsState) error { + if state.SessionConfig != nil { + if _, err := RenderSessionConfig(*state.SessionConfig); err != nil { + return err + } + } path, err := paths.SSHHostsStateFile() if err != nil { return err @@ -260,6 +273,11 @@ func SaveState(paths basedir.Paths, state HostsState) error { // WriteAmikaConfig renders the state to ~/.ssh/amika.conf atomically. func WriteAmikaConfig(paths basedir.Paths, state HostsState) error { + if state.SessionConfig != nil { + if _, err := RenderSessionConfig(*state.SessionConfig); err != nil { + return err + } + } path, err := paths.SSHAmikaConfigFile() if err != nil { return err @@ -267,6 +285,26 @@ func WriteAmikaConfig(paths basedir.Paths, state HostsState) error { return writeFileAtomic(path, []byte(Render(state)), 0o600) } +// ConfigureSession persists and renders the strict wildcard session block +// without disturbing legacy provider-native host entries. +func ConfigureSession(paths basedir.Paths, config SessionConfig) error { + if _, err := RenderSessionConfig(config); err != nil { + return err + } + state, err := LoadState(paths) + if err != nil { + return err + } + state.SessionConfig = &config + if err := SaveState(paths, state); err != nil { + return err + } + if err := WriteAmikaConfig(paths, state); err != nil { + return err + } + return EnsureInclude(paths) +} + // EnsureInclude makes sure ~/.ssh/config pulls in the managed amika.conf via an // Include directive near the top (Include must precede Host blocks to take // effect, since ssh resolves options first-match-wins). It is idempotent and diff --git a/go/internal/ssh/identity.go b/go/internal/ssh/identity.go new file mode 100644 index 00000000..7fefb913 --- /dev/null +++ b/go/internal/ssh/identity.go @@ -0,0 +1,103 @@ +package ssh + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + cryptossh "golang.org/x/crypto/ssh" +) + +// GenerateIdentity creates or validates an unencrypted user-owned Ed25519 +// keypair and returns its canonical public key. +func GenerateIdentity(privatePath string) (string, error) { + publicPath := privatePath + ".pub" + privateData, privateErr := os.ReadFile(privatePath) + publicData, publicErr := os.ReadFile(publicPath) + if privateErr == nil || publicErr == nil { + if privateErr != nil || publicErr != nil { + return "", fmt.Errorf("incomplete SSH identity pair") + } + privateInfo, err := os.Stat(privatePath) + if err != nil || privateInfo.Mode().Perm() != 0o600 { + return "", fmt.Errorf("SSH private key must have mode 0600") + } + signer, err := cryptossh.ParsePrivateKey(privateData) + if err != nil || signer.PublicKey().Type() != cryptossh.KeyAlgoED25519 { + return "", fmt.Errorf("existing SSH private key is not a valid Ed25519 key") + } + canonical, err := canonicalHostPublicKey(string(publicData)) + if err != nil || !bytes.Equal(signer.PublicKey().Marshal(), mustParsePublicKey(canonical).Marshal()) { + return "", fmt.Errorf("existing SSH keypair does not match") + } + return canonical, nil + } + if !errors.Is(privateErr, os.ErrNotExist) || !errors.Is(publicErr, os.ErrNotExist) { + return "", errors.Join(privateErr, publicErr) + } + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", err + } + privateBlock, err := cryptossh.MarshalPrivateKey(privateKey, "amika SSH identity") + if err != nil { + return "", err + } + publicKey, err := cryptossh.NewPublicKey(privateKey.Public()) + if err != nil { + return "", err + } + canonical := strings.TrimSpace(string(cryptossh.MarshalAuthorizedKey(publicKey))) + if err := writeFileAtomic(privatePath, pem.EncodeToMemory(privateBlock), 0o600); err != nil { + return "", err + } + if err := writeFileAtomic(publicPath, []byte(canonical+"\n"), 0o644); err != nil { + return "", err + } + return canonical, nil +} + +// ImportIdentity validates a public key and its conventional matching private +// key path without copying private material. +func ImportIdentity(publicPath string) (identityPath, canonicalPublicKey string, err error) { + if filepath.Ext(publicPath) != ".pub" { + return "", "", fmt.Errorf("imported public key path must end in .pub") + } + publicData, err := os.ReadFile(publicPath) + if err != nil { + return "", "", err + } + canonical, err := canonicalHostPublicKey(string(publicData)) + if err != nil { + return "", "", err + } + privatePath := strings.TrimSuffix(publicPath, ".pub") + privateInfo, err := os.Stat(privatePath) + if err != nil || !privateInfo.Mode().IsRegular() || privateInfo.Mode().Perm()&0o077 != 0 { + return "", "", fmt.Errorf("matching private key is missing or not owner-only") + } + privateData, err := os.ReadFile(privatePath) + if err != nil { + return "", "", err + } + signer, err := cryptossh.ParsePrivateKey(privateData) + if err != nil || signer.PublicKey().Type() != cryptossh.KeyAlgoED25519 { + return "", "", fmt.Errorf("matching private key is not an unencrypted Ed25519 key") + } + if !bytes.Equal(signer.PublicKey().Marshal(), mustParsePublicKey(canonical).Marshal()) { + return "", "", fmt.Errorf("imported SSH keypair does not match") + } + return privatePath, canonical, nil +} + +func mustParsePublicKey(value string) cryptossh.PublicKey { + key, _, _, _, _ := cryptossh.ParseAuthorizedKey([]byte(value)) + return key +} diff --git a/go/internal/ssh/identity_test.go b/go/internal/ssh/identity_test.go new file mode 100644 index 00000000..f3fa8add --- /dev/null +++ b/go/internal/ssh/identity_test.go @@ -0,0 +1,57 @@ +package ssh + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGenerateIdentityIsOwnerOnlyAndIdempotent(t *testing.T) { + privatePath := filepath.Join(t.TempDir(), ".ssh", "amika_id_ed25519") + first, err := GenerateIdentity(privatePath) + if err != nil { + t.Fatalf("GenerateIdentity: %v", err) + } + second, err := GenerateIdentity(privatePath) + if err != nil { + t.Fatalf("idempotent GenerateIdentity: %v", err) + } + if first != second { + t.Fatal("idempotent generation changed public key") + } + info, err := os.Stat(privatePath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("private mode = %o, want 600", info.Mode().Perm()) + } +} + +func TestImportIdentityRequiresMatchingPrivateKey(t *testing.T) { + directory := t.TempDir() + firstPath := filepath.Join(directory, "first") + firstPublic, err := GenerateIdentity(firstPath) + if err != nil { + t.Fatal(err) + } + secondPath := filepath.Join(directory, "second") + secondPublic, err := GenerateIdentity(secondPath) + if err != nil { + t.Fatal(err) + } + + identityPath, imported, err := ImportIdentity(firstPath + ".pub") + if err != nil { + t.Fatalf("ImportIdentity: %v", err) + } + if identityPath != firstPath || imported != firstPublic { + t.Fatalf("import = %q, %q", identityPath, imported) + } + if err := os.WriteFile(firstPath+".pub", []byte(secondPublic+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := ImportIdentity(firstPath + ".pub"); err == nil { + t.Fatal("ImportIdentity accepted a mismatched keypair") + } +} diff --git a/go/internal/ssh/known_hosts.go b/go/internal/ssh/known_hosts.go new file mode 100644 index 00000000..7a4d3d1a --- /dev/null +++ b/go/internal/ssh/known_hosts.go @@ -0,0 +1,99 @@ +package ssh + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gofixpoint/amika/go/internal/filelock" +) + +// FileHostKeyPinStore atomically maintains the dedicated alias-keyed known +// hosts file. +type FileHostKeyPinStore struct { + Path string +} + +// Pin adds a new alias pin, accepts an identical existing pin, and refuses a +// changed key. +func (s FileHostKeyPinStore) Pin(alias, hostPublicKey string) error { + line, err := KnownHostLine(alias, hostPublicKey) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.Path), 0o700); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + lock, err := filelock.Acquire(ctx, s.Path+".lock") + if err != nil { + return err + } + defer lock.Close() + + pins, err := readPins(s.Path) + if err != nil { + return err + } + canonical := strings.TrimSuffix(line, "\n") + if existing, found := pins[alias]; found { + if existing != canonical { + return ErrHostKeyMismatch + } + return nil + } + pins[alias] = canonical + aliases := make([]string, 0, len(pins)) + for pinnedAlias := range pins { + aliases = append(aliases, pinnedAlias) + } + sort.Strings(aliases) + var output strings.Builder + for _, pinnedAlias := range aliases { + output.WriteString(pins[pinnedAlias]) + output.WriteByte('\n') + } + return writeFileAtomic(s.Path, []byte(output.String()), 0o600) +} + +func readPins(path string) (map[string]string, error) { + pins := make(map[string]string) + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return pins, nil + } + if err != nil { + return nil, err + } + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 3 { + return nil, fmt.Errorf("invalid managed known-host line") + } + canonical, err := KnownHostLine(fields[0], fields[1]+" "+fields[2]) + if err != nil { + return nil, err + } + if _, duplicate := pins[fields[0]]; duplicate { + return nil, fmt.Errorf("duplicate managed known-host alias") + } + pins[fields[0]] = strings.TrimSuffix(canonical, "\n") + } + if err := scanner.Err(); err != nil { + return nil, err + } + return pins, nil +} diff --git a/go/internal/ssh/known_hosts_test.go b/go/internal/ssh/known_hosts_test.go new file mode 100644 index 00000000..c0ab5943 --- /dev/null +++ b/go/internal/ssh/known_hosts_test.go @@ -0,0 +1,33 @@ +package ssh + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFileHostKeyPinStoreRefusesChangedKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "amika_known_hosts") + store := FileHostKeyPinStore{Path: path} + first := testHostKey(t) + second := testHostKey(t) + alias := "team.sbx_1.amika" + if err := store.Pin(alias, first); err != nil { + t.Fatalf("first Pin: %v", err) + } + if err := store.Pin(alias, first); err != nil { + t.Fatalf("idempotent Pin: %v", err) + } + if err := store.Pin(alias, second); !errors.Is(err, ErrHostKeyMismatch) { + t.Fatalf("changed Pin error = %v", err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(contents), second) || string(contents) != alias+" "+first+"\n" { + t.Fatalf("known hosts changed after mismatch: %q", contents) + } +} diff --git a/go/internal/ssh/session.go b/go/internal/ssh/session.go new file mode 100644 index 00000000..5811187d --- /dev/null +++ b/go/internal/ssh/session.go @@ -0,0 +1,249 @@ +package ssh + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/coder/websocket" + "github.com/gofixpoint/amika/go/internal/apiclient" + "github.com/gofixpoint/amika/go/internal/wsstream" + cryptossh "golang.org/x/crypto/ssh" +) + +const proxyCopyBufferBytes = 32 * 1024 + +var safeAliasPart = regexp.MustCompile(`^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$`) + +// ErrInvalidSessionAlias marks a host value that is not a safe v2 Amika +// alias. +var ErrInvalidSessionAlias = errors.New("invalid Amika SSH host alias") + +// ErrHostKeyMismatch marks a descriptor whose immutable identity does not +// match the requested host. +var ErrHostKeyMismatch = errors.New("SSH host identity mismatch") + +// SandboxAlias identifies the immutable sandbox id parsed from a v2 host alias. +type SandboxAlias struct { + Name string + ID string +} + +// SessionConfig describes the strict wildcard SSH configuration. +type SessionConfig struct { + IdentityFile string + KnownHostsFile string + ProxyCommand string +} + +// SessionCreator creates a fresh transport descriptor for each SSH dial. +type SessionCreator interface { + CreateSSHSession(string) (*apiclient.SSHSession, error) +} + +// Stream is the binary WebSocket abstraction used by the stdio proxy. +type Stream interface { + io.Reader + io.Writer + io.Closer +} + +// SessionDialer opens a binary stream with a credential sent outside argv. +type SessionDialer interface { + Dial(context.Context, string, string) (Stream, error) +} + +// HostKeyPinStore atomically creates or verifies an alias-keyed known-host pin. +// An existing different key must fail closed. +type HostKeyPinStore interface { + Pin(string, string) error +} + +// BuildSessionAlias combines a safe human name and immutable sandbox id. +func BuildSessionAlias(name, id string) (string, error) { + if name == "" || id == "" || !safeAliasPart.MatchString(name) || !safeAliasPart.MatchString(id) || strings.Contains(id, ".") { + return "", ErrInvalidSessionAlias + } + alias := name + "." + id + ".amika" + if len(alias) > 253 { + return "", ErrInvalidSessionAlias + } + return alias, nil +} + +// ParseSessionAlias splits from the right so dotted sandbox names remain +// intact. +func ParseSessionAlias(alias string) (SandboxAlias, error) { + if len(alias) > 253 || !strings.HasSuffix(alias, ".amika") || strings.ContainsAny(alias, "\r\n\t *?![]\\") { + return SandboxAlias{}, ErrInvalidSessionAlias + } + withoutSuffix := strings.TrimSuffix(alias, ".amika") + separator := strings.LastIndexByte(withoutSuffix, '.') + if separator <= 0 || separator == len(withoutSuffix)-1 { + return SandboxAlias{}, ErrInvalidSessionAlias + } + parsed := SandboxAlias{Name: withoutSuffix[:separator], ID: withoutSuffix[separator+1:]} + if !safeAliasPart.MatchString(parsed.Name) || !safeAliasPart.MatchString(parsed.ID) || strings.Contains(parsed.ID, ".") { + return SandboxAlias{}, ErrInvalidSessionAlias + } + return parsed, nil +} + +// RenderSessionConfig renders the strict wildcard block used only by v2 +// aliases. +func RenderSessionConfig(config SessionConfig) (string, error) { + if !safeConfigPath(config.IdentityFile) || !safeConfigPath(config.KnownHostsFile) || config.ProxyCommand != "amika plumbing ssh-stdio-proxy %h" { + return "", ErrInvalidSessionAlias + } + return fmt.Sprintf(`Host *.amika + User amika + IdentityFile %s + IdentitiesOnly yes + StrictHostKeyChecking yes + UserKnownHostsFile %s + ProxyCommand %s + ServerAliveInterval 15 + ServerAliveCountMax 3 +`, config.IdentityFile, config.KnownHostsFile, config.ProxyCommand), nil +} + +// KnownHostLine returns one canonical alias-keyed Ed25519 pin. +func KnownHostLine(alias, hostPublicKey string) (string, error) { + if _, err := ParseSessionAlias(alias); err != nil { + return "", err + } + canonical, err := canonicalHostPublicKey(hostPublicKey) + if err != nil { + return "", err + } + return alias + " " + canonical + "\n", nil +} + +// PrepareSessionHost fetches a descriptor and pins its key before OpenSSH is +// launched. +func PrepareSessionHost( + creator SessionCreator, + pins HostKeyPinStore, + sandboxID string, + alias string, +) (*apiclient.SSHSession, error) { + parsed, err := ParseSessionAlias(alias) + if err != nil || parsed.ID != sandboxID { + return nil, ErrHostKeyMismatch + } + session, err := creator.CreateSSHSession(sandboxID) + if err != nil { + return nil, err + } + if err := session.Validate(sandboxID); err != nil { + return nil, err + } + canonical, err := canonicalHostPublicKey(session.HostPublicKey) + if err != nil { + return nil, err + } + if err := pins.Pin(alias, canonical); err != nil { + return nil, err + } + return session, nil +} + +// ProxySession creates a fresh descriptor, dials it with header credentials, +// and copies opaque bytes between OpenSSH standard I/O and the WebSocket. +func ProxySession( + ctx context.Context, + creator SessionCreator, + dialer SessionDialer, + alias string, + stdin io.Reader, + stdout io.Writer, +) error { + parsed, err := ParseSessionAlias(alias) + if err != nil { + return err + } + session, err := creator.CreateSSHSession(parsed.ID) + if err != nil { + return err + } + if err := session.Validate(parsed.ID); err != nil { + return err + } + stream, err := dialer.Dial(ctx, session.ConnectURL, session.ConnectCredential) + if err != nil { + return errors.New("failed to connect direct SSH transport") + } + defer stream.Close() + + type result struct{ err error } + results := make(chan result, 2) + go func() { + buffer := make([]byte, proxyCopyBufferBytes) + _, copyErr := io.CopyBuffer(stream, stdin, buffer) + results <- result{err: copyErr} + }() + go func() { + buffer := make([]byte, proxyCopyBufferBytes) + _, copyErr := io.CopyBuffer(stdout, stream, buffer) + results <- result{err: copyErr} + }() + first := <-results + _ = stream.Close() + second := <-results + if !expectedStreamClose(first.err) { + return errors.New("direct SSH transport closed unexpectedly") + } + if !expectedStreamClose(second.err) { + return errors.New("direct SSH transport closed unexpectedly") + } + return nil +} + +func expectedStreamClose(err error) bool { + if err == nil || errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return true + } + status := websocket.CloseStatus(err) + return status == websocket.StatusNormalClosure || status == websocket.StatusGoingAway +} + +// WebSocketDialer sends the connect credential only in the Authorization +// header and disables compression for opaque SSH ciphertext. +type WebSocketDialer struct { + HTTPClient *http.Client +} + +// Dial opens one bounded binary WebSocket stream. +func (d WebSocketDialer) Dial(ctx context.Context, connectURL, credential string) (Stream, error) { + header := make(http.Header) + header.Set("Authorization", "Bearer "+credential) + dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + connection, _, err := websocket.Dial(dialCtx, connectURL, &websocket.DialOptions{ + HTTPClient: d.HTTPClient, + HTTPHeader: header, + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + return nil, errors.New("WebSocket SSH handshake failed") + } + return wsstream.New(ctx, connection, 64*1024), nil +} + +func safeConfigPath(path string) bool { + return filepath.IsAbs(path) && filepath.Clean(path) == path && !strings.ContainsAny(path, "\r\n\t ") +} + +func canonicalHostPublicKey(value string) (string, error) { + key, _, options, rest, err := cryptossh.ParseAuthorizedKey([]byte(value)) + if err != nil || len(options) != 0 || strings.TrimSpace(string(rest)) != "" || key.Type() != cryptossh.KeyAlgoED25519 { + return "", ErrHostKeyMismatch + } + return strings.TrimSpace(string(cryptossh.MarshalAuthorizedKey(key))), nil +} diff --git a/go/internal/ssh/session_test.go b/go/internal/ssh/session_test.go new file mode 100644 index 00000000..b9868050 --- /dev/null +++ b/go/internal/ssh/session_test.go @@ -0,0 +1,215 @@ +package ssh + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "io" + "strings" + "testing" + + "github.com/coder/websocket" + "github.com/gofixpoint/amika/go/internal/apiclient" + cryptossh "golang.org/x/crypto/ssh" +) + +func TestParseSessionAliasUsesRightmostSandboxID(t *testing.T) { + parsed, err := ParseSessionAlias("my.team.sbx-123.amika") + if err != nil { + t.Fatalf("ParseSessionAlias: %v", err) + } + if parsed.Name != "my.team" || parsed.ID != "sbx-123" { + t.Fatalf("parsed = %#v", parsed) + } +} + +func TestRenderSessionConfigPinsHostKeysAndFetchesEveryDial(t *testing.T) { + config, err := RenderSessionConfig(SessionConfig{ + IdentityFile: "/home/user/.ssh/amika_id_ed25519", + KnownHostsFile: "/home/user/.ssh/amika_known_hosts", + ProxyCommand: "amika plumbing ssh-stdio-proxy %h", + }) + if err != nil { + t.Fatalf("RenderSessionConfig: %v", err) + } + for _, line := range []string{ + "Host *.amika", + "User amika", + "IdentityFile /home/user/.ssh/amika_id_ed25519", + "IdentitiesOnly yes", + "StrictHostKeyChecking yes", + "UserKnownHostsFile /home/user/.ssh/amika_known_hosts", + "ProxyCommand amika plumbing ssh-stdio-proxy %h", + "ServerAliveInterval 15", + } { + if !strings.Contains(config, line) { + t.Errorf("config missing %q:\n%s", line, config) + } + } +} + +func TestKnownHostLinePinsTheAliasToTheExactHostKey(t *testing.T) { + hostKey := testHostKey(t) + line, err := KnownHostLine( + "my.team.sbx-123.amika", + hostKey+" host-comment", + ) + if err != nil { + t.Fatalf("KnownHostLine: %v", err) + } + if line != "my.team.sbx-123.amika "+hostKey+"\n" { + t.Fatalf("known-host line = %q", line) + } +} + +type fakeCreator struct { + calls int + hostKey string +} + +type fakePinStore struct { + alias string + key string + calls int +} + +func (s *fakePinStore) Pin(alias, key string) error { + s.calls++ + s.alias = alias + s.key = key + return nil +} + +func TestPrepareSessionHostPinsAPIHostKeyBeforeOpenSSH(t *testing.T) { + creator := &fakeCreator{hostKey: testHostKey(t)} + pins := &fakePinStore{} + session, err := PrepareSessionHost( + creator, + pins, + "sbx_123", + "my.team.sbx_123.amika", + ) + if err != nil { + t.Fatalf("PrepareSessionHost: %v", err) + } + if session.SandboxID != "sbx_123" || creator.calls != 1 { + t.Fatalf("session = %#v, creator calls = %d", session, creator.calls) + } + if pins.calls != 1 || pins.alias != "my.team.sbx_123.amika" || pins.key != creator.hostKey { + t.Fatalf("pin calls = %d alias = %q key = %q", pins.calls, pins.alias, pins.key) + } +} + +func (c *fakeCreator) CreateSSHSession(name string) (*apiclient.SSHSession, error) { + c.calls++ + return &apiclient.SSHSession{ + SessionID: "sshs_1", + Transport: "direct_ws", + ConnectURL: "wss://sandbox.example/v1/ssh-sessions", + ConnectCredential: testConnectToken(), + SandboxID: name, + SSHUser: "amika", + HostPublicKey: c.hostKey, + }, nil +} + +type proxyStream struct { + read *bytes.Reader + readErr error + writes bytes.Buffer +} + +func (s *proxyStream) Read(p []byte) (int, error) { + read, err := s.read.Read(p) + if errors.Is(err, io.EOF) && s.readErr != nil { + return read, s.readErr + } + return read, err +} +func (s *proxyStream) Write(p []byte) (int, error) { return s.writes.Write(p) } +func (s *proxyStream) Close() error { return nil } + +type fakeSessionDialer struct { + stream *proxyStream + url string + credential string + calls int +} + +func (d *fakeSessionDialer) Dial(_ context.Context, url, credential string) (Stream, error) { + d.calls++ + d.url = url + d.credential = credential + return d.stream, nil +} + +func TestProxySessionCreatesSessionAndCopiesBytes(t *testing.T) { + creator := &fakeCreator{hostKey: testHostKey(t)} + stream := &proxyStream{read: bytes.NewReader([]byte("from-sshd"))} + dialer := &fakeSessionDialer{stream: stream} + var stdout bytes.Buffer + + err := ProxySession( + context.Background(), + creator, + dialer, + "my.team.sbx_123.amika", + strings.NewReader("from-openssh"), + &stdout, + ) + if err != nil { + t.Fatalf("ProxySession: %v", err) + } + if creator.calls != 1 || dialer.calls != 1 { + t.Fatalf("creator calls = %d, dialer calls = %d", creator.calls, dialer.calls) + } + if dialer.url != "wss://sandbox.example/v1/ssh-sessions" || dialer.credential != testConnectToken() { + t.Fatalf("dial = %q credential %q", dialer.url, dialer.credential) + } + if got := stream.writes.String(); got != "from-openssh" { + t.Fatalf("stream received %q", got) + } + if got := stdout.String(); got != "from-sshd" { + t.Fatalf("stdout received %q", got) + } +} + +func TestProxySessionAcceptsNormalWebSocketClosure(t *testing.T) { + creator := &fakeCreator{hostKey: testHostKey(t)} + stream := &proxyStream{ + read: bytes.NewReader(nil), + readErr: websocket.CloseError{Code: websocket.StatusNormalClosure}, + } + dialer := &fakeSessionDialer{stream: stream} + + if err := ProxySession( + context.Background(), + creator, + dialer, + "my.team.sbx_123.amika", + strings.NewReader(""), + io.Discard, + ); err != nil { + t.Fatalf("ProxySession normal close: %v", err) + } +} + +func testConnectToken() string { + return base64.RawURLEncoding.EncodeToString(make([]byte, 32)) +} + +func testHostKey(t *testing.T) string { + t.Helper() + public, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + key, err := cryptossh.NewPublicKey(public) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(cryptossh.MarshalAuthorizedKey(key))) +} diff --git a/go/internal/ssh/ssh.go b/go/internal/ssh/ssh.go index b8394822..eaede3ca 100644 --- a/go/internal/ssh/ssh.go +++ b/go/internal/ssh/ssh.go @@ -75,3 +75,21 @@ func DispatchSSH(client *apiclient.Client, name string, extraArgs []string, stdo cmd.Stderr = stderr return cmd.Run() } + +// ExecSessionSSH replaces the current process with OpenSSH targeting a strict +// v2 alias whose ProxyCommand fetches a fresh session per dial. +func ExecSessionSSH(alias string, forcePTY bool, extraArgs []string) error { + if _, err := ParseSessionAlias(alias); err != nil { + return err + } + sshArgs := []string{alias} + if forcePTY { + sshArgs = append([]string{"-t"}, sshArgs...) + } + sshArgs = append(sshArgs, extraArgs...) + sshBin, err := exec.LookPath("ssh") + if err != nil { + return fmt.Errorf("ssh not found: %w", err) + } + return syscall.Exec(sshBin, append([]string{"ssh"}, sshArgs...), os.Environ()) +} diff --git a/go/internal/ssh/websocket_dialer_test.go b/go/internal/ssh/websocket_dialer_test.go new file mode 100644 index 00000000..fb630790 --- /dev/null +++ b/go/internal/ssh/websocket_dialer_test.go @@ -0,0 +1,57 @@ +package ssh + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestWebSocketDialerSendsCredentialOnlyInHeader(t *testing.T) { + credential := testConnectToken() + seen := make(chan *http.Request, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + seen <- request.Clone(context.Background()) + connection, err := websocket.Accept(w, request, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled}) + if err != nil { + return + } + defer connection.CloseNow() + messageType, data, err := connection.Read(context.Background()) + if err == nil { + _ = connection.Write(context.Background(), messageType, data) + } + })) + defer server.Close() + + connectURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/ssh-sessions" + stream, err := (WebSocketDialer{}).Dial(context.Background(), connectURL, credential) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer stream.Close() + if _, err := stream.Write([]byte("ssh")); err != nil { + t.Fatalf("stream write: %v", err) + } + got, err := io.ReadAll(io.LimitReader(stream, 3)) + if err != nil { + t.Fatalf("stream read: %v", err) + } + if string(got) != "ssh" { + t.Fatalf("echo = %q", got) + } + request := <-seen + if request.Header.Get("Authorization") != "Bearer "+credential { + t.Fatalf("Authorization = %q", request.Header.Get("Authorization")) + } + if strings.Contains(request.URL.String(), credential) { + t.Fatalf("credential leaked into URL %q", request.URL) + } + if extension := request.Header.Get("Sec-WebSocket-Extensions"); extension != "" { + t.Fatalf("compression requested: %q", extension) + } +} diff --git a/go/internal/wsstream/stream.go b/go/internal/wsstream/stream.go new file mode 100644 index 00000000..d1d7b39c --- /dev/null +++ b/go/internal/wsstream/stream.go @@ -0,0 +1,84 @@ +// Package wsstream adapts bounded binary WebSocket messages to an opaque byte +// stream for SSH tunneling. +package wsstream + +import ( + "context" + "errors" + "io" + "sync" + + "github.com/coder/websocket" +) + +// Stream is a concurrent-reader/writer binary WebSocket byte stream. +type Stream struct { + connection *websocket.Conn + ctx context.Context + cancel context.CancelFunc + maxMessage int + readMu sync.Mutex + reader io.Reader + closeOnce sync.Once +} + +// New creates a stream with a bounded per-message read and write size. +func New(parent context.Context, connection *websocket.Conn, maxMessage int) *Stream { + ctx, cancel := context.WithCancel(parent) + connection.SetReadLimit(int64(maxMessage)) + return &Stream{ + connection: connection, + ctx: ctx, + cancel: cancel, + maxMessage: maxMessage, + } +} + +// Read joins consecutive binary messages into one byte stream. +func (s *Stream) Read(buffer []byte) (int, error) { + s.readMu.Lock() + defer s.readMu.Unlock() + for { + if s.reader == nil { + messageType, reader, err := s.connection.Reader(s.ctx) + if err != nil { + return 0, err + } + if messageType != websocket.MessageBinary { + _ = s.connection.Close(websocket.StatusUnsupportedData, "binary messages required") + return 0, errors.New("non-binary WebSocket message") + } + s.reader = reader + } + read, err := s.reader.Read(buffer) + if errors.Is(err, io.EOF) { + s.reader = nil + if read != 0 { + return read, nil + } + continue + } + return read, err + } +} + +// Write sends one bounded binary message. +func (s *Stream) Write(buffer []byte) (int, error) { + if len(buffer) > s.maxMessage { + return 0, websocket.ErrMessageTooBig + } + if err := s.connection.Write(s.ctx, websocket.MessageBinary, buffer); err != nil { + return 0, err + } + return len(buffer), nil +} + +// Close cancels active I/O and performs a normal WebSocket close handshake. +func (s *Stream) Close() error { + var closeErr error + s.closeOnce.Do(func() { + closeErr = s.connection.Close(websocket.StatusNormalClosure, "") + s.cancel() + }) + return closeErr +} diff --git a/install.sh b/install.sh index 9c23dd54..9212e2ff 100755 --- a/install.sh +++ b/install.sh @@ -5,6 +5,7 @@ INSTALL_DIR="${AMIKA_INSTALL_DIR:-/usr/local/bin}" GITHUB_REPO="gofixpoint/amika" DEFAULT_VERSION="0.13.0" DEFAULT_AMIKALOG_VERSION="0.2.0" +DEFAULT_AMIKAD_VERSION="0.1.0" COMPONENT="amika" INSTALL_VERSION="" DRY_RUN=false @@ -14,15 +15,15 @@ usage() { install.sh — install an amika release binary Downloads a specific release binary from GitHub and installs it. Installs the -amika CLI by default, or the separately-versioned amikalog CLI with --component. +amika CLI by default, or a separately-versioned component with --component. Usage: sh install.sh [--help] [--component NAME] [--install-version VERSION] [--dry-run] Flags: - --component Component to install: amika (default) or amikalog + --component Component to install: amika (default), amikad, or amikalog --install-version Install a specific version - (amika default: ${DEFAULT_VERSION}; amikalog default: ${DEFAULT_AMIKALOG_VERSION}) + (amika: ${DEFAULT_VERSION}; amikad: ${DEFAULT_AMIKAD_VERSION}; amikalog: ${DEFAULT_AMIKALOG_VERSION}) --dry-run Show what would be done without downloading or installing Environment variables: @@ -32,6 +33,7 @@ Examples: curl -fsSL https://raw.githubusercontent.com/gofixpoint/amika/main/install.sh | sh sh install.sh --install-version 0.1.0-rc.1 sh install.sh --component amikalog --install-version 0.2.0 + sh install.sh --component amikad --install-version 0.1.0 AMIKA_INSTALL_DIR=~/.local/bin sh install.sh EOF } @@ -125,9 +127,10 @@ detect_platform() { set_install_version() { case "$COMPONENT" in amika) ;; + amikad) ;; amikalog) ;; *) - echo "Error: unsupported component: $COMPONENT (want amika or amikalog)" >&2 + echo "Error: unsupported component: $COMPONENT (want amika, amikad, or amikalog)" >&2 exit 1 ;; esac @@ -135,6 +138,7 @@ set_install_version() { if [ -z "$INSTALL_VERSION" ]; then case "$COMPONENT" in amika) INSTALL_VERSION="$DEFAULT_VERSION" ;; + amikad) INSTALL_VERSION="$DEFAULT_AMIKAD_VERSION" ;; amikalog) INSTALL_VERSION="$DEFAULT_AMIKALOG_VERSION" ;; esac fi