diff --git a/CLAUDE.md b/CLAUDE.md index 04ce74a..315b04b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,8 @@ This fork adds the `AllowedAuthsCallback` field to `ssh.ServerConfig`, which ssh ``` main.go — flag parsing, signal handling, server lifecycle internal/server/ - ssh.go — Server struct, listener, connection dispatch, forking + ssh.go — Server struct, listener, connection dispatch, forking, gRPC server lifecycle + grpc.go — sshproxy.v1.SSHProxyService implementation (GetVersionInfo) auth.go — Public key, password, keyboard-interactive callbacks authzcheck.go — Authz service evaluation (SSH actions + RecordObligation) connection.go — Connection/Session state, workspace handshake, session reporting @@ -82,6 +83,12 @@ All clients use `gapi.ClientConfig` (address + optional TLS + token file). A ser When authz is not configured both functions return nil/false and callers fall back to static config. +## gRPC control interface + +`grpc.go` serves `sshproxy.v1.SSHProxyService` (proto + generated stubs live in the `common` module under `pkg/api/proto/sshproxy/v1` and `pkg/api/gen/go/sshproxy/v1`). It is built on `gapi.Server`, same as identity/provisioner/session, and is **opt-in**: it starts only when a port is set under the `grpc:` config block (`gapi.ServerConfig` — port, TLS, `authEnabled`, `allowed` callers). It is started/stopped by `Server.Start`/`Server.Stop` in the non-forking parent only; forking child processes never serve gRPC. + +- `GetVersionInfo` — returns `common.v1.GetVersionInfoResponse` (version, commit_id, description) sourced from `SSHPROXY_VERSION`/`SSHPROXY_COMMIT`. Same RPC name/signature every other k8shell service exposes. + ## Logging zerolog, JSON by default. Pass `--logtext` for human-readable output. Logger names: `ssh-server`, `ssh-failures`. Log level is set via the logger package defaults; no config field. @@ -94,7 +101,7 @@ zerolog, JSON by default. Pass `--logtext` for human-readable output. Logger nam ./ssh-proxy --child --config config.yaml [--logtext] ``` -Child processes do **not** share the parent's listener, NATS client, or identity/session/provisioner clients — they create their own. +Child processes do **not** share the parent's listener, NATS client, identity/session/provisioner clients, or gRPC server — they create their own (and never serve gRPC). ## Config defaults diff --git a/config/config.yaml b/config/config.yaml index 96e5ee4..11073a4 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -45,6 +45,20 @@ server: recordSFTP: false recordDirectTCPIP: true +# gRPC control interface (sshproxy.v1.SSHProxyService). +# Opt-in: omit this block or leave port unset/0 to disable it. +grpc: + port: 9050 + enableTLS: false + # certFile: /opt/shared/ssh-proxy/tls/tls.crt + # keyFile: /opt/shared/ssh-proxy/tls/tls.key + + # JWT required claims for authentication + authEnabled: false + audience: ssh-proxy + # allowed: + # - serviceAccount: api-server + nats: enabled: false url: nats://${NATS_HOST}:4222 diff --git a/go.mod b/go.mod index 18e15c2..9cfedf0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/k8shell-io/ssh-proxy go 1.24.5 require ( - github.com/k8shell-io/common v0.48.0 + github.com/k8shell-io/common v0.52.0 github.com/nats-io/nats.go v1.47.0 github.com/rs/zerolog v1.34.0 golang.org/x/crypto v0.43.0 @@ -11,6 +11,8 @@ require ( ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/coreos/go-oidc/v3 v3.16.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -20,6 +22,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/invopop/jsonschema v0.14.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -27,7 +30,9 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/net v0.45.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.37.0 // indirect diff --git a/go.sum b/go.sum index ead99c6..f026ac1 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -31,14 +35,12 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/k8shell-io/common v0.37.0 h1:whq66WosIJECKErUKZF1RQep7tdpOfI6GtP4hXREpsQ= -github.com/k8shell-io/common v0.37.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.39.0 h1:hfrKZYX2lBonornGrfK35rSY/+5o2sK+SakgUPKDL24= -github.com/k8shell-io/common v0.39.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.40.0 h1:MhQPVI5oe+JSdRJhWrtvCTJf0MaJDjM58KR7Nq3+lsM= -github.com/k8shell-io/common v0.40.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.48.0 h1:k+GvZ0YdVNHFU9rAb0T2skIZYdVdN370CzY01OmGSiw= -github.com/k8shell-io/common v0.48.0/go.mod h1:TcMCP6YSmuVFHi7igxE0hK4kRewdNcfOQmhsx7mJAWY= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/k8shell-io/common v0.51.0 h1:XfC/6JjYU1oN0opuKFBjbR8bpcKnorCAC/BbPmyP9XQ= +github.com/k8shell-io/common v0.51.0/go.mod h1:TcMCP6YSmuVFHi7igxE0hK4kRewdNcfOQmhsx7mJAWY= +github.com/k8shell-io/common v0.52.0 h1:DI/BKMJwv5TLV+Ubmbqwu3jxy9prwpTUwKAymIXBOFs= +github.com/k8shell-io/common v0.52.0/go.mod h1:TcMCP6YSmuVFHi7igxE0hK4kRewdNcfOQmhsx7mJAWY= github.com/k8shell-io/crypto v0.41.1-ssh-proxy h1:8+q6Ofc2ky23Oc9iNyiq8aeiQBIP+y3+O6zzHqe1f48= github.com/k8shell-io/crypto v0.41.1-ssh-proxy/go.mod h1:RVZeOJCpqtogniULztSXQESKJCfcI8WCxsS0FagMA8U= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -64,6 +66,8 @@ github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= @@ -74,7 +78,6 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -89,6 +92,8 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= diff --git a/internal/server/config.go b/internal/server/config.go index 6e7e6d3..929fef4 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -18,6 +18,7 @@ import ( // Config represents the server configuration type Config struct { Server ServerConfig `yaml:"server"` + Grpc gapi.ServerConfig `yaml:"grpc"` Nats natsc.NATSClientConfig `yaml:"nats"` Identity gapi.ClientConfig `yaml:"identity"` Session gapi.ClientConfig `yaml:"session"` @@ -26,6 +27,12 @@ type Config struct { Authz gapi.ClientConfig `yaml:"authz"` } +// GrpcEnabled reports whether the gRPC control interface should be started. +// It is opt-in: the server runs only when a port is configured under `grpc`. +func (c *Config) GrpcEnabled() bool { + return c.Grpc.Port != 0 +} + // ServerConfig represents the SSH server configuration. type ServerConfig struct { Port int `yaml:"port"` diff --git a/internal/server/grpc.go b/internal/server/grpc.go new file mode 100644 index 0000000..1f11c20 --- /dev/null +++ b/internal/server/grpc.go @@ -0,0 +1,45 @@ +// Copyright 2026 The K8shell Authors. All rights reserved. +// Use of this source code is governed by a AGPLv3 +// license that can be found in the LICENSE file. + +package server + +import ( + "context" + + commonv1 "github.com/k8shell-io/common/pkg/api/gen/go/common/v1" + sshproxyv1 "github.com/k8shell-io/common/pkg/api/gen/go/sshproxy/v1" + "github.com/rs/zerolog" +) + +// sshProxyDescription is the short human-readable summary of what this +// service does, returned by GetVersionInfo. +const sshProxyDescription = "Handles SSH and proxies channel traffic to the k8shelld daemon." + +// SSHProxyService implements the sshproxy.v1 gRPC service. +type SSHProxyService struct { + server *Server + log *zerolog.Logger + sshproxyv1.UnimplementedSSHProxyServiceServer +} + +// NewSSHProxyService returns a new SSHProxyService. +func NewSSHProxyService(server *Server) *SSHProxyService { + return &SSHProxyService{ + server: server, + log: server.log, + } +} + +// GetVersionInfo returns build and version metadata for this service: its +// released semantic version, the git commit it was built from, and a short +// description of what the service does. +func (s *SSHProxyService) GetVersionInfo( + _ context.Context, _ *commonv1.GetVersionInfoRequest, +) (*commonv1.GetVersionInfoResponse, error) { + return &commonv1.GetVersionInfoResponse{ + Version: SSHPROXY_VERSION, + CommitId: SSHPROXY_COMMIT, + Description: sshProxyDescription, + }, nil +} diff --git a/internal/server/grpc_test.go b/internal/server/grpc_test.go new file mode 100644 index 0000000..8d9a59a --- /dev/null +++ b/internal/server/grpc_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 The K8shell Authors. All rights reserved. +// Use of this source code is governed by a AGPLv3 +// license that can be found in the LICENSE file. + +package server + +import ( + "context" + "testing" + + commonv1 "github.com/k8shell-io/common/pkg/api/gen/go/common/v1" + log "github.com/k8shell-io/common/pkg/logger" +) + +func TestSSHProxyServiceGetVersionInfo(t *testing.T) { + logger := log.NewLogger("test") + svc := NewSSHProxyService(&Server{log: logger}) + + resp, err := svc.GetVersionInfo(context.Background(), &commonv1.GetVersionInfoRequest{}) + if err != nil { + t.Fatalf("GetVersionInfo returned error: %v", err) + } + + if resp.GetVersion() != SSHPROXY_VERSION { + t.Errorf("version = %q, want %q", resp.GetVersion(), SSHPROXY_VERSION) + } + if resp.GetCommitId() != SSHPROXY_COMMIT { + t.Errorf("commit_id = %q, want %q", resp.GetCommitId(), SSHPROXY_COMMIT) + } + if resp.GetDescription() == "" { + t.Error("description is empty") + } +} diff --git a/internal/server/ssh.go b/internal/server/ssh.go index ba0a75b..35a8481 100644 --- a/internal/server/ssh.go +++ b/internal/server/ssh.go @@ -25,12 +25,14 @@ import ( "github.com/k8shell-io/common/pkg/api/client/provisioner" sessionc "github.com/k8shell-io/common/pkg/api/client/session" authzv1 "github.com/k8shell-io/common/pkg/api/gen/go/authz/v1" + sshproxyv1 "github.com/k8shell-io/common/pkg/api/gen/go/sshproxy/v1" "github.com/k8shell-io/common/pkg/gapi" log "github.com/k8shell-io/common/pkg/logger" "github.com/k8shell-io/common/pkg/models" natsc "github.com/k8shell-io/common/pkg/nats" "github.com/rs/zerolog" "golang.org/x/crypto/ssh" + "google.golang.org/grpc" ) var ( @@ -54,6 +56,7 @@ type Server struct { provisioner *provisioner.Client authzClient authzv1.AuthzServiceClient fpub *NatsFailuresPublisher + grpc *gapi.Server configPath string } @@ -148,11 +151,42 @@ func NewServer(configPath string) (*Server, error) { } } } + + if err := server.initGRPCServer(); err != nil { + return nil, fmt.Errorf("failed to initialize gRPC server: %w", err) + } } return server, nil } +// initGRPCServer sets up the sshproxy.v1 gRPC control interface. It is +// opt-in: when no port is configured under `grpc` the server is left nil and +// Start/Stop skip it. Forking child processes never call this — only the +// non-forking parent serves gRPC. +func (s *Server) initGRPCServer() error { + if !s.Config.GrpcEnabled() { + s.log.Info().Msg("gRPC control interface disabled (no grpc.port configured)") + return nil + } + + grpcServer, err := gapi.NewServer(&s.Config.Grpc, true) + if err != nil { + return fmt.Errorf("create gRPC server: %w", err) + } + + err = grpcServer.RegisterService(func(srv *grpc.Server) error { + sshproxyv1.RegisterSSHProxyServiceServer(srv, NewSSHProxyService(s)) + return nil + }) + if err != nil { + return fmt.Errorf("register sshproxy service: %w", err) + } + + s.grpc = grpcServer + return nil +} + // Provisioner returns the provisioner client. // Backends interface implementation. func (s *Server) Provisioner() *provisioner.Client { @@ -477,6 +511,19 @@ func (s *Server) Start() error { s.wg.Add(1) go s.acceptConnections() + // Start the gRPC control interface when configured. gapi.Server.Start + // blocks until Stop is called, so run it in its own goroutine. + if s.grpc != nil { + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.log.Info().Int("port", s.Config.Grpc.Port).Msg("Starting gRPC control interface") + if err := s.grpc.Start(); err != nil { + s.log.Error().Err(err).Msg("gRPC server error") + } + }() + } + return nil } @@ -582,6 +629,10 @@ func (s *Server) Stop() { s.fpub.Close() } + if s.grpc != nil { + s.grpc.Stop() + } + s.wg.Wait() s.log.Info().Msg("SSH server stopped") }