Skip to content
Draft
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ build
data/
dist/
arcane
/.tmp/
*.key
*.pem
*.crt

# OS
.DS_Store
Expand Down Expand Up @@ -49,4 +53,4 @@ arcane-devcli
# btca
.btca

cli/arcane-cli
cli/arcane-cli
41 changes: 40 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,45 @@ _dev-frontend:
_dev-backend:
cd backend && air

[group('dev')]
_dev-agent:
#!/usr/bin/env bash
set -euo pipefail

if [ -z "${AGENT_TOKEN:-}" ]; then
echo "AGENT_TOKEN is required. Run: AGENT_TOKEN=<edge-environment-token> just dev agent"
exit 1
fi

port="${PORT:-3553}"
app_url="${APP_URL:-http://localhost:${port}}"
manager_api_url="${MANAGER_API_URL:-https://localhost:3552}"
edge_mtls_assets_dir="${EDGE_MTLS_ASSETS_DIR:-./.tmp/edge-test-agent/edge-mtls-agent}"
edge_mtls_ca_file="${EDGE_MTLS_CA_FILE:-./backend/local-manager.crt}"
database_url="${DATABASE_URL:-file:./.tmp/edge-test-agent/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate}"
projects_directory="${PROJECTS_DIRECTORY:-./.tmp/edge-test-agent/projects}"
git_work_dir="${GIT_WORK_DIR:-./.tmp/edge-test-agent/git}"
jwt_secret="${JWT_SECRET:-local-edge-test-jwt-secret-please-change}"
encryption_key="${ENCRYPTION_KEY:-local-edge-test-encryption-key-32}"

mkdir -p "${projects_directory}" "${git_work_dir}" "${edge_mtls_assets_dir}"

PORT="${port}" \
APP_URL="${app_url}" \
EDGE_AGENT=true \
EDGE_TRANSPORT=poll \
EDGE_MTLS_MODE=required \
EDGE_MTLS_ASSETS_DIR="${edge_mtls_assets_dir}" \
EDGE_MTLS_CA_FILE="${edge_mtls_ca_file}" \
AGENT_TOKEN="${AGENT_TOKEN}" \
MANAGER_API_URL="${manager_api_url}" \
DATABASE_URL="${database_url}" \
PROJECTS_DIRECTORY="${projects_directory}" \
GIT_WORK_DIR="${git_work_dir}" \
JWT_SECRET="${jwt_secret}" \
ENCRYPTION_KEY="${encryption_key}" \
go run ./backend/cmd

[group('dev')]
_dev-all:
#!/usr/bin/env bash
Expand All @@ -32,7 +71,7 @@ _dev-docker:
_dev-logs:
./scripts/development/dev.sh logs

# Run development servers. Valid targets: "frontend", "backend", "all", "docker", "logs".
# Run development servers. Valid targets: "frontend", "backend", "agent", "all", "docker", "logs".
[group('dev')]
dev target="docker":
@just "_dev-{{ target }}"
Expand Down
178 changes: 136 additions & 42 deletions backend/internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,18 +205,20 @@ func startEdgeTunnelClientIfConfigured(appCtx context.Context, cfg *config.Confi
EdgeAgent: cfg.EdgeAgent,
EdgeTransport: cfg.EdgeTransport,
EdgeReconnectInterval: cfg.EdgeReconnectInterval,
EdgeMTLSMode: cfg.EdgeMTLSMode,
EdgeMTLSAutoGenerate: cfg.EdgeMTLSAutoGenerate,
EdgeMTLSCAFile: cfg.EdgeMTLSCAFile,
EdgeMTLSCertFile: cfg.EdgeMTLSCertFile,
EdgeMTLSKeyFile: cfg.EdgeMTLSKeyFile,
EdgeMTLSServerName: cfg.EdgeMTLSServerName,
EdgeMTLSAssetsDir: cfg.EdgeMTLSAssetsDir,
ManagerApiUrl: cfg.ManagerApiUrl,
AgentToken: cfg.AgentToken,
Port: cfg.Port,
Listen: cfg.Listen,
}

slog.InfoContext(appCtx, "Starting edge tunnel client",
"transport_mode", edge.NormalizeEdgeTransport(edgeCfg.EdgeTransport),
"live_tunnel_attempt_grpc", edge.UseGRPCEdgeTransport(edgeCfg) || (edge.UsePollEdgeTransport(edgeCfg) && strings.TrimSpace(edgeCfg.GetManagerGRPCAddr()) != ""),
"live_tunnel_attempt_websocket", edge.UseWebSocketEdgeTransport(edgeCfg) || (edge.UsePollEdgeTransport(edgeCfg) && strings.TrimSpace(edgeCfg.GetManagerBaseURL()) != ""),
"manager_url", cfg.ManagerApiUrl,
)
slog.InfoContext(appCtx, "Starting edge agent session client", edge.StartupLogAttrs(edgeCfg)...)
errCh, err := edge.StartTunnelClientWithErrors(appCtx, edgeCfg, router)
if err != nil {
slog.ErrorContext(appCtx, "Failed to start edge tunnel client", "error", err)
Expand Down Expand Up @@ -246,6 +248,23 @@ func handleAgentBootstrapPairing(ctx context.Context, cfg *config.Config, httpCl

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

if cfg.EdgeAgent && strings.TrimSpace(cfg.ManagerApiUrl) != "" {
edgeClient, edgeErr := edge.NewManagerHTTPClient(&edge.Config{
ManagerApiUrl: cfg.ManagerApiUrl,
EdgeMTLSMode: cfg.EdgeMTLSMode,
EdgeMTLSAutoGenerate: cfg.EdgeMTLSAutoGenerate,
EdgeMTLSCAFile: cfg.EdgeMTLSCAFile,
EdgeMTLSCertFile: cfg.EdgeMTLSCertFile,
EdgeMTLSKeyFile: cfg.EdgeMTLSKeyFile,
EdgeMTLSServerName: cfg.EdgeMTLSServerName,
EdgeMTLSAssetsDir: cfg.EdgeMTLSAssetsDir,
}, 10*time.Second)
if edgeErr != nil {
return fmt.Errorf("failed to configure edge pairing client: %w", edgeErr)
}
httpClient = edgeClient
}

resp, err := httpClient.Do(req) //nolint:gosec // intentional request to configured manager pairing endpoint
if err != nil {
return fmt.Errorf("pairing request failed: %w", err)
Expand Down Expand Up @@ -290,45 +309,17 @@ func runServices(appCtx context.Context, cfg *config.Config, router http.Handler
}

listenAddr := cfg.ListenAddr()
httpHandler := router
useTLS := cfg.TLSEnabled
tlsCertFile := strings.TrimSpace(cfg.TLSCertFile)
tlsKeyFile := strings.TrimSpace(cfg.TLSKeyFile)

if useTLS && (tlsCertFile == "" || tlsKeyFile == "") {
return fmt.Errorf("TLS_ENABLED requires both TLS_CERT_FILE and TLS_KEY_FILE")
}

var grpcServer *grpc.Server
if !cfg.AgentMode && tunnelServer != nil {
grpcServer = grpc.NewServer(tunnelServer.GRPCServerOptions(appCtx)...)
tunnelpb.RegisterTunnelServiceServer(grpcServer, tunnelServer)

httpHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isTunnelGRPCRequestInternal(r) {
grpcReq := normalizeTunnelGRPCRequestPathInternal(r)
grpcServer.ServeHTTP(w, grpcReq)
return
}
router.ServeHTTP(w, r)
})
slog.InfoContext(appCtx, "Using shared HTTP/gRPC listener for edge tunnel", "addr", listenAddr)
useTLS, tlsCertFile, tlsKeyFile, edgeCfg, err := prepareServerTLSInternal(cfg)
if err != nil {
return err
}

var protocols http.Protocols
protocols.SetHTTP1(true)
if useTLS {
protocols.SetHTTP2(true)
} else {
protocols.SetUnencryptedHTTP2(true)
httpHandler = h2c.NewHandler(httpHandler, &http2.Server{})
}
httpHandler, grpcServer := configureTunnelServerInternal(appCtx, cfg, router, tunnelServer, listenAddr)
httpHandler, protocols := configureHTTPProtocolsInternal(useTLS, httpHandler)

srv := &http.Server{
Addr: listenAddr,
Handler: httpHandler,
Protocols: &protocols,
ReadHeaderTimeout: 5 * time.Second,
srv, err := newHTTPServerInternal(listenAddr, httpHandler, protocols, useTLS, edgeCfg)
if err != nil {
return err
}

go func() {
Expand Down Expand Up @@ -378,6 +369,109 @@ func runServices(appCtx context.Context, cfg *config.Config, router http.Handler
return nil
}

func prepareServerTLSInternal(cfg *config.Config) (bool, string, string, *edge.Config, error) {
useTLS := cfg.TLSEnabled
tlsCertFile := strings.TrimSpace(cfg.TLSCertFile)
tlsKeyFile := strings.TrimSpace(cfg.TLSKeyFile)
edgeCfg := buildEdgeRuntimeConfigInternal(cfg)
if useTLS && (tlsCertFile == "" || tlsKeyFile == "") {
return false, "", "", nil, fmt.Errorf("TLS_ENABLED requires both TLS_CERT_FILE and TLS_KEY_FILE")
}

if cfg.AgentMode {
return useTLS, tlsCertFile, tlsKeyFile, edgeCfg, nil
}

if err := edge.PrepareManagerMTLSAssets(edgeCfg); err != nil {
return false, "", "", nil, err
}

if edge.NormalizeEdgeMTLSMode(cfg.EdgeMTLSMode) != edge.EdgeMTLSModeDisabled {
if !useTLS {
return false, "", "", nil, fmt.Errorf("EDGE_MTLS_MODE requires TLS_ENABLED=true on the manager")
}
if err := edge.ValidateManagerMTLSConfig(edgeCfg); err != nil {
return false, "", "", nil, err
}
}

return useTLS, tlsCertFile, tlsKeyFile, edgeCfg, nil
}

func configureTunnelServerInternal(appCtx context.Context, cfg *config.Config, router http.Handler, tunnelServer *edge.TunnelServer, listenAddr string) (http.Handler, *grpc.Server) {
httpHandler := router
var grpcServer *grpc.Server

if !cfg.AgentMode && tunnelServer != nil {
grpcServer = grpc.NewServer(tunnelServer.GRPCServerOptions(appCtx)...)
tunnelpb.RegisterTunnelServiceServer(grpcServer, tunnelServer)

httpHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isTunnelGRPCRequestInternal(r) {
grpcReq := normalizeTunnelGRPCRequestPathInternal(r)
grpcServer.ServeHTTP(w, grpcReq)
return
}
router.ServeHTTP(w, r)
})
slog.InfoContext(appCtx, "Using shared HTTP/gRPC listener for edge tunnel", "addr", listenAddr)
}

return httpHandler, grpcServer
}

func configureHTTPProtocolsInternal(useTLS bool, handler http.Handler) (http.Handler, *http.Protocols) {
var protocols http.Protocols
protocols.SetHTTP1(true)
if useTLS {
protocols.SetHTTP2(true)
return handler, &protocols
}

protocols.SetUnencryptedHTTP2(true)
return h2c.NewHandler(handler, &http2.Server{}), &protocols
}

func newHTTPServerInternal(listenAddr string, handler http.Handler, protocols *http.Protocols, useTLS bool, edgeCfg *edge.Config) (*http.Server, error) {
srv := &http.Server{
Addr: listenAddr,
Handler: handler,
Protocols: protocols,
ReadHeaderTimeout: 5 * time.Second,
}
if !useTLS {
return srv, nil
}

tlsConfig, err := edge.BuildManagerServerTLSConfig(edgeCfg)
if err != nil {
return nil, err
}
if tlsConfig != nil {
srv.TLSConfig = tlsConfig
}
return srv, nil
}

func buildEdgeRuntimeConfigInternal(cfg *config.Config) *edge.Config {
return &edge.Config{
EdgeAgent: cfg.EdgeAgent,
EdgeTransport: cfg.EdgeTransport,
EdgeReconnectInterval: cfg.EdgeReconnectInterval,
EdgeMTLSMode: cfg.EdgeMTLSMode,
EdgeMTLSAutoGenerate: cfg.EdgeMTLSAutoGenerate,
EdgeMTLSCAFile: cfg.EdgeMTLSCAFile,
EdgeMTLSCertFile: cfg.EdgeMTLSCertFile,
EdgeMTLSKeyFile: cfg.EdgeMTLSKeyFile,
EdgeMTLSServerName: cfg.EdgeMTLSServerName,
EdgeMTLSAssetsDir: cfg.EdgeMTLSAssetsDir,
ManagerApiUrl: cfg.ManagerApiUrl,
AgentToken: cfg.AgentToken,
Port: cfg.Port,
Listen: cfg.Listen,
}
}

func normalizeTunnelGRPCRequestPathInternal(r *http.Request) *http.Request {
if r == nil {
return nil
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/bootstrap/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"net/http/httptest"
"testing"

"github.com/getarcaneapp/arcane/backend/internal/config"
tunnelpb "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge/proto/tunnel/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNormalizeTunnelGRPCRequestPathInternal(t *testing.T) {
Expand Down Expand Up @@ -114,3 +116,19 @@ func TestIsTunnelGRPCRequestInternal(t *testing.T) {
assert.False(t, isTunnelGRPCRequestInternal(req))
})
}

func TestPrepareServerTLSInternal_AgentModeSkipsManagerMTLSValidation(t *testing.T) {
cfg := &config.Config{
AgentMode: true,
EdgeMTLSMode: "required",
ManagerApiUrl: "https://127.0.0.1:3552",
}

useTLS, tlsCertFile, tlsKeyFile, edgeCfg, err := prepareServerTLSInternal(cfg)
require.NoError(t, err)
assert.False(t, useTLS)
assert.Empty(t, tlsCertFile)
assert.Empty(t, tlsKeyFile)
require.NotNil(t, edgeCfg)
assert.Equal(t, "required", edgeCfg.EdgeMTLSMode)
}
21 changes: 21 additions & 0 deletions backend/internal/bootstrap/edge_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,30 @@ func registerEdgeTunnelRoutes(
}

server := edge.NewTunnelServer(resolver, statusCallback)
server.SetConfig(&edge.Config{
EdgeMTLSMode: cfg.EdgeMTLSMode,
EdgeMTLSAutoGenerate: cfg.EdgeMTLSAutoGenerate,
EdgeMTLSCAFile: cfg.EdgeMTLSCAFile,
EdgeMTLSCertFile: cfg.EdgeMTLSCertFile,
EdgeMTLSKeyFile: cfg.EdgeMTLSKeyFile,
EdgeMTLSServerName: cfg.EdgeMTLSServerName,
EdgeMTLSAssetsDir: cfg.EdgeMTLSAssetsDir,
ManagerApiUrl: cfg.ManagerApiUrl,
})
server.SetEnvironmentNameResolver(func(ctx context.Context, envID string) (string, error) {
env, err := appServices.Environment.GetEnvironmentByID(ctx, envID)
if err != nil {
return "", err
}
if env == nil {
return "", nil
}
return env.Name, nil
})
server.SetEventCallback(eventCallback)
go server.StartCleanupLoop(ctx)
apiGroup.POST("/tunnel/poll", server.HandlePoll)
apiGroup.POST("/tunnel/mtls/enroll", server.HandleMTLSEnroll)
apiGroup.GET("/tunnel/connect", server.HandleConnect)
slog.InfoContext(ctx, "Configured edge tunnel server",
"poll_enabled", true,
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ type Config struct {
EdgeAgent bool `env:"EDGE_AGENT" default:"false"`
EdgeTransport string `env:"EDGE_TRANSPORT" default:"auto" options:"toLower"`
EdgeReconnectInterval int `env:"EDGE_RECONNECT_INTERVAL" default:"5"` // seconds
EdgeMTLSMode string `env:"EDGE_MTLS_MODE" default:"disabled" options:"toLower"`
EdgeMTLSAutoGenerate bool `env:"EDGE_MTLS_AUTO_GENERATE" default:"false"`
EdgeMTLSCAFile string `env:"EDGE_MTLS_CA_FILE" default:""`
EdgeMTLSCertFile string `env:"EDGE_MTLS_CERT_FILE" default:""`
EdgeMTLSKeyFile string `env:"EDGE_MTLS_KEY_FILE" default:""`
EdgeMTLSServerName string `env:"EDGE_MTLS_SERVER_NAME" default:""`
EdgeMTLSAssetsDir string `env:"EDGE_MTLS_ASSETS_DIR" default:""`

FilePerm os.FileMode `env:"FILE_PERM" default:"0644"`
DirPerm os.FileMode `env:"DIR_PERM" default:"0755"`
Expand Down
16 changes: 16 additions & 0 deletions backend/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,10 @@ func TestConfig_DockerSecretsFileSupport(t *testing.T) {
func TestConfig_OptionsToLower(t *testing.T) {
origLogLevel := os.Getenv("LOG_LEVEL")
origEdgeTransport := os.Getenv("EDGE_TRANSPORT")
origEdgeMTLSMode := os.Getenv("EDGE_MTLS_MODE")
defer restoreEnv("LOG_LEVEL", origLogLevel)
defer restoreEnv("EDGE_TRANSPORT", origEdgeTransport)
defer restoreEnv("EDGE_MTLS_MODE", origEdgeMTLSMode)

t.Run("LogLevel is converted to lowercase", func(t *testing.T) {
setEnv(t, "LOG_LEVEL", "DEBUG")
Expand Down Expand Up @@ -268,6 +270,20 @@ func TestConfig_OptionsToLower(t *testing.T) {
cfg := Load()
assert.Equal(t, "auto", cfg.EdgeTransport)
})

t.Run("EdgeMTLSMode is converted to lowercase", func(t *testing.T) {
setEnv(t, "EDGE_MTLS_MODE", "REQUIRED")

cfg := Load()
assert.Equal(t, "required", cfg.EdgeMTLSMode)
})

t.Run("EdgeMTLSMode defaults to disabled", func(t *testing.T) {
unsetEnv(t, "EDGE_MTLS_MODE")

cfg := Load()
assert.Equal(t, "disabled", cfg.EdgeMTLSMode)
})
}

func TestConfig_AllowDowngrade(t *testing.T) {
Expand Down
Loading
Loading