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..ce84f849df 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,32 @@ services: container_name: gw command: ["run", "gw"] ports: - - "6060:6060" + - "80:80" + - "443:443" depends_on: - mysql + volumes: + - ./certs:/certs 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: "http://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" 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 +243,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 +272,7 @@ services: - 3001:3000 - 4317:4317 - 4318:4318 + prometheus: image: prom/prometheus:v3.5.0 container_name: prometheus @@ -255,6 +282,7 @@ services: - ./config/prometheus.yml:/etc/prometheus/prometheus.yml depends_on: - apiv2 + dashboard: build: context: .. @@ -281,6 +309,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..abefb76fcb --- /dev/null +++ b/deployment/setup-wildcard-dns.sh @@ -0,0 +1,156 @@ +#!/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 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 + 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/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/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..3baab8dfa4 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..146c3da6c9 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/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/ctrl/main.go b/go/cmd/ctrl/main.go index b0f947517a..df5247429a 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, } @@ -145,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(), } 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()