package guestseed import ( "encoding/json" "fmt" "regexp" "runtime" "slices" "sort" "strings" "github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/cloudinit" "github.com/johnandersen777/socialweb-computer-kcp/internal/kit/selfexe" ) const ( GuestBinariesUnit = cloudinit.GuestBinariesUnit GuestBinariesScript = "/usr/local/bin/setup-guest-binaries.sh" GuestBinariesDir = "/usr/local/bin" GuestBinaryOpenTunnel = "opentunnel" GuestBinaryAgent = "socialweb-guest-agent" GuestBinaryWootty = "wootty" GuestBinaryAtirc = "atirc" GuestBinarySocialwebComputer = "socialweb-computer" GuestAgentUnit = "socialweb-guest-agent.service" GuestSecretsUnit = cloudinit.GuestSecretsUnit GuestAgentBinary = GuestBinariesDir + "/" + GuestBinaryAgent GuestAgentBidderPath = cloudinit.GuestAgentConfigDir + "/bidder.json" GuestDownloadsRoute = "/v1/downloads" guestBinaryAttempts = 60 guestBinaryGone = 2 ) var ( GuestArches = []string{"x86_64", "aarch64"} GuestTools = []string{GuestBinarySocialwebComputer} sha256HexRE = regexp.MustCompile(`^[0-9a-f]{64}$`) guestBinaryNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) unitNameRE = regexp.MustCompile(`^[A-Za-z0-9@._-]+\.service$`) ) type GuestBinaryPin struct { Name string Arch string Sha256 string } type GuestBinaryNeeds struct { OpenTunnel bool Agent bool Wootty bool Atirc bool Tools []string Secrets bool WebRTC bool Units []string } type WebRTCOptions struct { UDPPort int `json:"udpPort"` HostAddresses []string `json:"hostAddresses"` } type guestAgentBidder struct { Base string `json:"base"` Audience string `json:"audience"` AcceptPath string `json:"acceptPath"` WebRTC *WebRTCOptions `json:"webrtc,omitempty"` } func GuestDownloadName(name, arch string) string { return name + "-" + arch } type SelfDownload struct { Name string Path string selfexe.Pin } func SelfDownloadOf() (SelfDownload, error) { if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { return SelfDownload{}, fmt.Errorf("guestseed: the bidder runs %s/%s; it serves %s to linux/amd64 guests only", runtime.GOOS, runtime.GOARCH, GuestBinarySocialwebComputer) } pin, err := selfexe.Sum() if err != nil { return SelfDownload{}, err } return SelfDownload{Name: GuestDownloadName(GuestBinarySocialwebComputer, "x86_64"), Path: selfexe.Path, Pin: pin}, nil } func (n GuestBinaryNeeds) ToolsErr() error { for _, tool := range n.Tools { if !slices.Contains(GuestTools, tool) { return fmt.Errorf("guestseed: the guest asks for guest tool %q; a bidder serves only %s", tool, strings.Join(GuestTools, ", ")) } } return nil } func (n GuestBinaryNeeds) Any() bool { return n.OpenTunnel || n.Agent || n.Wootty || n.Atirc || len(n.Tools) > 0 } func (n GuestBinaryNeeds) Names() []string { var out []string if n.OpenTunnel { out = append(out, GuestBinaryOpenTunnel) } if n.Wootty { out = append(out, GuestBinaryWootty) } if n.Atirc { out = append(out, GuestBinaryAtirc) } if n.Agent { out = append(out, GuestBinaryAgent) } for _, tool := range n.Tools { if !slices.Contains(out, tool) { out = append(out, tool) } } return out } func GuestBinariesNeeded(userData string) GuestBinaryNeeds { const unitDir = "/etc/systemd/system/" out := GuestBinaryNeeds{} for _, p := range cloudinit.WriteFilePaths(userData) { if tool, ok := cloudinit.GuestToolOf(p); ok { out.Tools = append(out.Tools, tool) continue } switch { case p == cloudinit.GuestAgentTunnelsPath: out.Agent = true out.WebRTC = true case p == cloudinit.GuestAgentSecretsPath: out.Agent = true out.Secrets = true case p == cloudinit.GuestAgentAgentPath: out.Agent = true out.Atirc = true out.Units = append(out.Units, cloudinit.AgentUnit) case p == cloudinit.GuestAgentAssociationPath: out.Agent = true out.Units = append(out.Units, cloudinit.GuestRequesterUnit, cloudinit.GuestRequesterReleaseUnit) case strings.HasPrefix(p, unitDir+"guest-tunnel-") && !strings.HasPrefix(p, unitDir+"guest-tunnel-report-") && strings.HasSuffix(p, ".service"): out.OpenTunnel = true out.Units = append(out.Units, strings.TrimPrefix(p, unitDir)) case strings.HasPrefix(p, unitDir+cloudinit.ShellUnitPrefix) && strings.HasSuffix(p, ".service"): out.Wootty = true out.Units = append(out.Units, strings.TrimPrefix(p, unitDir)) } } if out.Agent { out.Units = append(out.Units, GuestAgentUnit) } if out.Secrets { out.Units = append(out.Units, GuestSecretsUnit) } sort.Strings(out.Units) sort.Strings(out.Tools) out.Tools = slices.Compact(out.Tools) return out } func GuestBinaryPins(downloads map[string]string, names []string) ([]GuestBinaryPin, error) { var out []GuestBinaryPin for _, name := range names { found := false for _, arch := range GuestArches { sum, ok := downloads[GuestDownloadName(name, arch)] if !ok { continue } out = append(out, GuestBinaryPin{Name: name, Arch: arch, Sha256: sum}) found = true } if !found { return nil, fmt.Errorf("guestseed: the bidder serves no %s download for any of %s", name, strings.Join(GuestArches, ", ")) } } return out, nil } func GuestBinariesModule(base string, pins []GuestBinaryPin, units []string) (cloudinit.UserDataModule, error) { base = strings.TrimRight(strings.TrimSpace(base), "/") if err := checkURLLike(base, "guest downloads base"); err != nil { return nil, err } if len(pins) == 0 { return nil, fmt.Errorf("guestseed: the guest binaries module names no binary") } var wants strings.Builder for _, pin := range pins { if !guestBinaryNameRE.MatchString(pin.Name) { return nil, fmt.Errorf("guestseed: guest binary name %q is not a file name", pin.Name) } if !slices.Contains(GuestArches, pin.Arch) { return nil, fmt.Errorf("guestseed: guest binary %s arch %q is not an arch", pin.Name, pin.Arch) } if !sha256HexRE.MatchString(pin.Sha256) { return nil, fmt.Errorf("guestseed: guest binary %s-%s sha256 %q is not 64 lowercase hex characters", pin.Name, pin.Arch, pin.Sha256) } fmt.Fprintf(&wants, "want %s %s %s\n", pin.Name, pin.Arch, pin.Sha256) } var needs []string for _, pin := range pins { if !slices.Contains(needs, pin.Name) { needs = append(needs, pin.Name) fmt.Fprintf(&wants, "need %s\n", pin.Name) } } for _, unit := range units { if !unitNameRE.MatchString(unit) { return nil, fmt.Errorf("guestseed: %q is not a systemd service name", unit) } } script := `#!/usr/bin/env bash set -uo pipefail BASE="` + base + `" case "$(uname -m)" in x86_64|amd64) ARCH=x86_64 ;; aarch64|arm64) ARCH=aarch64 ;; *) echo "setup-guest-binaries: the bidder serves no binaries for $(uname -m); giving up for good"; exit ` + fmt.Sprint(guestBinaryGone) + ` ;; esac FAIL=0 GONE=0 HAVE=" " fetch() { local name="$1" sum="$2" dest="` + GuestBinariesDir + `/$1" tmp got code rc all404=1 if [ -x "${dest}" ] && [ "$(sha256sum "${dest}" | cut -d' ' -f1)" = "${sum}" ]; then echo "setup-guest-binaries: installed ${name} sha256 ${sum}" return 0 fi tmp="$(mktemp "` + GuestBinariesDir + `/.${name}.XXXXXX")" || return 1 for attempt in $(seq 1 ` + fmt.Sprint(guestBinaryAttempts) + `); do code="$(curl -q -fsS --noproxy '*' --connect-timeout 10 -m 300 -o "${tmp}" -w '%{http_code}' "${BASE}` + GuestDownloadsRoute + `/${sum}/${name}-${ARCH}")" rc=$? if [ "${rc}" = 0 ]; then all404=0 got="$(sha256sum "${tmp}" | cut -d' ' -f1)" if [ "${got}" = "${sum}" ]; then chmod 0755 "${tmp}" && mv -f "${tmp}" "${dest}" echo "setup-guest-binaries: installed ${name} sha256 ${got}" return 0 fi echo "setup-guest-binaries: ${name} from ${BASE} has sha256 ${got}, not ${sum} (attempt ${attempt})" else [ "${code}" = 404 ] || all404=0 echo "setup-guest-binaries: fetching ${name} from ${BASE} failed: curl exit ${rc}, http ${code:-none} (attempt ${attempt})" fi sleep 3 done rm -f "${tmp}" if [ "${all404}" = 1 ]; then echo "setup-guest-binaries: the bidder no longer serves ${name} sha256 ${sum} (HTTP 404 on every attempt: its download set changed since this guest was provisioned, e.g. a bidder upgrade); giving up for good" return ` + fmt.Sprint(guestBinaryGone) + ` fi echo "setup-guest-binaries: gave up on ${name}; the unit retries" return 1 } want() { [ "$2" = "${ARCH}" ] || return 0 HAVE="${HAVE}$1 " fetch "$1" "$3" case "$?" in 0) ;; ` + fmt.Sprint(guestBinaryGone) + `) GONE=1 ;; *) FAIL=1 ;; esac } need() { case "${HAVE}" in *" $1 "*) return 0 ;; esac echo "setup-guest-binaries: the bidder serves no ${1} for ${ARCH}; giving up on it for good" GONE=1 } ` + wants.String() + `[ "${FAIL}" = 0 ] || exit 1 [ "${GONE}" = 0 ] || exit ` + fmt.Sprint(guestBinaryGone) + ` exit 0 ` unit := []string{ "[Unit]", "Description=Install the guest binaries the bidder serves", "After=network-online.target", "Wants=network-online.target", } if len(units) > 0 { unit = append(unit, "Before="+strings.Join(units, " ")) } unit = append(unit, "StartLimitIntervalSec=0", "", "[Service]", "Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=0", "ExecStart="+GuestBinariesScript, "Restart=on-failure", "RestartSec=10", "RestartPreventExitStatus="+fmt.Sprint(guestBinaryGone), "StandardOutput=journal+console", "StandardError=journal+console", "", "[Install]", "WantedBy=multi-user.target", "", ) content := strings.Join(unit, "\n") return func(cloudinit.Context) (cloudinit.UserDataPatch, error) { return cloudinit.UserDataPatch{ WriteFiles: []cloudinit.WriteFileEntry{ {Path: GuestBinariesScript, Owner: "root:root", Permissions: "0700", Content: script}, {Path: "/etc/systemd/system/" + GuestBinariesUnit, Owner: "root:root", Permissions: "0644", Content: content}, }, RuncmdPrepend: []any{ "systemctl daemon-reload", "systemctl enable --no-block " + GuestBinariesUnit, "systemctl start --no-block " + GuestBinariesUnit, }, }, nil }, nil } func GuestAgentModule(base, audience, acceptPath string, secrets bool, webrtc *WebRTCOptions) (cloudinit.UserDataModule, error) { base = strings.TrimRight(strings.TrimSpace(base), "/") if err := checkURLLike(base, "guest agent base"); err != nil { return nil, err } if err := checkAbsolute(acceptPath); err != nil { return nil, err } if webrtc != nil && (webrtc.UDPPort < 1 || webrtc.UDPPort > 65535 || len(webrtc.HostAddresses) == 0) { return nil, fmt.Errorf("guestseed: the guest agent webrtc port %d with %d host addresses is not a forwarded LAN port", webrtc.UDPPort, len(webrtc.HostAddresses)) } raw, err := json.Marshal(guestAgentBidder{Base: base, Audience: audience, AcceptPath: acceptPath, WebRTC: webrtc}) if err != nil { return nil, fmt.Errorf("guestseed: encoding %s: %w", GuestAgentBidderPath, err) } unit := strings.Join([]string{ "[Unit]", "Description=socialweb guest agent: the webrtc tunnels, the sealed secrets fallback, the agent start and the requester association of the guest", "After=network-online.target " + GuestBinariesUnit, "Wants=network-online.target", "Requires=" + GuestBinariesUnit, "ConditionPathExists=|" + cloudinit.GuestAgentTunnelsPath, "ConditionPathExists=|" + cloudinit.GuestAgentSecretsPath, "ConditionPathExists=|" + cloudinit.GuestAgentAgentPath, "ConditionPathExists=|" + cloudinit.GuestAgentAssociationPath, "StartLimitIntervalSec=0", "", "[Service]", "Type=simple", "ExecStart=" + GuestAgentBinary, "Restart=always", "RestartSec=2", "StandardOutput=journal+console", "StandardError=journal+console", "", "[Install]", "WantedBy=multi-user.target", "", }, "\n") files := []cloudinit.WriteFileEntry{ {Path: GuestAgentBidderPath, Owner: "root:root", Permissions: "0644", Content: string(raw) + "\n"}, {Path: "/etc/systemd/system/" + GuestAgentUnit, Owner: "root:root", Permissions: "0644", Content: unit}, } runcmd := []any{ "systemctl daemon-reload", "systemctl enable --no-block " + GuestAgentUnit, "systemctl start --no-block " + GuestAgentUnit, } if secrets { files = append(files, cloudinit.WriteFileEntry{Path: "/etc/systemd/system/" + GuestSecretsUnit, Owner: "root:root", Permissions: "0644", Content: guestSecretsUnit}) runcmd = append(runcmd, "systemctl enable --no-block "+GuestSecretsUnit, "systemctl start --no-block "+GuestSecretsUnit) } return func(cloudinit.Context) (cloudinit.UserDataPatch, error) { return cloudinit.UserDataPatch{WriteFiles: files, RuncmdPrepend: runcmd}, nil }, nil } var guestSecretsUnit = strings.Join([]string{ "[Unit]", "Description=socialweb guest secrets: wait until the bundle is written, by setup-secrets.service (getSecrets) or by the guest agent (the sealed fallback)", "After=" + GuestBinariesUnit, "Requires=" + GuestBinariesUnit, "ConditionPathExists=" + cloudinit.GuestAgentSecretsPath, "", "[Service]", "Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=infinity", "ExecStart=" + GuestAgentBinary + " wait-secrets", "StandardOutput=journal+console", "StandardError=journal+console", "", "[Install]", "WantedBy=multi-user.target", "", }, "\n")