Files

1250 lines
38 KiB
Go

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
}