Skip to content

Commit cf33ebb

Browse files
committed
feat: allow mTLS auth for edge agents
1 parent 85397c7 commit cf33ebb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+6193
-780
lines changed

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ build
1212
data/
1313
dist/
1414
arcane
15+
/.tmp/
16+
*.key
17+
*.pem
18+
*.crt
1519

1620
# OS
1721
.DS_Store
@@ -50,4 +54,4 @@ arcane-devcli
5054
.btca
5155

5256
cli/arcane-cli
53-
completions
57+
completions

Justfile

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,45 @@ _dev-frontend:
1515
_dev-backend:
1616
cd backend && air
1717

18+
[group('dev')]
19+
_dev-agent:
20+
#!/usr/bin/env bash
21+
set -euo pipefail
22+
23+
if [ -z "${AGENT_TOKEN:-}" ]; then
24+
echo "AGENT_TOKEN is required. Run: AGENT_TOKEN=<edge-environment-token> just dev agent"
25+
exit 1
26+
fi
27+
28+
port="${PORT:-3553}"
29+
app_url="${APP_URL:-http://localhost:${port}}"
30+
manager_api_url="${MANAGER_API_URL:-https://localhost:3552}"
31+
edge_mtls_assets_dir="${EDGE_MTLS_ASSETS_DIR:-./.tmp/edge-test-agent/edge-mtls-agent}"
32+
edge_mtls_ca_file="${EDGE_MTLS_CA_FILE:-./backend/local-manager.crt}"
33+
database_url="${DATABASE_URL:-file:./.tmp/edge-test-agent/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate}"
34+
projects_directory="${PROJECTS_DIRECTORY:-./.tmp/edge-test-agent/projects}"
35+
git_work_dir="${GIT_WORK_DIR:-./.tmp/edge-test-agent/git}"
36+
jwt_secret="${JWT_SECRET:-local-edge-test-jwt-secret-please-change}"
37+
encryption_key="${ENCRYPTION_KEY:-local-edge-test-encryption-key-32}"
38+
39+
mkdir -p "${projects_directory}" "${git_work_dir}" "${edge_mtls_assets_dir}"
40+
41+
PORT="${port}" \
42+
APP_URL="${app_url}" \
43+
EDGE_AGENT=true \
44+
EDGE_TRANSPORT=poll \
45+
EDGE_MTLS_MODE=required \
46+
EDGE_MTLS_ASSETS_DIR="${edge_mtls_assets_dir}" \
47+
EDGE_MTLS_CA_FILE="${edge_mtls_ca_file}" \
48+
AGENT_TOKEN="${AGENT_TOKEN}" \
49+
MANAGER_API_URL="${manager_api_url}" \
50+
DATABASE_URL="${database_url}" \
51+
PROJECTS_DIRECTORY="${projects_directory}" \
52+
GIT_WORK_DIR="${git_work_dir}" \
53+
JWT_SECRET="${jwt_secret}" \
54+
ENCRYPTION_KEY="${encryption_key}" \
55+
go run ./backend/cmd
56+
1857
[group('dev')]
1958
_dev-all:
2059
#!/usr/bin/env bash
@@ -32,7 +71,7 @@ _dev-docker:
3271
_dev-logs:
3372
./scripts/development/dev.sh logs
3473

35-
# Run development servers. Valid targets: "frontend", "backend", "all", "docker", "logs".
74+
# Run development servers. Valid targets: "frontend", "backend", "agent", "all", "docker", "logs".
3675
[group('dev')]
3776
dev target="docker":
3877
@just "_dev-{{ target }}"

backend/internal/bootstrap/bootstrap.go

Lines changed: 136 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -216,18 +216,20 @@ func startEdgeTunnelClientIfConfigured(appCtx context.Context, cfg *config.Confi
216216
EdgeAgent: cfg.EdgeAgent,
217217
EdgeTransport: cfg.EdgeTransport,
218218
EdgeReconnectInterval: cfg.EdgeReconnectInterval,
219+
EdgeMTLSMode: cfg.EdgeMTLSMode,
220+
EdgeMTLSAutoGenerate: cfg.EdgeMTLSAutoGenerate,
221+
EdgeMTLSCAFile: cfg.EdgeMTLSCAFile,
222+
EdgeMTLSCertFile: cfg.EdgeMTLSCertFile,
223+
EdgeMTLSKeyFile: cfg.EdgeMTLSKeyFile,
224+
EdgeMTLSServerName: cfg.EdgeMTLSServerName,
225+
EdgeMTLSAssetsDir: cfg.EdgeMTLSAssetsDir,
219226
ManagerApiUrl: cfg.ManagerApiUrl,
220227
AgentToken: cfg.AgentToken,
221228
Port: cfg.Port,
222229
Listen: cfg.Listen,
223230
}
224231

225-
slog.InfoContext(appCtx, "Starting edge tunnel client",
226-
"transport_mode", edge.NormalizeEdgeTransport(edgeCfg.EdgeTransport),
227-
"live_tunnel_attempt_grpc", edge.UseGRPCEdgeTransport(edgeCfg) || (edge.UsePollEdgeTransport(edgeCfg) && strings.TrimSpace(edgeCfg.GetManagerGRPCAddr()) != ""),
228-
"live_tunnel_attempt_websocket", edge.UseWebSocketEdgeTransport(edgeCfg) || (edge.UsePollEdgeTransport(edgeCfg) && strings.TrimSpace(edgeCfg.GetManagerBaseURL()) != ""),
229-
"manager_url", cfg.ManagerApiUrl,
230-
)
232+
slog.InfoContext(appCtx, "Starting edge agent session client", edge.StartupLogAttrs(edgeCfg)...)
231233
errCh, err := edge.StartTunnelClientWithErrors(appCtx, edgeCfg, router)
232234
if err != nil {
233235
slog.ErrorContext(appCtx, "Failed to start edge tunnel client", "error", err)
@@ -257,6 +259,23 @@ func handleAgentBootstrapPairing(ctx context.Context, cfg *config.Config, httpCl
257259

258260
req.Header.Set("X-API-Key", cfg.AgentToken)
259261

262+
if cfg.EdgeAgent && strings.TrimSpace(cfg.ManagerApiUrl) != "" {
263+
edgeClient, edgeErr := edge.NewManagerHTTPClient(&edge.Config{
264+
ManagerApiUrl: cfg.ManagerApiUrl,
265+
EdgeMTLSMode: cfg.EdgeMTLSMode,
266+
EdgeMTLSAutoGenerate: cfg.EdgeMTLSAutoGenerate,
267+
EdgeMTLSCAFile: cfg.EdgeMTLSCAFile,
268+
EdgeMTLSCertFile: cfg.EdgeMTLSCertFile,
269+
EdgeMTLSKeyFile: cfg.EdgeMTLSKeyFile,
270+
EdgeMTLSServerName: cfg.EdgeMTLSServerName,
271+
EdgeMTLSAssetsDir: cfg.EdgeMTLSAssetsDir,
272+
}, 10*time.Second)
273+
if edgeErr != nil {
274+
return fmt.Errorf("failed to configure edge pairing client: %w", edgeErr)
275+
}
276+
httpClient = edgeClient
277+
}
278+
260279
resp, err := httpClient.Do(req) //nolint:gosec // intentional request to configured manager pairing endpoint
261280
if err != nil {
262281
return fmt.Errorf("pairing request failed: %w", err)
@@ -301,45 +320,17 @@ func runServices(appCtx context.Context, cfg *config.Config, router http.Handler
301320
}
302321

303322
listenAddr := cfg.ListenAddr()
304-
httpHandler := router
305-
useTLS := cfg.TLSEnabled
306-
tlsCertFile := strings.TrimSpace(cfg.TLSCertFile)
307-
tlsKeyFile := strings.TrimSpace(cfg.TLSKeyFile)
308-
309-
if useTLS && (tlsCertFile == "" || tlsKeyFile == "") {
310-
return fmt.Errorf("TLS_ENABLED requires both TLS_CERT_FILE and TLS_KEY_FILE")
311-
}
312-
313-
var grpcServer *grpc.Server
314-
if !cfg.AgentMode && tunnelServer != nil {
315-
grpcServer = grpc.NewServer(tunnelServer.GRPCServerOptions(appCtx)...)
316-
tunnelpb.RegisterTunnelServiceServer(grpcServer, tunnelServer)
317-
318-
httpHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
319-
if isTunnelGRPCRequestInternal(r) {
320-
grpcReq := normalizeTunnelGRPCRequestPathInternal(r)
321-
grpcServer.ServeHTTP(w, grpcReq)
322-
return
323-
}
324-
router.ServeHTTP(w, r)
325-
})
326-
slog.InfoContext(appCtx, "Using shared HTTP/gRPC listener for edge tunnel", "addr", listenAddr)
323+
useTLS, tlsCertFile, tlsKeyFile, edgeCfg, err := prepareServerTLSInternal(cfg)
324+
if err != nil {
325+
return err
327326
}
328327

329-
var protocols http.Protocols
330-
protocols.SetHTTP1(true)
331-
if useTLS {
332-
protocols.SetHTTP2(true)
333-
} else {
334-
protocols.SetUnencryptedHTTP2(true)
335-
httpHandler = h2c.NewHandler(httpHandler, &http2.Server{})
336-
}
328+
httpHandler, grpcServer := configureTunnelServerInternal(appCtx, cfg, router, tunnelServer, listenAddr)
329+
httpHandler, protocols := configureHTTPProtocolsInternal(useTLS, httpHandler)
337330

338-
srv := &http.Server{
339-
Addr: listenAddr,
340-
Handler: httpHandler,
341-
Protocols: &protocols,
342-
ReadHeaderTimeout: 5 * time.Second,
331+
srv, err := newHTTPServerInternal(listenAddr, httpHandler, protocols, useTLS, edgeCfg)
332+
if err != nil {
333+
return err
343334
}
344335

345336
go func() {
@@ -389,6 +380,109 @@ func runServices(appCtx context.Context, cfg *config.Config, router http.Handler
389380
return nil
390381
}
391382

383+
func prepareServerTLSInternal(cfg *config.Config) (bool, string, string, *edge.Config, error) {
384+
useTLS := cfg.TLSEnabled
385+
tlsCertFile := strings.TrimSpace(cfg.TLSCertFile)
386+
tlsKeyFile := strings.TrimSpace(cfg.TLSKeyFile)
387+
edgeCfg := buildEdgeRuntimeConfigInternal(cfg)
388+
if useTLS && (tlsCertFile == "" || tlsKeyFile == "") {
389+
return false, "", "", nil, fmt.Errorf("TLS_ENABLED requires both TLS_CERT_FILE and TLS_KEY_FILE")
390+
}
391+
392+
if cfg.AgentMode {
393+
return useTLS, tlsCertFile, tlsKeyFile, edgeCfg, nil
394+
}
395+
396+
if err := edge.PrepareManagerMTLSAssets(edgeCfg); err != nil {
397+
return false, "", "", nil, err
398+
}
399+
400+
if edge.NormalizeEdgeMTLSMode(cfg.EdgeMTLSMode) != edge.EdgeMTLSModeDisabled {
401+
if !useTLS {
402+
return false, "", "", nil, fmt.Errorf("EDGE_MTLS_MODE requires TLS_ENABLED=true on the manager")
403+
}
404+
if err := edge.ValidateManagerMTLSConfig(edgeCfg); err != nil {
405+
return false, "", "", nil, err
406+
}
407+
}
408+
409+
return useTLS, tlsCertFile, tlsKeyFile, edgeCfg, nil
410+
}
411+
412+
func configureTunnelServerInternal(appCtx context.Context, cfg *config.Config, router http.Handler, tunnelServer *edge.TunnelServer, listenAddr string) (http.Handler, *grpc.Server) {
413+
httpHandler := router
414+
var grpcServer *grpc.Server
415+
416+
if !cfg.AgentMode && tunnelServer != nil {
417+
grpcServer = grpc.NewServer(tunnelServer.GRPCServerOptions(appCtx)...)
418+
tunnelpb.RegisterTunnelServiceServer(grpcServer, tunnelServer)
419+
420+
httpHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
421+
if isTunnelGRPCRequestInternal(r) {
422+
grpcReq := normalizeTunnelGRPCRequestPathInternal(r)
423+
grpcServer.ServeHTTP(w, grpcReq)
424+
return
425+
}
426+
router.ServeHTTP(w, r)
427+
})
428+
slog.InfoContext(appCtx, "Using shared HTTP/gRPC listener for edge tunnel", "addr", listenAddr)
429+
}
430+
431+
return httpHandler, grpcServer
432+
}
433+
434+
func configureHTTPProtocolsInternal(useTLS bool, handler http.Handler) (http.Handler, *http.Protocols) {
435+
var protocols http.Protocols
436+
protocols.SetHTTP1(true)
437+
if useTLS {
438+
protocols.SetHTTP2(true)
439+
return handler, &protocols
440+
}
441+
442+
protocols.SetUnencryptedHTTP2(true)
443+
return h2c.NewHandler(handler, &http2.Server{}), &protocols
444+
}
445+
446+
func newHTTPServerInternal(listenAddr string, handler http.Handler, protocols *http.Protocols, useTLS bool, edgeCfg *edge.Config) (*http.Server, error) {
447+
srv := &http.Server{
448+
Addr: listenAddr,
449+
Handler: handler,
450+
Protocols: protocols,
451+
ReadHeaderTimeout: 5 * time.Second,
452+
}
453+
if !useTLS {
454+
return srv, nil
455+
}
456+
457+
tlsConfig, err := edge.BuildManagerServerTLSConfig(edgeCfg)
458+
if err != nil {
459+
return nil, err
460+
}
461+
if tlsConfig != nil {
462+
srv.TLSConfig = tlsConfig
463+
}
464+
return srv, nil
465+
}
466+
467+
func buildEdgeRuntimeConfigInternal(cfg *config.Config) *edge.Config {
468+
return &edge.Config{
469+
EdgeAgent: cfg.EdgeAgent,
470+
EdgeTransport: cfg.EdgeTransport,
471+
EdgeReconnectInterval: cfg.EdgeReconnectInterval,
472+
EdgeMTLSMode: cfg.EdgeMTLSMode,
473+
EdgeMTLSAutoGenerate: cfg.EdgeMTLSAutoGenerate,
474+
EdgeMTLSCAFile: cfg.EdgeMTLSCAFile,
475+
EdgeMTLSCertFile: cfg.EdgeMTLSCertFile,
476+
EdgeMTLSKeyFile: cfg.EdgeMTLSKeyFile,
477+
EdgeMTLSServerName: cfg.EdgeMTLSServerName,
478+
EdgeMTLSAssetsDir: cfg.EdgeMTLSAssetsDir,
479+
ManagerApiUrl: cfg.ManagerApiUrl,
480+
AgentToken: cfg.AgentToken,
481+
Port: cfg.Port,
482+
Listen: cfg.Listen,
483+
}
484+
}
485+
392486
func normalizeTunnelGRPCRequestPathInternal(r *http.Request) *http.Request {
393487
if r == nil {
394488
return nil

backend/internal/bootstrap/bootstrap_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"net/http/httptest"
66
"testing"
77

8+
"github.com/getarcaneapp/arcane/backend/internal/config"
89
tunnelpb "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge/proto/tunnel/v1"
910
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
1012
)
1113

1214
func TestNormalizeTunnelGRPCRequestPathInternal(t *testing.T) {
@@ -114,3 +116,19 @@ func TestIsTunnelGRPCRequestInternal(t *testing.T) {
114116
assert.False(t, isTunnelGRPCRequestInternal(req))
115117
})
116118
}
119+
120+
func TestPrepareServerTLSInternal_AgentModeSkipsManagerMTLSValidation(t *testing.T) {
121+
cfg := &config.Config{
122+
AgentMode: true,
123+
EdgeMTLSMode: "required",
124+
ManagerApiUrl: "https://127.0.0.1:3552",
125+
}
126+
127+
useTLS, tlsCertFile, tlsKeyFile, edgeCfg, err := prepareServerTLSInternal(cfg)
128+
require.NoError(t, err)
129+
assert.False(t, useTLS)
130+
assert.Empty(t, tlsCertFile)
131+
assert.Empty(t, tlsKeyFile)
132+
require.NotNil(t, edgeCfg)
133+
assert.Equal(t, "required", edgeCfg.EdgeMTLSMode)
134+
}

backend/internal/bootstrap/edge_bootstrap.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,30 @@ func registerEdgeTunnelRoutes(
8282
}
8383

8484
server := edge.NewTunnelServer(resolver, statusCallback)
85+
server.SetConfig(&edge.Config{
86+
EdgeMTLSMode: cfg.EdgeMTLSMode,
87+
EdgeMTLSAutoGenerate: cfg.EdgeMTLSAutoGenerate,
88+
EdgeMTLSCAFile: cfg.EdgeMTLSCAFile,
89+
EdgeMTLSCertFile: cfg.EdgeMTLSCertFile,
90+
EdgeMTLSKeyFile: cfg.EdgeMTLSKeyFile,
91+
EdgeMTLSServerName: cfg.EdgeMTLSServerName,
92+
EdgeMTLSAssetsDir: cfg.EdgeMTLSAssetsDir,
93+
ManagerApiUrl: cfg.ManagerApiUrl,
94+
})
95+
server.SetEnvironmentNameResolver(func(ctx context.Context, envID string) (string, error) {
96+
env, err := appServices.Environment.GetEnvironmentByID(ctx, envID)
97+
if err != nil {
98+
return "", err
99+
}
100+
if env == nil {
101+
return "", nil
102+
}
103+
return env.Name, nil
104+
})
85105
server.SetEventCallback(eventCallback)
86106
go server.StartCleanupLoop(ctx)
87107
apiGroup.POST("/tunnel/poll", server.HandlePoll)
108+
apiGroup.POST("/tunnel/mtls/enroll", server.HandleMTLSEnroll)
88109
apiGroup.GET("/tunnel/connect", server.HandleConnect)
89110
slog.InfoContext(ctx, "Configured edge tunnel server",
90111
"poll_enabled", true,

backend/internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ type Config struct {
7171
EdgeAgent bool `env:"EDGE_AGENT" default:"false"`
7272
EdgeTransport string `env:"EDGE_TRANSPORT" default:"auto" options:"toLower"`
7373
EdgeReconnectInterval int `env:"EDGE_RECONNECT_INTERVAL" default:"5"` // seconds
74+
EdgeMTLSMode string `env:"EDGE_MTLS_MODE" default:"disabled" options:"toLower"`
75+
EdgeMTLSAutoGenerate bool `env:"EDGE_MTLS_AUTO_GENERATE" default:"false"`
76+
EdgeMTLSCAFile string `env:"EDGE_MTLS_CA_FILE" default:""`
77+
EdgeMTLSCertFile string `env:"EDGE_MTLS_CERT_FILE" default:""`
78+
EdgeMTLSKeyFile string `env:"EDGE_MTLS_KEY_FILE" default:""`
79+
EdgeMTLSServerName string `env:"EDGE_MTLS_SERVER_NAME" default:""`
80+
EdgeMTLSAssetsDir string `env:"EDGE_MTLS_ASSETS_DIR" default:""`
7481

7582
FilePerm os.FileMode `env:"FILE_PERM" default:"0644"`
7683
DirPerm os.FileMode `env:"DIR_PERM" default:"0755"`

backend/internal/config/config_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,10 @@ func TestConfig_DockerSecretsFileSupport(t *testing.T) {
238238
func TestConfig_OptionsToLower(t *testing.T) {
239239
origLogLevel := os.Getenv("LOG_LEVEL")
240240
origEdgeTransport := os.Getenv("EDGE_TRANSPORT")
241+
origEdgeMTLSMode := os.Getenv("EDGE_MTLS_MODE")
241242
defer restoreEnv("LOG_LEVEL", origLogLevel)
242243
defer restoreEnv("EDGE_TRANSPORT", origEdgeTransport)
244+
defer restoreEnv("EDGE_MTLS_MODE", origEdgeMTLSMode)
243245

244246
t.Run("LogLevel is converted to lowercase", func(t *testing.T) {
245247
setEnv(t, "LOG_LEVEL", "DEBUG")
@@ -268,6 +270,20 @@ func TestConfig_OptionsToLower(t *testing.T) {
268270
cfg := Load()
269271
assert.Equal(t, "auto", cfg.EdgeTransport)
270272
})
273+
274+
t.Run("EdgeMTLSMode is converted to lowercase", func(t *testing.T) {
275+
setEnv(t, "EDGE_MTLS_MODE", "REQUIRED")
276+
277+
cfg := Load()
278+
assert.Equal(t, "required", cfg.EdgeMTLSMode)
279+
})
280+
281+
t.Run("EdgeMTLSMode defaults to disabled", func(t *testing.T) {
282+
unsetEnv(t, "EDGE_MTLS_MODE")
283+
284+
cfg := Load()
285+
assert.Equal(t, "disabled", cfg.EdgeMTLSMode)
286+
})
271287
}
272288

273289
func TestConfig_AllowDowngrade(t *testing.T) {

0 commit comments

Comments
 (0)