diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..1cb1ed7 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,495 @@ +#!/usr/bin/env bash +# Vacuum Wall - SSL Proxy Firewall Appliance Installer +# Run as root on a fresh Debian 13 (trixie) system +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[!!]${NC} $*"; } +err() { echo -e "${RED}[!!]${NC} $*"; exit 1; } + +# --- CLI argument parsing --- +_cli_user="" +_cli_is_dev=false +_cli_path="" +_cli_mgmt_pass="" +_cli_mgmt_user="" +_cli_mgmt_domain="" +_cli_force_venv=false +_cli_wan_iface="" +_cli_lan_ifaces="" +while [[ $# -gt 0 ]]; do + case "$1" in + --user|-u) _cli_user="$2"; shift 2 ;; + --path|-p) _cli_path="$2"; shift 2 ;; + --dev) _cli_is_dev=true; shift ;; + --mgmt-pass) _cli_mgmt_pass="$2"; shift 2 ;; + --mgmt-user) _cli_mgmt_user="$2"; shift 2 ;; + --mgmt-domain) _cli_mgmt_domain="$2"; shift 2 ;; + --force-venv) _cli_force_venv=true; shift ;; + --wan-iface) _cli_wan_iface="$2"; shift 2 ;; + --lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;; + -h|--help) + printf '%s\n' \ + "Usage: scripts/install.sh [OPTIONS]" \ + "" \ + "Options:" \ + " --user, -u USER WebUI user (created if it does not exist, required for non-dev mode)" \ + " --path, -p DIR Install directory (default: repo root)" \ + " --dev Dev mode: auto-detect repo owner, skip safety warning" \ + " --mgmt-pass PASS WebUI basic auth password (required)" \ + " --mgmt-user USER WebUI basic auth username (default: admin)" \ + " --mgmt-domain DOMAIN Management domain (auto-detected)" \ + " --wan-iface IFACE WAN interface name (auto-detected)" \ + " --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \ + " -h, --help Show this help" \ + "" \ + "All options also have environment variable equivalents:" \ + " USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \ + " MGMT_DOMAIN, WAN_IFACE, LAN_IFACES." \ + " CLI flags take precedence over env vars." \ + "" \ + "Example (dev):" \ + " ./scripts/install.sh --dev --mgmt-pass pass" \ + "" \ + "Example (prod):" \ + " MGMT_PASS=pass ./scripts/install.sh --user vacuum-wall" + exit 0 + ;; + *) + err "Unknown argument: $1 (use --help for usage)" + ;; + esac +done + +# --- Resolve config: CLI flag > env var > default --- +REPO_DIR="$(cd "$(dirname "$0")/../" && pwd)" + +# Required settings (no defaults — must be provided) +MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}" +# Optional settings with defaults +MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}" + +# MGMT_DOMAIN — CLI > env > auto-detect from hostname +if [[ -n "$_cli_mgmt_domain" ]]; then + DOMAIN="$_cli_mgmt_domain" +elif [[ -n "${MGMT_DOMAIN:-}" ]]; then + DOMAIN="$MGMT_DOMAIN" +else + HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true) + if [[ -z "$HOSTNAME_F" ]]; then + err "Cannot determine system hostname — set MGMT_DOMAIN env var or --mgmt-domain." + fi + DOMAIN="${HOSTNAME_F}.local" +fi + +# Install directory (CLI > env > repo root) +INSTALL_DIR="${_cli_path:-${INSTALL_DIR:-}}" +if [[ -n "$INSTALL_DIR" ]]; then + PROJECT_DIR="$INSTALL_DIR" +else + PROJECT_DIR="$REPO_DIR" +fi + +# Network interfaces (CLI > env — auto-detect happens later if still unset) +WAN_IFACE="${_cli_wan_iface:-${WAN_IFACE:-}}" +LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}" + +# --- Pre-flight checks --- +[[ $EUID -eq 0 ]] || err "This script must be run as root." +[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu." + +# --- Validate required settings --- +missing=() +[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)") + +if (( ${#missing[@]} )); then + echo -e "${RED}[!!]${NC} Missing required settings:" + for v in "${missing[@]}"; do + case "$v" in + "MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';; + esac + done + printf '\nTo run: MGMT_PASS=pass ./scripts/install.sh\n' + exit 1 +fi +ACME_HOME="$PROJECT_DIR/data/acme" + +# Dev mode: auto-detect repo owner as service user +if [[ "$_cli_is_dev" == true ]]; then + _repo_owner=$(stat -c '%U' "$REPO_DIR" 2>/dev/null) || true + if [[ -n "$_repo_owner" && "$_repo_owner" != "root" ]]; then + _cli_user="$_repo_owner" + log "Dev mode: using repo owner '$_repo_owner' as service user" + else + err "Dev mode: cannot determine repo owner (root or unavailable)." + fi +fi + +# Resolve USER_NAME: dev mode auto-detects, non-dev requires --user +USER_NAME="${_cli_user:-${USER_NAME:-}}" +if [[ -z "$USER_NAME" ]]; then + err "WebUI user is required. Use --dev to auto-detect repo owner, or set --user / USER_NAME." +fi +# Daemon user name (derived from web UI user name) +USER_DAEMON_NAME="${USER_NAME}d" + +# --- Safety check: running service as a regular user --- +if [[ "$_cli_is_dev" != true ]] && id "$USER_NAME" &>/dev/null; then + _uid=$(id -u "$USER_NAME") + _shell=$(getent passwd "$USER_NAME" | cut -d: -f7) + if [[ "$_uid" -ge 1000 ]] && [[ "$_shell" != "/usr/sbin/nologin" && "$_shell" != "/bin/false" ]]; then + warn "USER_NAME='$USER_NAME' is a regular user (UID=$_uid, shell=$_shell)!" + warn "This runs the web service as your login account." + warn "Sudo access is held only by the daemon user ($USER_DAEMON_NAME)." + fi +fi +# Create WebUI user if it does not exist +if ! id "$USER_NAME" &>/dev/null; then + log "Creating system user $USER_NAME..." + useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin "$USER_NAME" +fi +# Shared group: use the WebUI user's primary group +USER_GROUP=$(id -gn "$USER_NAME") + +echo "============================================" +echo " Vacuum Wall Appliance Installer" +echo " Install dir: $PROJECT_DIR" +echo " WebUI user: $USER_NAME" +echo " Daemon user: $USER_DAEMON_NAME" +echo " Shared group: $USER_GROUP" +echo " Management domain: $DOMAIN" +echo "============================================" + +# --- 1. Install packages --- +log "Installing system packages..." +apt-get update -qq +apt-get install -y -qq \ + firewalld \ + nginx \ + dnsmasq \ + wireguard-tools \ + python3 \ + python3-pip \ + jq \ + curl \ + iptables \ + nftables \ + apache2-utils \ + avahi-daemon + +# --- 1b. Vendored libraries --- +log "Downloading vendored libraries..." +bash "${PROJECT_DIR}/scripts/update-vendor.sh" || err "update-vendor.sh failed" + +# --- 2. Setup users --- +log "WebUI user: $USER_NAME (group: $USER_GROUP)" + +# --- 2a. Create daemon user (has sudo for privileged operations) --- +if ! id "$USER_DAEMON_NAME" &>/dev/null; then + log "Creating system user $USER_DAEMON_NAME..." + useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin \ + --gid "$USER_GROUP" "$USER_DAEMON_NAME" +else + log "User $USER_DAEMON_NAME already exists." + usermod -g "$USER_GROUP" "$USER_DAEMON_NAME" 2>/dev/null || true +fi + +# --- 2b. Setup Python venv --- +if [[ -x "${PROJECT_DIR}/.venv/bin/python3" ]] && [[ "$_cli_force_venv" != true ]]; then + log "Python venv already exists, skipping (use --force-venv to recreate)." +else + log "Setting up Python virtual environment..." + rm -rf "${PROJECT_DIR}/.venv" + python3 -m venv "${PROJECT_DIR}/.venv" + "${PROJECT_DIR}/.venv/bin/pip" install -qe "${PROJECT_DIR}" + chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/.venv" + chmod -R g+x "${PROJECT_DIR}/.venv" +fi + +# Install the deploy hook into acme.sh's deploy directory +# (acme.sh only resolves hooks from $ACME_HOME/deploy/) +mkdir -p "$ACME_HOME/deploy" +cp "${PROJECT_DIR}/system/acme-deploy.sh" "$ACME_HOME/deploy/acme-deploy.sh" +chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/deploy/acme-deploy.sh" +chmod 0755 "$ACME_HOME/deploy/acme-deploy.sh" + +# --- 3. Setup directories --- +log "Creating config and data directories..." +mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall} +mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme} +mkdir -p /etc/wireguard +mkdir -p /etc/dnsmasq +# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev +if [[ "$_cli_is_dev" == true ]]; then + _dev_owner="$USER_NAME" +else + _dev_owner="$USER_DAEMON_NAME" +fi +chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR" +chmod -R g+rwX "$PROJECT_DIR" +find "$PROJECT_DIR" -type d -exec chmod g+s '{}' + + +# --- 4. Template rendering function --- +# Renders Jinja2 templates by injecting env vars as template context. +# Used for systemd units and sudoers files. +render_template() { + export USER_NAME USER_DAEMON_NAME USER_GROUP PROJECT_DIR ACME_HOME + "${PROJECT_DIR}/.venv/bin/python3" -c " +import sys, os +from jinja2 import Template +text = open(sys.argv[1]).read() +env = { + 'USER_NAME': os.environ['USER_NAME'], + 'USER_DAEMON_NAME': os.environ.get('USER_DAEMON_NAME', ''), + 'USER_GROUP': os.environ.get('USER_GROUP', ''), + 'PROJECT_DIR': os.environ['PROJECT_DIR'], + 'ACME_HOME': os.environ['ACME_HOME'], +} +print(Template(text).render(**env), end='') +" "$1" +} + +# --- 5. Install sudoers --- +log "Installing sudoers whitelist..." +export USER_DAEMON_NAME +render_template "${PROJECT_DIR}/system/sudoers.d/vacuum-walld" \ + | install -m 0440 /dev/stdin /etc/sudoers.d/vacuum-walld + +visudo -cf /etc/sudoers.d/vacuum-walld || err "Invalid sudoers file!" + +# --- 6. Install systemd units --- +log "Installing systemd units..." +export USER_GROUP +render_template "${PROJECT_DIR}/system/systemd/vacuum-walld.service" \ + | install -m 0644 /dev/stdin /etc/systemd/system/vacuum-walld.service + +render_template "${PROJECT_DIR}/system/systemd/vacuum-wall.service" \ + | install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall.service + +render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \ + | install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall-acme.service + +install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer +systemctl daemon-reload + +# --- 7. Enable IP forwarding (persistent via sysctl.conf) --- +log "Enabling IP forwarding..." +if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf 2>/dev/null; then + echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf +fi + +# --- 8. Detect network interfaces --- +log "Detecting network interfaces..." + +# Auto-detect WAN (interface with default gateway) +WAN_IFACE="${WAN_IFACE:-}" +if [[ -z "$WAN_IFACE" ]]; then + # Strip @if suffix — physical port index can change on reboot + DETECTED_WAN=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' | cut -d'@' -f1) + if [[ -n "$DETECTED_WAN" ]]; then + WAN_IFACE="$DETECTED_WAN" + log "Auto-detected WAN interface: $WAN_IFACE" + else + warn "Could not auto-detect WAN interface — set WAN_IFACE env var" + fi +fi + +# Auto-detect LAN (all non-loopback, non-Docker, non-WAN, non-virtual interfaces) +LAN_IFACES="${LAN_IFACES:-}" +if [[ -z "$LAN_IFACES" ]]; then + DETECTED_LANS=$(ls /sys/class/net/ 2>/dev/null \ + | grep -vE "^(lo|docker|br-|virbr|${WAN_IFACE})$") + if [[ -n "$DETECTED_LANS" ]]; then + LAN_IFACES=$(echo "$DETECTED_LANS" | paste -sd ',' -) + log "Auto-detected LAN interfaces: $LAN_IFACES" + else + warn "Could not auto-detect LAN interfaces — set LAN_IFACES env var" + fi +fi + +# --- 9. Enable and start core services --- +log "Enabling services..." +systemctl enable firewalld >/dev/null 2>&1 && log "Enabled firewalld" || warn "Could not enable firewalld" +systemctl enable nginx >/dev/null 2>&1 && log "Enabled nginx" || warn "Could not enable nginx" +systemctl enable dnsmasq >/dev/null 2>&1 && log "Enabled dnsmasq" || warn "Could not enable dnsmasq" +systemctl enable vacuum-walld >/dev/null 2>&1 && log "Enabled vacuum-walld" || warn "Could not enable vacuum-walld" +systemctl enable vacuum-wall >/dev/null 2>&1 && log "Enabled vacuum-wall" || warn "Could not enable vacuum-wall" +systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 && log "Enabled vacuum-wall-acme.timer" || warn "Could not enable vacuum-wall-acme.timer" +systemctl enable avahi-daemon >/dev/null 2>&1 && log "Enabled avahi-daemon" || warn "Could not enable avahi-daemon" + +# Clean up old nginx bootstrap configs (replaced by daemon-generated config) +rm -f /etc/nginx/conf.d/vacuum-wall-map.conf /etc/nginx/conf.d/vacuum-wall-mgmt.conf + +# Stop all services to ensure clean start order +systemctl stop vacuum-wall >/dev/null 2>&1 || true +systemctl stop vacuum-walld >/dev/null 2>&1 || true + +# Start services in dependency order +systemctl start firewalld >/dev/null 2>&1 && log "Started firewalld" || warn "Could not start firewalld" +systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon" +systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)" + +# Start daemon and wait for socket +systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon" + +_SOCKET="$PROJECT_DIR/data/daemon.sock" +for _i in $(seq 1 30); do + [[ -S "$_SOCKET" ]] && break + sleep 0.5 +done +if [[ ! -S "$_SOCKET" ]]; then + warn "Daemon socket not found at $_SOCKET — skipping API configuration" +else + chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true + chmod 0660 "$_SOCKET" 2>/dev/null || true + + # --- 10. Configure subsystems via daemon API --- + log "Configuring subsystems via daemon API..." + WAN_IFACE="$WAN_IFACE" \ + LAN_IFACES="$LAN_IFACES" \ + MGMT_DOMAIN="$DOMAIN" \ + MGMT_USER="$MGMT_USER" \ + MGMT_PASS="$MGMT_PASS" \ + "${PROJECT_DIR}/.venv/bin/python3" -c " +import daemon.client as c +from daemon.iface import ( + POST_ACME_SELF_SIGNED, POST_NGINX_DOMAINS_ADD, POST_NGINX_APPLY, + POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET, + GET_NETWORK_INFER_DHCP_RANGES, +) +import sys + +domain = '${DOMAIN}' +mgmt_user = '${MGMT_USER}' +mgmt_pass = '${MGMT_PASS}' +wan_iface = '${WAN_IFACE}' +lan_ifaces = '${LAN_IFACES}' + +# Self-signed cert for management domain +try: + res = c.post(POST_ACME_SELF_SIGNED, {'domain': domain, 'days': 365}) + print(f' [cert] Self-signed: {\"generated\" if res.get(\"generated\") else \"exists\"}') +except Exception as e: + print(f' [cert] Warning: {e}', file=sys.stderr) + +# Management proxy domain + htpasswd +try: + c.post(POST_NGINX_DOMAINS_ADD, { + 'domain': domain, + 'paths': { + '/': { + 'backend': {'host': '127.0.0.1', 'port': 9090, 'proto': 'http'}, + 'is_management': True, + }, + '/ws': { + 'backend': {'host': '127.0.0.1', 'port': 9091, 'proto': 'http'}, + 'is_websocket': True, + }, + }, + 'auth_user': mgmt_user, + 'auth_pass': mgmt_pass, + }) + c.post(POST_NGINX_APPLY) + print(f' [proxy] Management proxy configured for {domain}') +except Exception as e: + print(f' [proxy] Warning: {e}', file=sys.stderr) + +# Firewall config (interface detection done in bash above) +import json as _json +zones = {} + +if wan_iface: + zones['public'] = { + 'target': 'DEFAULT', + 'interfaces': [i for i in wan_iface.split(',') if i], + 'services': ['http', 'https', 'ssh'], + 'masquerade': True, + } + +if lan_ifaces: + zones['internal'] = { + 'target': 'ACCEPT', + 'interfaces': [i for i in lan_ifaces.split(',') if i], + 'services': ['dhcp', 'dns', 'ntp'], + 'masquerade': False, + } + +# Always create vpn zone skeleton for later WireGuard setup +zones['vpn'] = { + 'target': 'ACCEPT', + 'interfaces': [], + 'services': [], + 'masquerade': False, +} + +try: + c.post(POST_FIREWALL_CONFIG, {'zones': zones}) + c.post(POST_FIREWALL_CONFIG_APPLY) + print(' [firewall] Zones configured and applied') +except Exception as e: + print(f' [firewall] Warning: {e}', file=sys.stderr) + +# IP forwarding +try: + c.post(POST_NETWORK_SYSCTL_SET, {'name': 'net.ipv4.ip_forward', 'value': '1'}) + print(' [network] IP forwarding enabled') +except Exception as e: + print(f' [network] Warning: {e}', file=sys.stderr) + +# Infer DHCP ranges (logged for user reference) +try: + ranges = c.get(GET_NETWORK_INFER_DHCP_RANGES) + for iface, rng in ranges.get('ranges', {}).items(): + print(f' [suggestion] DHCP range for {iface}: {rng.get(\"start\")}-{rng.get(\"end\")}') +except Exception: + pass +" + log "Subsystem configuration complete" +fi + +systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI" + +nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \ + systemctl restart nginx >/dev/null 2>&1 && log "Restarted nginx" || \ + warn "Could not restart nginx (check config)" + +# --- Done --- +echo "" +echo "============================================" +echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}" +echo "============================================" +echo "" +echo " Management UI: https://$DOMAIN" +echo " User: $MGMT_USER" +echo " Daemon service: vacuum-walld.service" +echo " WebUI service: vacuum-wall.service" +echo " ACME renewal: vacuum-wall-acme.timer" +echo "" +echo " Firewall zones:" +if [[ -n "$WAN_IFACE" ]]; then + echo " public (WAN) → $WAN_IFACE" +else + echo " public (WAN) → not assigned" +fi +if [[ -n "$LAN_IFACES" ]]; then + echo " internal (LAN) → $LAN_IFACES" +else + echo " internal (LAN) → not assigned" +fi +echo "" +echo " Next steps:" +echo " 1. Register your ACME account at https://$DOMAIN/certs" +echo " 2. Verify zone assignments at https://$DOMAIN/interfaces" +echo " 3. Configure DHCP ranges for your LAN" +echo " 4. Add proxy domains with ACME certificates" +echo " 5. Set up WireGuard tunnel (optional)" +echo "" +echo " NOTE: A self-signed certificate was generated." +echo " From the WebUI, issue a real certificate for $DOMAIN" +echo " when DNS points to this appliance." +echo "" diff --git a/scripts/update-vendor.sh b/scripts/update-vendor.sh index acfc76a..6c87f10 100755 --- a/scripts/update-vendor.sh +++ b/scripts/update-vendor.sh @@ -1,54 +1,83 @@ #!/usr/bin/env bash # Download and vendor libraries into vendor/. -# Run from the project root after updating the VERSION variables below. +# Files are named {pkg}-{ver}.{ext}, with {pkg}.{ext} symlinks for stable references. set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +VENDOR="$PROJECT_DIR/vendor" +WEBUI_VENDOR="$PROJECT_DIR/webui/static/vendor" + # ---- Library versions ---- ACME_VERSION="3.1.3" HTM_VERSION="3.1.1" -VENDOR="vendor" - +# ---- Helpers ---- download() { local name="$1" url="$2" dest="$3" if [[ -n "${SKIP_DOWNLOAD:-}" ]]; then - echo "[skip] $name (SKIP_DOWNLOAD is set)" + echo "[skip] $name" + return + fi + if [[ -f "$dest" ]]; then + echo "[skip] $name (already present)" return fi echo "[download] $name → $dest" curl -sfL -o "$dest" "$url" } +ensure_symlink() { + local link="$1" target="$2" + if [[ -L "$link" ]]; then + cur=$(readlink "$link") + if [[ "$cur" != "$target" ]]; then + echo "[symlink] $link → $target (updated)" + ln -sf "$target" "$link" + fi + elif [[ -e "$link" ]]; then + echo "[symlink] $link (replacing existing file)" + mv -f "$link" "${link}.bak" && ln -sf "$target" "$link" + else + echo "[symlink] $link → $target" + ln -sf "$target" "$link" + fi +} + +# ---- acme.sh ---- +ACME_VERSIONED="$VENDOR/acme-${ACME_VERSION}.sh" +ACME_SYMLINK="$VENDOR/acme.sh" download "acme.sh@${ACME_VERSION}" \ "https://raw.githubusercontent.com/acmesh-official/acme.sh/${ACME_VERSION}/acme.sh" \ - "${VENDOR}/acme.sh" + "$ACME_VERSIONED" +chmod +x "$ACME_VERSIONED" +ensure_symlink "$ACME_SYMLINK" "acme-${ACME_VERSION}.sh" +# ---- htm ---- +HTM_VERSIONED="$VENDOR/htm-${HTM_VERSION}.js" +HTM_SYMLINK="$VENDOR/htm.js" download "htm@${HTM_VERSION}" \ - "https://raw.githubusercontent.com/developit/htm/${HTM_VERSION}/mini/index.module.js" \ - "${VENDOR}/htm.js" + "https://cdn.jsdelivr.net/npm/htm@${HTM_VERSION}/mini/index.module.js" \ + "$HTM_VERSIONED" +ensure_symlink "$HTM_SYMLINK" "htm-${HTM_VERSION}.js" -chmod +x "${VENDOR}/acme.sh" +# ---- ACME_HOME (acme.sh runtime home) ---- +ACME_HOME="${ACME_HOME:-$PROJECT_DIR/data/acme}" +mkdir -p "$ACME_HOME" +if [[ ! -x "$ACME_HOME/acme.sh" ]]; then + cp "$ACME_SYMLINK" "$ACME_HOME/acme.sh" + chmod +x "$ACME_HOME/acme.sh" +fi -# --- Symlinks for webui --- -WEBUI_VENDOR="webui/static/vendor" +# ---- Webui symlinks (point to versioned files) ---- mkdir -p "$WEBUI_VENDOR" - WEBUI_LINKS=( - "htm.js:../../../vendor/htm.js" + "htm.js:../../../vendor/htm-${HTM_VERSION}.js" ) - for entry in "${WEBUI_LINKS[@]}"; do - IFS=':' read -r name target <<< "$entry" - if [[ -L "${WEBUI_VENDOR}/${name}" ]]; then - cur=$(readlink "${WEBUI_VENDOR}/${name}") - if [[ "$cur" != "$target" ]]; then - echo "[symlink] ${WEBUI_VENDOR}/${name} → ${target} (updated)" - ln -sf "$target" "${WEBUI_VENDOR}/${name}" - fi - else - echo "[symlink] ${WEBUI_VENDOR}/${name} → ${target}" - ln -sf "$target" "${WEBUI_VENDOR}/${name}" - fi + IFS=':' read -r name target <<< "$entry" + ensure_symlink "${WEBUI_VENDOR}/${name}" "$target" done echo "[done] All libraries vendored."