package main import ( "bufio" "context" crypto_rand "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "os" "path/filepath" "strings" "time" "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/service" "github.com/spf13/cobra" ) var provisionCmd = &cobra.Command{ Use: "provision", Short: "Create all infrastructure (servers, network, LB, firewall)", RunE: func(cmd *cobra.Command, args []string) error { token, _ := cmd.Root().PersistentFlags().GetString("token") zone, _ := cmd.Flags().GetString("zone") plan, _ := cmd.Flags().GetString("plan") sshKey, _ := cmd.Flags().GetString("ssh-key") s3Secret, _ := cmd.Flags().GetString("s3-secret") withScanner, _ := cmd.Flags().GetBool("with-scanner") return cmdProvision(token, zone, plan, sshKey, s3Secret, withScanner) }, } func init() { provisionCmd.Flags().String("zone", "", "UpCloud zone (interactive picker if omitted)") provisionCmd.Flags().String("plan", "", "Server plan (interactive picker if omitted)") provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)") provisionCmd.Flags().String("s3-secret", "", "S3 secret access key (for existing object storage)") provisionCmd.Flags().Bool("with-scanner", false, "Deploy vulnerability scanner alongside hold") _ = provisionCmd.MarkFlagRequired("ssh-key") rootCmd.AddCommand(provisionCmd) } func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bool) error { cfg, err := loadConfig(zone, plan, sshKeyPath, s3Secret) if err != nil { return err } naming := cfg.Naming() svc, err := newService(token) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() // Load existing state or start fresh state, err := loadState() if err != nil { state = &InfraState{} } // Use zone from state if not provided via flags if cfg.Zone == "" && state.Zone != "" { cfg.Zone = state.Zone } // Only need interactive picker if we still need to create resources needsServers := state.Appview.UUID == "" || state.Hold.UUID == "" if cfg.Zone == "" || (needsServers && cfg.Plan == "") { if err := resolveInteractive(ctx, svc, cfg); err != nil { return err } } if state.Zone == "" { state.Zone = cfg.Zone } state.ClientName = cfg.ClientName state.RepoBranch = cfg.RepoBranch // Scanner setup if withScanner { state.ScannerEnabled = true if state.ScannerSecret == "" { secret, err := generateScannerSecret() if err != nil { return fmt.Errorf("generate scanner secret: %w", err) } state.ScannerSecret = secret fmt.Printf("Generated scanner shared secret\n") } _ = saveState(state) } fmt.Printf("Provisioning %s infrastructure in zone %s...\n", naming.DisplayName(), cfg.Zone) if needsServers { fmt.Printf("Server plan: %s\n", cfg.Plan) } fmt.Println() // S3 secret key — from flag for existing storage, from API for new s3SecretKey := cfg.S3SecretKey // 1. Object storage if state.ObjectStorage.UUID != "" { fmt.Printf("Object storage: %s (exists)\n", state.ObjectStorage.UUID) // Refresh discoverable fields if missing (e.g. pre-seeded UUID only) if state.ObjectStorage.Endpoint == "" || state.ObjectStorage.Bucket == "" { fmt.Println(" Discovering endpoint, bucket, access key...") discovered, err := lookupObjectStorage(ctx, svc, state.ObjectStorage.UUID) if err != nil { return err } state.ObjectStorage.Endpoint = discovered.Endpoint state.ObjectStorage.Region = discovered.Region if discovered.Bucket != "" { state.ObjectStorage.Bucket = discovered.Bucket } if discovered.AccessKeyID != "" { state.ObjectStorage.AccessKeyID = discovered.AccessKeyID } _ = saveState(state) } } else { fmt.Println("Creating object storage...") objState, secretKey, err := provisionObjectStorage(ctx, svc, cfg.Zone, naming.S3Name()) if err != nil { return fmt.Errorf("object storage: %w", err) } state.ObjectStorage = objState s3SecretKey = secretKey _ = saveState(state) fmt.Printf(" S3 Secret Key: %s\n", secretKey) } fmt.Printf(" Endpoint: %s\n", state.ObjectStorage.Endpoint) fmt.Printf(" Region: %s\n", state.ObjectStorage.Region) fmt.Printf(" Bucket: %s\n", state.ObjectStorage.Bucket) fmt.Printf(" Access Key: %s\n\n", state.ObjectStorage.AccessKeyID) // Hold domain is zone-based (e.g. us-chi1.cove.seamark.dev) holdDomain := cfg.Zone + ".cove." + cfg.BaseDomain // Build config template values vals := &ConfigValues{ S3Endpoint: state.ObjectStorage.Endpoint, S3Region: state.ObjectStorage.Region, S3Bucket: state.ObjectStorage.Bucket, S3AccessKey: state.ObjectStorage.AccessKeyID, S3SecretKey: s3SecretKey, Zone: cfg.Zone, HoldDomain: holdDomain, HoldDid: "did:web:" + holdDomain, BasePath: naming.BasePath(), ScannerSecret: state.ScannerSecret, } // 2. Private network if state.Network.UUID != "" { fmt.Printf("Network: %s (exists)\n", state.Network.UUID) } else { fmt.Println("Creating private network...") network, err := svc.CreateNetwork(ctx, &request.CreateNetworkRequest{ Name: naming.NetworkName(), Zone: cfg.Zone, IPNetworks: upcloud.IPNetworkSlice{ { Address: privateNetworkCIDR, DHCP: upcloud.True, DHCPDefaultRoute: upcloud.False, DHCPDns: []string{"8.8.8.8", "1.1.1.1"}, Family: upcloud.IPAddressFamilyIPv4, Gateway: "", }, }, }) if err != nil { return fmt.Errorf("create network: %w", err) } state.Network = StateRef{UUID: network.UUID} _ = saveState(state) fmt.Printf(" Network: %s (%s)\n", network.UUID, privateNetworkCIDR) } // Find Debian template (needed for server creation) templateUUID, err := findDebianTemplate(ctx, svc) if err != nil { return err } // 3. Appview server appviewCreated := false if state.Appview.UUID != "" { fmt.Printf("Appview: %s (exists)\n", state.Appview.UUID) appviewScript, err := generateAppviewCloudInit(cfg, vals) if err != nil { return err } if err := syncCloudInit("appview", state.Appview.PublicIP, appviewScript); err != nil { return err } appviewConfigYAML, err := renderConfig(appviewConfigTmpl, vals) if err != nil { return fmt.Errorf("render appview config: %w", err) } if err := syncConfigKeys("appview", state.Appview.PublicIP, naming.AppviewConfigPath(), appviewConfigYAML); err != nil { return fmt.Errorf("appview config sync: %w", err) } } else { fmt.Println("Creating appview server...") appviewUserData, err := generateAppviewCloudInit(cfg, vals) if err != nil { return err } appview, err := createServer(ctx, svc, cfg, templateUUID, state.Network.UUID, naming.Appview(), appviewUserData) if err != nil { return fmt.Errorf("create appview: %w", err) } state.Appview = *appview _ = saveState(state) appviewCreated = true fmt.Printf(" Appview: %s (public: %s, private: %s)\n", appview.UUID, appview.PublicIP, appview.PrivateIP) } // 4. Hold server holdCreated := false if state.Hold.UUID != "" { fmt.Printf("Hold: %s (exists)\n", state.Hold.UUID) holdScript, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled) if err != nil { return err } if err := syncCloudInit("hold", state.Hold.PublicIP, holdScript); err != nil { return err } holdConfigYAML, err := renderConfig(holdConfigTmpl, vals) if err != nil { return fmt.Errorf("render hold config: %w", err) } if err := syncConfigKeys("hold", state.Hold.PublicIP, naming.HoldConfigPath(), holdConfigYAML); err != nil { return fmt.Errorf("hold config sync: %w", err) } if state.ScannerEnabled { scannerConfigYAML, err := renderConfig(scannerConfigTmpl, vals) if err != nil { return fmt.Errorf("render scanner config: %w", err) } if err := syncConfigKeys("scanner", state.Hold.PublicIP, naming.ScannerConfigPath(), scannerConfigYAML); err != nil { return fmt.Errorf("scanner config sync: %w", err) } } } else { fmt.Println("Creating hold server...") holdUserData, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled) if err != nil { return err } hold, err := createServer(ctx, svc, cfg, templateUUID, state.Network.UUID, naming.Hold(), holdUserData) if err != nil { return fmt.Errorf("create hold: %w", err) } state.Hold = *hold _ = saveState(state) holdCreated = true fmt.Printf(" Hold: %s (public: %s, private: %s)\n", hold.UUID, hold.PublicIP, hold.PrivateIP) } // 5. Firewall rules (idempotent — replaces all rules) fmt.Println("Configuring firewall rules...") for _, s := range []struct { name string uuid string }{ {"appview", state.Appview.UUID}, {"hold", state.Hold.UUID}, } { if err := createFirewallRules(ctx, svc, s.uuid, privateNetworkCIDR); err != nil { return fmt.Errorf("firewall %s: %w", s.name, err) } } // 6. Load balancer if state.LB.UUID != "" { fmt.Printf("Load balancer: %s (exists)\n", state.LB.UUID) } else { fmt.Println("Creating load balancer (Essentials tier)...") lb, err := createLoadBalancer(ctx, svc, cfg, naming, state.Network.UUID, state.Appview.PrivateIP, state.Hold.PrivateIP, holdDomain) if err != nil { return fmt.Errorf("create LB: %w", err) } state.LB = StateRef{UUID: lb.UUID} _ = saveState(state) } // Always reconcile forwarded headers rule (handles existing LBs) if err := ensureLBForwardedHeaders(ctx, svc, state.LB.UUID); err != nil { return fmt.Errorf("LB forwarded headers: %w", err) } // Ensure route-hold rule includes forwarded headers action if err := ensureLBHoldForwardedHeaders(ctx, svc, state.LB.UUID, holdDomain); err != nil { return fmt.Errorf("LB hold forwarded headers: %w", err) } // Always reconcile scanner block rule if err := ensureLBScannerBlock(ctx, svc, state.LB.UUID); err != nil { return fmt.Errorf("LB scanner block: %w", err) } // Always reconcile TLS certs (handles partial failures and re-runs) tlsDomains := []string{cfg.BaseDomain} tlsDomains = append(tlsDomains, cfg.RegistryDomains...) tlsDomains = append(tlsDomains, holdDomain) if err := ensureLBCertificates(ctx, svc, state.LB.UUID, tlsDomains); err != nil { return fmt.Errorf("LB certificates: %w", err) } // Fetch LB DNS name for output lbDNS := "" if state.LB.UUID != "" { lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{UUID: state.LB.UUID}) if err == nil { for _, n := range lb.Networks { if n.Type == upcloud.LoadBalancerNetworkTypePublic { lbDNS = n.DNSName } } } } // 7. Build locally and deploy binaries to new servers if appviewCreated || holdCreated { rootDir := projectRoot() if err := runGenerate(rootDir); err != nil { return fmt.Errorf("go generate: %w", err) } fmt.Println("\nBuilding locally (GOOS=linux GOARCH=amd64)...") if appviewCreated { outputPath := filepath.Join(rootDir, "bin", "atcr-appview") if err := buildLocal(rootDir, outputPath, "./cmd/appview"); err != nil { return fmt.Errorf("build appview: %w", err) } } if holdCreated { outputPath := filepath.Join(rootDir, "bin", "atcr-hold") if err := buildLocal(rootDir, outputPath, "./cmd/hold"); err != nil { return fmt.Errorf("build hold: %w", err) } if state.ScannerEnabled { outputPath := filepath.Join(rootDir, "bin", "atcr-scanner") if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil { return fmt.Errorf("build scanner: %w", err) } } } fmt.Println("\nWaiting for cloud-init to complete on new servers...") if appviewCreated { if err := waitForSetup(state.Appview.PublicIP, "appview"); err != nil { return err } } if holdCreated { if err := waitForSetup(state.Hold.PublicIP, "hold"); err != nil { return err } } fmt.Println("\nDeploying binaries...") if appviewCreated { localPath := filepath.Join(rootDir, "bin", "atcr-appview") remotePath := naming.InstallDir() + "/bin/" + naming.Appview() if err := scpFile(localPath, state.Appview.PublicIP, remotePath); err != nil { return fmt.Errorf("upload appview: %w", err) } } if holdCreated { localPath := filepath.Join(rootDir, "bin", "atcr-hold") remotePath := naming.InstallDir() + "/bin/" + naming.Hold() if err := scpFile(localPath, state.Hold.PublicIP, remotePath); err != nil { return fmt.Errorf("upload hold: %w", err) } if state.ScannerEnabled { scannerLocal := filepath.Join(rootDir, "bin", "atcr-scanner") scannerRemote := naming.InstallDir() + "/bin/" + naming.Scanner() if err := scpFile(scannerLocal, state.Hold.PublicIP, scannerRemote); err != nil { return fmt.Errorf("upload scanner: %w", err) } } } } fmt.Println("\n=== Provisioning Complete ===") fmt.Println() fmt.Println("DNS records needed:") if lbDNS != "" { fmt.Printf(" CNAME %-24s → %s\n", cfg.BaseDomain, lbDNS) for _, rd := range cfg.RegistryDomains { fmt.Printf(" CNAME %-24s → %s\n", rd, lbDNS) } fmt.Printf(" CNAME %-24s → %s\n", holdDomain, lbDNS) } else { fmt.Println(" (LB DNS name not yet available — check 'status' in a few minutes)") } fmt.Println() fmt.Println("SSH access:") fmt.Printf(" ssh root@%s # appview\n", state.Appview.PublicIP) fmt.Printf(" ssh root@%s # hold\n", state.Hold.PublicIP) fmt.Println() fmt.Println("Next steps:") if appviewCreated || holdCreated { fmt.Println(" 1. Edit configs if needed, then start services:") } else { fmt.Println(" 1. Start services:") } if state.ScannerEnabled { fmt.Printf(" systemctl start %s / %s / %s\n", naming.Appview(), naming.Hold(), naming.Scanner()) } else { fmt.Printf(" systemctl start %s / %s\n", naming.Appview(), naming.Hold()) } fmt.Println(" 2. Configure DNS records above") return nil } // provisionObjectStorage creates a new Managed Object Storage with a user, access key, and bucket. // Returns the state and the secret key separately (only available at creation time). func provisionObjectStorage(ctx context.Context, svc *service.Service, zone, s3Name string) (ObjectStorageState, string, error) { // Map compute zone to object storage region (e.g. us-chi1 → us-east-1) region := objectStorageRegion(zone) storage, err := svc.CreateManagedObjectStorage(ctx, &request.CreateManagedObjectStorageRequest{ Name: s3Name, Region: region, ConfiguredStatus: upcloud.ManagedObjectStorageConfiguredStatusStarted, Networks: []upcloud.ManagedObjectStorageNetwork{ { Family: upcloud.IPAddressFamilyIPv4, Name: "public", Type: "public", }, }, }) if err != nil { return ObjectStorageState{}, "", fmt.Errorf("create storage: %w", err) } fmt.Printf(" Created: %s (region: %s)\n", storage.UUID, region) // Find endpoint var endpoint string for _, ep := range storage.Endpoints { if ep.DomainName != "" { endpoint = "https://" + ep.DomainName break } } // Create user _, err = svc.CreateManagedObjectStorageUser(ctx, &request.CreateManagedObjectStorageUserRequest{ ServiceUUID: storage.UUID, Username: s3Name, }) if err != nil { return ObjectStorageState{}, "", fmt.Errorf("create user: %w", err) } // Attach admin policy err = svc.AttachManagedObjectStorageUserPolicy(ctx, &request.AttachManagedObjectStorageUserPolicyRequest{ ServiceUUID: storage.UUID, Username: s3Name, Name: "admin", }) if err != nil { return ObjectStorageState{}, "", fmt.Errorf("attach policy: %w", err) } // Create access key (secret is only returned here) accessKey, err := svc.CreateManagedObjectStorageUserAccessKey(ctx, &request.CreateManagedObjectStorageUserAccessKeyRequest{ ServiceUUID: storage.UUID, Username: s3Name, }) if err != nil { return ObjectStorageState{}, "", fmt.Errorf("create access key: %w", err) } secretKey := "" if accessKey.SecretAccessKey != nil { secretKey = *accessKey.SecretAccessKey } // Create bucket _, err = svc.CreateManagedObjectStorageBucket(ctx, &request.CreateManagedObjectStorageBucketRequest{ ServiceUUID: storage.UUID, Name: s3Name, }) if err != nil { return ObjectStorageState{}, "", fmt.Errorf("create bucket: %w", err) } return ObjectStorageState{ UUID: storage.UUID, Endpoint: endpoint, Region: region, Bucket: s3Name, AccessKeyID: accessKey.AccessKeyID, }, secretKey, nil } // objectStorageRegion maps a compute zone to the nearest object storage region. func objectStorageRegion(zone string) string { switch { case strings.HasPrefix(zone, "us-"): return "us-east-1" case strings.HasPrefix(zone, "de-"): return "europe-1" case strings.HasPrefix(zone, "fi-"): return "europe-1" case strings.HasPrefix(zone, "nl-"): return "europe-1" case strings.HasPrefix(zone, "es-"): return "europe-1" case strings.HasPrefix(zone, "pl-"): return "europe-1" case strings.HasPrefix(zone, "se-"): return "europe-1" case strings.HasPrefix(zone, "au-"): return "australia-1" case strings.HasPrefix(zone, "sg-"): return "singapore-1" default: return "us-east-1" } } func createServer(ctx context.Context, svc *service.Service, cfg *InfraConfig, templateUUID, networkUUID, title, userData string) (*ServerState, error) { storageTier := "maxiops" if strings.HasPrefix(strings.ToUpper(cfg.Plan), "DEV-") { storageTier = "standard" } // Look up the plan's storage size from the API instead of hardcoding. diskSize := 25 // fallback plans, err := svc.GetPlans(ctx) if err == nil { for _, p := range plans.Plans { if p.Name == cfg.Plan { diskSize = p.StorageSize break } } } details, err := svc.CreateServer(ctx, &request.CreateServerRequest{ Zone: cfg.Zone, Title: title, Hostname: title, Plan: cfg.Plan, Metadata: upcloud.True, UserData: userData, Firewall: "on", PasswordDelivery: "none", StorageDevices: request.CreateServerStorageDeviceSlice{ { Action: "clone", Storage: templateUUID, Title: title + "-disk", Size: diskSize, Tier: storageTier, }, }, Networking: &request.CreateServerNetworking{ Interfaces: request.CreateServerInterfaceSlice{ { Index: 1, Type: upcloud.IPAddressAccessPublic, IPAddresses: request.CreateServerIPAddressSlice{ {Family: upcloud.IPAddressFamilyIPv4}, }, }, { Index: 2, Type: upcloud.IPAddressAccessPrivate, Network: networkUUID, IPAddresses: request.CreateServerIPAddressSlice{ {Family: upcloud.IPAddressFamilyIPv4}, }, }, }, }, LoginUser: &request.LoginUser{ CreatePassword: "no", SSHKeys: request.SSHKeySlice{cfg.SSHPublicKey}, }, }) if err != nil { return nil, err } fmt.Printf(" Waiting for server %s to start...\n", details.UUID) details, err = svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{ UUID: details.UUID, DesiredState: upcloud.ServerStateStarted, }) if err != nil { return nil, fmt.Errorf("wait for server: %w", err) } s := &ServerState{UUID: details.UUID} for _, iface := range details.Networking.Interfaces { for _, addr := range iface.IPAddresses { if addr.Family == upcloud.IPAddressFamilyIPv4 { switch iface.Type { case upcloud.IPAddressAccessPublic: s.PublicIP = addr.Address case upcloud.IPAddressAccessPrivate: s.PrivateIP = addr.Address } } } } return s, nil } func createFirewallRules(ctx context.Context, svc *service.Service, serverUUID, privateCIDR string) error { networkBase := strings.TrimSuffix(privateCIDR, "/24") networkBase = strings.TrimSuffix(networkBase, ".0") return svc.CreateFirewallRules(ctx, &request.CreateFirewallRulesRequest{ ServerUUID: serverUUID, FirewallRules: request.FirewallRuleSlice{ { Direction: upcloud.FirewallRuleDirectionIn, Action: upcloud.FirewallRuleActionAccept, Family: upcloud.IPAddressFamilyIPv4, Protocol: upcloud.FirewallRuleProtocolTCP, DestinationPortStart: "22", DestinationPortEnd: "22", Position: 1, Comment: "Allow SSH", }, { Direction: upcloud.FirewallRuleDirectionIn, Action: upcloud.FirewallRuleActionAccept, Family: upcloud.IPAddressFamilyIPv4, SourceAddressStart: networkBase + ".0", SourceAddressEnd: networkBase + ".255", Position: 2, Comment: "Allow private network", }, { Direction: upcloud.FirewallRuleDirectionIn, Action: upcloud.FirewallRuleActionAccept, Family: upcloud.IPAddressFamilyIPv4, Protocol: upcloud.FirewallRuleProtocolUDP, SourcePortStart: "123", SourcePortEnd: "123", Position: 3, Comment: "Allow NTP replies", }, { Direction: upcloud.FirewallRuleDirectionIn, Action: upcloud.FirewallRuleActionDrop, Position: 4, Comment: "Drop all other inbound", }, }, }) } func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain string) (*upcloud.LoadBalancer, error) { lb, err := svc.CreateLoadBalancer(ctx, &request.CreateLoadBalancerRequest{ Name: naming.LBName(), Plan: "essentials", Zone: cfg.Zone, ConfiguredStatus: upcloud.LoadBalancerConfiguredStatusStarted, Networks: []request.LoadBalancerNetwork{ { Name: "public", Type: upcloud.LoadBalancerNetworkTypePublic, Family: upcloud.LoadBalancerAddressFamilyIPv4, }, { Name: "private", Type: upcloud.LoadBalancerNetworkTypePrivate, Family: upcloud.LoadBalancerAddressFamilyIPv4, UUID: networkUUID, }, }, Frontends: []request.LoadBalancerFrontend{ { Name: "https", Mode: upcloud.LoadBalancerModeHTTP, Port: 443, DefaultBackend: "appview", Networks: []upcloud.LoadBalancerFrontendNetwork{ {Name: "public"}, }, Rules: []request.LoadBalancerFrontendRule{ { Name: "set-forwarded-headers", Priority: 1, Matchers: []upcloud.LoadBalancerMatcher{}, Actions: []upcloud.LoadBalancerAction{ request.NewLoadBalancerSetForwardedHeadersAction(), }, }, { Name: "route-hold", Priority: 10, Matchers: []upcloud.LoadBalancerMatcher{ { Type: upcloud.LoadBalancerMatcherTypeHost, Host: &upcloud.LoadBalancerMatcherHost{ Value: holdDomain, }, }, }, Actions: []upcloud.LoadBalancerAction{ request.NewLoadBalancerSetForwardedHeadersAction(), { Type: upcloud.LoadBalancerActionTypeUseBackend, UseBackend: &upcloud.LoadBalancerActionUseBackend{ Backend: "hold", }, }, }, }, }, }, { Name: "http-redirect", Mode: upcloud.LoadBalancerModeHTTP, Port: 80, DefaultBackend: "appview", Networks: []upcloud.LoadBalancerFrontendNetwork{ {Name: "public"}, }, Rules: []request.LoadBalancerFrontendRule{ { Name: "redirect-https", Priority: 10, Matchers: []upcloud.LoadBalancerMatcher{ { Type: upcloud.LoadBalancerMatcherTypeSrcPort, SrcPort: &upcloud.LoadBalancerMatcherInteger{ Method: upcloud.LoadBalancerIntegerMatcherMethodEqual, Value: 80, }, }, }, Actions: []upcloud.LoadBalancerAction{ { Type: upcloud.LoadBalancerActionTypeHTTPRedirect, HTTPRedirect: &upcloud.LoadBalancerActionHTTPRedirect{ Scheme: upcloud.LoadBalancerActionHTTPRedirectSchemeHTTPS, }, }, }, }, }, }, }, Resolvers: []request.LoadBalancerResolver{}, Backends: []request.LoadBalancerBackend{ { Name: "appview", Members: []request.LoadBalancerBackendMember{ { Name: "appview-1", Type: upcloud.LoadBalancerBackendMemberTypeStatic, IP: appviewIP, Port: 5000, Weight: 100, MaxSessions: 1000, Enabled: true, }, }, Properties: &upcloud.LoadBalancerBackendProperties{ HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, HealthCheckURL: "/health", }, }, { Name: "hold", Members: []request.LoadBalancerBackendMember{ { Name: "hold-1", Type: upcloud.LoadBalancerBackendMemberTypeStatic, IP: holdIP, Port: 8080, Weight: 100, MaxSessions: 1000, Enabled: true, }, }, Properties: &upcloud.LoadBalancerBackendProperties{ HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, HealthCheckURL: "/xrpc/_health", }, }, }, }) if err != nil { return nil, err } return lb, nil } // ensureLBCertificates reconciles TLS certificate bundles on the load balancer. // It skips domains that already have a TLS config attached and creates missing ones. func ensureLBCertificates(ctx context.Context, svc *service.Service, lbUUID string, tlsDomains []string) error { lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{UUID: lbUUID}) if err != nil { return fmt.Errorf("get load balancer: %w", err) } // Build set of existing TLS config names on the "https" frontend existing := make(map[string]bool) for _, fe := range lb.Frontends { if fe.Name == "https" { for _, tc := range fe.TLSConfigs { existing[tc.Name] = true } } } for _, domain := range tlsDomains { certName := "tls-" + strings.ReplaceAll(domain, ".", "-") if existing[certName] { fmt.Printf(" TLS certificate: %s (exists)\n", domain) continue } bundle, err := svc.CreateLoadBalancerCertificateBundle(ctx, &request.CreateLoadBalancerCertificateBundleRequest{ Type: upcloud.LoadBalancerCertificateBundleTypeDynamic, Name: certName, KeyType: "ecdsa", Hostnames: []string{domain}, }) if err != nil { return fmt.Errorf("create TLS cert for %s: %w", domain, err) } _, err = svc.CreateLoadBalancerFrontendTLSConfig(ctx, &request.CreateLoadBalancerFrontendTLSConfigRequest{ ServiceUUID: lbUUID, FrontendName: "https", Config: request.LoadBalancerFrontendTLSConfig{ Name: certName, CertificateBundleUUID: bundle.UUID, }, }) if err != nil { return fmt.Errorf("attach TLS cert %s to frontend: %w", domain, err) } fmt.Printf(" TLS certificate: %s\n", domain) } return nil } // ensureLBForwardedHeaders ensures the "https" frontend has a set_forwarded_headers rule. // This makes the LB set X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Port headers, // overwriting any pre-existing values (prevents spoofing). func ensureLBForwardedHeaders(ctx context.Context, svc *service.Service, lbUUID string) error { rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{ ServiceUUID: lbUUID, FrontendName: "https", }) if err != nil { return fmt.Errorf("get frontend rules: %w", err) } for _, r := range rules { if r.Name == "set-forwarded-headers" { // Verify it has the set_forwarded_headers action for _, a := range r.Actions { if a.SetForwardedHeaders != nil { fmt.Println(" Forwarded headers rule: exists and valid") return nil } } // Rule exists but is misconfigured — delete and recreate fmt.Println(" Forwarded headers rule: exists but misconfigured, recreating") if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ ServiceUUID: lbUUID, FrontendName: "https", Name: r.Name, }); err != nil { return fmt.Errorf("delete misconfigured forwarded headers rule: %w", err) } break } } _, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{ ServiceUUID: lbUUID, FrontendName: "https", Rule: request.LoadBalancerFrontendRule{ Name: "set-forwarded-headers", Priority: 1, Matchers: []upcloud.LoadBalancerMatcher{}, Actions: []upcloud.LoadBalancerAction{ request.NewLoadBalancerSetForwardedHeadersAction(), }, }, }) if err != nil { return fmt.Errorf("create forwarded headers rule: %w", err) } fmt.Println(" Forwarded headers rule: created") return nil } // ensureLBHoldForwardedHeaders ensures the "route-hold" rule includes a // set_forwarded_headers action alongside use_backend. Without this, the LB // doesn't set X-Forwarded-For on hold-routed traffic. func ensureLBHoldForwardedHeaders(ctx context.Context, svc *service.Service, lbUUID, holdDomain string) error { rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{ ServiceUUID: lbUUID, FrontendName: "https", }) if err != nil { return fmt.Errorf("get frontend rules: %w", err) } for _, r := range rules { if r.Name == "route-hold" { hasForwarded := false for _, a := range r.Actions { if a.SetForwardedHeaders != nil { hasForwarded = true break } } if hasForwarded { fmt.Println(" Route-hold forwarded headers: exists") return nil } // Delete and recreate with both actions fmt.Println(" Route-hold forwarded headers: missing, recreating rule") if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ ServiceUUID: lbUUID, FrontendName: "https", Name: r.Name, }); err != nil { return fmt.Errorf("delete route-hold rule: %w", err) } break } } _, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{ ServiceUUID: lbUUID, FrontendName: "https", Rule: request.LoadBalancerFrontendRule{ Name: "route-hold", Priority: 10, Matchers: []upcloud.LoadBalancerMatcher{ { Type: upcloud.LoadBalancerMatcherTypeHost, Host: &upcloud.LoadBalancerMatcherHost{ Value: holdDomain, }, }, }, Actions: []upcloud.LoadBalancerAction{ request.NewLoadBalancerSetForwardedHeadersAction(), { Type: upcloud.LoadBalancerActionTypeUseBackend, UseBackend: &upcloud.LoadBalancerActionUseBackend{ Backend: "hold", }, }, }, }, }) if err != nil { return fmt.Errorf("create route-hold rule: %w", err) } fmt.Println(" Route-hold forwarded headers: created") return nil } // ensureLBScannerBlock ensures the "https" frontend has a rule that returns 403 // for common scanner paths (.php, .asp, .aspx, .jsp, .cgi, .env). func ensureLBScannerBlock(ctx context.Context, svc *service.Service, lbUUID string) error { rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{ ServiceUUID: lbUUID, FrontendName: "https", }) if err != nil { return fmt.Errorf("get frontend rules: %w", err) } for _, r := range rules { if r.Name == "block-scanners" { for _, a := range r.Actions { if a.HTTPReturn != nil { fmt.Println(" Scanner block rule: exists and valid") return nil } } fmt.Println(" Scanner block rule: exists but misconfigured, recreating") if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ ServiceUUID: lbUUID, FrontendName: "https", Name: r.Name, }); err != nil { return fmt.Errorf("delete misconfigured scanner block rule: %w", err) } break } } ignoreCase := true _, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{ ServiceUUID: lbUUID, FrontendName: "https", Rule: request.LoadBalancerFrontendRule{ Name: "block-scanners", Priority: 2, Matchers: []upcloud.LoadBalancerMatcher{ request.NewLoadBalancerPathMatcher( upcloud.LoadBalancerStringMatcherMethodRegexp, `\.(php|asp|aspx|jsp|cgi|env)$`, &ignoreCase, ), }, Actions: []upcloud.LoadBalancerAction{ { Type: upcloud.LoadBalancerActionTypeHTTPReturn, HTTPReturn: &upcloud.LoadBalancerActionHTTPReturn{ Status: 403, ContentType: "text/plain", Payload: base64.StdEncoding.EncodeToString([]byte("Forbidden")), }, }, }, }, }) if err != nil { return fmt.Errorf("create scanner block rule: %w", err) } fmt.Println(" Scanner block rule: created") return nil } // lookupObjectStorage discovers details of an existing Managed Object Storage. func lookupObjectStorage(ctx context.Context, svc *service.Service, uuid string) (ObjectStorageState, error) { storage, err := svc.GetManagedObjectStorage(ctx, &request.GetManagedObjectStorageRequest{ UUID: uuid, }) if err != nil { return ObjectStorageState{}, fmt.Errorf("get object storage %s: %w", uuid, err) } var endpoint string for _, ep := range storage.Endpoints { if ep.DomainName != "" { endpoint = "https://" + ep.DomainName break } } var bucket string buckets, err := svc.GetManagedObjectStorageBucketMetrics(ctx, &request.GetManagedObjectStorageBucketMetricsRequest{ ServiceUUID: uuid, }) if err == nil { for _, b := range buckets { if !b.Deleted { bucket = b.Name break } } } var accessKeyID string users, err := svc.GetManagedObjectStorageUsers(ctx, &request.GetManagedObjectStorageUsersRequest{ ServiceUUID: uuid, }) if err == nil { for _, u := range users { for _, k := range u.AccessKeys { if k.Status == "Active" { accessKeyID = k.AccessKeyID break } } if accessKeyID != "" { break } } } return ObjectStorageState{ UUID: uuid, Endpoint: endpoint, Region: storage.Region, Bucket: bucket, AccessKeyID: accessKeyID, }, nil } func findDebianTemplate(ctx context.Context, svc *service.Service) (string, error) { storages, err := svc.GetStorages(ctx, &request.GetStoragesRequest{ Type: "template", }) if err != nil { return "", fmt.Errorf("list templates: %w", err) } var debian13, debian12 string for _, s := range storages.Storages { title := strings.ToLower(s.Title) if strings.Contains(title, "debian") { if strings.Contains(title, "13") || strings.Contains(title, "trixie") { debian13 = s.UUID } else if strings.Contains(title, "12") || strings.Contains(title, "bookworm") { debian12 = s.UUID } } } if debian13 != "" { return debian13, nil } if debian12 != "" { fmt.Println(" Debian 13 not available, using Debian 12") return debian12, nil } return "", fmt.Errorf("no Debian template found — check UpCloud template list") } const cloudInitPath = "/var/lib/cloud/instance/scripts/part-001" // syncCloudInit compares a locally-generated cloud-init script against what's // on the server. If they differ (or the remote is missing), it prompts the // user and re-runs the script over SSH. func syncCloudInit(name, ip, localScript string) error { // Fetch the remote script remoteScript, err := runSSH(ip, fmt.Sprintf("cat %s 2>/dev/null || echo '__MISSING__'", cloudInitPath), false) if err != nil { fmt.Printf(" cloud-init: could not reach %s (%v)\n", name, err) return nil } remoteScript = strings.TrimSpace(remoteScript) if remoteScript == "__MISSING__" { fmt.Printf(" cloud-init: not found on %s (server may need initial setup)\n", name) } else { localHash := fmt.Sprintf("%x", sha256.Sum256([]byte(strings.TrimSpace(localScript)))) remoteHash := fmt.Sprintf("%x", sha256.Sum256([]byte(remoteScript))) if localHash == remoteHash { fmt.Printf(" cloud-init: up to date\n") return nil } fmt.Printf(" cloud-init: differs from local\n") } fmt.Printf(" Re-run cloud-init on %s? [Y/n] ", name) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() answer := strings.TrimSpace(strings.ToLower(scanner.Text())) if answer != "" && answer != "y" && answer != "yes" { fmt.Printf(" Skipped\n") // Still update the remote reference so next provision sees an accurate diff if err := writeRemoteCloudInit(ip, localScript); err != nil { fmt.Printf(" WARNING: could not update remote cloud-init reference: %v\n", err) } return nil } // Write the reference file first so next provision can detect real diffs, // regardless of whether the script execution succeeds or fails. if err := writeRemoteCloudInit(ip, localScript); err != nil { fmt.Printf(" WARNING: could not update remote cloud-init reference: %v\n", err) } fmt.Printf(" Running cloud-init on %s (%s)... (this may take several minutes)\n", name, ip) output, err := runSSH(ip, localScript, true) if err != nil { fmt.Printf(" ERROR: %v\n", err) fmt.Printf(" Output:\n%s\n", output) return fmt.Errorf("cloud-init %s failed", name) } fmt.Printf(" %s: cloud-init complete\n", name) return nil } // generateScannerSecret generates a random 32-byte hex-encoded shared secret // for authenticating scanner-to-hold WebSocket connections. func generateScannerSecret() (string, error) { b := make([]byte, 32) if _, err := crypto_rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // writeRemoteCloudInit writes the local cloud-init script to the remote server // so that subsequent provision runs can accurately detect real changes. // Uses base64 encoding to avoid heredoc nesting issues (the cloud-init script // itself contains heredocs like CFGEOF and SVCEOF). func writeRemoteCloudInit(ip, script string) error { encoded := base64.StdEncoding.EncodeToString([]byte(script)) cmd := fmt.Sprintf("mkdir -p $(dirname %s) && echo '%s' | base64 -d > %s", cloudInitPath, encoded, cloudInitPath) _, err := runSSH(ip, cmd, false) return err } // waitForSetup polls SSH availability on a newly created server, then waits // for cloud-init to complete before returning. func waitForSetup(ip, name string) error { fmt.Printf(" %s (%s): waiting for SSH...\n", name, ip) for i := 0; i < 30; i++ { _, err := runSSH(ip, "echo ssh_ready", false) if err == nil { break } if i == 29 { return fmt.Errorf("SSH not available after 5 minutes on %s (%s)", name, ip) } time.Sleep(10 * time.Second) } fmt.Printf(" %s: waiting for cloud-init...\n", name) _, err := runSSH(ip, "cloud-init status --wait 2>/dev/null || true", false) if err != nil { return fmt.Errorf("cloud-init wait on %s: %w", name, err) } fmt.Printf(" %s: ready\n", name) return nil }