add keyless key file

rabbitprincess committed Mar 2, 2026 at 00:26 UTC f6d60f7c473c4672700732e92926686752257766
7 files changed +245 -61
cmd/relay-server/main.go
+57 -52
@@ -19,79 +19,84 @@ import (
19 "gosuda.org/portal/portal/sni"
20 )
21
22 -var (
23 - // Server
24 - flagPort int
25 - flagAdminSecretKey string
26 - flagNoIndex bool
27 - flagLeaseBPS int
28 -
29 - // Portal
30 - flagPortalURL string
31 - flagBootstraps []string
32 -
33 - // TLS
34 - flagSNIPort string
22 +const (
23 + defaultHTTPPort = 4017
24 + defaultPortalURL = "http://localhost:4017"
25 + defaultSNIPort = ":443"
26 + defaultKeylessKeyFile = "/etc/portal/keyless/privkey.pem"
27 )
28
29 +// flagPortalURL is kept for package-level consumers in other files.
30 +var flagPortalURL string
31 +
32 +type relayServerConfig struct {
33 + Port int
34 + AdminSecretKey string
35 + NoIndex bool
36 + LeaseBPS int
37 + PortalURL string
38 + Bootstraps []string
39 + SNIPort string
40 + KeylessKeyFile string
41 +}
42 +
43 func main() {
44 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
45
40 - defaultPortalURL := strings.TrimSuffix(os.Getenv("PORTAL_URL"), "/")
41 - if defaultPortalURL == "" {
42 - // Prefer explicit scheme for localhost so downstream URL building is unambiguous
43 - defaultPortalURL = "http://localhost:4017"
46 + cfg := relayServerConfig{}
47 +
48 + portalURL := strings.TrimSuffix(strings.TrimSpace(os.Getenv("PORTAL_URL")), "/")
49 + if portalURL == "" {
50 + // Prefer explicit scheme for localhost so downstream URL building is unambiguous.
51 + portalURL = defaultPortalURL
52 }
45 - defaultBootstraps := os.Getenv("BOOTSTRAP_URIS")
46 - if defaultBootstraps == "" {
47 - defaultBootstraps = defaultBootstrapFrom(defaultPortalURL)
53 + bootstrapsCSV := strings.TrimSpace(os.Getenv("BOOTSTRAP_URIS"))
54 + if bootstrapsCSV == "" {
55 + bootstrapsCSV = defaultBootstrapFrom(portalURL)
56 }
49 - defaultSNIPort := os.Getenv("SNI_PORT")
50 - if defaultSNIPort == "" {
51 - defaultSNIPort = ":443"
57 + sniPort := strings.TrimSpace(os.Getenv("SNI_PORT"))
58 + if sniPort == "" {
59 + sniPort = defaultSNIPort
60 + }
61 + keylessKey := strings.TrimSpace(os.Getenv("KEYLESS_KEY_FILE"))
62 + if keylessKey == "" {
63 + keylessKey = defaultKeylessKeyFile
64 }
65
54 - // Server flags
55 - flag.IntVar(&flagPort, "port", 4017, "HTTP server port")
56 - flag.StringVar(&flagAdminSecretKey, "admin-secret-key", os.Getenv("ADMIN_SECRET_KEY"), "admin auth secret (env: ADMIN_SECRET_KEY)")
57 - flag.BoolVar(&flagNoIndex, "noindex", os.Getenv("NOINDEX") == "true", "disallow crawlers (env: NOINDEX)")
58 - flag.IntVar(&flagLeaseBPS, "lease-bps", 0, "bytes-per-second limit per lease (0=unlimited)")
59 -
60 - // Portal flags
61 - var flagBootstrapsCSV string
62 - flag.StringVar(&flagPortalURL, "portal-url", defaultPortalURL, "portal base URL (env: PORTAL_URL)")
63 - flag.StringVar(&flagBootstrapsCSV, "bootstraps", defaultBootstraps, "bootstrap URIs, comma-separated (env: BOOTSTRAP_URIS)")
64 -
65 - // TLS flags
66 - flag.StringVar(&flagSNIPort, "sni-port", defaultSNIPort, "SNI router port (env: SNI_PORT)")
67 -
66 + flag.IntVar(&cfg.Port, "port", defaultHTTPPort, "HTTP server port")
67 + flag.StringVar(&cfg.AdminSecretKey, "admin-secret-key", strings.TrimSpace(os.Getenv("ADMIN_SECRET_KEY")), "admin auth secret (env: ADMIN_SECRET_KEY)")
68 + flag.BoolVar(&cfg.NoIndex, "noindex", strings.EqualFold(strings.TrimSpace(os.Getenv("NOINDEX")), "true"), "disallow crawlers (env: NOINDEX)")
69 + flag.IntVar(&cfg.LeaseBPS, "lease-bps", 0, "bytes-per-second limit per lease (0=unlimited)")
70 + flag.StringVar(&cfg.PortalURL, "portal-url", portalURL, "portal base URL (env: PORTAL_URL)")
71 + flag.StringVar(&bootstrapsCSV, "bootstraps", bootstrapsCSV, "bootstrap URIs, comma-separated (env: BOOTSTRAP_URIS)")
72 + flag.StringVar(&cfg.SNIPort, "sni-port", sniPort, "SNI router port (env: SNI_PORT)")
73 + flag.StringVar(&cfg.KeylessKeyFile, "keyless-key-file", keylessKey, "PEM private key path for relay keyless signer (env: KEYLESS_KEY_FILE)")
74 flag.Parse()
75
70 - flagBootstraps = parseURLs(flagBootstrapsCSV)
71 - if err := runServer(); err != nil {
76 + cfg.Bootstraps = parseURLs(bootstrapsCSV)
77 + flagPortalURL = cfg.PortalURL
78 + if err := runServer(cfg); err != nil {
79 log.Fatal().Err(err).Msg("execute root command")
80 }
81 }
82
76 -func runServer() error {
83 +func runServer(cfg relayServerConfig) error {
84 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
85 defer stop()
86
87 log.Info().
81 - Str("portal_base_url", flagPortalURL).
82 - Strs("bootstrap_uris", flagBootstraps).
88 + Str("portal_base_url", cfg.PortalURL).
89 + Strs("bootstrap_uris", cfg.Bootstraps).
90 Msg("[server] frontend configuration")
91
85 - serv := portal.NewRelayServer(
86 - ctx,
87 - flagBootstraps,
88 - flagSNIPort,
89 - flagPortalURL,
90 - )
92 + serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, cfg.SNIPort, cfg.PortalURL, cfg.KeylessKeyFile)
93 + if err != nil {
94 + return fmt.Errorf("create relay server: %w", err)
95 + }
96
97 frontend := NewFrontend()
93 - authManager := manager.NewAuthManager(flagAdminSecretKey)
94 - admin := NewAdmin(int64(flagLeaseBPS), frontend, authManager)
98 + authManager := manager.NewAuthManager(cfg.AdminSecretKey)
99 + admin := NewAdmin(int64(cfg.LeaseBPS), frontend, authManager)
100 frontend.SetAdmin(admin)
101
102 // Load persisted admin settings (ban list, BPS limits, IP bans)
@@ -129,11 +134,11 @@ func runServer() error {
134 })
135
136 if err := serv.Start(); err != nil {
132 - log.Fatal().Err(err).Msg("[server] Failed to start relay server")
137 + return fmt.Errorf("start relay server: %w", err)
138 }
139 defer serv.Stop()
140
136 - httpSrv := serveHTTP(fmt.Sprintf(":%d", flagPort), serv, admin, frontend, flagNoIndex, stop)
141 + httpSrv := serveHTTP(fmt.Sprintf(":%d", cfg.Port), serv, admin, frontend, cfg.NoIndex, stop)
142
143 <-ctx.Done()
144 log.Info().Msg("[server] shutting down...")
cmd/relay-server/serve.go
+69 -1
@@ -4,6 +4,8 @@ import (
4 "bufio"
5 "context"
6 "embed"
7 + "encoding/json"
8 + "errors"
9 "io"
10 "net"
11 "net/http"
@@ -13,6 +15,7 @@ import (
15 "github.com/rs/zerolog/log"
16
17 "gosuda.org/portal/portal"
18 + "gosuda.org/portal/portal/keyless"
19 "gosuda.org/portal/sdk"
20 )
21
@@ -20,7 +23,14 @@ import (
23 var distFS embed.FS
24
25 // serveHTTP builds the HTTP mux and returns the server.
23 -func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, frontend *Frontend, noIndex bool, cancel context.CancelFunc) *http.Server {
26 +func serveHTTP(
27 + addr string,
28 + serv *portal.RelayServer,
29 + admin *Admin,
30 + frontend *Frontend,
31 + noIndex bool,
32 + cancel context.CancelFunc,
33 +) *http.Server {
34 if addr == "" {
35 addr = ":0"
36 }
@@ -66,6 +76,11 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fr
76 registry.HandleSDKRequest(w, r, serv)
77 })
78
79 + // Keyless signer endpoint.
80 + appMux.HandleFunc("/v1/sign", func(w http.ResponseWriter, r *http.Request) {
81 + handleKeylessSign(w, r, serv.GetKeylessSigner())
82 + })
83 +
84 // App UI index page - serve React frontend with SSR (delegates to serveAppStatic)
85 appMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
86 // serveAppStatic handles both "/" and 404 fallback with SSR
@@ -134,6 +149,59 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fr
149 return srv
150 }
151
152 +func handleKeylessSign(w http.ResponseWriter, r *http.Request, signer *keyless.Signer) {
153 + if signer == nil {
154 + writeSignError(w, http.StatusNotFound, "keyless signer is disabled")
155 + return
156 + }
157 +
158 + if r.Method != http.MethodPost {
159 + w.Header().Set("Allow", http.MethodPost)
160 + writeSignError(w, http.StatusMethodNotAllowed, "method not allowed")
161 + return
162 + }
163 +
164 + if ct := r.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(ct, "application/json") {
165 + writeSignError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
166 + return
167 + }
168 +
169 + defer r.Body.Close()
170 +
171 + var req keyless.SignRequest
172 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
173 + writeSignError(w, http.StatusBadRequest, "invalid json body")
174 + return
175 + }
176 +
177 + resp, err := signer.Sign(r.Context(), &req)
178 + if err != nil {
179 + status := http.StatusInternalServerError
180 + switch {
181 + case errors.Is(err, keyless.ErrSignerDisabled):
182 + status = http.StatusNotFound
183 + case errors.Is(err, keyless.ErrInvalidArgument):
184 + status = http.StatusBadRequest
185 + case errors.Is(err, keyless.ErrPermissionDenied):
186 + status = http.StatusForbidden
187 + }
188 + writeSignError(w, status, err.Error())
189 + return
190 + }
191 +
192 + w.Header().Set("Content-Type", "application/json")
193 + if err := json.NewEncoder(w).Encode(resp); err != nil {
194 + log.Error().Err(err).Msg("[signer] failed to encode sign response")
195 + writeSignError(w, http.StatusInternalServerError, "failed to encode response")
196 + }
197 +}
198 +
199 +func writeSignError(w http.ResponseWriter, status int, message string) {
200 + w.Header().Set("Content-Type", "application/json")
201 + w.WriteHeader(status)
202 + _ = json.NewEncoder(w).Encode(keyless.ErrorResponse{Error: message})
203 +}
204 +
205 // shouldProxyHTTP checks if the request should be proxied via HTTP.
206 // Returns true if TLS mode is no-tls.
207 func shouldProxyHTTP(host string, serv *portal.RelayServer) bool {
docker-compose.yml
+1
@@ -17,6 +17,7 @@ services:
17
18 # SNI router port (for TLS passthrough)
19 SNI_PORT: ${SNI_PORT:-:443}
20 + KEYLESS_KEY_FILE: ${KEYLESS_KEY_FILE:-/etc/portal/keyless/privkey.pem}
21 ports:
22 - "${PORTAL_PORT:-4017}:${PORTAL_PORT:-4017}"
23 - "80:80"
go.mod
+1 -1
@@ -3,7 +3,7 @@ module gosuda.org/portal
3 go 1.26.0
4
5 require (
6 - github.com/gosuda/keyless_tls v0.0.0-20260227040929-641e56ad6610
6 + github.com/gosuda/keyless_tls v0.0.1-0.20260227054723-d699441f3834
7 github.com/rs/zerolog v1.34.0
8 golang.org/x/net v0.50.0
9 )
go.sum
+2 -2
@@ -1,7 +1,7 @@
1 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
2 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
3 -github.com/gosuda/keyless_tls v0.0.0-20260227040929-641e56ad6610 h1:P29C9M4t/LHIiEkwTZ96ohlPb3qmzwxLdhNai878LMQ=
4 -github.com/gosuda/keyless_tls v0.0.0-20260227040929-641e56ad6610/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
3 +github.com/gosuda/keyless_tls v0.0.1-0.20260227054723-d699441f3834 h1:P+oEVDMGuhFxU3pBMvStOPbDLin8GO9pv7+iA/xaYMs=
4 +github.com/gosuda/keyless_tls v0.0.1-0.20260227054723-d699441f3834/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
5 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
6 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
7 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
portal/keyless/signer.go new
+87
@@ -0,0 +1,87 @@
1 +package keyless
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "fmt"
7 + "os"
8 + "strings"
9 + "time"
10 +
11 + ksigner "github.com/gosuda/keyless_tls/relay/signer"
12 + "github.com/gosuda/keyless_tls/relay/signrpc"
13 +)
14 +
15 +const (
16 + defaultKeyID = "relay-cert"
17 + defaultAllowedSkew = 30 * time.Second
18 +)
19 +
20 +var (
21 + ErrSignerDisabled = errors.New("keyless signer is disabled")
22 + ErrInvalidArgument = ksigner.ErrInvalidArgument
23 + ErrPermissionDenied = ksigner.ErrPermissionDenied
24 +)
25 +
26 +type SignRequest = signrpc.SignRequest
27 +type SignResponse = signrpc.SignResponse
28 +type ErrorResponse = signrpc.ErrorResponse
29 +
30 +// Signer serves the keyless signing endpoint used by tunnel keyless mode.
31 +type Signer struct {
32 + service *ksigner.Service
33 + keyID string
34 +}
35 +
36 +type Config struct {
37 + KeyFile string
38 +}
39 +
40 +func NewSigner(cfg Config) (*Signer, error) {
41 + keyFile := strings.TrimSpace(cfg.KeyFile)
42 + if keyFile == "" {
43 + return nil, nil
44 + }
45 +
46 + keyPEM, err := os.ReadFile(keyFile)
47 + if err != nil {
48 + if errors.Is(err, os.ErrNotExist) {
49 + return nil, nil
50 + }
51 + return nil, fmt.Errorf("read KEYLESS_KEY_FILE: %w", err)
52 + }
53 +
54 + signingKey, err := ksigner.ParsePrivateKeyPEM(keyPEM)
55 + if err != nil {
56 + return nil, fmt.Errorf("parse keyless signing key: %w", err)
57 + }
58 +
59 + store := ksigner.NewStaticKeyStore()
60 + if err := store.Put(defaultKeyID, signingKey); err != nil {
61 + return nil, fmt.Errorf("register keyless signing key: %w", err)
62 + }
63 +
64 + svc := &ksigner.Service{
65 + Store: store,
66 + AllowedSkew: defaultAllowedSkew,
67 + }
68 +
69 + return &Signer{
70 + service: svc,
71 + keyID: defaultKeyID,
72 + }, nil
73 +}
74 +
75 +func (s *Signer) KeyID() string {
76 + if s == nil {
77 + return ""
78 + }
79 + return s.keyID
80 +}
81 +
82 +func (s *Signer) Sign(ctx context.Context, req *SignRequest) (*SignResponse, error) {
83 + if s == nil || s.service == nil {
84 + return nil, ErrSignerDisabled
85 + }
86 + return s.service.Sign(ctx, req)
87 +}
portal/relay.go
+28 -5
@@ -3,6 +3,7 @@ package portal
3 import (
4 "context"
5 "crypto/subtle"
6 + "fmt"
7 "net/url"
8 "strings"
9 "sync"
@@ -10,6 +11,7 @@ import (
11
12 "github.com/rs/zerolog/log"
13
14 + "gosuda.org/portal/portal/keyless"
15 "gosuda.org/portal/portal/sni"
16 )
17
@@ -17,9 +19,10 @@ type RelayServer struct {
19 address []string
20 BaseHost string
21
20 - leaseManager *LeaseManager
21 - reverseHub *ReverseHub
22 - sniRouter *sni.Router
22 + leaseManager *LeaseManager
23 + reverseHub *ReverseHub
24 + sniRouter *sni.Router
25 + keylessSigner *keyless.Signer
26
27 stopch chan struct{}
28 waitgroup sync.WaitGroup
@@ -31,7 +34,8 @@ func NewRelayServer(
34 address []string,
35 sniPort string,
36 portalURL string,
34 -) *RelayServer {
37 + keylessKey string,
38 +) (*RelayServer, error) {
39 baseDomain := extractBaseDomain(portalURL)
40 if baseDomain == "" {
41 log.Warn().Msg("[RelayServer] Could not extract base domain from portal URL")
@@ -45,6 +49,20 @@ func NewRelayServer(
49 sniRouter: sni.NewRouter(sniPort),
50 stopch: make(chan struct{}),
51 }
52 +
53 + signer, err := keyless.NewSigner(keyless.Config{
54 + KeyFile: keylessKey,
55 + })
56 + if err != nil {
57 + return nil, fmt.Errorf("configure keyless signer: %w", err)
58 + }
59 + server.keylessSigner = signer
60 + if signer != nil {
61 + log.Info().
62 + Str("key_id", signer.KeyID()).
63 + Msg("[signer] keyless signer enabled at /v1/sign")
64 + }
65 +
66 server.leaseManager.SetOnLeaseDeleted(server.reverseHub.DropLease)
67 server.reverseHub.SetAuthorizer(func(leaseID, token string) bool {
68 entry, ok := server.leaseManager.GetLeaseByID(strings.TrimSpace(leaseID))
@@ -57,7 +75,7 @@ func NewRelayServer(
75 }
76 return subtle.ConstantTimeCompare([]byte(expected), []byte(strings.TrimSpace(token))) == 1
77 })
60 - return server
78 + return server, nil
79 }
80
81 func extractBaseDomain(rawURL string) string {
@@ -97,6 +115,11 @@ func (g *RelayServer) GetSNIRouter() *sni.Router {
115 return g.sniRouter
116 }
117
118 +// GetKeylessSigner returns relay keyless signer when configured.
119 +func (g *RelayServer) GetKeylessSigner() *keyless.Signer {
120 + return g.keylessSigner
121 +}
122 +
123 // Start starts the relay server.
124 func (g *RelayServer) Start() error {
125 g.leaseManager.Start()