#!/bin/bash
# ============================================================
# alpha-cws — cloud workspaces sold on the code store (workspace.<domain>):
# one UNPRIVILEGED LXD container per buyer, running code-server, reached ONLY
# through nginx at https://<store>/w/<id>/ behind the store's sign-in gate.
#
#   alpha-cws setup                         bridge cwsbr0 + profile cws + firewall + the store's nginx block (idempotent)
#   alpha-cws image                         {ready|building} — starts a background build of alpha-cws-base when missing
#   alpha-cws image-status                  {ready, building, log}
#   alpha-cws create <id> <memMB> <cpu> <diskGB>   launch cws-<id>; JSON {ok, ip}
#   alpha-cws start|stop|restart <id>       JSON {ok, state, ip}
#   alpha-cws info <id>                     JSON {ok, state, ip}
#   alpha-cws delete <id>                   JSON {ok}
#
# Isolation (the whole point — a buyer gets root-like control of a shell):
#   · unprivileged container (uid-mapped), no nesting, 512 processes, its own memory/CPU/disk limits
#   · its own bridge cwsbr0 (10.78.0.0/24, NAT, no IPv6) with security.port_isolation — no workspace sees another
#   · nftables table inet alpha_cws: from cwsbr0 the HOST accepts only DNS/DHCP (+ replies); forwarding to private
#     ranges and to mail ports (25/465/587) is dropped. code-server listens inside on :8080 with auth none — nothing
#     but this host can reach it, and nginx only proxies after the gate (sitestore_front · adapter gate) said yes.
# Runs every lxc call through systemd-run: php-fpm's PrivateDevices sandbox cannot setns into the snap.
# ============================================================
set -uo pipefail
LXC="systemd-run --quiet --pipe --wait --collect /snap/bin/lxc"
POOL=alpha
NET=cwsbr0
NETADDR=10.78.0.1/24
PROFILE=cws
IMG=alpha-cws-base
STATE=/var/lib/alpha-cws
SNIP_DIR=/etc/nginx/alpha-sitestore.d/code
SNIP=$SNIP_DIR/workspaces.conf
mkdir -p "$STATE"

j()   { jq -cn "$@"; }
die() { j --arg m "$*" '{ok:false,msg:$m}'; exit 1; }
vid() { [[ "${1:-}" =~ ^[a-f0-9]{12}$ ]] || die "bad workspace id"; }
vint(){ [[ "${1:-}" =~ ^[0-9]+$ ]] || die "bad number"; }
fpm_sock() { ls -1 /run/php/php*-fpm.sock 2>/dev/null | grep -v 'php-fpm.sock$' | sort -V | tail -1 || echo /run/php/php-fpm.sock; }

pool_ready(){ [ "$($LXC storage list --format json 2>/dev/null | jq -r --arg p "$POOL" '.[] | select(.name == $p) | .status')" = "Created" ]; }
pool_quota(){   # same as alpha-virt ensure_pool_quota: disk limits are only real with btrfs quotas on (and settled)
  local pool="/var/snap/lxd/common/lxd/storage-pools/$POOL" bt=/snap/lxd/current/bin/btrfs ns=/run/snapd/ns/lxd.mnt
  { [ -e "$ns" ] && [ -x "$bt" ]; } || return 0
  systemd-run --quiet --pipe --wait --collect -- nsenter --mount="$ns" -- "$bt" qgroup show -re "$pool" >/dev/null 2>&1 \
    || systemd-run --quiet --pipe --wait --collect -- nsenter --mount="$ns" -- "$bt" quota enable "$pool" >/dev/null 2>&1 || return 0
  systemd-run --quiet --pipe --wait --collect -- nsenter --mount="$ns" -- "$bt" quota rescan -w "$pool" >/dev/null 2>&1 || true
}
ip_of(){ $LXC list "^cws-$1\$" --format json 2>/dev/null | jq -r '.[0].state.network.eth0.addresses[]? | select(.family == "inet") | .address' | head -1; }
state_of(){ $LXC list "^cws-$1\$" --format json 2>/dev/null | jq -r '.[0].status // "Missing"'; }
wait_ip(){ local i ip; for i in $(seq 1 40); do ip="$(ip_of "$1")"; [ -n "$ip" ] && { echo "$ip"; return 0; }; sleep 1; done; return 1; }

firewall(){
  nft list table inet alpha_cws >/dev/null 2>&1 && nft delete table inet alpha_cws
  nft -f - <<EOF
table inet alpha_cws {
  chain input {
    type filter hook input priority -5; policy accept;
    iifname "$NET" ct state established,related accept
    iifname "$NET" udp dport { 53, 67 } accept
    iifname "$NET" tcp dport 53 accept
    iifname "$NET" drop
  }
  chain forward {
    type filter hook forward priority -5; policy accept;
    iifname "$NET" ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10, 127.0.0.0/8 } drop
    iifname "$NET" tcp dport { 25, 465, 587 } drop
  }
}
EOF
}

nginx_block(){   # the /w/<id>/ proxy, included by the code store's vhost (alpha-sitestore render)
  local sock   # the same PHP the store itself runs on (its vhost's fastcgi_pass), else the newest pool
  sock="$(sed -n 's/.*fastcgi_pass unix:\([^;]*\);.*/\1/p' /etc/nginx/sites-available/site-code 2>/dev/null | head -1)"
  [ -S "$sock" ] || sock="$(fpm_sock)"
  install -d -m 0755 "$SNIP_DIR"
  cat > "$SNIP.new" <<EOF
# Cloud workspaces — written by /usr/local/sbin/alpha-cws. /w/<id>/ → the buyer's container (code-server :8080),
# only after the store's gate (sitestore_front.php, SS_GATE) confirmed the signed-in buyer owns that workspace.
location ~ "^/w/([a-f0-9]{12})/" {   # ⚠️ quoted: nginx reads an unquoted {12} as a block brace
    auth_request /_cws_gate;
    auth_request_set \$cws_up \$upstream_http_x_cws_upstream;
    error_page 401 = @cws_signin;
    rewrite "^/w/[a-f0-9]{12}/(.*)\$" /\$1 break;
    proxy_pass http://\$cws_up;
    proxy_http_version 1.1;
    proxy_set_header Upgrade \$http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host \$host;
    proxy_set_header Cookie "";
    proxy_read_timeout 1d;
    proxy_send_timeout 1d;
    client_max_body_size 200m;
}
location = /w { return 302 /account; }
location @cws_signin { return 302 /auth/signin?next=account; }
location = /_cws_gate {
    internal;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME /var/www/site-code/index.php;
    fastcgi_param SS_GATE 1;
    fastcgi_param SS_ORIG_URI \$request_uri;
    fastcgi_pass_request_body off;
    fastcgi_param CONTENT_LENGTH "";
    fastcgi_pass unix:$sock;
}
EOF
  if ! cmp -s "$SNIP.new" "$SNIP"; then
    [ -f "$SNIP" ] && cp "$SNIP" "$SNIP.bak"
    mv "$SNIP.new" "$SNIP"
    if ! nginx -t >/dev/null 2>&1; then
      if [ -f "$SNIP.bak" ]; then mv "$SNIP.bak" "$SNIP"; else rm -f "$SNIP"; fi
      return 1
    fi
    systemctl reload nginx
  else rm -f "$SNIP.new"; fi
  # a store vhost from before the include hook gets re-rendered once
  if [ -f /etc/nginx/sites-available/site-code ] && ! grep -q 'alpha-sitestore.d/code' /etc/nginx/sites-available/site-code; then
    /usr/local/sbin/alpha-sitestore code render >/dev/null 2>&1 || true
  fi
  return 0
}

setup(){
  if ! $LXC network show "$NET" >/dev/null 2>&1; then
    $LXC network create "$NET" ipv4.address="$NETADDR" ipv4.nat=true ipv6.address=none >/dev/null 2>&1 || die "could not create the $NET bridge"
  fi
  if ! $LXC profile show "$PROFILE" >/dev/null 2>&1; then $LXC profile create "$PROFILE" >/dev/null 2>&1 || die "could not create the $PROFILE profile"; fi
  $LXC profile edit "$PROFILE" >/dev/null 2>&1 <<EOF || die "could not write the $PROFILE profile"
description: Cloud workspaces (alpha-cws) — isolated, unprivileged
config:
  security.nesting: "false"
  security.privileged: "false"
  limits.processes: "512"
  boot.autostart: "false"
devices:
  eth0:
    type: nic
    name: eth0
    network: $NET
    security.port_isolation: "true"
  root:
    type: disk
    path: /
    pool: $POOL
EOF
  firewall || die "the firewall rules did not load"
  # ufw (default: deny incoming, deny routed) must let the bridge reach its DNS/DHCP and route OUT to the internet —
  # narrower than lxdbr0's rules on purpose; the alpha_cws table above still drops private ranges and mail ports.
  # ufw skips a rule it already has, so this is idempotent.
  if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q '^Status: active'; then
    wan="$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}')"
    ufw allow in on "$NET" to any port 53,67 proto udp comment 'alpha-cws dns/dhcp' >/dev/null 2>&1 || true
    ufw allow in on "$NET" to any port 53 proto tcp comment 'alpha-cws dns' >/dev/null 2>&1 || true
    [ -n "$wan" ] && { ufw route allow in on "$NET" out on "$wan" comment 'alpha-cws internet' >/dev/null 2>&1 || true; }
  fi
  nginx_block || die "nginx refused the workspace block — nothing changed"
}

build_image(){   # runs detached (systemd unit alpha-cws-image); log in $STATE/image.log
  local b="cws-build-$$" log="$STATE/image.log"
  exec >"$log" 2>&1
  echo "== $(date -Is) building $IMG"
  setup >/dev/null || { echo "setup failed"; exit 1; }
  pool_ready || { echo "storage pool $POOL not ready"; exit 1; }
  # ⚠️ images.lxd.canonical.com carries NO Ubuntu containers any more — Ubuntu comes from Canonical's own
  # simplestreams feed (the standard "ubuntu:" remote, added if missing); Debian 13 from images: is the fallback
  $LXC remote list --format csv 2>/dev/null | grep -q '^ubuntu,'     || $LXC remote add ubuntu https://cloud-images.ubuntu.com/releases --protocol=simplestreams --public >/dev/null 2>&1 || true
  $LXC launch ubuntu:24.04 "$b" -p "$PROFILE" -c limits.memory=2048MiB -c limits.cpu=2     || $LXC launch images:debian/13 "$b" -p "$PROFILE" -c limits.memory=2048MiB -c limits.cpu=2 || { echo "launch failed"; exit 1; }
  wait_ip_b(){ local i; for i in $(seq 1 60); do $LXC exec "$b" -- getent hosts archive.ubuntu.com >/dev/null 2>&1 && return 0; sleep 2; done; return 1; }
  wait_ip_b || { echo "no network inside the builder"; $LXC delete -f "$b"; exit 1; }
  $LXC exec "$b" -- bash -s <<'IN' || { echo "provisioning failed"; $LXC delete -f "$b"; exit 1; }
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get install -y -q curl ca-certificates git build-essential python3 python3-pip python3-venv nodejs npm unzip zip sudo nano less htop jq ripgrep
curl -fsSL https://code-server.dev/install.sh | sh
id coder >/dev/null 2>&1 || useradd -m -s /bin/bash coder
echo 'coder ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/coder && chmod 440 /etc/sudoers.d/coder
install -d -o coder -g coder /home/coder/projects /home/coder/.config/code-server /home/coder/.local/share/code-server/User
cat > /home/coder/.config/code-server/config.yaml <<'CFG'
bind-addr: 0.0.0.0:8080
auth: none
cert: false
disable-telemetry: true
disable-update-check: true
CFG
cat > /home/coder/.local/share/code-server/User/settings.json <<'SET'
{ "workbench.colorTheme": "Default Dark Modern", "security.workspace.trust.enabled": false, "telemetry.telemetryLevel": "off" }
SET
cat > /home/coder/projects/README.md <<'MD'
# Your workspace

This is your own Linux machine in the cloud — open a terminal (Ctrl+`), `sudo apt install` what you need,
and keep your projects in this folder. Node.js, Python and git are already installed.
MD
chown -R coder:coder /home/coder
systemctl enable code-server@coder
apt-get clean && rm -rf /var/lib/apt/lists/*
IN
  $LXC stop "$b" || true
  $LXC image delete "$IMG" >/dev/null 2>&1 || true
  $LXC publish "$b" --alias "$IMG" description="Alpha cloud workspace (code-server)" || { echo "publish failed"; $LXC delete -f "$b"; exit 1; }
  $LXC delete -f "$b"
  echo "== $(date -Is) done"
}
image_ready(){ $LXC image list --format json 2>/dev/null | jq -e --arg a "$IMG" 'any(.[]; any(.aliases[]?; .name == $a))' >/dev/null 2>&1; }

a="${1:-}"; shift || true
case "$a" in
  setup) setup; j '{ok:true,msg:"Workspace network, firewall and proxy are in place."}' ;;
  net)   firewall && j '{ok:true}' || die "firewall failed" ;;          # boot: alpha-cws-net.service
  image)
    if image_ready; then j '{ok:true,ready:true}'; exit 0; fi
    if systemctl is-active --quiet alpha-cws-image; then j '{ok:true,ready:false,building:true}'; exit 0; fi
    systemctl reset-failed alpha-cws-image >/dev/null 2>&1 || true
    systemd-run --unit alpha-cws-image --collect /usr/local/sbin/alpha-cws image-build >/dev/null 2>&1 || die "could not start the image build"
    j '{ok:true,ready:false,building:true,msg:"Building the workspace image — about five minutes."}' ;;
  image-build) build_image ;;
  image-status)
    r=false; image_ready && r=true
    b=false; systemctl is-active --quiet alpha-cws-image && b=true
    j --argjson r "$r" --argjson b "$b" --arg l "$(tail -c 1500 "$STATE/image.log" 2>/dev/null)" '{ok:true,ready:$r,building:$b,log:$l}' ;;
  create)
    vid "${1:-}"; vint "${2:-}"; vint "${3:-}"; vint "${4:-}"
    [ "$2" -ge 512 ] && [ "$2" -le 16384 ] || die "memory out of range"
    [ "$3" -ge 1 ] && [ "$3" -le 4 ] || die "cpu out of range"
    [ "$4" -ge 2 ] && [ "$4" -le 100 ] || die "disk out of range"
    image_ready || die "The workspace image is not built yet."
    pool_ready || die "The storage pool is not ready — try again in a minute."
    [ "$(state_of "$1")" = "Missing" ] || die "That workspace already exists."
    setup >/dev/null
    pool_quota
    out="$($LXC launch "$IMG" "cws-$1" -p "$PROFILE" -c limits.memory="${2}MiB" -c limits.cpu="$3" -d root,size="${4}GiB" </dev/null 2>&1)" \
      || { $LXC delete -f "cws-$1" >/dev/null 2>&1; die "launch failed: $(printf '%s' "$out" | tail -c 300)"; }
    ip="$(wait_ip "$1")" || die "The workspace started but got no address."
    j --arg ip "$ip" '{ok:true,state:"Running",ip:$ip}' ;;
  start|stop|restart)
    vid "${1:-}"
    case "$a" in start) $LXC start "cws-$1" >/dev/null 2>&1 ;; stop) $LXC stop -f "cws-$1" >/dev/null 2>&1 ;; restart) $LXC restart -f "cws-$1" >/dev/null 2>&1 ;; esac
    ip=""; [ "$a" != stop ] && ip="$(wait_ip "$1")"
    j --arg s "$(state_of "$1")" --arg ip "$ip" '{ok:true,state:$s,ip:$ip}' ;;
  info) vid "${1:-}"; j --arg s "$(state_of "$1")" --arg ip "$(ip_of "$1")" '{ok:true,state:$s,ip:$ip}' ;;
  delete)
    vid "${1:-}"
    pool_ready || die "The storage pool is not mounted right now — deleting would leave the disk behind."
    [ "$(state_of "$1")" = "Missing" ] || $LXC delete -f "cws-$1" >/dev/null 2>&1 || die "LXD refused the delete"
    j '{ok:true}' ;;
  *) echo "usage: alpha-cws setup | image | image-status | create <id> <memMB> <cpu> <diskGB> | start|stop|restart|info|delete <id>" >&2; exit 2 ;;
esac
