From c79040c5092dd301f4f5e3b2828688a93209091d Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Tue, 23 Sep 2025 16:42:49 -0400 Subject: [PATCH] deploy directly to GCE Signed-off-by: Jason Hall --- CLAUDE.md | 43 +++++++- README.md | 12 ++- docs/MIGRATION.md | 42 ++++++++ docs/cost.md | 59 +++++++++++ go.mod | 4 +- iac/chessh.tf | 97 +----------------- iac/cost.md | 49 --------- iac/dns.tf | 13 --- iac/gce-vm.tf | 171 +++++++++++++++++++++++++++++++ iac/gke-autopilot.tf | 13 --- iac/outputs.tf | 30 +----- iac/providers.tf | 21 +--- iac/ssh-key.tf | 4 +- iac/ssh-proxy.tf | 210 --------------------------------------- iac/tcp-load-balancer.tf | 47 +++++++++ main.go | 13 +-- 16 files changed, 390 insertions(+), 438 deletions(-) create mode 100644 docs/MIGRATION.md create mode 100644 docs/cost.md delete mode 100644 iac/cost.md delete mode 100644 iac/dns.tf create mode 100644 iac/gce-vm.tf delete mode 100644 iac/gke-autopilot.tf delete mode 100644 iac/ssh-proxy.tf create mode 100644 iac/tcp-load-balancer.tf diff --git a/CLAUDE.md b/CLAUDE.md index c635785..d15ce06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,22 @@ CheSSH is a multiplayer chess game playable via SSH. The project consists of: ## Architecture +### Deployment Architecture + +The production system uses a simplified GCE VM + TCP Load Balancer architecture: + +- **Load Balancer**: Global TCP Load Balancer serving SSH on port 22 +- **Backend**: GCE VM instances running Container-Optimized OS +- **Container**: Chess application with SSH server (port 2222) and health endpoint (port 8080) +- **Health Checks**: HTTP health checks on `/health` endpoint for auto-healing +- **DNS**: Direct A records pointing to load balancer IP +- **Secrets**: SSH host keys managed in Google Secret Manager + +This provides direct SSH access without proxy layers for better performance and simpler infrastructure. + ### Core Components -- **main.go**: Main entry point with Bubble Tea model for the chess TUI. Contains game state management, input handling, and UI rendering. Can run locally or as SSH server via `-port` flag. +- **main.go**: Main entry point with Bubble Tea model for the chess TUI. Contains game state management, input handling, and UI rendering. Supports both local mode and SSH server via `-port` flag. Includes `/health` endpoint when `PORT` env var is set. - **server.go**: SSH server implementation using Charm's wish framework. Handles SSH sessions and integrates with the multiplayer system. - **multiplayer.go**: Game session management, player matchmaking, and real-time updates between players. Implements the GameManager singleton for coordinating games. - **chess.go**: Core chess engine with complete game logic including piece movement rules, board state, check/checkmate detection, castling, and en passant. @@ -52,6 +65,31 @@ go run . -port 2222 ssh localhost -p 2222 ``` +### Deployment +```bash +cd iac/ +terraform init +terraform apply +``` + +## Infrastructure Management + +### Container Updates +- Code changes automatically trigger new container builds via `ko_build.chessh` +- Terraform detects new image references and performs rolling updates +- Use `terraform plan` and `terraform apply` for deployments +- GCE instance groups handle rolling updates automatically with health checks + +### Health Checks +- HTTP endpoint at `/health` returns 200 OK when service is healthy +- Load balancer uses this for backend health monitoring +- Auto-healing replaces unhealthy instances automatically + +### Scaling +- Base e2-micro instance handles ~50-100 concurrent SSH sessions +- Scale up machine types (e2-small, e2-medium) for higher load +- Instance group can be configured for auto-scaling if needed + ## Code Organization - Chess engine logic is completely separated from UI concerns @@ -59,6 +97,7 @@ ssh localhost -p 2222 - SSH server integration uses Bubble Tea middleware from wish framework - Game state is synchronized between players via GameUpdate messages - Matchmaking automatically pairs players when they connect +- Health endpoint is served alongside SSH when PORT environment variable is set ## Key Features @@ -68,3 +107,5 @@ ssh localhost -p 2222 - Graceful handling of player disconnections - Unicode chess piece symbols with color differentiation - Game status display (check, checkmate, turn indication) +- HTTP health checks for production reliability +- Direct TCP load balancing without proxy overhead \ No newline at end of file diff --git a/README.md b/README.md index bed4d9f..95ed8e6 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ A multiplayer chess game playable via SSH. ## Play the game ``` -ssh chessh.imjasonh.dev +ssh chessh.app.imjasonh.com ``` ![Screenshot of CheSSH gameplay](screenshot.png) -You may need to wait for another player to play against, or join from another terminal to play against yourself. +You may need to wait for another player to play against, or join from another terminal if you want to play with yourself. Players are automatically matched when they connect to the SSH server. @@ -39,4 +39,10 @@ terraform init terraform apply ``` -This will create a GKE Autopilot cluster, Cloud Run service, host key secret, GCLB, routing rules, etc., to be able to host the server on the internet. +This will create: +- A container image containing the app's code +- GCE VM instances running the app container +- TCP Load Balancer for direct SSH access on port 22 +- Health checks and auto-healing for high availability +- DNS records pointing to the load balancer +- SSH host key management via Secret Manager diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..07ba00b --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,42 @@ +# Migration: Cloud Run → GCE VM with TCP Load Balancer + +## Overview +This migration moves ChessSH from Cloud Run + GKE SSH proxy to a direct GCE VM deployment with TCP load balancing. + +## Architecture Changes + +### Before (Cloud Run + SSH Proxy) +- Cloud Run service hosting the chess app with WebSocket endpoint +- GKE Autopilot cluster with SSH proxy deployment +- Kubernetes LoadBalancer service for SSH traffic +- SSH proxy converts WebSocket ↔ SSH traffic + +### After (GCE VM + TCP Load Balancer) +- GCE VM instances running chess containers directly +- TCP Load Balancer with direct SSH access (port 22) +- HTTP health checks on port 8080 +- No proxy layer - direct SSH connections + +## Benefits +- **Simpler architecture**: No WebSocket proxy layer +- **Better performance**: Direct TCP connections +- **Native SSH features**: Full SSH compatibility +- **Cost optimization**: Single layer instead of Cloud Run + GKE + +## New Resources +- `gce-vm.tf`: VM instances, instance groups, health checks +- `tcp-load-balancer.tf`: Global TCP load balancer +- Updated `main.go`: Simple `/health` endpoint instead of WebSocket proxy +- Updated `dns.tf`: Points to load balancer IP + +## Migration Steps +1. Deploy new infrastructure alongside existing +2. Test health checks and SSH connectivity +3. Update DNS to point to new load balancer +4. Clean up old Cloud Run and GKE resources + +## Connection Method +- **Before**: `ssh chessh.domain.com -p 22` (via proxy) +- **After**: `ssh chessh.domain.com -p 22` (direct) + +The user experience remains the same, but the backend is significantly simplified. \ No newline at end of file diff --git a/docs/cost.md b/docs/cost.md new file mode 100644 index 0000000..93b36fb --- /dev/null +++ b/docs/cost.md @@ -0,0 +1,59 @@ +# Cost Analysis: GCE VM + TCP Load Balancer Architecture + +Based on the current GCE VM with TCP Load Balancer architecture, estimated costs: + +## Minimum Monthly Cost (No Traffic) + +**Compute Engine VM:** +- 1 × e2-micro instance (1 vCPU, 1GB RAM): ~$6-8/month +- Persistent disk (10GB standard): ~$0.40/month + +**TCP Load Balancer:** +- Global Network Load Balancer with forwarding rules: ~$18-22/month +- Health check service: ~$0.50/month + +**Other Resources (minimal cost):** +- DNS zone and records: ~$0.50/month +- Secret Manager (2 secrets): ~$0.06/month +- Container Registry storage: ~$0.10/month + +**Total estimated minimum cost: ~$25-31/month** + +The TCP Load Balancer remains the largest cost component (~70% of total). However, this architecture is more cost-effective than the previous Cloud Run + GKE setup by eliminating the GKE Autopilot cluster overhead. + +## Usage-Based Costs + +**VM Instance Scaling:** +- Base e2-micro handles ~50-100 concurrent SSH sessions efficiently +- For higher load, can scale up to larger machine types: + - e2-small (2 vCPU, 2GB): ~$12-16/month + - e2-medium (2 vCPU, 4GB): ~$24-32/month + +**Per-Connection Costs:** +Since the VM runs 24/7, marginal cost per additional SSH connection is minimal until CPU/memory limits are reached. + +**Example Scaling Scenarios:** +- 50 concurrent users: Base e2-micro (~$25-31/month total) +- 150 concurrent users: e2-small (~$30-38/month total) +- 500+ concurrent users: e2-medium + load balancer (~$42-54/month total) + +## Cost Advantages vs Previous Architecture + +**Eliminated Costs:** +- GKE Autopilot cluster management fees +- Multiple pod overhead +- WebSocket proxy layer complexity + +**Simplified Pricing:** +- Predictable monthly VM cost regardless of session count +- Direct SSH connections without proxy overhead +- Single-instance architecture easier to monitor and optimize + +## High-Scale Considerations + +For very high usage (1000+ concurrent users), consider: +- Auto-scaling instance groups with multiple VMs +- Regional distribution for latency optimization +- Cost would scale roughly linearly: ~$50-100 per 1000 concurrent users + +The current architecture provides excellent cost efficiency for small-to-medium scale deployments while maintaining the flexibility to scale up cost-effectively. \ No newline at end of file diff --git a/go.mod b/go.mod index b130e3b..206ee56 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,6 @@ require ( github.com/charmbracelet/bubbletea v1.3.9 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/wish v1.4.7 - github.com/gorilla/websocket v1.5.3 - github.com/imjasonh/ssh-proxy v0.0.0-20250914024405-4c08c8a3c84d ) require ( @@ -42,6 +40,8 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/imjasonh/ssh-proxy v0.0.0-20250914024405-4c08c8a3c84d // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/iac/chessh.tf b/iac/chessh.tf index 8dc01e6..b5710ea 100644 --- a/iac/chessh.tf +++ b/iac/chessh.tf @@ -1,7 +1,4 @@ -resource "google_project_service" "cloudrun" { - service = "run.googleapis.com" -} - +# Container image build configuration data "apko_config" "config" { config_contents = jsonencode({ contents = { @@ -22,95 +19,3 @@ resource "ko_build" "chessh" { base_image = apko_build.base.image_ref depends_on = [google_artifact_registry_repository.artifact_repo] } - -# Service account for the SSH proxy -resource "google_service_account" "chessh" { - account_id = "${var.name}-chessh" - display_name = "chessh" - description = "Service account for chessh Cloud Run service" -} - -resource "google_cloud_run_v2_service" "chessh" { - name = var.name - location = var.region - - deletion_protection = var.deletion_protection - - template { - service_account = google_service_account.chessh.email - execution_environment = "EXECUTION_ENVIRONMENT_GEN2" - timeout = "${60 * 60}s" # 1 hour, effectively maximum session duration - - containers { - image = ko_build.chessh.image_ref - - # Cloud Run automatically sets PORT=8080 - env { - name = "LOG_LEVEL" - value = "info" - } - env { - name = "SSH_HOST_KEY_SECRET" - value = google_secret_manager_secret_version.ssh_host_private_key_version.id - } - - ports { - container_port = 8080 - name = "http1" - } - - resources { - startup_cpu_boost = true - - limits = { - cpu = "4" - memory = "4Gi" - } - } - - # startup_probe { - # initial_delay_seconds = 10 - # timeout_seconds = 3 - # period_seconds = 10 - # failure_threshold = 3 - - # http_get { - # path = "/health" - # port = 8080 - # } - # } - # liveness_probe { - # initial_delay_seconds = 60 - # timeout_seconds = 3 - # period_seconds = 30 - # failure_threshold = 3 - - # http_get { - # path = "/health" - # port = 8080 - # } - # } - } - - scaling { - min_instance_count = 0 - max_instance_count = 1 - } - } - - traffic { - type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" - percent = 100 - } - - depends_on = [google_project_service.cloudrun] -} - -# Allow unauthenticated access to Cloud Run service (for WebSocket from proxy) -resource "google_cloud_run_service_iam_member" "chessh_invoker" { - project = var.project_id - location = google_cloud_run_v2_service.chessh.location - service = google_cloud_run_v2_service.chessh.name - role = "roles/run.invoker" - member = "allUsers" # In production, restrict to proxy service account -} diff --git a/iac/cost.md b/iac/cost.md deleted file mode 100644 index c978c69..0000000 --- a/iac/cost.md +++ /dev/null @@ -1,49 +0,0 @@ -# Minimum Cost - -Based on the configured resources, estimated minimum monthly cost with no traffic: - -GKE Autopilot: - -- 1 pod with 100m CPU / 128Mi RAM: ~$3-5/month (Autopilot charges for actual pod resources) - -LoadBalancer Service: - -- TCP LoadBalancer with forwarding rules: ~$18-22/month (Google Cloud Load Balancer pricing) - -Cloud Run: - -- Minimal idle cost for 1 CPU/2GB instance: ~$0-2/month (only charges when processing requests) - -Other resources (minimal cost): - -- DNS zone and records: ~$0.50/month -- Secret Manager: ~$0.18/month (6 secrets) -- Container Registry storage: ~$0.10/month - -Total estimated minimum cost: ~$22-30/month - -The LoadBalancer is the largest cost component at ~75% of the total. The GKE Autopilot pod and Cloud Run service are relatively efficient for small workloads, but the dedicated TCP LoadBalancer has a fixed monthly cost regardless of traffic volume. - -# Usage Cost - -Cloud Run per-second costs: - -- CPU: 1 vCPU × $0.00002400/vCPU-second = $0.000024/second -- Memory: 2GB × $0.00000250/GB-second = $0.000005/second -- Total: ~$0.000029/second per Cloud Run instance - -Per-80-user cost: - -- Assuming each instance handles ~80 concurrent chess games -- Cost per user-second: $0.000029 ÷ 80 = ~$0.00000036/user-second -- Cost per user-minute: ~$0.000022/user-minute -- Cost per user-hour: ~$0.0013/user-hour - -Example scenarios: -- 80 users playing for 10 minutes: ~$0.018 -- 160 users (2 instances) for 30 minutes: ~$0.11 -- 800 users (10 instances) for 1 hour: ~$1.04 - -The chess game's real-time nature means users stay connected for entire game durations (typically 10-60 minutes), making this quite cost-effective at ~$0.0013 per user per hour of gameplay. - -On the other hand, a consistently active user would cost ~$0.94/user/month, so if this got popular and garnered 1M active users 24/7, it would cost $940k. So, that's something to think about... diff --git a/iac/dns.tf b/iac/dns.tf deleted file mode 100644 index f9f3f79..0000000 --- a/iac/dns.tf +++ /dev/null @@ -1,13 +0,0 @@ -# DNS onfiguration for chessh.${var.domain} - -# Use existing DNS zone specified by dns_zone variable -data "google_dns_managed_zone" "existing" { name = var.dns_zone } - -# DNS A record for chessh.${var.domain} -resource "google_dns_record_set" "chessh" { - name = "chessh.${var.domain}." - type = "A" - ttl = 300 - managed_zone = data.google_dns_managed_zone.existing.name - rrdatas = [data.kubernetes_service.ssh_proxy.status.0.load_balancer.0.ingress.0.ip] -} diff --git a/iac/gce-vm.tf b/iac/gce-vm.tf new file mode 100644 index 0000000..ef0707e --- /dev/null +++ b/iac/gce-vm.tf @@ -0,0 +1,171 @@ +# Enable required APIs +resource "google_project_service" "compute" { + service = "compute.googleapis.com" +} + +resource "google_project_service" "container" { + service = "container.googleapis.com" +} + +resource "google_project_service" "secretmanager" { + service = "secretmanager.googleapis.com" +} + +# Service account for the GCE instances +resource "google_service_account" "chessh_vm" { + account_id = "${var.name}-vm" + display_name = "ChessH VM Instance" + description = "Service account for ChessH GCE VM instances" +} + +# Allow the VM service account to pull from Artifact Registry +resource "google_project_iam_member" "chessh_vm_artifact_registry" { + project = var.project_id + role = "roles/artifactregistry.reader" + member = "serviceAccount:${google_service_account.chessh_vm.email}" +} + +# Allow the VM service account to access secrets (for SSH host key) +resource "google_secret_manager_secret_iam_member" "ssh_host_key_accessor_vm" { + secret_id = google_secret_manager_secret.ssh_host_private_key.id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.chessh_vm.email}" +} + +# Firewall rule to allow chess SSH traffic (port 2222) +resource "google_compute_firewall" "chessh_ssh" { + name = "${var.name}-ssh" + network = "default" + + allow { + protocol = "tcp" + ports = ["2222"] + } + + source_ranges = ["0.0.0.0/0"] + target_tags = ["chessh-ssh"] +} + +# Firewall rule to allow health check traffic (port 8080) +resource "google_compute_firewall" "chessh_health" { + name = "${var.name}-health" + network = "default" + + allow { + protocol = "tcp" + ports = ["8080"] + } + + # Google Cloud health check IP ranges + source_ranges = ["35.191.0.0/16", "130.211.0.0/22"] + target_tags = ["chessh-health"] +} + +# Instance template for ChessH +resource "google_compute_instance_template" "chessh" { + name = "${var.name}-template-v2" + description = "Template for ChessH game server instances" + + machine_type = "e2-micro" + region = var.region + + disk { + source_image = "projects/cos-cloud/global/images/family/cos-stable" + auto_delete = true + boot = true + disk_type = "pd-standard" + disk_size_gb = 10 + } + + network_interface { + network = "default" + access_config { + # Ephemeral external IP + } + } + + service_account { + email = google_service_account.chessh_vm.email + scopes = ["https://www.googleapis.com/auth/cloud-platform"] + } + + tags = ["chessh-ssh", "chessh-health"] + + metadata = { + "gce-container-declaration" = jsonencode({ + spec = { + containers = [{ + name = var.name + image = ko_build.chessh.image_ref + env = [ + { + name = "PORT" + value = "8080" + }, + { + name = "SSH_HOST_KEY_SECRET" + value = google_secret_manager_secret_version.ssh_host_private_key_version.id + }, + { + name = "LOG_LEVEL" + value = "info" + } + ] + args = ["-port", "2222"] + }] + restartPolicy = "Always" + } + }) + } + + depends_on = [ + google_project_service.compute, + google_project_service.container, + ko_build.chessh + ] +} + +# Instance group manager +resource "google_compute_region_instance_group_manager" "chessh" { + name = "${var.name}-group" + region = var.region + + base_instance_name = "${var.name}-instance" + target_size = 1 + + version { + instance_template = google_compute_instance_template.chessh.id + } + + named_port { + name = "ssh" + port = 2222 + } + + named_port { + name = "health" + port = 8080 + } + + auto_healing_policies { + health_check = google_compute_health_check.chessh.id + initial_delay_sec = 60 + } + + depends_on = [google_compute_instance_template.chessh] +} + +# Health check for the instances +resource "google_compute_health_check" "chessh" { + name = "${var.name}-health-check" + + timeout_sec = 5 + check_interval_sec = 10 + healthy_threshold = 2 + unhealthy_threshold = 3 + + http_health_check { + port = 8080 + request_path = "/health" + } +} diff --git a/iac/gke-autopilot.tf b/iac/gke-autopilot.tf deleted file mode 100644 index 19f607e..0000000 --- a/iac/gke-autopilot.tf +++ /dev/null @@ -1,13 +0,0 @@ -# GKE Autopilot Cluster for SSH proxy -resource "google_container_cluster" "autopilot" { - name = "chessh-cluster" - location = var.region - enable_autopilot = true - deletion_protection = var.deletion_protection -} - -# Wait for cluster to be ready -resource "time_sleep" "wait_for_cluster" { - depends_on = [google_container_cluster.autopilot] - create_duration = "30s" -} diff --git a/iac/outputs.tf b/iac/outputs.tf index aced7dd..15d9d63 100644 --- a/iac/outputs.tf +++ b/iac/outputs.tf @@ -1,5 +1,5 @@ output "chessh_domain" { - description = "Full domain for chessh service" + description = "Full domain for chessh service (manual DNS setup required)" value = "chessh.${var.domain}" } @@ -10,12 +10,7 @@ output "dns_zone_used" { output "external_ip" { description = "External IP address for SSH access" - value = data.kubernetes_service.ssh_proxy.status[0].load_balancer[0].ingress[0].ip -} - -output "cloud_run_url" { - description = "URL of the Cloud Run service" - value = google_cloud_run_v2_service.chessh.uri + value = google_compute_global_address.chessh_lb.address } output "host_key_secret_name" { @@ -29,14 +24,9 @@ output "ssh_public_key" { sensitive = false } -output "service_account_email" { - description = "Service account email for chessh" - value = google_service_account.chessh.email -} - -output "ssh_proxy_service_account_email" { - description = "Service account email for SSH proxy" - value = google_service_account.chessh.email +output "vm_service_account_email" { + description = "Service account email for chessh VM instances" + value = google_service_account.chessh_vm.email } output "chessh_image_ref" { @@ -44,13 +34,3 @@ output "chessh_image_ref" { value = ko_build.chessh.image_ref } -output "ssh_proxy_image_ref" { - description = "Container image reference for SSH proxy" - value = ko_build.ssh_proxy.image_ref -} - -output "websocket_url" { - description = "WebSocket URL used by SSH proxy (internal)" - value = "${replace(google_cloud_run_v2_service.chessh.uri, "https://", "wss://")}/ssh" -} - diff --git a/iac/providers.tf b/iac/providers.tf index 6a0aae2..59d10a0 100644 --- a/iac/providers.tf +++ b/iac/providers.tf @@ -2,12 +2,11 @@ terraform { required_version = ">= 1.0" required_providers { - google = { source = "hashicorp/google" } - kubernetes = { source = "hashicorp/kubernetes" } - ko = { source = "ko-build/ko" } - time = { source = "hashicorp/time" } - random = { source = "hashicorp/random" } - apko = { source = "chainguard-dev/apko" } + google = { source = "hashicorp/google" } + ko = { source = "ko-build/ko" } + time = { source = "hashicorp/time" } + random = { source = "hashicorp/random" } + apko = { source = "chainguard-dev/apko" } } } @@ -16,16 +15,6 @@ provider "google" { region = var.region } -data "google_client_config" "default" {} - -# Use the GKE Autopilot cluster created in gke-autopilot.tf -# The try() function prevents errors when the cluster doesn't exist yet -provider "kubernetes" { - host = try("https://${google_container_cluster.autopilot.endpoint}", "") - token = data.google_client_config.default.access_token - cluster_ca_certificate = try(base64decode(google_container_cluster.autopilot.master_auth[0].cluster_ca_certificate), "") -} - provider "ko" { repo = "${var.region}-docker.pkg.dev/${var.project_id}/chessh" } diff --git a/iac/ssh-key.tf b/iac/ssh-key.tf index 0ab1e52..e2fe2a7 100644 --- a/iac/ssh-key.tf +++ b/iac/ssh-key.tf @@ -40,10 +40,10 @@ output "ssh_public_key_secret_name" { value = google_secret_manager_secret.ssh_host_public_key.id } -# Grant chessh service account access to the SSH private key secret +# Grant chessh VM service account access to the SSH private key secret resource "google_secret_manager_secret_iam_member" "chessh_secret_access" { project = var.project_id secret_id = google_secret_manager_secret.ssh_host_private_key.secret_id role = "roles/secretmanager.secretAccessor" - member = "serviceAccount:${google_service_account.chessh.email}" + member = "serviceAccount:${google_service_account.chessh_vm.email}" } diff --git a/iac/ssh-proxy.tf b/iac/ssh-proxy.tf deleted file mode 100644 index 883973b..0000000 --- a/iac/ssh-proxy.tf +++ /dev/null @@ -1,210 +0,0 @@ - - -# Build the SSH proxy image -resource "ko_build" "ssh_proxy" { - importpath = "github.com/imjasonh/ssh-proxy/cmd/ssh-proxy" -} - -# Service account for the SSH proxy -resource "google_service_account" "ssh_proxy" { - account_id = "${var.name}-ssh-proxy" - display_name = "SSH Proxy" - description = "Service account for SSH proxy that connects to Cloud Run" -} - -# IAM member for SSH proxy is defined in cloudrun.tf - -# Kubernetes service account for SSH proxy -resource "kubernetes_service_account" "ssh_proxy" { - metadata { - name = "${var.name}-ssh-proxy" - namespace = "default" - annotations = { - "iam.gke.io/gcp-service-account" = google_service_account.ssh_proxy.email - } - } - - depends_on = [ - google_container_cluster.autopilot, - time_sleep.wait_for_cluster - ] -} - -# Workload Identity binding for SSH proxy -resource "google_service_account_iam_member" "ssh_proxy_workload_identity" { - service_account_id = google_service_account.ssh_proxy.name - role = "roles/iam.workloadIdentityUser" - member = "serviceAccount:${var.project_id}.svc.id.goog[default/${kubernetes_service_account.ssh_proxy.metadata[0].name}]" -} - -# Allow SSH proxy service account to invoke Cloud Run -resource "google_cloud_run_service_iam_member" "ssh_proxy_invoker" { - project = var.project_id - location = google_cloud_run_v2_service.chessh.location - service = google_cloud_run_v2_service.chessh.name - role = "roles/run.invoker" - member = "serviceAccount:${google_service_account.ssh_proxy.email}" -} - -# Deploy SSH proxy as a Kubernetes Deployment -resource "kubernetes_deployment" "ssh_proxy" { - metadata { - name = "${var.name}-ssh-proxy" - namespace = "default" - labels = { - app = "${var.name}-ssh-proxy" - } - } - - # Ignore GKE Autopilot annotations that get added automatically - lifecycle { - ignore_changes = [ - metadata[0].annotations["autopilot.gke.io/resource-adjustment"], - metadata[0].annotations["autopilot.gke.io/warden-version"], - ] - } - - spec { - replicas = 1 - - selector { - match_labels = { - app = "${var.name}-ssh-proxy" - } - } - - template { - metadata { - labels = { - app = "${var.name}-ssh-proxy" - } - annotations = { - "iam.gke.io/gcp-service-account" = google_service_account.ssh_proxy.email - } - } - - spec { - service_account_name = kubernetes_service_account.ssh_proxy.metadata[0].name - - # GKE Autopilot adds these automatically - security_context { - run_as_non_root = true - seccomp_profile { type = "RuntimeDefault" } - } - - toleration { - effect = "NoSchedule" - key = "kubernetes.io/arch" - operator = "Equal" - value = "amd64" - } - - container { - image = ko_build.ssh_proxy.image_ref - name = "ssh-proxy" - - resources { - requests = { - cpu = "100m" - memory = "128Mi" - ephemeral-storage = "1Gi" - } - limits = { - cpu = "500m" - memory = "512Mi" - ephemeral-storage = "1Gi" - } - } - - security_context { - allow_privilege_escalation = false - privileged = false - read_only_root_filesystem = true - run_as_non_root = true - capabilities { drop = ["NET_RAW"] } - } - - env { - name = "SSH_ADDR" - value = ":22" - } - env { - name = "WEBSOCKET_URL" - value = "${replace(google_cloud_run_v2_service.chessh.uri, "https://", "wss://")}/ssh" - } - - port { - container_port = 22 - name = "ssh" - } - - liveness_probe { - tcp_socket { - port = 22 - } - initial_delay_seconds = 10 - period_seconds = 30 - } - - readiness_probe { - tcp_socket { - port = 22 - } - initial_delay_seconds = 5 - period_seconds = 10 - } - } - } - } - } - - depends_on = [ - google_container_cluster.autopilot, - time_sleep.wait_for_cluster, - ko_build.ssh_proxy, - google_cloud_run_service_iam_member.ssh_proxy_invoker, - google_service_account_iam_member.ssh_proxy_workload_identity, - ] -} - -# Service for SSH proxy using LoadBalancer -resource "kubernetes_service" "ssh_proxy" { - metadata { - name = "${var.name}-ssh" - namespace = "default" - } - - spec { - type = "LoadBalancer" - selector = { - app = "${var.name}-ssh-proxy" - } - port { - name = "ssh" - port = 22 - target_port = 22 - protocol = "TCP" - } - } - - depends_on = [ - google_container_cluster.autopilot, - time_sleep.wait_for_cluster - ] -} - -# Wait for LoadBalancer IP allocation -resource "time_sleep" "wait_for_loadbalancer_ip" { - depends_on = [kubernetes_service.ssh_proxy] - create_duration = "60s" -} - -# Data source to get the LoadBalancer IP address once it's available -data "kubernetes_service" "ssh_proxy" { - metadata { - name = kubernetes_service.ssh_proxy.metadata[0].name - namespace = kubernetes_service.ssh_proxy.metadata[0].namespace - } - - depends_on = [time_sleep.wait_for_loadbalancer_ip] -} diff --git a/iac/tcp-load-balancer.tf b/iac/tcp-load-balancer.tf new file mode 100644 index 0000000..c5cb41a --- /dev/null +++ b/iac/tcp-load-balancer.tf @@ -0,0 +1,47 @@ +# Global static IP address for the load balancer +resource "google_compute_global_address" "chessh_lb" { + name = "${var.name}-lb-ip" +} + +# Backend service for the instance group +resource "google_compute_backend_service" "chessh" { + name = "${var.name}-backend" + protocol = "TCP" + port_name = "ssh" + timeout_sec = 30 + + health_checks = [google_compute_health_check.chessh.id] + + backend { + group = google_compute_region_instance_group_manager.chessh.instance_group + balancing_mode = "CONNECTION" + max_connections_per_instance = 1000 + } + + depends_on = [google_compute_region_instance_group_manager.chessh] +} + +# Target TCP proxy +resource "google_compute_target_tcp_proxy" "chessh" { + name = "${var.name}-tcp-proxy" + backend_service = google_compute_backend_service.chessh.id +} + +# Global forwarding rule for TCP traffic +resource "google_compute_global_forwarding_rule" "chessh_tcp" { + name = "${var.name}-tcp-forwarding" + target = google_compute_target_tcp_proxy.chessh.id + port_range = "22" + ip_address = google_compute_global_address.chessh_lb.address +} + +# Output the load balancer IP +output "load_balancer_ip" { + description = "IP address of the TCP load balancer" + value = google_compute_global_address.chessh_lb.address +} + +output "ssh_connection_command" { + description = "Command to connect via SSH" + value = "ssh ${google_compute_global_address.chessh_lb.address}" +} \ No newline at end of file diff --git a/main.go b/main.go index 4496bd2..ee9da09 100644 --- a/main.go +++ b/main.go @@ -25,8 +25,6 @@ import ( "github.com/charmbracelet/wish" "github.com/charmbracelet/wish/bubbletea" "github.com/charmbracelet/wish/logging" - "github.com/gorilla/websocket" - sshproxy "github.com/imjasonh/ssh-proxy" ) type model struct { @@ -583,12 +581,11 @@ func main() { }() if httpPort := os.Getenv("PORT"); httpPort != "" { - log.Print("Starting WebSocket to SSH proxy on port ", httpPort) - http.HandleFunc("/ssh", sshproxy.ProxyWebSocketToSSH(fmt.Sprintf(":%d", *sshPort), websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true // Allow connections from any origin for now - }, - })) + log.Print("Starting HTTP health check server on port ", httpPort) + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) if err := http.ListenAndServe(fmt.Sprintf(":%s", httpPort), nil); err != nil { log.Fatalln("HTTP server error:", err) }