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

import (
	"cmp"
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"strconv"
	"strings"
)

const (
	TunnelsModuleID = "tunnels"

	OnNetworkScriptPath = "/usr/local/bin/send-onnetwork.sh"

	DefaultTunnelServer = "https://opentunnel.xyz"

	GuestTunnelScriptPath = "/usr/local/bin/guest-tunnel.sh"

	GuestTunnelReportPath = "/usr/local/bin/guest-tunnel-report.sh"

	GuestTunnelRunDir = "/run/guest-tunnels"

	GuestTunnelStateDir = "/var/lib/guest-tunnels"

	OpenTunnelBinary = "/usr/local/bin/opentunnel"

	GuestBinariesUnit = "setup-guest-binaries.service"

	GuestAgentConfigDir = "/etc/socialweb-guest-agent"

	GuestAgentTunnelsPath = GuestAgentConfigDir + "/tunnels.json"

	TunnelTransportOpenTunnel = "opentunnel"

	TunnelTransportWebRTC = "webrtc"

	DefaultShellEnvFile = "/etc/wootty/wootty.env"

	ShellTokenMinLength = 32

	WoottyRunPath = "/usr/local/bin/wootty-run.sh"

	WoottyBinary = "/usr/local/bin/wootty"

	ShellUnitPrefix = "wootty-"
)

var (
	tunnelTagRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`)

	tunnelServerRE = regexp.MustCompile(`^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?$`)

	tunnelPathRE = regexp.MustCompile(`^/[A-Za-z0-9._/-]+$`)
)

type Tunnel struct {
	Tag string

	Port int

	Server string

	Transport string

	ShellEnvFile string
}

type TunnelsOptions struct {
	Tunnels []Tunnel
}

type guestAgentTunnel struct {
	Tag string `json:"tag"`

	Port int `json:"port"`
}

type guestAgentTunnels struct {
	Tunnels []guestAgentTunnel `json:"tunnels"`
}

func TunnelURLPath(tag string) string {
	return GuestTunnelRunDir + "/" + tag + ".url"
}

func TunnelUnit(tag string) string {
	return "guest-tunnel-" + tag + ".service"
}

func TunnelReportUnit(tag string) string {
	return "guest-tunnel-report-" + tag + ".service"
}

func ShellUnit(tag string) string {
	return ShellUnitPrefix + tag + ".service"
}

func (t Tunnel) server() string {
	return strings.TrimRight(cmp.Or(strings.TrimSpace(t.Server), DefaultTunnelServer), "/")
}

func (t Tunnel) transport() string {
	return cmp.Or(strings.TrimSpace(t.Transport), TunnelTransportOpenTunnel)
}

func (opts TunnelsOptions) Validate() error {
	if len(opts.Tunnels) == 0 {
		return errors.New("cloudinit tunnels: no tunnel is asked for")
	}
	tags := map[string]bool{}
	shellPorts := map[int]string{}
	for _, t := range opts.Tunnels {
		if !tunnelTagRE.MatchString(t.Tag) {
			return fmt.Errorf("cloudinit tunnels: tag %q is not a DNS label of at most 32 characters", t.Tag)
		}
		if tags[t.Tag] {
			return fmt.Errorf("cloudinit tunnels: tag %q is asked for twice", t.Tag)
		}
		if t.Tag == GuestSecretsTag {
			return fmt.Errorf("cloudinit tunnels: tag %q names the guest's secrets key, not a tunnel", t.Tag)
		}
		tags[t.Tag] = true
		if t.Port < 1 || t.Port > 65535 {
			return fmt.Errorf("cloudinit tunnels: tunnel %s port %d is not a port", t.Tag, t.Port)
		}
		switch t.transport() {
		case TunnelTransportOpenTunnel:
			if !tunnelServerRE.MatchString(t.server()) {
				return fmt.Errorf("cloudinit tunnels: tunnel %s server %q is not an http(s) origin", t.Tag, t.Server)
			}
		case TunnelTransportWebRTC:
			if strings.TrimSpace(t.Server) != "" {
				return fmt.Errorf("cloudinit tunnels: tunnel %s names a server; only an opentunnel tunnel has one", t.Tag)
			}
		default:
			return fmt.Errorf("cloudinit tunnels: tunnel %s transport %q is not opentunnel or webrtc", t.Tag, t.Transport)
		}
		if t.ShellEnvFile == "" {
			continue
		}
		if t.transport() != TunnelTransportOpenTunnel {
			return fmt.Errorf("cloudinit tunnels: tunnel %s asks for a wootty shell on transport %s; a shell is served over opentunnel only", t.Tag, t.transport())
		}
		if !tunnelPathRE.MatchString(t.ShellEnvFile) || strings.Contains(t.ShellEnvFile, "..") {
			return fmt.Errorf("cloudinit tunnels: tunnel %s envFile %q is not an absolute path", t.Tag, t.ShellEnvFile)
		}
		if other, ok := shellPorts[t.Port]; ok {
			return fmt.Errorf("cloudinit tunnels: shells %s and %s both ask for port %d", other, t.Tag, t.Port)
		}
		shellPorts[t.Port] = t.Tag
	}
	return nil
}

func TunnelsModule(opts TunnelsOptions) UserDataModule {
	return func(Context) (UserDataPatch, error) {
		return tunnelsPatch(opts)
	}
}

var tunnelsModule UserDataModule = func(ctx Context) (UserDataPatch, error) {
	if ctx.Tunnels == nil {
		return UserDataPatch{}, errors.New("cloudinit tunnels: the tunnels module needs Context.Tunnels")
	}
	return tunnelsPatch(*ctx.Tunnels)
}

func tunnelsPatch(opts TunnelsOptions) (UserDataPatch, error) {
	if err := opts.Validate(); err != nil {
		return UserDataPatch{}, err
	}
	patch := UserDataPatch{}
	webrtc := guestAgentTunnels{Tunnels: []guestAgentTunnel{}}
	units := []string{}
	shells := false
	for _, t := range opts.Tunnels {
		if t.transport() == TunnelTransportWebRTC {
			webrtc.Tunnels = append(webrtc.Tunnels, guestAgentTunnel{Tag: t.Tag, Port: t.Port})
			continue
		}
		port := strconv.Itoa(t.Port)
		patch.WriteFiles = append(patch.WriteFiles,
			WriteFileEntry{Path: "/etc/systemd/system/" + TunnelUnit(t.Tag), Owner: "root:root", Permissions: "0644", Content: unitFile(
				[]string{
					"Description=opentunnel route of guest tunnel " + t.Tag + " to 127.0.0.1:" + port,
					"After=network-online.target " + GuestBinariesUnit,
					"Wants=network-online.target " + GuestBinariesUnit,
					"StartLimitIntervalSec=0",
				},
				[]string{
					"Type=simple",
					"ExecStart=" + GuestTunnelScriptPath + " " + t.Tag + " " + port + " " + t.server(),
					"Restart=always",
					"RestartSec=10",
					"StandardOutput=journal+console",
					"StandardError=journal+console",
				})},
			WriteFileEntry{Path: "/etc/systemd/system/" + TunnelReportUnit(t.Tag), Owner: "root:root", Permissions: "0644", Content: unitFile(
				[]string{
					"Description=Report the URL of guest tunnel " + t.Tag + " to the market",
					"After=network-online.target " + GuestLoginUnit,
				},
				[]string{
					"Type=oneshot",
					"TimeoutStartSec=600",
					"ExecStart=" + GuestTunnelReportPath + " " + t.Tag,
					"StandardOutput=journal+console",
					"StandardError=journal+console",
				})},
		)
		units = append(units, TunnelUnit(t.Tag))
		if t.ShellEnvFile == "" {
			continue
		}
		shells = true
		patch.WriteFiles = append(patch.WriteFiles, WriteFileEntry{Path: "/etc/systemd/system/" + ShellUnit(t.Tag), Owner: "root:root", Permissions: "0644", Content: unitFile(
			[]string{
				"Description=wootty browser shell of guest tunnel " + t.Tag + " on 127.0.0.1:" + port,
				"After=network-online.target " + GuestBinariesUnit + " " + strings.Join(SecretsReadyUnits, " "),
				"Wants=network-online.target " + GuestBinariesUnit + " " + GuestSecretsUnit,
				"StartLimitIntervalSec=0",
			},
			[]string{
				"Type=simple",
				"EnvironmentFile=-" + t.ShellEnvFile,
				"Environment=HOME=/root TERM=xterm-256color",
				"WorkingDirectory=/root",
				"ExecStart=" + WoottyRunPath + " " + port,
				"Restart=always",
				"RestartSec=5",
				"StandardOutput=journal",
				"StandardError=journal",
			})})
		units = append(units, ShellUnit(t.Tag))
	}
	if shells {
		patch.WriteFiles = append(patch.WriteFiles, WriteFileEntry{Path: WoottyRunPath, Owner: "root:root", Permissions: "0700", Content: woottyRunScript})
	}
	if len(webrtc.Tunnels) > 0 {
		raw, err := json.Marshal(webrtc)
		if err != nil {
			return UserDataPatch{}, fmt.Errorf("cloudinit tunnels: encoding %s: %w", GuestAgentTunnelsPath, err)
		}
		patch.WriteFiles = append(patch.WriteFiles,
			WriteFileEntry{Path: GuestAgentTunnelsPath, Owner: "root:root", Permissions: "0644", Content: string(raw) + "\n"})
	}
	if len(units) == 0 {
		return patch, nil
	}
	patch.Packages = []string{"jq", "curl", "ca-certificates"}
	patch.WriteFiles = append(patch.WriteFiles,
		WriteFileEntry{Path: GuestTunnelScriptPath, Owner: "root:root", Permissions: "0700", Content: guestTunnelScript},
		WriteFileEntry{Path: GuestTunnelReportPath, Owner: "root:root", Permissions: "0700", Content: guestTunnelReportScript},
	)
	list := strings.Join(units, " ")
	patch.RuncmdPrepend = []any{
		"systemctl daemon-reload",
		"systemctl start --no-block " + list,
	}
	patch.Runcmd = []any{
		"systemctl enable " + list,
		"systemctl start --no-block " + list,
	}
	return patch, nil
}

func unitFile(unit, service []string) string {
	lines := append([]string{"[Unit]"}, unit...)
	lines = append(lines, "", "[Service]")
	lines = append(lines, service...)
	lines = append(lines, "", "[Install]", "WantedBy=multi-user.target", "")
	return strings.Join(lines, "\n")
}

const guestTunnelScript = `#!/usr/bin/env bash
set -euo pipefail
TAG="$1"
PORT="$2"
API="$3"
if [ ! -x "` + OpenTunnelBinary + `" ]; then
  echo "guest-tunnel: ` + OpenTunnelBinary + ` is not installed yet (` + GuestBinariesUnit + `); ${TAG} retries" >&2
  exit 1
fi
BASE="` + GuestTunnelStateDir + `/${TAG}"
export HOME=/root
export XDG_CONFIG_HOME="${BASE}/config"
export XDG_DATA_HOME="${BASE}/data"
export XDG_STATE_HOME="${BASE}/state"
export XDG_RUNTIME_DIR="` + GuestTunnelRunDir + `/${TAG}"
install -d -m 0700 -o root -g root "` + GuestTunnelStateDir + `" "${BASE}" "${XDG_CONFIG_HOME}" "${XDG_CONFIG_HOME}/opentunnel" \
  "${XDG_DATA_HOME}" "${XDG_STATE_HOME}" "` + GuestTunnelRunDir + `" "${XDG_RUNTIME_DIR}"
CONF="${XDG_CONFIG_HOME}/opentunnel/${TAG}.toml"
if [ ! -s "${CONF}" ]; then
  ROUTE="$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n')"
  ( umask 077; printf '[routes]\n%s = "127.0.0.1:%s"\n' "${ROUTE}" "${PORT}" > "${CONF}.tmp" )
  mv -f "${CONF}.tmp" "${CONF}"
fi
ROUTE="$(sed -n 's/^\([0-9a-f]\{16\}\) = .*/\1/p' "${CONF}" | head -n 1)"
[ -n "${ROUTE}" ] || { echo "guest-tunnel: ${CONF} names no route" >&2; exit 1; }
OT=(` + OpenTunnelBinary + ` --profile "${TAG}" --api "${API}")
IDENTITY="${XDG_DATA_HOME}/opentunnel/${TAG}/tunnel.json"
if [ ! -s "${IDENTITY}" ]; then
  echo "guest-tunnel: creating the ${TAG} tunnel at ${API} (the certificate can take minutes)"
  "${OT[@]}" up
  "${OT[@]}" down || true
fi
HOST="$(jq -r '.hostname // empty' "${IDENTITY}")"
[ -n "${HOST}" ] || { echo "guest-tunnel: ${IDENTITY} names no hostname" >&2; exit 1; }
URL="https://${ROUTE}.${HOST}"
printf '%s\n' "${URL}" > "` + GuestTunnelRunDir + `/${TAG}.url.tmp"
mv -f "` + GuestTunnelRunDir + `/${TAG}.url.tmp" "` + GuestTunnelRunDir + `/${TAG}.url"
echo "guest-tunnel: ${TAG} serves 127.0.0.1:${PORT} at ${URL}"
systemctl start --no-block "guest-tunnel-report-${TAG}.service"
exec "${OT[@]}" serve
`

const guestTunnelReportScript = `#!/usr/bin/env bash
set -uo pipefail
TAG="$1"
URL="$(cat "` + GuestTunnelRunDir + `/${TAG}.url" 2>/dev/null || true)"
if [ -z "${URL}" ]; then echo "guest-tunnel-report: no URL for ${TAG} yet" >&2; exit 1; fi
if [ ! -x "` + OnNetworkScriptPath + `" ]; then
  echo "guest-tunnel-report: the bidder installed no ` + OnNetworkScriptPath + `; ${TAG} at ${URL} stays unreported" >&2
  exit 0
fi
exec "` + OnNetworkScriptPath + `" "${TAG}" "${URL}"
`

var woottyRunScript = `#!/usr/bin/env bash
set -euo pipefail
PORT="$1"
if [ ! -f "` + GuestSecretsReadyPath + `" ] && [ ! -f "` + SecretsDoneStamp + `" ]; then
  echo "wootty: the secrets bundle is not written yet (` + GuestSecretsReadyPath + `); the shell waits" >&2
  exit 1
fi
if [ ! -x "` + WoottyBinary + `" ]; then
  echo "wootty: ` + WoottyBinary + ` is not installed yet (` + GuestBinariesUnit + `); the shell waits" >&2
  exit 1
fi
TOKEN="${WOOTTY_AUTH_TOKEN:-}"
if [ "${#TOKEN}" -lt ` + strconv.Itoa(ShellTokenMinLength) + ` ]; then
  echo "wootty: WOOTTY_AUTH_TOKEN from the secrets bundle is missing or shorter than ` + strconv.Itoa(ShellTokenMinLength) + ` characters; no shell starts without auth" >&2
  exit 1
fi
unset TOKEN
export WOOTTY_HOST=127.0.0.1
export WOOTTY_PORT="${PORT}"
unset WOOTTY_ALLOW_INSECURE_NO_AUTH
exec ` + WoottyBinary + ` run /bin/bash -l
`