~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
541 lines · 13.1 KB Raw History
package cloudinit

import (
	"cmp"
	"encoding/json"
	"fmt"
	"maps"
	"reflect"
	"regexp"
	"slices"
	"strings"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/strs"
	"gopkg.in/yaml.v3"
)

type Context struct {
	VMName            string
	SSHAuthorizedKey  string
	TargetPort        int
	HostAliases       []string
	SecretsURL        string
	SecretsRoute      string
	SecretsAud        string
	SecretsAcceptPath string
	DNSGateway        string
	DNSFallbacks      []string
	DumbpipeVersion   string
	Tunnels           *TunnelsOptions
}

type WriteFileEntry struct {
	Path        string
	Owner       string
	Permissions string
	Content     string
	Extra       map[string]any
}

func (w WriteFileEntry) Map() map[string]any {
	out := make(map[string]any, len(w.Extra)+4)
	maps.Copy(out, w.Extra)
	out["path"] = w.Path
	if w.Owner != "" {
		out["owner"] = w.Owner
	}
	if w.Permissions != "" {
		out["permissions"] = w.Permissions
	}
	out["content"] = w.Content
	return out
}

type UserDataPatch struct {
	Apt            map[string]any
	Packages       []string
	PackageUpdate  *bool
	Users          []any
	WriteFiles     []WriteFileEntry
	Runcmd         []any
	RuncmdPrepend  []any
	Bootcmd        []any
	CACertsTrusted []string
	DisableRoot    *bool
	SSHPwAuth      *bool
}

type UserDataModule func(ctx Context) (UserDataPatch, error)

type LegacyTransportError struct {
	Transport string
}

func (e *LegacyTransportError) Error() string {
	return fmt.Sprintf("guest transport %q was removed (legacy-transport): %s is the guest transport, and an https tunnel is a ComputeContract's spec.tunnels", e.Transport, GuestTransportModule)
}

type RemovedTransportFlagError struct {
	Flag string
}

func (e *RemovedTransportFlagError) Error() string {
	return fmt.Sprintf("--%s was removed (legacy-transport): %s is the guest transport, and an https tunnel is a ComputeContract's spec.tunnels", e.Flag, GuestTransportModule)
}

type UnknownModuleError struct {
	ID string
}

func (e *UnknownModuleError) Error() string {
	return "unknown user-data module: " + e.ID
}

const (
	ResolvConfPath         = "/etc/resolv.conf"
	DefaultDNSGateway      = "172.30.0.1"
	GuestTransportModule   = "iroh"
	DumbpipeVersionDefault = "0.39.0"
	GuestLoginScriptPath   = "/usr/local/bin/guest-login.sh"
	GuestLoginUnit         = "guest-login.service"
	GuestLoginTokenPath    = "/root/secrets/openbao/token"
	GuestLoginNamespace    = "prove-sshd"
	GuestLoginDefaultMount = "sshsig"
	PoweroffEndsVMModule   = "poweroff-ends-vm"
	PoweroffEndsVMPath     = "/usr/lib/systemd/system-shutdown/socialweb-poweroff-ends-vm.shutdown"
)

var DefaultDNSFallbacks = []string{"1.1.1.1", "9.9.9.9"}

var RemovedTransportModules = []string{"tunnel", "fedproxy-ssh", "fedproxy-web"}

func GetUserDataModules(ids []any) ([]UserDataModule, error) {
	out := make([]UserDataModule, 0, len(ids))
	for _, idOrFn := range ids {
		switch v := idOrFn.(type) {
		case UserDataModule:
			out = append(out, v)
		case string:
			removed := slices.Contains(RemovedTransportModules, v)
			if removed {
				return nil, &LegacyTransportError{Transport: v}
			}
			module, ok := registry[v]
			if !ok {
				return nil, &UnknownModuleError{ID: v}
			}
			out = append(out, module)
		default:
			return nil, fmt.Errorf("unknown user-data module: %v", v)
		}
	}
	return out, nil
}

func ListUserDataModules() []string {
	out := make([]string, 0, len(registry))
	for id := range registry {
		out = append(out, id)
	}
	return out
}

func RemovedTransportFlagsIn(options map[string]any, flags []string) []error {
	out := []error{}
	for _, flag := range flags {
		camel := regexp.MustCompile(`-([a-z])`).ReplaceAllStringFunc(flag, func(s string) string {
			return strings.ToUpper(s[1:])
		})
		if _, ok := options[camel]; ok {
			out = append(out, &RemovedTransportFlagError{Flag: flag})
		}
	}
	return out
}

func RenderResolver(nameservers []string) string {
	unique := []string{}
	seen := map[string]bool{}
	for _, n := range nameservers {
		if n == "" || seen[n] {
			continue
		}
		seen[n] = true
		unique = append(unique, n)
	}
	lines := make([]string, 0, len(unique))
	for _, n := range unique {
		lines = append(lines, "nameserver "+n)
	}
	return strings.Join(lines, "\n") + "\noptions timeout:2 attempts:3\n"
}

func asArray(v any) []any {
	if arr, ok := v.([]any); ok {
		return arr
	}
	return []any{}
}

func asObj(v any) map[string]any {
	if m, ok := v.(map[string]any); ok {
		return m
	}
	return map[string]any{}
}

func dedupeByKey(arr []any, key func(map[string]any) string) []any {
	indexOf := map[string]int{}
	out := []any{}
	for _, item := range arr {
		m, ok := item.(map[string]any)
		if !ok {
			out = append(out, item)
			continue
		}
		k := key(m)
		if k != "" {
			if idx, ok := indexOf[k]; ok {
				out[idx] = m
				continue
			}
			indexOf[k] = len(out)
		}
		out = append(out, m)
	}
	return out
}

func dedupeStrings(values []string) []string {
	seen := map[string]bool{}
	out := []string{}
	for _, v := range values {
		if seen[v] {
			continue
		}
		seen[v] = true
		out = append(out, v)
	}
	return out
}

func applyPatch(base map[string]any, p UserDataPatch) map[string]any {
	out := make(map[string]any, len(base)+8)
	maps.Copy(out, base)
	if p.Apt != nil {
		apt := map[string]any{}
		maps.Copy(apt, asObj(out["apt"]))
		maps.Copy(apt, p.Apt)
		out["apt"] = apt
	}
	if p.Packages != nil {
		existing := []any{}
		for _, v := range asArray(out["packages"]) {
			existing = append(existing, v)
		}
		seen := map[string]bool{}
		pkgs := []any{}
		for _, v := range existing {
			if s, ok := v.(string); ok {
				if seen[s] {
					continue
				}
				seen[s] = true
			}
			pkgs = append(pkgs, v)
		}
		for _, s := range p.Packages {
			if seen[s] {
				continue
			}
			seen[s] = true
			pkgs = append(pkgs, s)
		}
		out["packages"] = pkgs
	}
	if p.PackageUpdate != nil {
		out["package_update"] = *p.PackageUpdate
	}
	if p.Users != nil {
		users := append([]any{}, asArray(out["users"])...)
		users = append(users, p.Users...)
		out["users"] = dedupeByKey(users, func(m map[string]any) string {
			s, _ := m["name"].(string)
			return s
		})
	}
	if p.WriteFiles != nil {
		files := append([]any{}, asArray(out["write_files"])...)
		for _, entry := range p.WriteFiles {
			files = append(files, entry.Map())
		}
		out["write_files"] = dedupeByKey(files, func(m map[string]any) string {
			s, _ := m["path"].(string)
			return s
		})
	}
	if p.Bootcmd != nil {
		out["bootcmd"] = dedupeAny(append(append([]any{}, asArray(out["bootcmd"])...), p.Bootcmd...))
	}
	if p.CACertsTrusted != nil {
		ca := map[string]any{}
		maps.Copy(ca, asObj(out["ca_certs"]))
		existing := asArray(ca["trusted"])
		merged := append(append([]any{}, existing...), strs.AnySlice(p.CACertsTrusted)...)
		ca["trusted"] = dedupeAny(merged)
		out["ca_certs"] = ca
	}
	if p.Runcmd != nil || p.RuncmdPrepend != nil {
		runcmd := append([]any{}, p.RuncmdPrepend...)
		runcmd = append(runcmd, asArray(out["runcmd"])...)
		runcmd = append(runcmd, p.Runcmd...)
		out["runcmd"] = runcmd
	}
	if p.DisableRoot != nil {
		out["disable_root"] = *p.DisableRoot
	}
	if p.SSHPwAuth != nil {
		out["ssh_pwauth"] = *p.SSHPwAuth
	}
	return out
}

func dedupeAny(values []any) []any {
	out := []any{}
	for _, v := range values {
		duplicate := false
		for _, seen := range out {
			if reflect.DeepEqual(seen, v) {
				duplicate = true
				break
			}
		}
		if !duplicate {
			out = append(out, v)
		}
	}
	return out
}

func marshalJSONIndent(value any) (string, error) {
	var buf strings.Builder
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	enc.SetIndent("", "  ")
	if err := enc.Encode(value); err != nil {
		return "", err
	}
	return strings.TrimSuffix(buf.String(), "\n"), nil
}

var packageNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]*$`)

func PackagesGuardCommand(packages []string) (string, error) {
	names := dedupeStrings(packages)
	for _, name := range names {
		if !packageNameRE.MatchString(name) {
			return "", fmt.Errorf("the composed user_data asks for the package %q, which is not a package name this will put in a shell command", name)
		}
	}
	quoted := make([]string, 0, len(names))
	for _, name := range names {
		quoted = append(quoted, `"`+name+`"`)
	}
	list := strings.Join(quoted, " ")
	return `sh -c 'missing=""; for p in ` + list + `; do dpkg -s "$p" >/dev/null 2>&1 || ` +
		`missing="$missing $p"; done; [ -z "$missing" ] || { apt-get update && ` +
		`DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ` +
		`-o Dpkg::Options::=--force-confold -o Dpkg::Options::=--force-confdef $missing; }'`, nil
}

func guardPackages(base map[string]any) (map[string]any, error) {
	requested := []string{}
	for _, v := range asArray(base["packages"]) {
		if s, ok := v.(string); ok {
			requested = append(requested, s)
		}
	}
	if len(requested) == 0 {
		return base, nil
	}
	command, err := PackagesGuardCommand(requested)
	if err != nil {
		return nil, err
	}
	out := make(map[string]any, len(base))
	maps.Copy(out, base)
	delete(out, "packages")
	out["package_update"] = false
	out["runcmd"] = append([]any{command}, asArray(out["runcmd"])...)
	return out, nil
}

func parseBase(base string) map[string]any {
	if base == "" {
		return map[string]any{}
	}
	trimmed := regexp.MustCompile(`(?i)^#cloud-config\s*`).ReplaceAllString(base, "")
	var parsed map[string]any
	if err := yaml.Unmarshal([]byte(trimmed), &parsed); err != nil || parsed == nil {
		return map[string]any{}
	}
	return parsed
}

func WriteFilePaths(userData string) []string {
	var out []string
	for _, v := range asArray(parseBase(userData)["write_files"]) {
		if p, ok := asObj(v)["path"].(string); ok && p != "" {
			out = append(out, p)
		}
	}
	return out
}

var nameserverRE = regexp.MustCompile(`^\s*nameserver\s+(\S+)`)

func nameserversIn(content string) []string {
	out := []string{}
	for line := range strings.SplitSeq(content, "\n") {
		if m := nameserverRE.FindStringSubmatch(line); m != nil {
			out = append(out, m[1])
		}
	}
	return out
}

func resolvWriteCommand(resolver string) string {
	return "printf '%s' '" + strings.ReplaceAll(resolver, "'", `'\''`) + "' > " + ResolvConfPath
}

func trailingOnce(arr []any, item string) []any {
	out := []any{}
	for _, v := range arr {
		if s, ok := v.(string); ok && s == item {
			continue
		}
		out = append(out, v)
	}
	return append(out, item)
}

func assertResolver(base map[string]any, gateway string, fallbacks []string) map[string]any {
	out := make(map[string]any, len(base))
	maps.Copy(out, base)
	files := append([]any{}, asArray(out["write_files"])...)
	idx := -1
	for i, raw := range files {
		m, ok := raw.(map[string]any)
		if !ok {
			continue
		}
		if p, _ := m["path"].(string); p == ResolvConfPath {
			idx = i
			break
		}
	}
	existing := ""
	if idx >= 0 {
		if s, ok := files[idx].(map[string]any)["content"].(string); ok {
			existing = s
		}
	}
	extras := []string{}
	for _, n := range nameserversIn(existing) {
		if n == gateway {
			continue
		}
		skip := slices.Contains(fallbacks, n)
		if !skip {
			extras = append(extras, n)
		}
	}
	resolver := RenderResolver(append([]string{gateway}, append(fallbacks, extras...)...))
	entry := map[string]any{
		"path":        ResolvConfPath,
		"owner":       "root:root",
		"permissions": "0644",
		"content":     resolver,
	}
	if idx >= 0 {
		merged := map[string]any{}
		maps.Copy(merged, files[idx].(map[string]any))
		maps.Copy(merged, entry)
		files[idx] = merged
	} else {
		files = append(files, entry)
	}
	out["write_files"] = files
	command := resolvWriteCommand(resolver)
	out["bootcmd"] = trailingOnce(asArray(out["bootcmd"]), command)
	out["runcmd"] = trailingOnce(asArray(out["runcmd"]), command)
	return out
}

type BuildOptions struct {
	Ctx          Context
	Base         string
	Modules      []any
	Overrides    *UserDataPatch
	DNSGateway   string
	DNSFallbacks []string
}

func BuildUserData(opts BuildOptions) (string, error) {
	ctx := opts.Ctx
	gateway := opts.DNSGateway
	gateway = cmp.Or(gateway, ctx.DNSGateway)
	gateway = cmp.Or(gateway, DefaultDNSGateway)
	fallbacks := opts.DNSFallbacks
	if fallbacks == nil {
		fallbacks = ctx.DNSFallbacks
	}
	if fallbacks == nil {
		fallbacks = DefaultDNSFallbacks
	}
	modules, err := GetUserDataModules(opts.Modules)
	if err != nil {
		return "", err
	}
	obj := parseBase(opts.Base)
	for _, module := range modules {
		patch, err := module(ctx)
		if err != nil {
			return "", err
		}
		obj = applyPatch(obj, patch)
	}
	if opts.Overrides != nil {
		obj = applyPatch(obj, *opts.Overrides)
	}
	obj, err = guardPackages(obj)
	if err != nil {
		return "", err
	}
	obj = assertResolver(obj, gateway, fallbacks)
	body, err := yaml.Marshal(obj)
	if err != nil {
		return "", err
	}
	return "#cloud-config\n" + string(body), nil
}

func AcceptBundleModule(acceptPathVM string, bundle map[string]any) UserDataModule {
	parts := strings.Split(acceptPathVM, "/")
	parent := strings.Join(parts[:len(parts)-1], "/")
	return func(Context) (UserDataPatch, error) {
		content, err := marshalJSONIndent(bundle)
		if err != nil {
			return UserDataPatch{}, err
		}
		return UserDataPatch{
			WriteFiles: []WriteFileEntry{{
				Path:        acceptPathVM,
				Owner:       "root:root",
				Permissions: "0600",
				Content:     content,
			}},
			RuncmdPrepend: []any{[]any{"sh", "-c", "install -d -m 0700 -o root -g root " + parent}},
		}, nil
	}
}