diff --git a/.gitignore b/.gitignore index 4d252d3bf0..88cc3f8af4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ dist .secrets.json certs/ deployment/data/* +metald.db bin/ diff --git a/QUICKSTART-DEPLOY.md b/QUICKSTART-DEPLOY.md index ad09a2e3bd..fa33201f1e 100644 --- a/QUICKSTART-DEPLOY.md +++ b/QUICKSTART-DEPLOY.md @@ -7,18 +7,56 @@ This guide will help you get the Unkey deployment platform up and running locall - Docker and Docker Compose - Go 1.24 or later - A terminal/command line +- dnsmasq (for wildcard DNS setup) ## Step 1: Start the Platform -1. Start all services using Docker Compose: +1. Set up the API key for ctrl service authentication by adding it to `go/apps/ctrl/.env`: ```bash -docker-compose up metald-aio dashboard ctrl -d +UNKEY_API_KEY="your-local-dev-key" ``` -2. Wait for all services to be healthy +2. Set up dashboard environment variables for ctrl authentication in `apps/dashboard/.env.local`: -The platform now uses a Docker backend that creates containers instead of VMs, making it much faster and easier to run locally. +```bash +CTRL_URL="http://127.0.0.1:7091" +CTRL_API_KEY="your-local-dev-key" +``` + +Note: Use the same API key value in both files for authentication to work properly. + +3. Start all necessary services using Docker Compose: + +```bash +docker compose -f ./deployment/docker-compose.yaml up mysql planetscale clickhouse redis s3 dashboard gw metald ctrl -d --build +``` + +This will start: +- **mysql**: Database for storing workspace, project, and deployment data +- **planetscale**: PlanetScale HTTP simulator for database access +- **clickhouse**: Analytics database for metrics and logs +- **redis**: Caching layer for session and temporary data +- **s3**: MinIO S3-compatible storage for assets and vault data +- **dashboard**: Web UI for managing deployments (port 3000) +- **gw**: Gateway service for routing traffic (ports 80/443) +- **metald**: VM/container management service (port 8090) +- **ctrl**: Control plane service for managing deployments (port 7091) + +4. Set up wildcard DNS for `unkey.local`: + +```bash +./deployment/setup-wildcard-dns.sh +``` + +5. **OPTIONAL**: Install self-signed certificate for HTTPS (to avoid SSL errors): + +```bash +# For macOS +sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./deployment/certs/unkey.local.crt +``` + +Note: Certificates should be mounted to `deployment/certs`. You can skip this if you're fine with SSL errors in your browser. ## Step 2: Set Up Your Workspace @@ -48,45 +86,61 @@ Go to http://localhost:3000/projects cd go ``` -2. Create a version using the CLI with your copied IDs: +2. Set up API key authentication for the CLI (choose one option): +**Option A: Environment variable (recommended)** ```bash -go run . version create \ +export API_KEY="your-local-dev-key" +``` + +**Option B: CLI flag** +Use `--api-key="your-local-dev-key"` in the command below. + +3. Deploy using the CLI with your copied IDs: + +```bash +go run . deploy \ --context=./demo_api \ - --workspace-id=YOUR_WORKSPACE_ID \ - --project-id=YOUR_PROJECT_ID + --workspace-id="REPLACE_ME" \ + --project-id="REPLACE_ME" \ + --control-plane-url="http://127.0.0.1:7091" \ + --api-key="your-local-dev-key" \ + --keyspace-id="REPLACE_ME" # This is optional if you want key verifications ``` -Keep the context as shown, there's a demo api in that folder. -Replace `YOUR_WORKSPACE_ID` and `YOUR_PROJECT_ID` with the actual values you copied from the dashboard. +Replace the placeholder values: +- `REPLACE_ME` with your actual workspace ID, project ID, and keyspace ID +- `your-local-dev-key` with the same API key value you set in steps 1 and 2 +- Keep `--context=./demo_api` as shown (there's a demo API in that folder) + +**Note**: If using Option A (environment variable), you can omit the `--api-key` flag from the command. 3. The CLI will: - - Always build a fresh Docker image from your code - - Set the PORT environment variable to 8080 in the container - - Use the Docker backend to create a container instead of a VM - - Automatically allocate a random host port (e.g., 35432) to avoid conflicts - - Show real-time progress as your deployment goes through the stages + - Build a Docker image from the demo_api code + - Create a deployment on the Unkey platform + - Show real-time progress through deployment stages + - Deploy using metald's VM/container backend -## Step 4: View Your Deployment +## Step 4: Test Your Deployment -1. Once the deployment completes, the CLI will show you the available domains: +1. Once deployment completes, test the API in the unkey root directory: +```bash +curl --cacert ./deployment/certs/unkey.local.crt https://REPLACE_ME/v1/liveness ``` -Deployment Complete - Version ID: v_xxxxxxxxxxxxxxxxxx - Status: Ready - Environment: Production -Domains - https://main-commit-workspace.unkey.app - http://localhost:35432 +Replace: +- `REPLACE_ME` (URL) with your deployment domain + +**Note:** The liveness endpoint is public and doesn't require authentication. For protected endpoints, include an Authorization header: + +```bash +curl --cacert ./deployment/certs/unkey.local.crt -H "Authorization: Bearer YOUR_API_KEY" https://YOUR_DOMAIN/protected/endpoint ``` -2. If you're using the `demo_api` you can curl the `/v1/liveness` endpoint -3. Return to the dashboard and navigate to: +2. Return to the dashboard to monitor your deployment: ``` -http://localhost:3000/versions http://localhost:3000/deployments ``` diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 7f0678b42b..9bb24bf917 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -17,6 +17,10 @@ DATABASE_PASSWORD= UNKEY_WORKSPACE_ID= UNKEY_API_ID= +# Control Plane +CTRL_URL=http://127.0.0.1:7091 +CTRL_API_KEY="your-local-dev-key" + # ClickHouse CLICKHOUSE_URL= diff --git a/apps/dashboard/lib/env.ts b/apps/dashboard/lib/env.ts index d6f0681bbe..92e24f6eb0 100644 --- a/apps/dashboard/lib/env.ts +++ b/apps/dashboard/lib/env.ts @@ -29,6 +29,7 @@ export const env = () => AGENT_TOKEN: z.string(), CTRL_URL: z.string().url().optional(), + CTRL_API_KEY: z.string().optional(), GITHUB_KEYS_URI: z.string().optional(), diff --git a/apps/dashboard/lib/trpc/routers/deploy/deployment/rollback.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/rollback.ts index 855bc1d25e..98343aedf3 100644 --- a/apps/dashboard/lib/trpc/routers/deploy/deployment/rollback.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/deployment/rollback.ts @@ -36,6 +36,12 @@ export const rollback = t.procedure DeploymentService, createConnectTransport({ baseUrl: ctrlUrl, + interceptors: [ + (next) => (req) => { + req.header.set("Authorization", `Bearer ${env().CTRL_API_KEY}`); + return next(req); + }, + ], }), ); diff --git a/deployment/docker-compose.yaml b/deployment/docker-compose.yaml index d43d6b31d8..86a0eb2a95 100644 --- a/deployment/docker-compose.yaml +++ b/deployment/docker-compose.yaml @@ -344,6 +344,9 @@ services: UNKEY_VAULT_S3_ACCESS_KEY_SECRET: "minio_root_password" UNKEY_VAULT_MASTER_KEYS: "Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U=" + # API key for simple authentication (temporary, will be replaced with JWT) + UNKEY_API_KEY: "your-local-dev-key" + otel: networks: - default @@ -398,6 +401,7 @@ services: # Environment NODE_ENV: "production" CTRL_URL: "http://ctrl:7091" + CTRL_API_KEY: "your-local-dev-key" # Bootstrap workspace/API IDs # Reading from env file, no override necessary volumes: diff --git a/go/apps/ctrl/.gitignore b/go/apps/ctrl/.gitignore new file mode 100644 index 0000000000..e168d8665c --- /dev/null +++ b/go/apps/ctrl/.gitignore @@ -0,0 +1,3 @@ +# Environment variables with secrets +.env +.env.local \ No newline at end of file diff --git a/go/apps/ctrl/config.go b/go/apps/ctrl/config.go index ab180bbb7d..e533c7a24e 100644 --- a/go/apps/ctrl/config.go +++ b/go/apps/ctrl/config.go @@ -64,6 +64,10 @@ type Config struct { // AuthToken is the authentication token for control plane API access AuthToken string + // APIKey is the API key for simple authentication (demo purposes only) + // TODO: Replace with JWT authentication when moving to private IP + APIKey string + // MetaldAddress is the full URL of the metald service for VM operations (e.g., "https://metald.example.com:8080") MetaldAddress string diff --git a/go/apps/ctrl/middleware/auth.go b/go/apps/ctrl/middleware/auth.go new file mode 100644 index 0000000000..8dd444041e --- /dev/null +++ b/go/apps/ctrl/middleware/auth.go @@ -0,0 +1,87 @@ +package middleware + +import ( + "context" + "fmt" + "strings" + + "connectrpc.com/connect" +) + +// AuthConfig contains configuration for the authentication middleware +type AuthConfig struct { + // APIKey is the expected API key for authentication + APIKey string +} + +// AuthMiddleware provides simple API key authentication +// TODO: Replace with JWT authentication when moving to private IP +type AuthMiddleware struct { + config AuthConfig +} + +// NewAuthMiddleware creates a new authentication middleware +func NewAuthMiddleware(config AuthConfig) *AuthMiddleware { + return &AuthMiddleware{ + config: config, + } +} + +// ConnectInterceptor returns a Connect interceptor for gRPC-like services +func (m *AuthMiddleware) ConnectInterceptor() connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + procedure := req.Spec().Procedure + + // Skip authentication for health check endpoint + if procedure == "/ctrl.v1.CtrlService/Liveness" { + return next(ctx, req) + } + + // Extract API key from Authorization header + // TODO: Replace with JWT token extraction when moving to private IP + authHeader := strings.TrimSpace(req.Header().Get("Authorization")) + if authHeader == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, + fmt.Errorf("Missing Authorization header")) + } + + // Parse authorization header with case-insensitive Bearer scheme + const bearerScheme = "bearer" + if len(authHeader) < len(bearerScheme)+1 { + return nil, connect.NewError(connect.CodeUnauthenticated, + fmt.Errorf("Invalid Authorization header format. Expected: Bearer ")) + } + + // Extract scheme and check case-insensitively + schemePart := strings.ToLower(authHeader[:len(bearerScheme)]) + if schemePart != bearerScheme { + return nil, connect.NewError(connect.CodeUnauthenticated, + fmt.Errorf("Invalid Authorization header format. Expected: Bearer ")) + } + + // Ensure there's a space after the scheme + if authHeader[len(bearerScheme)] != ' ' { + return nil, connect.NewError(connect.CodeUnauthenticated, + fmt.Errorf("Invalid Authorization header format. Expected: Bearer ")) + } + + // Extract and trim the token + apiKey := strings.TrimSpace(authHeader[len(bearerScheme)+1:]) + if apiKey == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, + fmt.Errorf("API key cannot be empty")) + } + + // Simple API key validation against environment variable + // TODO: Replace with JWT validation when moving to private IP + if apiKey != m.config.APIKey { + return nil, connect.NewError(connect.CodeUnauthenticated, + fmt.Errorf("Invalid API key")) + } + + // Continue to next handler + return next(ctx, req) + } + } +} \ No newline at end of file diff --git a/go/apps/ctrl/run.go b/go/apps/ctrl/run.go index 15ab0bba48..0bf4bf08e3 100644 --- a/go/apps/ctrl/run.go +++ b/go/apps/ctrl/run.go @@ -9,6 +9,7 @@ import ( "time" "connectrpc.com/connect" + "github.com/unkeyed/unkey/go/apps/ctrl/middleware" "github.com/unkeyed/unkey/go/apps/ctrl/services/acme" "github.com/unkeyed/unkey/go/apps/ctrl/services/acme/providers" "github.com/unkeyed/unkey/go/apps/ctrl/services/ctrl" @@ -203,16 +204,32 @@ func Run(ctx context.Context, cfg Config) error { // Create the connect handler mux := http.NewServeMux() - // Create the service handlers with interceptors - mux.Handle(ctrlv1connect.NewCtrlServiceHandler(ctrl.New(cfg.InstanceID, database))) - mux.Handle(ctrlv1connect.NewDeploymentServiceHandler(deployment.New(database, partitionDB, hydraEngine, logger))) - mux.Handle(ctrlv1connect.NewOpenApiServiceHandler(openapi.New(database, logger))) + // Create authentication middleware (required except for health check and ACME routes) + authMiddleware := middleware.NewAuthMiddleware(middleware.AuthConfig{ + APIKey: cfg.APIKey, + }) + authInterceptor := authMiddleware.ConnectInterceptor() + + if cfg.APIKey != "" { + logger.Info("API key authentication enabled for ctrl service") + } else { + logger.Warn("No API key configured - authentication will reject all requests except health check and ACME routes") + } + + // Create the service handlers with auth interceptor (always applied) + connectOptions := []connect.HandlerOption{ + connect.WithInterceptors(authInterceptor), + } + + mux.Handle(ctrlv1connect.NewCtrlServiceHandler(ctrl.New(cfg.InstanceID, database), connectOptions...)) + mux.Handle(ctrlv1connect.NewDeploymentServiceHandler(deployment.New(database, partitionDB, hydraEngine, logger), connectOptions...)) + mux.Handle(ctrlv1connect.NewOpenApiServiceHandler(openapi.New(database, logger), connectOptions...)) mux.Handle(ctrlv1connect.NewAcmeServiceHandler(acme.New(acme.Config{ PartitionDB: partitionDB, DB: database, HydraEngine: hydraEngine, Logger: logger, - }))) + }), connectOptions...)) // Configure server addr := fmt.Sprintf(":%d", cfg.HttpPort) diff --git a/go/cmd/ctrl/main.go b/go/cmd/ctrl/main.go index 0ebd58cbab..60f2a04381 100644 --- a/go/cmd/ctrl/main.go +++ b/go/cmd/ctrl/main.go @@ -54,6 +54,8 @@ var Cmd = &cli.Command{ // Control Plane Specific cli.String("auth-token", "Authentication token for control plane API access. Required for secure deployments.", cli.EnvVar("UNKEY_AUTH_TOKEN")), + cli.String("api-key", "API key for simple authentication (demo purposes only). Will be replaced with JWT authentication.", + cli.Required(), cli.EnvVar("UNKEY_API_KEY")), cli.String("metald-address", "Full URL of the metald service for VM operations. Required for deployments. Example: https://metald.example.com:8080", cli.Required(), cli.EnvVar("UNKEY_METALD_ADDRESS")), cli.String("spiffe-socket-path", "Path to SPIFFE agent socket for mTLS authentication. Default: /var/lib/spire/agent/agent.sock", @@ -120,6 +122,7 @@ func action(ctx context.Context, cmd *cli.Command) error { // Control Plane Specific AuthToken: cmd.String("auth-token"), + APIKey: cmd.String("api-key"), MetaldAddress: cmd.String("metald-address"), SPIFFESocketPath: cmd.String("spiffe-socket-path"), diff --git a/go/cmd/deploy/control_plane.go b/go/cmd/deploy/control_plane.go index 78db70644f..78f29a75eb 100644 --- a/go/cmd/deploy/control_plane.go +++ b/go/cmd/deploy/control_plane.go @@ -61,7 +61,12 @@ func (c *ControlPlaneClient) CreateDeployment(ctx context.Context, dockerImage s DockerImage: dockerImage, }) - createReq.Header().Set("Authorization", "Bearer "+c.opts.AuthToken) + // Use API key for authentication if provided, fallback to auth token + authHeader := c.opts.APIKey + if authHeader == "" { + authHeader = c.opts.AuthToken + } + createReq.Header().Set("Authorization", "Bearer "+authHeader) createResp, err := c.client.CreateDeployment(ctx, createReq) if err != nil { @@ -81,7 +86,12 @@ func (c *ControlPlaneClient) GetDeployment(ctx context.Context, deploymentId str getReq := connect.NewRequest(&ctrlv1.GetDeploymentRequest{ DeploymentId: deploymentId, }) - getReq.Header().Set("Authorization", "Bearer "+c.opts.AuthToken) + // Use API key for authentication if provided, fallback to auth token + authHeader := c.opts.APIKey + if authHeader == "" { + authHeader = c.opts.AuthToken + } + getReq.Header().Set("Authorization", "Bearer "+authHeader) getResp, err := c.client.GetDeployment(ctx, getReq) if err != nil { @@ -226,10 +236,15 @@ func (c *ControlPlaneClient) handleCreateDeploymentError(err error) error { // Check if it's an auth error if connectErr := new(connect.Error); errors.As(err, &connectErr) { if connectErr.Code() == connect.CodeUnauthenticated { + // Determine which auth method was used for better error message + authMethod := "API key" + if c.opts.APIKey == "" { + authMethod = "auth token" + } return fault.Wrap(err, fault.Code(codes.UnkeyAuthErrorsAuthenticationMalformed), - fault.Internal(fmt.Sprintf("Authentication failed with token: %s", c.opts.AuthToken)), - fault.Public("Authentication failed. Check your auth token."), + fault.Internal(fmt.Sprintf("Authentication failed with %s", authMethod)), + fault.Public(fmt.Sprintf("Authentication failed. Check your %s.", authMethod)), ) } } diff --git a/go/cmd/deploy/main.go b/go/cmd/deploy/main.go index f8c2e5a994..446c99f69c 100644 --- a/go/cmd/deploy/main.go +++ b/go/cmd/deploy/main.go @@ -95,6 +95,7 @@ type DeployOptions struct { Verbose bool ControlPlaneURL string AuthToken string + APIKey string Linux bool } @@ -124,6 +125,7 @@ var DeployFlags = []cli.Flag{ // Control plane flags (internal) cli.String("control-plane-url", "Control plane URL", cli.Default(DefaultControlPlaneURL)), cli.String("auth-token", "Control plane auth token", cli.Default(DefaultAuthToken)), + cli.String("api-key", "API key for ctrl service authentication", cli.EnvVar("API_KEY")), } // WARNING: Changing the "Description" part will also affect generated MDX. @@ -206,6 +208,7 @@ func DeployAction(ctx context.Context, cmd *cli.Command) error { Verbose: cmd.Bool("verbose"), ControlPlaneURL: cmd.String("control-plane-url"), AuthToken: cmd.String("auth-token"), + APIKey: cmd.String("api-key"), Linux: cmd.Bool("linux"), } diff --git a/tools/local/src/cmd/dashboard.ts b/tools/local/src/cmd/dashboard.ts index 60dbd2bf10..c286bbb9ec 100644 --- a/tools/local/src/cmd/dashboard.ts +++ b/tools/local/src/cmd/dashboard.ts @@ -34,6 +34,7 @@ export async function bootstrapDashboard(resources: { }, ControlPlane: { CTRL_URL: "http://localhost:7091", + CTRL_API_KEY: "your-local-dev-key", }, });