From 4c7e1591d45ee6a0cdc244c30ed114e0225ef648 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 12 Sep 2025 19:29:10 +0200 Subject: [PATCH 1/4] feat(deploy): run locally --- .gitignore | 1 + deployment/docker-compose.yaml | 58 ++-- deployment/setup-wildcard-dns.sh | 151 +++++++++++ .../services/deployment/backend_adapter.go | 8 +- .../services/deployment/backends/docker.go | 63 +++-- .../services/deployment/backends/interface.go | 4 +- .../ctrl/services/deployment/backends/k8s.go | 220 ++++++++++----- .../services/deployment/deploy_workflow.go | 34 ++- go/cmd/ctrl/main.go | 1 + go/cmd/localtls/main.go | 252 ++++++++++++++++++ 10 files changed, 678 insertions(+), 114 deletions(-) create mode 100755 deployment/setup-wildcard-dns.sh create mode 100644 go/cmd/localtls/main.go diff --git a/.gitignore b/.gitignore index 96a379f1dd..e6aecdaef9 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist .react-email .secrets.json +certs/ diff --git a/deployment/docker-compose.yaml b/deployment/docker-compose.yaml index 093d23011a..a867a83497 100644 --- a/deployment/docker-compose.yaml +++ b/deployment/docker-compose.yaml @@ -23,6 +23,7 @@ services: retries: 10 start_period: 40s interval: 10s + planetscale: container_name: planetscale image: ghcr.io/mattrobenolt/ps-http-sim:v0.0.12 @@ -39,6 +40,7 @@ services: condition: service_healthy ports: - 3900:3900 + apiv2_lb: container_name: apiv2_lb image: nginx:1.29.0 @@ -49,6 +51,7 @@ services: ports: - 2112:2112 - 7070:7070 + apiv2: deploy: replicas: 3 @@ -68,15 +71,13 @@ services: UNKEY_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true" UNKEY_CLICKHOUSE_URL: "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" UNKEY_CHPROXY_AUTH_TOKEN: "chproxy-test-token-123" - UNKEY_OTEL: true - OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318" - OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf" + UNKEY_OTEL: false VAULT_S3_URL: "http://s3:3902" VAULT_S3_BUCKET: "vault" VAULT_S3_ACCESS_KEY_ID: "minio_root_user" VAULT_S3_ACCESS_KEY_SECRET: "minio_root_password" VAULT_MASTER_KEYS: "Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U=" - # UNKEY_PROMETHEUS_PORT: 2112 + redis: container_name: redis image: redis:8.0 @@ -88,6 +89,7 @@ services: retries: 5 start_period: 10s interval: 5s + agent: container_name: agent command: ["/usr/local/bin/unkey", "agent", "--config", "config.docker.json"] @@ -109,6 +111,7 @@ services: VAULT_S3_ACCESS_KEY_SECRET: "minio_root_password" VAULT_MASTER_KEYS: "Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U=" CLICKHOUSE_URL: "clickhouse://default:password@clickhouse:9000" + clickhouse: build: context: .. @@ -160,6 +163,7 @@ services: retries: 10 start_period: 15s interval: 5s + api: container_name: api build: @@ -200,14 +204,29 @@ services: container_name: gw command: ["run", "gw"] ports: - - "6060:6060" + - "80:80" + - "443:443" depends_on: - mysql environment: - UNKEY_HTTP_PORT: 6060 - UNKEY_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/partition_001?parseTime=true" + UNKEY_HTTP_PORT: 80 + UNKEY_HTTPS_PORT: 443 + + UNKEY_TLS_ENABLED: true + UNKEY_DEFAULT_CERT_DOMAIN: "unkey.local" + UNKEY_MAIN_DOMAIN: "unkey.local" + UNKEY_CTRL_ADDR: "ctrl:7091" + + UNKEY_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/partition_001?parseTime=true&interpolateParams=true" + UNKEY_KEYS_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" UNKEY_CLICKHOUSE_URL: "clickhouse://default:password@clickhouse:9000?secure=false&skip_verify=true" - UNKEY_OTEL: true + UNKEY_REDIS_URL: "redis://redis:6379" + + UNKEY_VAULT_S3_URL: "http://s3:3902" + UNKEY_VAULT_S3_BUCKET: "acme-vault" + UNKEY_VAULT_S3_ACCESS_KEY_ID: "minio_root_user" + UNKEY_VAULT_S3_ACCESS_KEY_SECRET: "minio_root_password" + UNKEY_VAULT_MASTER_KEYS: "Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U=" ctrl: build: @@ -221,23 +240,27 @@ services: - "7091:7091" depends_on: - mysql - # - metald-aio - - otel - s3 + volumes: + - /var/run/docker.sock:/var/run/docker.sock environment: - # Database configuration - use existing mysql service - UNKEY_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true" - UNKEY_DATABASE_HYDRA: "unkey:password@tcp(mysql:3306)/hydra?parseTime=true" - UNKEY_DATABASE_PARTITION: "unkey:password@tcp(mysql:3306)/partition_001?parseTime=true" + UNKEY_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" + UNKEY_DATABASE_HYDRA: "unkey:password@tcp(mysql:3306)/hydra?parseTime=true&interpolateParams=true" + UNKEY_DATABASE_PARTITION: "unkey:password@tcp(mysql:3306)/partition_001?parseTime=true&interpolateParams=true" # Control plane configuration UNKEY_HTTP_PORT: "7091" - UNKEY_METALD_ADDRESS: "http://metald-aio:8080" + UNKEY_METALD_ADDRESS: "http://metald-aio:8090" + UNKEY_METALD_BACKEND: "docker" + UNKEY_DEFAULT_DOMAIN: "unkey.local" + UNKEY_DOCKER_RUNNING: "true" + UNKEY_VAULT_S3_URL: "http://s3:3902" - UNKEY_VAULT_S3_BUCKET: "vault" + UNKEY_VAULT_S3_BUCKET: "acme-vault" UNKEY_VAULT_S3_ACCESS_KEY_ID: "minio_root_user" UNKEY_VAULT_S3_ACCESS_KEY_SECRET: "minio_root_password" UNKEY_VAULT_MASTER_KEYS: "Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U=" + otel: image: grafana/otel-lgtm:0.11.7 container_name: otel @@ -246,6 +269,7 @@ services: - 3001:3000 - 4317:4317 - 4318:4318 + prometheus: image: prom/prometheus:v3.5.0 container_name: prometheus @@ -255,6 +279,7 @@ services: - ./config/prometheus.yml:/etc/prometheus/prometheus.yml depends_on: - apiv2 + dashboard: build: context: .. @@ -281,6 +306,7 @@ services: NODE_ENV: "production" # Bootstrap workspace/API IDs # Reading from env file, no override necessary + # Unkey Deploy Services - All-in-one development container with all 4 services # ############################################################################# diff --git a/deployment/setup-wildcard-dns.sh b/deployment/setup-wildcard-dns.sh new file mode 100755 index 0000000000..12a50ce3a7 --- /dev/null +++ b/deployment/setup-wildcard-dns.sh @@ -0,0 +1,151 @@ +#!/bin/bash + +# Setup wildcard DNS for *.unkey.local using dnsmasq + +set -e + +# Detect OS +OS="unknown" +if [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" +elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + OS="linux" +else + echo "Unsupported OS: $OSTYPE" + exit 1 +fi + +echo "Detected OS: $OS" +echo "" +echo "This script will set up dnsmasq to resolve *.unkey.local to 127.0.0.1" +echo "This allows you to use any subdomain like my-deployment.unkey.local" +echo "" + +# Check if dnsmasq is installed +if command -v dnsmasq &> /dev/null; then + echo "dnsmasq is already installed" +else + echo "dnsmasq is not installed" + echo "" + if [[ "$OS" == "macos" ]]; then + echo "Would you like to install dnsmasq using Homebrew? (y/n)" + else + echo "Would you like to install dnsmasq using your package manager? (y/n)" + fi + read -r response + if [[ "$response" != "y" ]]; then + echo "Installation cancelled" + exit 1 + fi + + # Install dnsmasq based on OS + if [[ "$OS" == "macos" ]]; then + if ! command -v brew &> /dev/null; then + echo "Homebrew is not installed. Please install Homebrew first." + exit 1 + fi + echo "Installing dnsmasq with Homebrew..." + brew install dnsmasq + else + # Linux installation + if command -v apt-get &> /dev/null; then + echo "Installing dnsmasq with apt..." + sudo apt-get update && sudo apt-get install -y dnsmasq + elif command -v yum &> /dev/null; then + echo "Installing dnsmasq with yum..." + sudo yum install -y dnsmasq + elif command -v dnf &> /dev/null; then + echo "Installing dnsmasq with dnf..." + sudo dnf install -y dnsmasq + elif command -v pacman &> /dev/null; then + echo "Installing dnsmasq with pacman..." + sudo pacman -S --noconfirm dnsmasq + else + echo "Could not detect package manager. Please install dnsmasq manually." + exit 1 + fi + fi +fi + +echo "" +echo "Configuring dnsmasq for *.unkey.local..." + +if [[ "$OS" == "macos" ]]; then + # macOS configuration + DNSMASQ_CONF="$(brew --prefix)/etc/dnsmasq.conf" + + # Backup existing config if it exists + if [[ -f "$DNSMASQ_CONF" ]]; then + cp "$DNSMASQ_CONF" "$DNSMASQ_CONF.backup.$(date +%Y%m%d_%H%M%S)" + echo "Backed up existing config" + fi + + # Add our configuration + echo "address=/unkey.local/127.0.0.1" > "$DNSMASQ_CONF" + echo "Configured dnsmasq to resolve *.unkey.local to 127.0.0.1" + + # Start dnsmasq service + echo "" + echo "Would you like to start dnsmasq as a service? (y/n)" + read -r response + if [[ "$response" == "y" ]]; then + sudo brew services start dnsmasq + echo "dnsmasq service started" + fi + + # Setup resolver + echo "" + echo "Setting up macOS resolver for .unkey.local domain..." + sudo mkdir -p /etc/resolver + echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/unkey.local > /dev/null + echo "Resolver configured" + +else + # Linux configuration + DNSMASQ_CONF="/etc/dnsmasq.d/unkey.local.conf" + + # Create configuration + echo "address=/unkey.local/127.0.0.1" | sudo tee "$DNSMASQ_CONF" > /dev/null + echo "Configured dnsmasq to resolve *.unkey.local to 127.0.0.1" + + # Restart dnsmasq service + echo "" + echo "Would you like to restart dnsmasq service? (y/n)" + read -r response + if [[ "$response" == "y" ]]; then + if systemctl is-active --quiet dnsmasq; then + sudo systemctl restart dnsmasq + echo "dnsmasq service restarted" + else + sudo systemctl start dnsmasq + sudo systemctl enable dnsmasq + echo "dnsmasq service started and enabled" + fi + fi + + # Configure systemd-resolved or NetworkManager if present + if systemctl is-active --quiet systemd-resolved; then + echo "" + echo "systemd-resolved detected. You may need to configure it to use dnsmasq." + echo "Add 'DNS=127.0.0.1' to /etc/systemd/resolved.conf and restart systemd-resolved" + fi +fi + +echo "" +echo "Setup complete!" +echo "" +echo "Test your setup with:" +echo " dig test.unkey.local" +echo " ping my-deployment.unkey.local" +echo " curl http://anything.unkey.local" +echo "" +echo "To undo these changes:" +if [[ "$OS" == "macos" ]]; then + echo " sudo brew services stop dnsmasq" + echo " sudo rm /etc/resolver/unkey.local" + echo " brew uninstall dnsmasq # optional" +else + echo " sudo systemctl stop dnsmasq" + echo " sudo rm $DNSMASQ_CONF" + echo " sudo systemctl restart dnsmasq" +fi diff --git a/go/apps/ctrl/services/deployment/backend_adapter.go b/go/apps/ctrl/services/deployment/backend_adapter.go index 3f5b6d3ba1..c64045eaec 100644 --- a/go/apps/ctrl/services/deployment/backend_adapter.go +++ b/go/apps/ctrl/services/deployment/backend_adapter.go @@ -60,8 +60,8 @@ type LocalBackendAdapter struct { logger logging.Logger } -func NewLocalBackendAdapter(backendType string, logger logging.Logger) (*LocalBackendAdapter, error) { - backend, err := backends.NewBackend(backendType, logger) +func NewLocalBackendAdapter(backendType string, logger logging.Logger, isRunningDocker bool) (*LocalBackendAdapter, error) { + backend, err := backends.NewBackend(backendType, logger, isRunningDocker) if err != nil { return nil, err } @@ -117,10 +117,10 @@ func (f *LocalBackendAdapter) Name() string { } // NewDeploymentBackend creates the appropriate backend based on configuration -func NewDeploymentBackend(metalDClient metaldv1connect.VmServiceClient, fallbackType string, logger logging.Logger) (DeploymentBackend, error) { +func NewDeploymentBackend(metalDClient metaldv1connect.VmServiceClient, fallbackType string, logger logging.Logger, isRunningDocker bool) (DeploymentBackend, error) { if fallbackType != "" { logger.Info("using local deployment backend", "type", fallbackType) - return NewLocalBackendAdapter(fallbackType, logger) + return NewLocalBackendAdapter(fallbackType, logger, isRunningDocker) } if metalDClient == nil { diff --git a/go/apps/ctrl/services/deployment/backends/docker.go b/go/apps/ctrl/services/deployment/backends/docker.go index a4107de451..cfa9e3ad5b 100644 --- a/go/apps/ctrl/services/deployment/backends/docker.go +++ b/go/apps/ctrl/services/deployment/backends/docker.go @@ -21,10 +21,11 @@ import ( // DockerBackend implements DeploymentBackend using Docker containers type DockerBackend struct { - logger logging.Logger - dockerClient *client.Client - deployments map[string]*dockerDeployment - mutex sync.RWMutex + logger logging.Logger + dockerClient *client.Client + deployments map[string]*dockerDeployment + mutex sync.RWMutex + isRunningDocker bool // Whether this service is running in Docker } type dockerDeployment struct { @@ -36,7 +37,7 @@ type dockerDeployment struct { } // NewDockerBackend creates a new Docker backend -func NewDockerBackend(logger logging.Logger) (*DockerBackend, error) { +func NewDockerBackend(logger logging.Logger, isRunningDocker bool) (*DockerBackend, error) { dockerClient, err := client.NewClientWithOpts( client.FromEnv, client.WithAPIVersionNegotiation(), @@ -54,9 +55,10 @@ func NewDockerBackend(logger logging.Logger) (*DockerBackend, error) { } return &DockerBackend{ - logger: logger.With("backend", "docker"), - dockerClient: dockerClient, - deployments: make(map[string]*dockerDeployment), + logger: logger.With("backend", "docker"), + dockerClient: dockerClient, + deployments: make(map[string]*dockerDeployment), + isRunningDocker: isRunningDocker, }, nil } @@ -149,15 +151,42 @@ func (d *DockerBackend) GetDeploymentStatus(ctx context.Context, deploymentID st } // Get host and port from container - host := "localhost" - port := int32(8080) // Default port - - // Try to get the actual mapped port - if inspect.NetworkSettings != nil && inspect.NetworkSettings.Ports != nil { - for containerPort, bindings := range inspect.NetworkSettings.Ports { - if strings.Contains(string(containerPort), "8080") && len(bindings) > 0 { - if p, err := strconv.Atoi(bindings[0].HostPort); err == nil { - port = int32(p) + var host string + port := int32(8080) // Container internal port + + // Always use container IP when available (works from inside and outside Docker) + if inspect.NetworkSettings != nil { + // Try legacy IPAddress field first + if inspect.NetworkSettings.IPAddress != "" { + host = inspect.NetworkSettings.IPAddress + port = int32(8080) + } else if inspect.NetworkSettings.Networks != nil { + // Try to get IP from any network + for _, network := range inspect.NetworkSettings.Networks { + if network.IPAddress != "" { + host = network.IPAddress + port = int32(8080) + break + } + } + } + } + + // If no container IP found, fall back to external access + if host == "" { + // Fallback: use external access method + if d.isRunningDocker { + host = "host.docker.internal" + } else { + host = "localhost" + } + // Get the mapped port for external access + if inspect.NetworkSettings != nil && inspect.NetworkSettings.Ports != nil { + for containerPort, bindings := range inspect.NetworkSettings.Ports { + if strings.Contains(string(containerPort), "8080") && len(bindings) > 0 { + if p, err := strconv.Atoi(bindings[0].HostPort); err == nil { + port = int32(p) + } } } } diff --git a/go/apps/ctrl/services/deployment/backends/interface.go b/go/apps/ctrl/services/deployment/backends/interface.go index c9349cc5f5..a65d05005e 100644 --- a/go/apps/ctrl/services/deployment/backends/interface.go +++ b/go/apps/ctrl/services/deployment/backends/interface.go @@ -49,7 +49,7 @@ func ValidateBackendType(backendType string) error { } // NewBackend creates a new deployment backend based on the specified type -func NewBackend(backendType string, logger logging.Logger) (DeploymentBackend, error) { +func NewBackend(backendType string, logger logging.Logger, isRunningDocker bool) (DeploymentBackend, error) { // Validate backend type first if err := ValidateBackendType(backendType); err != nil { return nil, err @@ -62,7 +62,7 @@ func NewBackend(backendType string, logger logging.Logger) (DeploymentBackend, e case BackendTypeK8s: return NewK8sBackend(logger) case BackendTypeDocker: - return NewDockerBackend(logger) + return NewDockerBackend(logger, isRunningDocker) default: return nil, fmt.Errorf("unsupported backend type: %s; allowed values: %q, %q", backendType, BackendTypeK8s, BackendTypeDocker) } diff --git a/go/apps/ctrl/services/deployment/backends/k8s.go b/go/apps/ctrl/services/deployment/backends/k8s.go index 8324a7ea7b..ea4f01a36c 100644 --- a/go/apps/ctrl/services/deployment/backends/k8s.go +++ b/go/apps/ctrl/services/deployment/backends/k8s.go @@ -11,6 +11,7 @@ import ( "time" appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -31,15 +32,18 @@ type K8sBackend struct { namespace string deployments map[string]*k8sDeployment mutex sync.RWMutex + ttlSeconds int32 // TTL for auto-termination (0 = no TTL) } type k8sDeployment struct { DeploymentID string DeploymentName string + JobName string // Job name for TTL-enabled deployments ServiceName string VMIDs []string Image string CreatedAt time.Time + UseJob bool // Whether this deployment uses a Job (for TTL) or Deployment } // NewK8sBackend creates a new Kubernetes backend @@ -67,6 +71,7 @@ func NewK8sBackend(logger logging.Logger) (*K8sBackend, error) { clientset: clientset, namespace: namespace, deployments: make(map[string]*k8sDeployment), + ttlSeconds: 7200, // 2 hours default TTL for auto-cleanup }, nil } @@ -85,63 +90,124 @@ func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, // Sanitize deployment ID for Kubernetes RFC 1123 compliance deploymentName, serviceName := k.sanitizeK8sNames(deploymentID) - - // Create deployment - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: deploymentName, - Namespace: k.namespace, - Labels: map[string]string{ - "unkey.deployment.id": deploymentID, - "unkey.managed.by": "ctrl-fallback", + jobName := fmt.Sprintf("job-%s", deploymentName) + + // Decide whether to use Job (with TTL) or Deployment + useJob := k.ttlSeconds > 0 + + if useJob { + // Create Job with TTL for auto-termination + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + Namespace: k.namespace, + Labels: map[string]string{ + "unkey.deployment.id": deploymentID, + "unkey.managed.by": "ctrl-fallback", + }, }, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: ptr.P(int32(vmCount)), - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ + Spec: batchv1.JobSpec{ + TTLSecondsAfterFinished: &k.ttlSeconds, // Auto-cleanup after completion + ActiveDeadlineSeconds: ptr.P(int64(k.ttlSeconds)), // Max runtime + Parallelism: ptr.P(int32(vmCount)), + Completions: ptr.P(int32(vmCount)), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "unkey.deployment.id": deploymentID, + "unkey.managed.by": "ctrl-fallback", + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, // Required for Jobs + Containers: []corev1.Container{ + { + Name: "app", + Image: image, + Ports: []corev1.ContainerPort{ + { + ContainerPort: 8080, + Protocol: corev1.ProtocolTCP, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1000m"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + }, + }, + }, + }, + }, + }, + } + + _, err := k.clientset.BatchV1().Jobs(k.namespace).Create(ctx, job, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create job: %w", err) + } + } else { + // Create regular Deployment without TTL + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Namespace: k.namespace, + Labels: map[string]string{ "unkey.deployment.id": deploymentID, + "unkey.managed.by": "ctrl-fallback", }, }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.P(int32(vmCount)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ "unkey.deployment.id": deploymentID, - "unkey.managed.by": "ctrl-fallback", }, }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: image, - Ports: []corev1.ContainerPort{ - { - ContainerPort: 8080, - Protocol: corev1.ProtocolTCP, - }, - }, - // This REALLY doesn't matter for dev - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("100m"), - corev1.ResourceMemory: resource.MustParse("128Mi"), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "unkey.deployment.id": deploymentID, + "unkey.managed.by": "ctrl-fallback", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: image, + Ports: []corev1.ContainerPort{ + { + ContainerPort: 8080, + Protocol: corev1.ProtocolTCP, + }, }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("1000m"), - corev1.ResourceMemory: resource.MustParse("1Gi"), + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1000m"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, }, }, }, }, }, }, - }, - } + } - _, err := k.clientset.AppsV1().Deployments(k.namespace).Create(ctx, deployment, metav1.CreateOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to create deployment: %w", err) + _, err := k.clientset.AppsV1().Deployments(k.namespace).Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create deployment: %w", err) + } } // Create service to expose the deployment @@ -169,10 +235,14 @@ func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, }, } - _, err = k.clientset.CoreV1().Services(k.namespace).Create(ctx, service, metav1.CreateOptions{}) + _, err := k.clientset.CoreV1().Services(k.namespace).Create(ctx, service, metav1.CreateOptions{}) if err != nil { - // Clean up deployment if service creation fails - k.clientset.AppsV1().Deployments(k.namespace).Delete(ctx, deploymentName, metav1.DeleteOptions{}) + // Clean up deployment/job if service creation fails + if useJob { + k.clientset.BatchV1().Jobs(k.namespace).Delete(ctx, jobName, metav1.DeleteOptions{}) + } else { + k.clientset.AppsV1().Deployments(k.namespace).Delete(ctx, deploymentName, metav1.DeleteOptions{}) + } return nil, fmt.Errorf("failed to create service: %w", err) } @@ -181,10 +251,12 @@ func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, k.deployments[deploymentID] = &k8sDeployment{ DeploymentID: deploymentID, DeploymentName: deploymentName, + JobName: jobName, ServiceName: serviceName, VMIDs: vmIDs, Image: image, CreatedAt: time.Now(), + UseJob: useJob, } k.mutex.Unlock() @@ -207,10 +279,38 @@ func (k *K8sBackend) GetDeploymentStatus(ctx context.Context, deploymentID strin return nil, fmt.Errorf("deployment %s not found", deploymentID) } - // Get deployment status - deployment, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, deploymentInfo.DeploymentName, metav1.GetOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to get deployment: %w", err) + // Get deployment/job status + var state metaldv1.VmState + if deploymentInfo.UseJob { + job, err := k.clientset.BatchV1().Jobs(k.namespace).Get(ctx, deploymentInfo.JobName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get job: %w", err) + } + + // Determine state based on job status + if job.Status.Succeeded > 0 { + state = metaldv1.VmState_VM_STATE_RUNNING + } else if job.Status.Failed > 0 { + state = metaldv1.VmState_VM_STATE_SHUTDOWN + } else if job.Status.Active > 0 { + state = metaldv1.VmState_VM_STATE_RUNNING + } else { + state = metaldv1.VmState_VM_STATE_CREATED + } + } else { + deployment, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, deploymentInfo.DeploymentName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get deployment: %w", err) + } + + // Determine state based on deployment status + if deployment.Status.ReadyReplicas == *deployment.Spec.Replicas { + state = metaldv1.VmState_VM_STATE_RUNNING + } else if deployment.Status.ReadyReplicas > 0 { + state = metaldv1.VmState_VM_STATE_RUNNING // Partially running + } else { + state = metaldv1.VmState_VM_STATE_CREATED + } } // Get service to find the cluster IP @@ -222,16 +322,6 @@ func (k *K8sBackend) GetDeploymentStatus(ctx context.Context, deploymentID strin // Create VM responses vms := make([]*metaldv1.GetDeploymentResponse_Vm, 0, len(deploymentInfo.VMIDs)) - // Determine state based on deployment status - state := metaldv1.VmState_VM_STATE_UNSPECIFIED - if deployment.Status.ReadyReplicas == *deployment.Spec.Replicas { - state = metaldv1.VmState_VM_STATE_RUNNING - } else if deployment.Status.ReadyReplicas > 0 { - state = metaldv1.VmState_VM_STATE_RUNNING // Partially running - } else { - state = metaldv1.VmState_VM_STATE_CREATED - } - // Always use cluster IP and container port for backend communication host := service.Spec.ClusterIP port := int32(8080) // Always use container port for backend service calls @@ -271,9 +361,15 @@ func (k *K8sBackend) DeleteDeployment(ctx context.Context, deploymentID string) "error", err) } - // Delete deployment - if err := k.clientset.AppsV1().Deployments(k.namespace).Delete(ctx, deploymentInfo.DeploymentName, metav1.DeleteOptions{}); err != nil { - return fmt.Errorf("failed to delete deployment %s: %w", deploymentInfo.DeploymentName, err) + // Delete deployment or job + if deploymentInfo.UseJob { + if err := k.clientset.BatchV1().Jobs(k.namespace).Delete(ctx, deploymentInfo.JobName, metav1.DeleteOptions{}); err != nil { + return fmt.Errorf("failed to delete job %s: %w", deploymentInfo.JobName, err) + } + } else { + if err := k.clientset.AppsV1().Deployments(k.namespace).Delete(ctx, deploymentInfo.DeploymentName, metav1.DeleteOptions{}); err != nil { + return fmt.Errorf("failed to delete deployment %s: %w", deploymentInfo.DeploymentName, err) + } } k.logger.Info("Kubernetes deployment deleted", diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index 4152b83d38..b7757a078d 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -29,18 +29,19 @@ type DeployWorkflow struct { } type DeployWorkflowConfig struct { - Logger logging.Logger - DB db.Database - PartitionDB db.Database - MetalD metaldv1connect.VmServiceClient - MetaldBackend string - DefaultDomain string + Logger logging.Logger + DB db.Database + PartitionDB db.Database + MetalD metaldv1connect.VmServiceClient + MetaldBackend string + DefaultDomain string + IsRunningDocker bool } // NewDeployWorkflow creates a new deploy workflow instance func NewDeployWorkflow(cfg DeployWorkflowConfig) *DeployWorkflow { // Create the appropriate deployment backend - deploymentBackend, err := NewDeploymentBackend(cfg.MetalD, cfg.MetaldBackend, cfg.Logger) + deploymentBackend, err := NewDeploymentBackend(cfg.MetalD, cfg.MetaldBackend, cfg.Logger, cfg.IsRunningDocker) if err != nil { // Log error but continue - workflow will fail when trying to use the backend cfg.Logger.Error("failed to initialize deployment backend", @@ -322,13 +323,12 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Create gateway configs for all domains in bulk (except local ones) err = hydra.StepVoid(ctx, "create-gateway-configs-bulk", func(stepCtx context.Context) error { - // Prepare gateway configs for all non-local domains var gatewayParams []partitiondb.UpsertGatewayParams var skippedDomains []string for _, domain := range allDomains { - if isLocalHostname(domain) { + if isLocalHostname(domain, w.defaultDomain) { skippedDomains = append(skippedDomains, domain) continue } @@ -596,17 +596,25 @@ func (w *DeployWorkflow) createGatewayConfig(deploymentID, keyspaceID string, vm return gatewayConfig, nil } -// isLocalHostname checks if a hostname is for local development -func isLocalHostname(hostname string) bool { +// isLocalHostname checks if a hostname should be skipped from gateway config creation +// Returns true for localhost/development domains that shouldn't get gateway configs +func isLocalHostname(hostname, defaultDomain string) bool { // Lowercase for case-insensitive comparison hostname = strings.ToLower(hostname) + defaultDomain = strings.ToLower(defaultDomain) - // Exact matches for common local hosts + // Exact matches for common local hosts - these should be skipped if hostname == "localhost" || hostname == "127.0.0.1" { return true } - // Check for local-only TLD suffixes + // If hostname uses the default domain, it should NOT be skipped (return false) + // This allows gateway configs to be created for the default domain + if strings.HasSuffix(hostname, "."+defaultDomain) || hostname == defaultDomain { + return false + } + + // Check for local-only TLD suffixes - these should be skipped // Note: .dev is a real TLD owned by Google, so it's excluded localSuffixes := []string{ ".local", diff --git a/go/cmd/ctrl/main.go b/go/cmd/ctrl/main.go index b0f947517a..580d14956c 100644 --- a/go/cmd/ctrl/main.go +++ b/go/cmd/ctrl/main.go @@ -77,6 +77,7 @@ var Cmd = &cli.Command{ cli.String("acme-cloudflare-api-token", "Cloudflare API token for Let's Encrypt", cli.EnvVar("UNKEY_ACME_CLOUDFLARE_API_TOKEN")), cli.String("default-domain", "Default domain for auto-generated hostnames", cli.Default("unkey.app"), cli.EnvVar("UNKEY_DEFAULT_DOMAIN")), + cli.Bool("docker-running", "Whether this service is running in Docker (affects host address for container communication)", cli.EnvVar("UNKEY_DOCKER_RUNNING")), }, Action: action, } diff --git a/go/cmd/localtls/main.go b/go/cmd/localtls/main.go new file mode 100644 index 0000000000..0f1961baf5 --- /dev/null +++ b/go/cmd/localtls/main.go @@ -0,0 +1,252 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "database/sql" + "encoding/pem" + "fmt" + "math/big" + "os" + "time" + + _ "github.com/go-sql-driver/mysql" + vaultv1 "github.com/unkeyed/unkey/go/gen/proto/vault/v1" + "github.com/unkeyed/unkey/go/pkg/cli" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/otel/logging" + pdb "github.com/unkeyed/unkey/go/pkg/partition/db" + "github.com/unkeyed/unkey/go/pkg/vault" + "github.com/unkeyed/unkey/go/pkg/vault/storage" +) + +var Cmd = &cli.Command{ + Name: "local-tls", + Usage: "Manage self-signed TLS certificates for local development", + Description: `Generate and install self-signed wildcard certificates for *.unkey.local +This command creates certificates and stores them in the database with encrypted private keys, +allowing the gateway to serve HTTPS traffic for local development.`, + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate and install self-signed certificate for *.unkey.local", + Flags: []cli.Flag{ + cli.String("mysql-dsn", "MySQL connection string for partition database", + cli.Default("unkey:password@tcp(localhost:3306)/partition_001?parseTime=true&interpolateParams=true"), + cli.EnvVar("UNKEY_DATABASE_PARTITION")), + + cli.String("vault-s3-url", "S3 URL for vault service", + cli.Default("http://localhost:3902"), + cli.EnvVar("UNKEY_VAULT_S3_URL")), + + cli.String("vault-s3-bucket", "S3 bucket for vault service", + cli.Default("acme-vault"), + cli.EnvVar("UNKEY_VAULT_S3_BUCKET")), + + cli.String("vault-s3-access-key", "S3 access key ID", + cli.Default("minio_root_user"), + cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_ID")), + + cli.String("vault-s3-secret", "S3 access key secret", + cli.Default("minio_root_password"), + cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_SECRET")), + + cli.String("vault-master-keys", "Vault master keys", + cli.Default("Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U="), + cli.EnvVar("UNKEY_VAULT_MASTER_KEYS")), + + cli.String("workspace-id", "Workspace ID for the certificate", + cli.Default("unkey")), + + cli.String("hostname", "Hostname for the certificate", + cli.Default("*.unkey.local")), + + cli.Int("days", "Certificate validity in days", + cli.Default(365)), + + cli.String("cert-dir", "Directory to save certificate files", + cli.Default("./certs")), + }, + Action: generateCertificate, + }, + }, +} + +func generateCertificate(ctx context.Context, cmd *cli.Command) error { + fmt.Println("Generating self-signed wildcard certificate...") + + hostname := cmd.String("hostname") + days := cmd.Int("days") + certDir := cmd.String("cert-dir") + + // Generate private key + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return fmt.Errorf("failed to generate private key: %w", err) + } + + // Create certificate template + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Unkey Local Development"}, + Country: []string{"US"}, + CommonName: "unkey.local", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Duration(days) * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{ + hostname, + "unkey.local", + }, + } + + // Generate certificate + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + // Encode certificate to PEM + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + // Encode private key to PEM + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + fmt.Println("Certificate generated successfully!") + + // Save to files for backup + if err := os.MkdirAll(certDir, 0755); err != nil { + return fmt.Errorf("failed to create cert directory: %w", err) + } + + certFile := fmt.Sprintf("%s/unkey.local.crt", certDir) + keyFile := fmt.Sprintf("%s/unkey.local.key", certDir) + + if err := os.WriteFile(certFile, certPEM, 0644); err != nil { + return fmt.Errorf("failed to write certificate file: %w", err) + } + + if err := os.WriteFile(keyFile, privateKeyPEM, 0600); err != nil { + return fmt.Errorf("failed to write private key file: %w", err) + } + + fmt.Printf("Certificate files saved to %s/\n", certDir) + + // Store in database + return storeCertificateInDB(cmd, string(certPEM), string(privateKeyPEM), certFile) +} + +func storeCertificateInDB(cmd *cli.Command, certPEM, privateKeyPEM, certFile string) error { + fmt.Println("\nStoring certificate in database...") + + logger := logging.New() + + // Connect to MySQL using db.New + partitionDB, err := db.New(db.Config{ + PrimaryDSN: cmd.String("mysql-dsn"), + Logger: logging.New(), + }) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + // Initialize storage for vault + s3Storage, err := storage.NewS3(storage.S3Config{ + S3URL: cmd.String("vault-s3-url"), + S3Bucket: cmd.String("vault-s3-bucket"), + S3AccessKeyID: cmd.String("vault-s3-access-key"), + S3AccessKeySecret: cmd.String("vault-s3-secret"), + Logger: logger, + }) + if err != nil { + return fmt.Errorf("failed to initialize S3 storage: %w", err) + } + + // Initialize vault service + vaultService, err := vault.New(vault.Config{ + Logger: logger, + Storage: s3Storage, + MasterKeys: []string{cmd.String("vault-master-keys")}, + }) + if err != nil { + return fmt.Errorf("failed to initialize vault service: %w", err) + } + + bgCtx := context.Background() + + // Encrypt the private key + encryptResp, err := vaultService.Encrypt(bgCtx, &vaultv1.EncryptRequest{ + Keyring: "unkey", + Data: privateKeyPEM, + }) + if err != nil { + return fmt.Errorf("failed to encrypt private key: %w", err) + } + + // Insert certificate into database + workspaceID := cmd.String("workspace-id") + hostname := cmd.String("hostname") + now := time.Now().UnixMilli() + + err = pdb.Query.InsertCertificate(bgCtx, partitionDB.RW(), pdb.InsertCertificateParams{ + WorkspaceID: workspaceID, + Hostname: hostname, + Certificate: certPEM, + EncryptedPrivateKey: encryptResp.Encrypted, + CreatedAt: now, + UpdatedAt: sql.NullInt64{Valid: true, Int64: now}, + }) + + if err != nil { + return fmt.Errorf("failed to insert certificate: %w", err) + } + + fmt.Println("Certificate successfully stored in database!") + fmt.Printf("\nSetup complete! The gateway can now use the certificate for %s\n", hostname) + + // Print instructions for trusting the certificate + fmt.Println("\nšŸ” To trust this certificate in your browser and system:") + fmt.Printf("\n Certificate file: %s\n", certFile) + fmt.Println("\n macOS:") + fmt.Printf(" sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain %s\n", certFile) + fmt.Println("\n Linux:") + fmt.Printf(" sudo cp %s /usr/local/share/ca-certificates/unkey-local.crt\n", certFile) + fmt.Println(" sudo update-ca-certificates") + fmt.Println("\n Windows:") + fmt.Printf(" certlm.msc -> Trusted Root Certification Authorities -> Import %s\n", certFile) + fmt.Println("\n Chrome/Chromium (if system trust doesn't work):") + fmt.Println(" Settings -> Privacy and Security -> Manage Certificates -> Authorities -> Import") + fmt.Printf(" Then import: %s\n", certFile) + + return nil +} + +func main() { + app := &cli.Command{ + Name: "localtls", + Usage: "Run localtls", + Description: `LocalTLS CLI – run and administer LocalTLS services.`, + Commands: []*cli.Command{ + Cmd, + }, + } + + err := app.Run(context.Background(), os.Args) + if err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } +} From f39401dec2b6a7fdc57da74febeb7aa1f08ccb80 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 12 Sep 2025 19:50:22 +0200 Subject: [PATCH 2/4] feat(deploy): fix cert --- go/cmd/localtls/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/cmd/localtls/main.go b/go/cmd/localtls/main.go index 0f1961baf5..60db4b66e9 100644 --- a/go/cmd/localtls/main.go +++ b/go/cmd/localtls/main.go @@ -92,9 +92,9 @@ func generateCertificate(ctx context.Context, cmd *cli.Command) error { template := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{ - Organization: []string{"Unkey Local Development"}, + Organization: []string{"Unkey"}, Country: []string{"US"}, - CommonName: "unkey.local", + CommonName: "*.unkey.local", }, NotBefore: time.Now(), NotAfter: time.Now().Add(time.Duration(days) * 24 * time.Hour), From 312a2803a7c582b48c336ad8593c7e08e22c264b Mon Sep 17 00:00:00 2001 From: Flo Date: Mon, 15 Sep 2025 10:35:22 +0200 Subject: [PATCH 3/4] use flag for cert gen --- deployment/docker-compose.yaml | 3 + go/apps/gw/config.go | 4 + go/apps/gw/localcert.go | 152 ++++++++++++++++++++ go/apps/gw/run.go | 16 +++ go/cmd/gw/main.go | 7 + go/cmd/localtls/main.go | 252 --------------------------------- 6 files changed, 182 insertions(+), 252 deletions(-) create mode 100644 go/apps/gw/localcert.go delete mode 100644 go/cmd/localtls/main.go diff --git a/deployment/docker-compose.yaml b/deployment/docker-compose.yaml index a867a83497..20fbccc26a 100644 --- a/deployment/docker-compose.yaml +++ b/deployment/docker-compose.yaml @@ -208,6 +208,8 @@ services: - "443:443" depends_on: - mysql + volumes: + - ./certs:/certs environment: UNKEY_HTTP_PORT: 80 UNKEY_HTTPS_PORT: 443 @@ -216,6 +218,7 @@ services: UNKEY_DEFAULT_CERT_DOMAIN: "unkey.local" UNKEY_MAIN_DOMAIN: "unkey.local" UNKEY_CTRL_ADDR: "ctrl:7091" + UNKEY_REQUIRE_LOCAL_CERT: true UNKEY_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/partition_001?parseTime=true&interpolateParams=true" UNKEY_KEYS_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/unkey?parseTime=true&interpolateParams=true" diff --git a/go/apps/gw/config.go b/go/apps/gw/config.go index a9c04ae78c..9326b3f468 100644 --- a/go/apps/gw/config.go +++ b/go/apps/gw/config.go @@ -77,6 +77,10 @@ type Config struct { // --- Vault Configuration --- VaultMasterKeys []string VaultS3 *storage.S3Config + + // --- Local Certificate Configuration --- + // RequireLocalCert specifies whether to generate a local self-signed certificate for *.unkey.local + RequireLocalCert bool } func (c Config) Validate() error { diff --git a/go/apps/gw/localcert.go b/go/apps/gw/localcert.go new file mode 100644 index 0000000000..4252286c7f --- /dev/null +++ b/go/apps/gw/localcert.go @@ -0,0 +1,152 @@ +package gw + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "database/sql" + "encoding/pem" + "fmt" + "math/big" + "os" + "time" + + vaultv1 "github.com/unkeyed/unkey/go/gen/proto/vault/v1" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/otel/logging" + pdb "github.com/unkeyed/unkey/go/pkg/partition/db" + "github.com/unkeyed/unkey/go/pkg/vault" +) + +type LocalCertConfig struct { + Logger logging.Logger + PartitionedDB db.Database + VaultService *vault.Service + Hostname string + WorkspaceID string +} + +func generateLocalCertificate(ctx context.Context, cfg LocalCertConfig) error { + logger := cfg.Logger + logger.Info("Checking for existing local certificate", "hostname", cfg.Hostname) + + // Check if certificate already exists in database + existing, err := pdb.Query.FindCertificateByHostname(ctx, cfg.PartitionedDB.RO(), cfg.Hostname) + + if err != nil && !db.IsNotFound(err) { + return fmt.Errorf("failed to check for existing certificate: %w", err) + } + + // If we found an existing certificate, use it + if err == nil && existing.Certificate != "" && existing.EncryptedPrivateKey != "" { + logger.Info("Using existing local certificate", "hostname", cfg.Hostname) + return nil + } + + logger.Info("Generating self-signed wildcard certificate", "hostname", cfg.Hostname) + + // Generate private key + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return fmt.Errorf("failed to generate private key: %w", err) + } + + // Create certificate template + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Unkey"}, + Country: []string{"US"}, + CommonName: cfg.Hostname, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{ + cfg.Hostname, + "unkey.local", + }, + } + + // Generate certificate + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + // Encode certificate to PEM + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + // Encode private key to PEM + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + // Save to files for backup and user trust installation + certDir := "./certs" + if err := os.MkdirAll(certDir, 0755); err != nil { + return fmt.Errorf("failed to create cert directory: %w", err) + } + + certFile := fmt.Sprintf("%s/unkey.local.crt", certDir) + keyFile := fmt.Sprintf("%s/unkey.local.key", certDir) + + if err := os.WriteFile(certFile, certPEM, 0644); err != nil { + return fmt.Errorf("failed to write certificate file: %w", err) + } + + if err := os.WriteFile(keyFile, privateKeyPEM, 0600); err != nil { + return fmt.Errorf("failed to write private key file: %w", err) + } + + // Encrypt the private key + encryptResp, err := cfg.VaultService.Encrypt(ctx, &vaultv1.EncryptRequest{ + Keyring: "unkey", + Data: string(privateKeyPEM), + }) + if err != nil { + return fmt.Errorf("failed to encrypt private key: %w", err) + } + + // Insert certificate into database + now := time.Now().UnixMilli() + err = pdb.Query.InsertCertificate(ctx, cfg.PartitionedDB.RW(), pdb.InsertCertificateParams{ + WorkspaceID: cfg.WorkspaceID, + Hostname: cfg.Hostname, + Certificate: string(certPEM), + EncryptedPrivateKey: encryptResp.Encrypted, + CreatedAt: now, + UpdatedAt: sql.NullInt64{Valid: true, Int64: now}, + }) + + if err != nil { + return fmt.Errorf("failed to insert certificate: %w", err) + } + + fmt.Println("\n================================================================================") + fmt.Println("šŸ” LOCAL CERTIFICATE GENERATED") + fmt.Println("================================================================================") + fmt.Printf("\nCertificate file: %s\n", certFile) + fmt.Println("\nTo trust this certificate in your browser and system:") + fmt.Println("\n macOS:") + fmt.Printf(" sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain %s\n", certFile) + fmt.Println("\n Linux:") + fmt.Printf(" sudo cp %s /usr/local/share/ca-certificates/unkey-local.crt\n", certFile) + fmt.Println(" sudo update-ca-certificates") + fmt.Println("\n Windows:") + fmt.Printf(" certlm.msc -> Trusted Root Certification Authorities -> Import %s\n", certFile) + fmt.Println("\n Chrome/Chromium (if system trust doesn't work):") + fmt.Println(" Settings -> Privacy and Security -> Manage Certificates -> Authorities -> Import") + fmt.Printf(" Then import: %s\n", certFile) + fmt.Println("\n================================================================================") + + return nil +} diff --git a/go/apps/gw/run.go b/go/apps/gw/run.go index 8ef2b28680..1cf88648b4 100644 --- a/go/apps/gw/run.go +++ b/go/apps/gw/run.go @@ -140,6 +140,22 @@ func Run(ctx context.Context, cfg Config) error { } shutdowns.Register(partitionedDB.Close) + // Generate local certificate if requested + if cfg.RequireLocalCert { + localCertCfg := LocalCertConfig{ + Logger: logger, + PartitionedDB: partitionedDB, + VaultService: vaultSvc, + Hostname: "*.unkey.local", + WorkspaceID: "unkey", + } + + err := generateLocalCertificate(ctx, localCertCfg) + if err != nil { + return fmt.Errorf("failed to generate local certificate: %w", err) + } + } + // Create separate non-partitioned database connection for keys service var mainDB db.Database mainDB, err = db.New(db.Config{ diff --git a/go/cmd/gw/main.go b/go/cmd/gw/main.go index d5e48c6d5c..cd35af64df 100644 --- a/go/cmd/gw/main.go +++ b/go/cmd/gw/main.go @@ -86,6 +86,10 @@ var Cmd = &cli.Command{ cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_ID")), cli.String("vault-s3-access-key-secret", "S3 secret access key", cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_SECRET")), + + // Local Certificate Configuration + cli.Bool("require-local-cert", "Generate and use self-signed certificate for *.unkey.local if it doesn't exist", + cli.EnvVar("UNKEY_REQUIRE_LOCAL_CERT")), }, Action: action, } @@ -142,6 +146,9 @@ func action(ctx context.Context, cmd *cli.Command) error { // Vault configuration VaultMasterKeys: cmd.StringSlice("vault-master-keys"), VaultS3: vaultS3Config, + + // Local Certificate configuration + RequireLocalCert: cmd.Bool("require-local-cert"), } err := config.Validate() diff --git a/go/cmd/localtls/main.go b/go/cmd/localtls/main.go deleted file mode 100644 index 60db4b66e9..0000000000 --- a/go/cmd/localtls/main.go +++ /dev/null @@ -1,252 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "database/sql" - "encoding/pem" - "fmt" - "math/big" - "os" - "time" - - _ "github.com/go-sql-driver/mysql" - vaultv1 "github.com/unkeyed/unkey/go/gen/proto/vault/v1" - "github.com/unkeyed/unkey/go/pkg/cli" - "github.com/unkeyed/unkey/go/pkg/db" - "github.com/unkeyed/unkey/go/pkg/otel/logging" - pdb "github.com/unkeyed/unkey/go/pkg/partition/db" - "github.com/unkeyed/unkey/go/pkg/vault" - "github.com/unkeyed/unkey/go/pkg/vault/storage" -) - -var Cmd = &cli.Command{ - Name: "local-tls", - Usage: "Manage self-signed TLS certificates for local development", - Description: `Generate and install self-signed wildcard certificates for *.unkey.local -This command creates certificates and stores them in the database with encrypted private keys, -allowing the gateway to serve HTTPS traffic for local development.`, - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate and install self-signed certificate for *.unkey.local", - Flags: []cli.Flag{ - cli.String("mysql-dsn", "MySQL connection string for partition database", - cli.Default("unkey:password@tcp(localhost:3306)/partition_001?parseTime=true&interpolateParams=true"), - cli.EnvVar("UNKEY_DATABASE_PARTITION")), - - cli.String("vault-s3-url", "S3 URL for vault service", - cli.Default("http://localhost:3902"), - cli.EnvVar("UNKEY_VAULT_S3_URL")), - - cli.String("vault-s3-bucket", "S3 bucket for vault service", - cli.Default("acme-vault"), - cli.EnvVar("UNKEY_VAULT_S3_BUCKET")), - - cli.String("vault-s3-access-key", "S3 access key ID", - cli.Default("minio_root_user"), - cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_ID")), - - cli.String("vault-s3-secret", "S3 access key secret", - cli.Default("minio_root_password"), - cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_SECRET")), - - cli.String("vault-master-keys", "Vault master keys", - cli.Default("Ch9rZWtfMmdqMFBJdVhac1NSa0ZhNE5mOWlLSnBHenFPENTt7an5MRogENt9Si6wms4pQ2XIvqNSIgNpaBenJmXgcInhu6Nfv2U="), - cli.EnvVar("UNKEY_VAULT_MASTER_KEYS")), - - cli.String("workspace-id", "Workspace ID for the certificate", - cli.Default("unkey")), - - cli.String("hostname", "Hostname for the certificate", - cli.Default("*.unkey.local")), - - cli.Int("days", "Certificate validity in days", - cli.Default(365)), - - cli.String("cert-dir", "Directory to save certificate files", - cli.Default("./certs")), - }, - Action: generateCertificate, - }, - }, -} - -func generateCertificate(ctx context.Context, cmd *cli.Command) error { - fmt.Println("Generating self-signed wildcard certificate...") - - hostname := cmd.String("hostname") - days := cmd.Int("days") - certDir := cmd.String("cert-dir") - - // Generate private key - privateKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - return fmt.Errorf("failed to generate private key: %w", err) - } - - // Create certificate template - template := x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - Organization: []string{"Unkey"}, - Country: []string{"US"}, - CommonName: "*.unkey.local", - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(time.Duration(days) * 24 * time.Hour), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - DNSNames: []string{ - hostname, - "unkey.local", - }, - } - - // Generate certificate - certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) - if err != nil { - return fmt.Errorf("failed to create certificate: %w", err) - } - - // Encode certificate to PEM - certPEM := pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", - Bytes: certBytes, - }) - - // Encode private key to PEM - privateKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(privateKey), - }) - - fmt.Println("Certificate generated successfully!") - - // Save to files for backup - if err := os.MkdirAll(certDir, 0755); err != nil { - return fmt.Errorf("failed to create cert directory: %w", err) - } - - certFile := fmt.Sprintf("%s/unkey.local.crt", certDir) - keyFile := fmt.Sprintf("%s/unkey.local.key", certDir) - - if err := os.WriteFile(certFile, certPEM, 0644); err != nil { - return fmt.Errorf("failed to write certificate file: %w", err) - } - - if err := os.WriteFile(keyFile, privateKeyPEM, 0600); err != nil { - return fmt.Errorf("failed to write private key file: %w", err) - } - - fmt.Printf("Certificate files saved to %s/\n", certDir) - - // Store in database - return storeCertificateInDB(cmd, string(certPEM), string(privateKeyPEM), certFile) -} - -func storeCertificateInDB(cmd *cli.Command, certPEM, privateKeyPEM, certFile string) error { - fmt.Println("\nStoring certificate in database...") - - logger := logging.New() - - // Connect to MySQL using db.New - partitionDB, err := db.New(db.Config{ - PrimaryDSN: cmd.String("mysql-dsn"), - Logger: logging.New(), - }) - if err != nil { - return fmt.Errorf("failed to connect to database: %w", err) - } - - // Initialize storage for vault - s3Storage, err := storage.NewS3(storage.S3Config{ - S3URL: cmd.String("vault-s3-url"), - S3Bucket: cmd.String("vault-s3-bucket"), - S3AccessKeyID: cmd.String("vault-s3-access-key"), - S3AccessKeySecret: cmd.String("vault-s3-secret"), - Logger: logger, - }) - if err != nil { - return fmt.Errorf("failed to initialize S3 storage: %w", err) - } - - // Initialize vault service - vaultService, err := vault.New(vault.Config{ - Logger: logger, - Storage: s3Storage, - MasterKeys: []string{cmd.String("vault-master-keys")}, - }) - if err != nil { - return fmt.Errorf("failed to initialize vault service: %w", err) - } - - bgCtx := context.Background() - - // Encrypt the private key - encryptResp, err := vaultService.Encrypt(bgCtx, &vaultv1.EncryptRequest{ - Keyring: "unkey", - Data: privateKeyPEM, - }) - if err != nil { - return fmt.Errorf("failed to encrypt private key: %w", err) - } - - // Insert certificate into database - workspaceID := cmd.String("workspace-id") - hostname := cmd.String("hostname") - now := time.Now().UnixMilli() - - err = pdb.Query.InsertCertificate(bgCtx, partitionDB.RW(), pdb.InsertCertificateParams{ - WorkspaceID: workspaceID, - Hostname: hostname, - Certificate: certPEM, - EncryptedPrivateKey: encryptResp.Encrypted, - CreatedAt: now, - UpdatedAt: sql.NullInt64{Valid: true, Int64: now}, - }) - - if err != nil { - return fmt.Errorf("failed to insert certificate: %w", err) - } - - fmt.Println("Certificate successfully stored in database!") - fmt.Printf("\nSetup complete! The gateway can now use the certificate for %s\n", hostname) - - // Print instructions for trusting the certificate - fmt.Println("\nšŸ” To trust this certificate in your browser and system:") - fmt.Printf("\n Certificate file: %s\n", certFile) - fmt.Println("\n macOS:") - fmt.Printf(" sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain %s\n", certFile) - fmt.Println("\n Linux:") - fmt.Printf(" sudo cp %s /usr/local/share/ca-certificates/unkey-local.crt\n", certFile) - fmt.Println(" sudo update-ca-certificates") - fmt.Println("\n Windows:") - fmt.Printf(" certlm.msc -> Trusted Root Certification Authorities -> Import %s\n", certFile) - fmt.Println("\n Chrome/Chromium (if system trust doesn't work):") - fmt.Println(" Settings -> Privacy and Security -> Manage Certificates -> Authorities -> Import") - fmt.Printf(" Then import: %s\n", certFile) - - return nil -} - -func main() { - app := &cli.Command{ - Name: "localtls", - Usage: "Run localtls", - Description: `LocalTLS CLI – run and administer LocalTLS services.`, - Commands: []*cli.Command{ - Cmd, - }, - } - - err := app.Run(context.Background(), os.Args) - if err != nil { - fmt.Println(err.Error()) - os.Exit(1) - } -} From 6e6adf0090a727047aee33d181d6d022ce11d9e4 Mon Sep 17 00:00:00 2001 From: Flo Date: Mon, 15 Sep 2025 11:31:23 +0200 Subject: [PATCH 4/4] rabbit --- deployment/docker-compose.yaml | 2 +- deployment/setup-wildcard-dns.sh | 9 +++++++-- go/apps/ctrl/config.go | 4 ++++ go/apps/ctrl/services/deployment/backends/k8s.go | 2 +- .../ctrl/services/deployment/deploy_workflow.go | 14 +++++++------- go/cmd/ctrl/main.go | 3 +++ 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/deployment/docker-compose.yaml b/deployment/docker-compose.yaml index 20fbccc26a..ce84f849df 100644 --- a/deployment/docker-compose.yaml +++ b/deployment/docker-compose.yaml @@ -217,7 +217,7 @@ services: UNKEY_TLS_ENABLED: true UNKEY_DEFAULT_CERT_DOMAIN: "unkey.local" UNKEY_MAIN_DOMAIN: "unkey.local" - UNKEY_CTRL_ADDR: "ctrl:7091" + UNKEY_CTRL_ADDR: "http://ctrl:7091" UNKEY_REQUIRE_LOCAL_CERT: true UNKEY_DATABASE_PRIMARY: "unkey:password@tcp(mysql:3306)/partition_001?parseTime=true&interpolateParams=true" diff --git a/deployment/setup-wildcard-dns.sh b/deployment/setup-wildcard-dns.sh index 12a50ce3a7..abefb76fcb 100755 --- a/deployment/setup-wildcard-dns.sh +++ b/deployment/setup-wildcard-dns.sh @@ -104,8 +104,13 @@ else # Linux configuration DNSMASQ_CONF="/etc/dnsmasq.d/unkey.local.conf" - # Create configuration - echo "address=/unkey.local/127.0.0.1" | sudo tee "$DNSMASQ_CONF" > /dev/null + # Create configuration in dnsmasq.d directory (included by default in most dnsmasq setups) + # This keeps our config separate from the main dnsmasq configuration + { + echo "# Unkey local development DNS configuration" + echo "# Resolve all *.unkey.local domains to localhost" + echo "address=/unkey.local/127.0.0.1" + } | sudo tee "$DNSMASQ_CONF" > /dev/null echo "Configured dnsmasq to resolve *.unkey.local to 127.0.0.1" # Restart dnsmasq service diff --git a/go/apps/ctrl/config.go b/go/apps/ctrl/config.go index 0ac209a5f9..41194c1170 100644 --- a/go/apps/ctrl/config.go +++ b/go/apps/ctrl/config.go @@ -85,6 +85,10 @@ type Config struct { Acme AcmeConfig DefaultDomain string + + // IsRunningDocker indicates whether this service is running inside a Docker container + // Affects host address resolution for container-to-container communication + IsRunningDocker bool } func (c Config) Validate() error { diff --git a/go/apps/ctrl/services/deployment/backends/k8s.go b/go/apps/ctrl/services/deployment/backends/k8s.go index ea4f01a36c..3baab8dfa4 100644 --- a/go/apps/ctrl/services/deployment/backends/k8s.go +++ b/go/apps/ctrl/services/deployment/backends/k8s.go @@ -107,7 +107,7 @@ func (k *K8sBackend) CreateDeployment(ctx context.Context, deploymentID string, }, }, Spec: batchv1.JobSpec{ - TTLSecondsAfterFinished: &k.ttlSeconds, // Auto-cleanup after completion + TTLSecondsAfterFinished: &k.ttlSeconds, // Auto-cleanup after completion ActiveDeadlineSeconds: ptr.P(int64(k.ttlSeconds)), // Max runtime Parallelism: ptr.P(int32(vmCount)), Completions: ptr.P(int32(vmCount)), diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index b7757a078d..146c3da6c9 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -29,13 +29,13 @@ type DeployWorkflow struct { } type DeployWorkflowConfig struct { - Logger logging.Logger - DB db.Database - PartitionDB db.Database - MetalD metaldv1connect.VmServiceClient - MetaldBackend string - DefaultDomain string - IsRunningDocker bool + Logger logging.Logger + DB db.Database + PartitionDB db.Database + MetalD metaldv1connect.VmServiceClient + MetaldBackend string + DefaultDomain string + IsRunningDocker bool } // NewDeployWorkflow creates a new deploy workflow instance diff --git a/go/cmd/ctrl/main.go b/go/cmd/ctrl/main.go index 580d14956c..df5247429a 100644 --- a/go/cmd/ctrl/main.go +++ b/go/cmd/ctrl/main.go @@ -146,6 +146,9 @@ func action(ctx context.Context, cmd *cli.Command) error { DefaultDomain: cmd.String("default-domain"), + // Docker configuration + IsRunningDocker: cmd.Bool("docker-running"), + // Common Clock: clock.New(), }