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()