mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-23 10:44:16 +00:00
Two corrections to the HTTP/2 change, both found by running it. UpCloud refuses http2_enabled on a backend that is not TLS: invalid_params_properties.http2_enabled='Tls must be enabled to enable HTTP2.' This load balancer reaches both origins over the private network in cleartext, so the option is not available here at all, and asking for it failed the whole provision run with a 400 after the frontend had already been changed. Backend HTTP/2 is removed rather than made conditional: it would need TLS terminated at the origins, which is a much larger change than it earns. The frontend is the leg that mattered, and it is enabled and confirmed live — all three domains now negotiate h2 by ALPN. The second correction is more interesting: enabling HTTP/2 did not fix the symptom it was aimed at, and briefly made it look worse. With the browser no longer rationing itself to ~6 connections, every row of the crew tab now reaches the server at once, and the 504s went from a flat 10s to a climbing ladder: [HTTP/2 504 10026ms] ... [HTTP/2 504 15230ms] ... [HTTP/2 504 25976ms] HTTP/1.1 had been hiding a server-side limit by throttling the client. The queue moved; it did not disappear. What is actually slow is resolveHandle, which calls live identity resolution per row with no bound of its own, so a DID whose resolution hangs — a did:web on a host that stopped answering, a PDS that accepts and then stalls — holds its request open until the proxy gives up. The crew tab issues one request per member, so a hold with hundreds of crew gets hundreds of chances to hit one. resolveHandle now bounds each lookup at 2s. A handle is decoration on a row whose DID is already rendered beside it, so waiting seconds for one and failing the row when it does not arrive trades something load-bearing for something cosmetic. Two seconds is far above a warm cached lookup and far below the proxy's 10s cut, so a slow DID costs its own row a handle and costs the page nothing. The quota lookup on the same handler was the other suspect and was measured out: records(collection, did) is indexed and the aggregation returns immediately on the production database. This does not remove the N+1 — the tab still issues one request per member, and a batch endpoint is the real fix. It removes the failure. Verified: the guard is covered by a test using a directory that hangs until its context is cancelled, asserting both that the call returns near the bound and that it did not return suspiciously early. make lint 0 issues, make test green across 44 packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AV6Mk2AgghFsNo4HWQEaBV
1526 lines
48 KiB
Go
1526 lines
48 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")
|
|
withLabeler, _ := cmd.Flags().GetBool("with-labeler")
|
|
withBilling, _ := cmd.Flags().GetBool("with-billing")
|
|
return cmdProvision(token, zone, plan, sshKey, s3Secret, withScanner, withLabeler, withBilling)
|
|
},
|
|
}
|
|
|
|
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 when creating new servers)")
|
|
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.Flags().Bool("with-labeler", false, "Deploy content moderation labeler alongside appview")
|
|
provisionCmd.Flags().Bool("with-billing", false, "Compile Stripe billing into the appview (pkg/billing, `billing` build tag). Off by default.")
|
|
rootCmd.AddCommand(provisionCmd)
|
|
}
|
|
|
|
func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, withLabeler, withBilling 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)
|
|
}
|
|
|
|
// Labeler setup
|
|
if withLabeler {
|
|
state.LabelerEnabled = true
|
|
_ = 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
|
|
// Labeler domain is a fixed subdomain on the base domain (e.g. labeler.seamark.dev)
|
|
labelerDomain := "labeler." + 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,
|
|
LabelerDomain: labelerDomain,
|
|
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, state.LabelerEnabled)
|
|
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)
|
|
}
|
|
if state.LabelerEnabled {
|
|
labelerConfigYAML, err := renderConfig(labelerConfigTmpl, vals)
|
|
if err != nil {
|
|
return fmt.Errorf("render labeler config: %w", err)
|
|
}
|
|
if err := syncConfigKeys("labeler", state.Appview.PublicIP, naming.LabelerConfigPath(), labelerConfigYAML); err != nil {
|
|
return fmt.Errorf("labeler config sync: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Println("Creating appview server...")
|
|
appviewUserData, err := generateAppviewCloudInit(cfg, vals, state.LabelerEnabled)
|
|
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, labelerDomain, state.LabelerEnabled)
|
|
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)
|
|
}
|
|
|
|
// Same reason: HTTP/2 and the client timeout must reach an LB that already
|
|
// exists, not just one created by this run.
|
|
if err := ensureLBHTTP2(ctx, svc, state.LB.UUID); err != nil {
|
|
return fmt.Errorf("LB http2: %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)
|
|
}
|
|
|
|
// Ensure labeler backend + route-labeler rule when labeler is enabled
|
|
if state.LabelerEnabled {
|
|
if err := ensureLBLabelerRoute(ctx, svc, state.LB.UUID, state.Appview.PrivateIP, labelerDomain); err != nil {
|
|
return fmt.Errorf("LB labeler route: %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 state.LabelerEnabled {
|
|
tlsDomains = append(tlsDomains, labelerDomain)
|
|
}
|
|
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 := runMakeBuildTrixie(rootDir, withBilling); err != nil {
|
|
return fmt.Errorf("build: %w", err)
|
|
}
|
|
if err := verifyAppviewBilling(filepath.Join(rootDir, "bin", "atcr-appview"), withBilling); err != nil {
|
|
return 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 state.LabelerEnabled {
|
|
labelerLocal := filepath.Join(rootDir, "bin", "atcr-labeler")
|
|
labelerRemote := naming.InstallDir() + "/bin/" + naming.Labeler()
|
|
if err := scpFile(labelerLocal, state.Appview.PublicIP, labelerRemote); err != nil {
|
|
return fmt.Errorf("upload labeler: %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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Labeler binary: build and upload when labeler is enabled but appview was
|
|
// not freshly created (the appviewCreated branch above already handled it).
|
|
if state.LabelerEnabled && !appviewCreated {
|
|
rootDir := projectRoot()
|
|
labelerLocal := filepath.Join(rootDir, "bin", "atcr-labeler")
|
|
if err := runMakeBuildTrixie(rootDir, withBilling); err != nil {
|
|
return fmt.Errorf("build labeler: %w", err)
|
|
}
|
|
labelerRemote := naming.InstallDir() + "/bin/" + naming.Labeler()
|
|
fmt.Println("Deploying labeler binary...")
|
|
if err := scpFile(labelerLocal, state.Appview.PublicIP, labelerRemote); err != nil {
|
|
return fmt.Errorf("upload labeler: %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)
|
|
if state.LabelerEnabled {
|
|
fmt.Printf(" CNAME %-24s → %s\n", labelerDomain, 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:")
|
|
}
|
|
services := []string{naming.Appview(), naming.Hold()}
|
|
if state.ScannerEnabled {
|
|
services = append(services, naming.Scanner())
|
|
}
|
|
if state.LabelerEnabled {
|
|
services = append(services, naming.Labeler())
|
|
}
|
|
fmt.Printf(" systemctl start %s\n", strings.Join(services, " / "))
|
|
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) {
|
|
if cfg.SSHPublicKey == "" {
|
|
return nil, fmt.Errorf("creating server %s requires --ssh-key (path to SSH public key file)", title)
|
|
}
|
|
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",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// lbClientTimeout is the frontend's client-side timeout, in seconds. It is
|
|
// stated explicitly rather than left to the UpCloud default so the value the
|
|
// deployment relies on is visible here: the admin panel's slow endpoints are
|
|
// cut at this boundary, and the 504 that produces is what the UI surfaces as
|
|
// "Server error". Changing it changes that behaviour, so it is a deliberate
|
|
// knob, not an incidental one.
|
|
const lbClientTimeout = 10
|
|
|
|
// httpFrontendProperties are the https frontend's properties.
|
|
//
|
|
// HTTP/2 is the one that matters for the browser: over HTTP/1.1 a browser
|
|
// opens at most ~6 connections per origin, so a page that fans out many small
|
|
// requests (the admin crew tab issues one per member) queues them six at a
|
|
// time and blocks every other request to the same host behind them. h2
|
|
// multiplexes them over a single connection and the queue disappears.
|
|
//
|
|
// Clients that need an HTTP/1.1 upgrade are unaffected: h2 is negotiated per
|
|
// connection via ALPN, so a WebSocket client simply selects http/1.1.
|
|
func httpFrontendProperties() *upcloud.LoadBalancerFrontendProperties {
|
|
return &upcloud.LoadBalancerFrontendProperties{
|
|
HTTP2Enabled: new(true),
|
|
TimeoutClient: lbClientTimeout,
|
|
}
|
|
}
|
|
|
|
// Backends are deliberately left at HTTP/1.1.
|
|
//
|
|
// UpCloud refuses http2_enabled on a backend that is not TLS:
|
|
//
|
|
// invalid_params_properties.http2_enabled='Tls must be enabled to enable HTTP2.'
|
|
//
|
|
// This LB reaches both origins over the private network in cleartext, so the
|
|
// option is simply unavailable here; requesting it fails the whole provision
|
|
// run with a 400. Backend HTTP/2 would first require terminating TLS at the
|
|
// origins, which is a larger change than the frontend win justifies.
|
|
//
|
|
// The frontend is where it mattered anyway: that is the leg a browser speaks,
|
|
// and the ~6-connections-per-origin limit it removes is what made one slow
|
|
// endpoint block every other request to the same host.
|
|
//
|
|
// Both origins still wrap their handler in h2c, so if backend TLS is ever
|
|
// added, enabling this becomes a config change rather than a code change. Note
|
|
// the hold would still have to opt out even then: it serves
|
|
// com.atproto.sync.subscribeRepos over WebSocket to external relays and to the
|
|
// scanner, and WebSocket over HTTP/2 needs the RFC 8441 Extended CONNECT that
|
|
// Go's http2 server does not implement for Upgrade:.
|
|
|
|
func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain, labelerDomain string, withLabeler bool) (*upcloud.LoadBalancer, error) {
|
|
frontendRules := []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",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
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 withLabeler {
|
|
frontendRules = append(frontendRules, labelerFrontendRule(labelerDomain))
|
|
backends = append(backends, labelerBackend(appviewIP))
|
|
}
|
|
|
|
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",
|
|
Properties: httpFrontendProperties(),
|
|
Networks: []upcloud.LoadBalancerFrontendNetwork{
|
|
{Name: "public"},
|
|
},
|
|
Rules: frontendRules,
|
|
},
|
|
{
|
|
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: backends,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return lb, nil
|
|
}
|
|
|
|
// labelerBackend builds the labeler LB backend pointing at the appview server's
|
|
// private IP on the labeler listen port.
|
|
func labelerBackend(appviewIP string) request.LoadBalancerBackend {
|
|
return request.LoadBalancerBackend{
|
|
Name: "labeler",
|
|
Members: []request.LoadBalancerBackendMember{
|
|
{
|
|
Name: "labeler-1",
|
|
Type: upcloud.LoadBalancerBackendMemberTypeStatic,
|
|
IP: appviewIP,
|
|
Port: 5002,
|
|
Weight: 100,
|
|
MaxSessions: 1000,
|
|
Enabled: true,
|
|
},
|
|
},
|
|
Properties: &upcloud.LoadBalancerBackendProperties{
|
|
HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP,
|
|
HealthCheckURL: "/.well-known/did.json",
|
|
},
|
|
}
|
|
}
|
|
|
|
// labelerFrontendRule returns a host-match rule routing labelerDomain to the
|
|
// labeler backend with forwarded headers.
|
|
func labelerFrontendRule(labelerDomain string) request.LoadBalancerFrontendRule {
|
|
return request.LoadBalancerFrontendRule{
|
|
Name: "route-labeler",
|
|
Priority: 20,
|
|
Matchers: []upcloud.LoadBalancerMatcher{
|
|
{
|
|
Type: upcloud.LoadBalancerMatcherTypeHost,
|
|
Host: &upcloud.LoadBalancerMatcherHost{
|
|
Value: labelerDomain,
|
|
},
|
|
},
|
|
},
|
|
Actions: []upcloud.LoadBalancerAction{
|
|
request.NewLoadBalancerSetForwardedHeadersAction(),
|
|
{
|
|
Type: upcloud.LoadBalancerActionTypeUseBackend,
|
|
UseBackend: &upcloud.LoadBalancerActionUseBackend{
|
|
Backend: "labeler",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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).
|
|
// ensureLBHTTP2 reconciles HTTP/2 and the client timeout onto an LB that
|
|
// already exists.
|
|
//
|
|
// createLoadBalancer only runs when there is no LB yet, so without this the
|
|
// properties above would reach a fresh deployment and never an existing one.
|
|
// This follows ensureLBForwardedHeaders: read what is there, change only what
|
|
// differs, and say what it did. Running it twice is a no-op.
|
|
func ensureLBHTTP2(ctx context.Context, svc *service.Service, lbUUID string) error {
|
|
fe, err := svc.GetLoadBalancerFrontend(ctx, &request.GetLoadBalancerFrontendRequest{
|
|
ServiceUUID: lbUUID,
|
|
Name: "https",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("get https frontend: %w", err)
|
|
}
|
|
|
|
want := httpFrontendProperties()
|
|
haveHTTP2 := fe.Properties != nil && fe.Properties.HTTP2Enabled != nil && *fe.Properties.HTTP2Enabled
|
|
haveTimeout := fe.Properties != nil && fe.Properties.TimeoutClient == want.TimeoutClient
|
|
if haveHTTP2 && haveTimeout {
|
|
fmt.Println(" Frontend HTTP/2 + timeout: already set")
|
|
} else {
|
|
if _, err := svc.ModifyLoadBalancerFrontend(ctx, &request.ModifyLoadBalancerFrontendRequest{
|
|
ServiceUUID: lbUUID,
|
|
Name: "https",
|
|
Frontend: request.ModifyLoadBalancerFrontend{
|
|
Properties: want,
|
|
},
|
|
}); err != nil {
|
|
return fmt.Errorf("modify https frontend: %w", err)
|
|
}
|
|
fmt.Printf(" Frontend HTTP/2: enabled (timeout_client=%ds)\n", want.TimeoutClient)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ensureLBLabelerRoute idempotently ensures the LB has a "labeler" backend
|
|
// pointing at the appview server's private IP and a "route-labeler" frontend
|
|
// rule matching labelerDomain. Used to add labeler routing to a pre-existing LB
|
|
// during a re-provision with --with-labeler.
|
|
func ensureLBLabelerRoute(ctx context.Context, svc *service.Service, lbUUID, appviewIP, labelerDomain string) error {
|
|
// 1. Ensure backend exists
|
|
backends, err := svc.GetLoadBalancerBackends(ctx, &request.GetLoadBalancerBackendsRequest{ServiceUUID: lbUUID})
|
|
if err != nil {
|
|
return fmt.Errorf("get backends: %w", err)
|
|
}
|
|
hasBackend := false
|
|
for _, b := range backends {
|
|
if b.Name == "labeler" {
|
|
hasBackend = true
|
|
break
|
|
}
|
|
}
|
|
if !hasBackend {
|
|
_, err := svc.CreateLoadBalancerBackend(ctx, &request.CreateLoadBalancerBackendRequest{
|
|
ServiceUUID: lbUUID,
|
|
Backend: labelerBackend(appviewIP),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create labeler backend: %w", err)
|
|
}
|
|
fmt.Println(" Labeler backend: created")
|
|
} else {
|
|
fmt.Println(" Labeler backend: exists")
|
|
}
|
|
|
|
// 2. Ensure frontend rule exists with correct host matcher
|
|
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-labeler" {
|
|
// Verify the host matcher and use_backend action are correct
|
|
hostOK := false
|
|
for _, m := range r.Matchers {
|
|
if m.Host != nil && m.Host.Value == labelerDomain {
|
|
hostOK = true
|
|
break
|
|
}
|
|
}
|
|
backendOK := false
|
|
for _, a := range r.Actions {
|
|
if a.UseBackend != nil && a.UseBackend.Backend == "labeler" {
|
|
backendOK = true
|
|
break
|
|
}
|
|
}
|
|
if hostOK && backendOK {
|
|
fmt.Println(" Route-labeler rule: exists and valid")
|
|
return nil
|
|
}
|
|
fmt.Println(" Route-labeler 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 route-labeler rule: %w", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if _, err := svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
Rule: labelerFrontendRule(labelerDomain),
|
|
}); err != nil {
|
|
return fmt.Errorf("create route-labeler rule: %w", err)
|
|
}
|
|
fmt.Println(" Route-labeler rule: 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 := range 30 {
|
|
_, 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
|
|
}
|