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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
14 changes: 14 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ 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.51.0
github.com/nats-io/nats.go v1.47.0
github.com/rs/zerolog v1.34.0
golang.org/x/crypto v0.43.0
google.golang.org/grpc v1.76.0
)

require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/coreos/go-oidc/v3 v3.16.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
Expand All @@ -20,14 +22,17 @@ 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
github.com/mattn/go-colorable v0.1.13 // indirect
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
Expand Down
21 changes: 12 additions & 9 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
Expand Down Expand Up @@ -31,14 +35,10 @@ 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/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=
Expand All @@ -64,6 +64,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=
Expand All @@ -74,7 +76,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=
Expand All @@ -89,6 +90,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=
Expand Down
7 changes: 7 additions & 0 deletions internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down
45 changes: 45 additions & 0 deletions internal/server/grpc.go
Original file line number Diff line number Diff line change
@@ -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
}
33 changes: 33 additions & 0 deletions internal/server/grpc_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
51 changes: 51 additions & 0 deletions internal/server/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -54,6 +56,7 @@ type Server struct {
provisioner *provisioner.Client
authzClient authzv1.AuthzServiceClient
fpub *NatsFailuresPublisher
grpc *gapi.Server
configPath string
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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")
}
Expand Down
Loading