package guestseed import ( "cmp" "fmt" "net/netip" "net/url" "path" "regexp" "sort" "strconv" "strings" "github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/cloudinit" "github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/market" "github.com/johnandersen777/socialweb-computer-kcp/internal/guestnet" ) const ( AcceptPathVM = "/root/secrets/publicdomainrelay.com/market/accept.json" SecretsPrefix = "/root/secrets/digitalocean.com/serviceaccount" OnNetworkBaseURLPath = SecretsPrefix + "/base_url" GuestCurlrcPath = "/root/.curlrc" GuestLoginScript = cloudinit.GuestLoginScriptPath GuestLoginUnit = cloudinit.GuestLoginUnit GuestLoginTokenPath = cloudinit.GuestLoginTokenPath GuestLoginNamespace = cloudinit.GuestLoginNamespace GuestLoginDefaultMount = cloudinit.GuestLoginDefaultMount GuestLoginRoute = "/v1" OnNetworkScript = cloudinit.OnNetworkScriptPath OnNetworkUnit = "guest-onnetwork.service" IrohServiceUnit = "iroh.service" IrohListenerScript = "/usr/local/bin/iroh-listener.sh" IrohTicketPath = "/run/guest-iroh-ticket" GuestFQDNPath = "/run/guest-fqdn" IrohTargetPort = 22 PoweroffModule = cloudinit.PoweroffEndsVMModule PoweroffPath = cloudinit.PoweroffEndsVMPath ) var ( guestLoginRoleRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) guestLoginMountRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) guestBindSecretRE = regexp.MustCompile(`^[0-9a-f]{64}$`) guestLocalhostURL = regexp.MustCompile(`(?i)https://([a-z0-9.-]+\.localhost)`) guestAnyLocalhostURL = regexp.MustCompile(`(?i)https?://([a-z0-9.-]+\.localhost)`) guestHostURL = regexp.MustCompile(`(?i)(https?)://(?:[a-z0-9.-]+\.localhost|` + regexp.QuoteMeta(guestnet.HostAddress) + `)(?::([0-9]{1,5}))?`) ) type GuestLoginOptions = cloudinit.GuestLoginOptions type ProvisionOptions struct { UserData string Login *GuestLoginOptions AcceptPath string FallbackURL string BearerAudience string Reporter bool BaseURL bool HostAddress string TLSPort int HostURL string Downloads map[string]string WebRTC *WebRTCOptions } func GuestLoginModule(opts GuestLoginOptions) (cloudinit.UserDataModule, error) { if err := validateGuestLogin(opts); err != nil { return nil, err } return cloudinit.GuestLoginModule(opts), nil } func IrohTransportModule(userData string) (cloudinit.UserDataModule, error) { if strings.Contains(userData, IrohServiceUnit) || strings.Contains(userData, IrohListenerScript) { return nil, nil } modules, err := cloudinit.GetUserDataModules([]any{cloudinit.GuestTransportModule}) if err != nil { return nil, err } if len(modules) != 1 { return nil, fmt.Errorf("guestseed: the %s user-data module is not registered", cloudinit.GuestTransportModule) } inner := modules[0] return func(ctx cloudinit.Context) (cloudinit.UserDataPatch, error) { patch, err := inner(ctx) if err != nil { return cloudinit.UserDataPatch{}, err } if ctx.SSHAuthorizedKey != "" { return patch, nil } files := patch.WriteFiles[:0] for _, file := range patch.WriteFiles { if file.Path == "/root/.ssh/authorized_keys" { continue } files = append(files, file) } patch.WriteFiles = files return patch, nil }, nil } func WithIrohTransport(userData string, authorizedKeys, hostAliases []string) (string, error) { if !strings.HasPrefix(strings.TrimLeft(userData, " \t\r\n"), "#cloud-config") { return userData, nil } module, err := IrohTransportModule(userData) if err != nil || module == nil { return userData, err } keys := make([]string, 0, len(authorizedKeys)) for _, key := range authorizedKeys { key = strings.TrimSpace(key) if key != "" { keys = append(keys, key) } } return cloudinit.BuildUserData(cloudinit.BuildOptions{ Base: userData, Modules: []any{module}, Ctx: cloudinit.Context{ SSHAuthorizedKey: strings.Join(keys, "\n"), TargetPort: IrohTargetPort, HostAliases: hostAliases, DumbpipeVersion: cloudinit.DumbpipeVersionDefault, }, }) } func AcceptBundle(acceptPath string, bundle map[string]any) (cloudinit.UserDataModule, error) { acceptPath = cmp.Or(acceptPath, AcceptPathVM) if err := checkAbsolute(acceptPath); err != nil { return nil, err } return cloudinit.AcceptBundleModule(acceptPath, bundle), nil } func OnNetworkReporterModule(userData, fallbackURL, acceptPath, bearerAudience, hostURL string) (cloudinit.UserDataModule, bool, error) { if strings.Contains(userData, OnNetworkUnit) { return nil, false, nil } acceptPath = cmp.Or(acceptPath, AcceptPathVM) if err := checkAbsolute(acceptPath); err != nil { return nil, false, err } targets := guestnet.RelayTargets(userData, fallbackURL) if len(targets) == 0 || targets[0].URL == "" { return nil, false, fmt.Errorf("guestseed: the guest reports on-network to no URL; the fallback %q is not one", fallbackURL) } if hostURL = strings.TrimRight(strings.TrimSpace(hostURL), "/"); hostURL != "" { targets = append([]guestnet.RelayTarget{{URL: hostURL + guestnet.EventsPath}}, targets...) } for _, target := range targets { if err := checkURLLike(target.URL, "on-network URL"); err != nil { return nil, false, err } } if bearerAudience != "" && !safeInDoubleQuotes(bearerAudience) { return nil, false, fmt.Errorf("guestseed: bearer audience %q carries a character a shell would read", bearerAudience) } var attempts strings.Builder auth := "" if bearerAudience != "" { auth = ` -H "Authorization: Bearer ${TOKEN}"` } for i, target := range targets { code := fmt.Sprintf("CODE%d", i+1) resolve := "" if target.Resolve != "" { resolve = `--resolve ` + strconv.Quote(target.Resolve) + ` ` } fmt.Fprintf(&attempts, " %s=\"$(curl -sS -m 20 -o \"${OUT}\" -w '%%{http_code}' %s\\\n", code, resolve) fmt.Fprintf(&attempts, " -X POST %s -H 'Content-Type: application/json'%s -d \"${PAYLOAD}\" \\\n", strconv.Quote(target.URL), auth) attempts.WriteString(" 2>>\"${ERR}\" || echo 000)\"\n") fmt.Fprintf(&attempts, " echo \"guest-onnetwork: http ${%s} %s\"\n", code, target.URL) fmt.Fprintf(&attempts, " if [ \"${%s}\" = \"200\" ]; then echo \"guest-onnetwork: reported ${FQDN}\"; exit 0; fi\n", code) } script := `#!/usr/bin/env bash set -uo pipefail ACCEPT_JSON="` + acceptPath + `" if [ ! -f "${ACCEPT_JSON}" ]; then echo "guest-onnetwork: no ${ACCEPT_JSON}"; exit 0; fi TAG="${1:-}" OUT="/tmp/guest-onnetwork${TAG:+-${TAG}}.out" ERR="/tmp/guest-onnetwork${TAG:+-${TAG}}.err" FQDN="" if [ -n "${TAG}" ]; then FQDN="${2:-}" if [ -z "${FQDN}" ]; then echo "guest-onnetwork: no address for the tag ${TAG}"; exit 1; fi else for _ in $(seq 1 45); do FQDN="$(cat /run/guest-fqdn 2>/dev/null || true)" [ -n "${FQDN}" ] && break systemctl is-failed --quiet iroh.service && { echo "guest-onnetwork: iroh.service failed"; break; } sleep 2 done if [ -z "${FQDN}" ]; then echo "guest-onnetwork: no /run/guest-fqdn" echo "guest-onnetwork: iroh is $(systemctl is-active iroh.service 2>&1), restarts=$(systemctl show -p NRestarts --value iroh.service 2>&1)" journalctl -u iroh.service --no-pager -n 40 2>&1 | tail -40 exit 0 fi fi ACCEPT_URI="$(jq -r '.accept.uri // empty' "${ACCEPT_JSON}")" ACCEPT_CID="$(jq -r '.accept.cid // empty' "${ACCEPT_JSON}")" if [ -z "${ACCEPT_URI}" ] || [ -z "${ACCEPT_CID}" ]; then echo "guest-onnetwork: accept.json has no accept ref"; exit 0; fi PAYLOAD="$(jq -nc --arg au "${ACCEPT_URI}" --arg ac "${ACCEPT_CID}" --arg addr "${FQDN}" --arg tag "${TAG}" \ --arg nsid "` + market.ComputeEventsVMOnNetworkNSID + `" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ '{acceptUri: $au, acceptCid: $ac, record: ({"$type": $nsid, address: $addr, createdAt: $at} + (if $tag == "" then {} else {tag: $tag} end))}')" echo "guest-onnetwork: reporting ${TAG:+${TAG} }${FQDN}" for _ in 1 2 3 4 5; do ` + bearerLines(bearerAudience) + attempts.String() + ` sleep 6 done echo "guest-onnetwork: gave up; last response: $(cat "${OUT}" 2>/dev/null)" if [ -n "${TAG}" ]; then exit 1; fi ` after := "iroh.service" wants := "iroh.service" if bearerAudience != "" { after += " " + GuestLoginUnit wants += " " + GuestLoginUnit } unit := strings.Join([]string{ "[Unit]", "Description=Report the guest iroh ticket to the market", "After=network-online.target " + after, "Wants=network-online.target " + wants, "", "[Service]", "Type=oneshot", "RemainAfterExit=yes", "ExecStart=" + OnNetworkScript, "StandardOutput=journal+console", "StandardError=journal+console", "", "[Install]", "WantedBy=multi-user.target", "", }, "\n") module := func(cloudinit.Context) (cloudinit.UserDataPatch, error) { return cloudinit.UserDataPatch{ WriteFiles: []cloudinit.WriteFileEntry{ {Path: OnNetworkScript, Owner: "root:root", Permissions: "0700", Content: script}, {Path: "/etc/systemd/system/" + OnNetworkUnit, Owner: "root:root", Permissions: "0644", Content: unit}, }, RuncmdPrepend: []any{ "systemctl daemon-reload", "systemctl enable --no-block " + OnNetworkUnit, "systemctl start --no-block " + OnNetworkUnit, }, }, nil } return module, true, nil } func bearerLines(audience string) string { if audience == "" { return "" } return ` TOKEN="$(` + GuestLoginScript + ` token "` + audience + `")" || TOKEN="" if [ -z "${TOKEN}" ]; then echo "guest-onnetwork: no token from the bidder's OpenBao yet"; sleep 6; continue; fi ` } func OnNetworkBaseURLModule(userData, fallbackURL string) (cloudinit.UserDataModule, bool, error) { if strings.Contains(userData, OnNetworkBaseURLPath) { return nil, false, nil } target := guestnet.OnNetworkURLFromBundle(userData, fallbackURL) if target == "" { return nil, false, fmt.Errorf("guestseed: the guest names no on-network URL; the fallback %q is not one", fallbackURL) } if err := checkURLLike(target, "on-network URL"); err != nil { return nil, false, err } u, err := url.Parse(target) if err != nil || u.Host == "" { return nil, false, fmt.Errorf("guestseed: on-network URL %q is not a URL", target) } origin := u.Scheme + "://" + u.Host parent := path.Dir(OnNetworkBaseURLPath) module := func(cloudinit.Context) (cloudinit.UserDataPatch, error) { return cloudinit.UserDataPatch{ WriteFiles: []cloudinit.WriteFileEntry{{Path: OnNetworkBaseURLPath, Owner: "root:root", Permissions: "0600", Content: origin + "\n"}}, RuncmdPrepend: []any{[]any{"sh", "-c", "install -d -m 0700 -o root -g root " + shellQuote(parent)}}, }, nil } return module, true, nil } func PointAtHostModules(userData, gateway string, tlsPort int) (string, []cloudinit.UserDataModule, error) { if tlsPort < 0 || tlsPort > 65535 { return "", nil, fmt.Errorf("guestseed: guest TLS port %d is not a port", tlsPort) } rewritten := userData if tlsPort > 0 { rewritten = addGuestTLSPort(userData, tlsPort) } hosts := localhostHosts(rewritten) if len(hosts) == 0 { return rewritten, nil, nil } addr, err := netip.ParseAddr(strings.TrimSpace(gateway)) if err != nil || !addr.Is4() { return "", nil, fmt.Errorf("guestseed: host address %q is not an IPv4 address", gateway) } var lines []any for _, host := range hosts { lines = append(lines, []any{"sh", "-c", "echo " + addr.String() + " " + host + " >> /etc/hosts"}) } for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { lines[i], lines[j] = lines[j], lines[i] } var files []cloudinit.WriteFileEntry if tlsPort > 0 { files = []cloudinit.WriteFileEntry{{Path: GuestCurlrcPath, Owner: "root:root", Permissions: "0644", Content: "resolve = *:" + strconv.Itoa(tlsPort) + ":" + addr.String() + "\n"}} } module := func(cloudinit.Context) (cloudinit.UserDataPatch, error) { return cloudinit.UserDataPatch{WriteFiles: files, RuncmdPrepend: lines}, nil } return rewritten, []cloudinit.UserDataModule{module}, nil } func InjectAcceptBundle(userData string, bundle map[string]any, acceptPath string) (string, error) { module, err := AcceptBundle(acceptPath, bundle) if err != nil { return "", err } return cloudinit.BuildUserData(cloudinit.BuildOptions{ Base: userData, Modules: []any{module, PoweroffModule}, }) } func WithGuestLogin(userData string, opts GuestLoginOptions) (string, error) { module, err := GuestLoginModule(opts) if err != nil { return "", err } return cloudinit.BuildUserData(cloudinit.BuildOptions{Base: userData, Modules: []any{module}}) } func WithOnNetworkReporter(userData, fallbackURL, acceptPath, bearerAudience string) (string, error) { module, add, err := OnNetworkReporterModule(userData, fallbackURL, acceptPath, bearerAudience, "") if err != nil || !add { return userData, err } return cloudinit.BuildUserData(cloudinit.BuildOptions{Base: userData, Modules: []any{module}}) } func WithOnNetworkBaseURL(userData, fallbackURL string) (string, error) { module, add, err := OnNetworkBaseURLModule(userData, fallbackURL) if err != nil || !add { return userData, err } return cloudinit.BuildUserData(cloudinit.BuildOptions{Base: userData, Modules: []any{module}}) } func Provision(opts ProvisionOptions) (string, error) { acceptPath := opts.AcceptPath acceptPath = cmp.Or(acceptPath, AcceptPathVM) modules := []any{} ctx := cloudinit.Context{SecretsAcceptPath: acceptPath} if opts.TLSPort > 0 && opts.TLSPort <= 65535 { opts.UserData = addGuestTLSPort(opts.UserData, opts.TLSPort) opts.FallbackURL = addGuestTLSPort(opts.FallbackURL, opts.TLSPort) if opts.Login != nil { login := *opts.Login login.URL = addGuestTLSPort(login.URL, opts.TLSPort) opts.Login = &login } } if opts.Login != nil { module, err := GuestLoginModule(*opts.Login) if err != nil { return "", err } ctx.VMName = opts.Login.Role modules = append(modules, module) } if opts.Reporter { module, add, err := OnNetworkReporterModule(opts.UserData, opts.FallbackURL, acceptPath, opts.BearerAudience, opts.HostURL) if err != nil { return "", err } if add { modules = append(modules, module) } } if opts.BaseURL { module, add, err := OnNetworkBaseURLModule(opts.UserData, opts.FallbackURL) if err != nil { return "", err } if add { modules = append(modules, module) } } rewritten, hostModules, err := PointAtHostModules(opts.UserData, opts.HostAddress, opts.TLSPort) if err != nil { return "", err } for _, module := range hostModules { modules = append(modules, module) } binaries, err := guestBinaryModules(opts, acceptPath) if err != nil { return "", err } modules = append(modules, binaries...) if len(modules) == 0 { return rewritten, nil } return cloudinit.BuildUserData(cloudinit.BuildOptions{Base: rewritten, Modules: modules, Ctx: ctx}) } func guestBinaryModules(opts ProvisionOptions, acceptPath string) ([]any, error) { needs := GuestBinariesNeeded(opts.UserData) if err := needs.ToolsErr(); err != nil { return nil, err } if !needs.Any() { return nil, nil } if strings.TrimSpace(opts.HostURL) == "" { return nil, fmt.Errorf("guestseed: the guest asks for %s, and the bidder has no host URL to serve them from", strings.Join(needs.Names(), ", ")) } pins, err := GuestBinaryPins(opts.Downloads, needs.Names()) if err != nil { return nil, err } var out []any if needs.Agent { agent, err := GuestAgentModule(opts.HostURL, opts.BearerAudience, acceptPath, needs.Secrets, opts.WebRTC) if err != nil { return nil, err } out = append(out, agent) } install, err := GuestBinariesModule(opts.HostURL, pins, needs.Units) if err != nil { return nil, err } return append(out, install), nil } func validateGuestLogin(opts GuestLoginOptions) error { url := strings.TrimRight(strings.TrimSpace(opts.URL), "/") if err := checkURLLike(url, "guest login URL"); err != nil { return err } if len(opts.Role) > 201 || !guestLoginRoleRE.MatchString(opts.Role) { return fmt.Errorf("guestseed: guest login role %q is not a ComputeVM name", opts.Role) } if !guestBindSecretRE.MatchString(opts.BindSecret) { return fmt.Errorf("guestseed: guest login bind secret is not 64 lowercase hex characters") } mount := opts.Mount mount = cmp.Or(mount, GuestLoginDefaultMount) if len(mount) > 63 || !guestLoginMountRE.MatchString(mount) { return fmt.Errorf("guestseed: guest login mount %q is not an auth mount name", mount) } if opts.SSHDir != "" { if err := checkAbsolute(opts.SSHDir); err != nil { return err } } if opts.TokenPath != "" { if err := checkAbsolute(opts.TokenPath); err != nil { return err } } if opts.Attempts < 0 || opts.Attempts > 1000 { return fmt.Errorf("guestseed: guest login attempts %d is not between 0 and 1000", opts.Attempts) } if opts.RetryDelaySec < 0 || opts.RetryDelaySec > 300 { return fmt.Errorf("guestseed: guest login retry delay %d s is not between 0 and 300", opts.RetryDelaySec) } return nil } func addGuestTLSPort(userData string, port int) string { matches := guestLocalhostURL.FindAllStringSubmatchIndex(userData, -1) if len(matches) == 0 { return userData } var b strings.Builder last := 0 suffix := ":" + strconv.Itoa(port) for _, m := range matches { end := m[1] if end < len(userData) && hostContinuation(rune(userData[end])) { continue } b.WriteString(userData[last:m[0]]) b.WriteString("https://") b.WriteString(userData[m[2]:m[3]]) b.WriteString(suffix) last = end } b.WriteString(userData[last:]) return b.String() } func HostPortsOf(userData string, tlsPort int) []int { if tlsPort <= 0 || tlsPort > 65535 { return nil } ports := []int{tlsPort} for _, m := range guestHostURL.FindAllStringSubmatchIndex(userData, -1) { if m[1] < len(userData) && hostContinuation(rune(userData[m[1]])) { continue } port := 80 if strings.EqualFold(userData[m[2]:m[3]], "https") { port = 443 } if m[4] >= 0 { port, _ = strconv.Atoi(userData[m[4]:m[5]]) } ports = append(ports, port) } return guestnet.HostPorts(ports) } func hostContinuation(r rune) bool { return r == ':' || r == '.' || r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') } func localhostHosts(userData string) []string { seen := map[string]bool{} var hosts []string for _, m := range guestAnyLocalhostURL.FindAllStringSubmatch(userData, -1) { host := strings.ToLower(m[1]) if seen[host] { continue } seen[host] = true hosts = append(hosts, host) } sort.Strings(hosts) return hosts } func checkAbsolute(p string) error { if p == "" || !strings.HasPrefix(p, "/") || strings.Contains(p, "..") { return fmt.Errorf("guestseed: %q is not an absolute path inside the guest", p) } if !safeInDoubleQuotes(p) { return fmt.Errorf("guestseed: %q carries a character a shell would read", p) } return nil } func checkURLLike(raw, what string) error { u, err := url.Parse(raw) if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { return fmt.Errorf("guestseed: %s %q is not an absolute http or https URL", what, raw) } if u.User != nil || u.Fragment != "" { return fmt.Errorf("guestseed: %s %q carries a user or fragment", what, raw) } if !safeInDoubleQuotes(raw) { return fmt.Errorf("guestseed: %s %q carries a character a shell would read", what, raw) } return nil } func safeInDoubleQuotes(s string) bool { return !strings.ContainsAny(s, "\"'`$\\\n\r") } func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }