From ea94435c4f6aad9238c33bd7623f38764a603b16 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 4 Jul 2026 07:45:00 +0200 Subject: [PATCH] Add self-hosted Gitea deployment, mirroring all GitHub repos publicly Deploys Gitea+Caddy (auto-HTTPS via Let's Encrypt) at kuhy.duckdns.org, extends setup_wireguard_ssh.sh with an allow-web firewall subcommand, and mirrors all 21 GitHub repos (5 private) via Gitea's native pull-mirror. Runs containers with host networking to work around a discovered bug where this host's nftables forward-chain silently blocks Docker bridge egress. Adds a self-hosted-service-exposure skill capturing the reusable pattern and gotchas for future public-facing deployments. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01C5jnu99ZuENSkuFQKLcSdh --- .../self-hosted-service-exposure/SKILL.md | 142 +++++++++++ .../self-hosted-gitea-2026-07-04.json | 67 +++++ .../features/migrate_github_to_gitea.sh | 115 +++++++++ .../single_use/features/setup_gitea.sh | 239 ++++++++++++++++++ .../features/setup_wireguard_ssh.sh | 22 +- 5 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 .github/skills/self-hosted-service-exposure/SKILL.md create mode 100644 docs/superpowers/evidence/self-hosted-gitea-2026-07-04.json create mode 100755 linux_configuration/scripts/single_use/features/migrate_github_to_gitea.sh create mode 100755 linux_configuration/scripts/single_use/features/setup_gitea.sh diff --git a/.github/skills/self-hosted-service-exposure/SKILL.md b/.github/skills/self-hosted-service-exposure/SKILL.md new file mode 100644 index 0000000..3353db8 --- /dev/null +++ b/.github/skills/self-hosted-service-exposure/SKILL.md @@ -0,0 +1,142 @@ +--- +name: self-hosted-service-exposure +description: Use BEFORE deploying any Docker-based self-hosted service on this machine that needs to be reachable from the public internet (a git server, a note server, any "expose X to the internet" or "access X from my phone off WiFi" request). Captures the working DuckDNS + Caddy + firewall pattern and a critical nftables/Docker networking gotcha discovered while standing up self-hosted Gitea. +--- + +# Self-hosted service exposure (public internet) + +## What's already true on this host — don't rebuild these + +- **DuckDNS** (`kuhy.duckdns.org`) is kept updated by an existing cron job installed + by `install_joplin.sh`'s `setup_duckdns()` (`~/.joplin-server/duckdns-update.sh`, + `*/5 * * * *`). Check `crontab -l | grep duckdns` before adding a new updater — + `setup_wireguard_ssh.sh`'s own `setup_duckdns()` also has a + `duckdns_already_updated()` guard for this reason. **Do not add a second one.** +- **The firewall is owned entirely by** + `linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh`. It + regenerates `/etc/nftables.conf` from scratch on every `setup` run (`flush + ruleset` + fixed heredoc). **Never hand-edit `/etc/nftables.conf` directly or + from a second script** — the next `setup` re-run (e.g. adding a WireGuard peer) + will silently wipe an independent edit. To open a new port, extend this script + (see its `allow-web` subcommand, which persists an `ALLOW_WEB` flag and adds + `tcp dport { 80, 443 } accept`) rather than writing a new firewall rule + elsewhere. If a service needs a port other than 80/443, add another named flag + following the same pattern, don't fork the ruleset logic. +- Run `sudo setup_wireguard_ssh.sh allow-web` once per host to open 80/443; it's + idempotent and safe to call from a new service's own setup script. + +## The pattern: Caddy + app container, not a hand-rolled reverse proxy + +For any new publicly-exposed service: + +1. **Caddy** (`caddy:2.8` image) is the only container bound to host `80`/`443`. + A ~5-line Caddyfile (`domain { reverse_proxy target:port }`) gets you automatic + Let's Encrypt HTTPS with no manual cert handling. +2. The app container itself is **never** bound to a public port — only reachable + internally by Caddy. +3. Headless bootstrap: prefer the app's own env-var/CLI config over a web + installer wizard, so the whole thing is scriptable with zero manual steps + (see `setup_gitea.sh`'s `GITEA__security__INSTALL_LOCK=true` + + `gitea admin user create` for a working example). + +## The critical gotcha: Docker bridge networking silently loses outbound access + +**Symptom**: Caddy's ACME certificate request times out +(`context deadline exceeded`), or an app container can't reach any external API, +even though `curl` from the host itself works fine and DNS resolves correctly +inside the container. + +**Root cause**: this host's custom nftables ruleset defines its own +`chain forward { policy drop; }` with **zero accept rules**. Docker manages a +*separate* set of forwarding rules via legacy `iptables` (`ip_tables` kernel +module, not `nf_tables`) that correctly `ACCEPT` bridge-network traffic — but +`ip_tables` and `nf_tables` both register at the same `NF_INET_FORWARD` netfilter +hook, and **a DROP verdict from either one is terminal**, regardless of what the +other subsystem decided. So nftables' default-drop forward chain silently kills +all Docker bridge-network container egress, even though `docker ps`/`iptables -L +DOCKER-FORWARD` show everything looks correctly configured on Docker's side. + +Confirm this is what's happening: +```bash +docker exec wget -qO- --timeout=5 https://api.ipify.org # hangs/times out +sudo nft list ruleset | grep -A3 "chain forward" # policy drop, no rules +sudo iptables -L DOCKER-FORWARD -n -v # shows ACCEPT rules matching, but doesn't matter +``` + +**Fix used for Gitea (recommended, minimal blast radius)**: run the +public-facing containers with `network_mode: host` instead of a Docker bridge +network. This sidesteps the FORWARD chain entirely (host-networked containers +only hit INPUT/OUTPUT, both already permissive on this host) without touching +the shared firewall script. Bind the app itself to `127.0.0.1:` explicitly +(e.g. `GITEA__server__HTTP_ADDR=127.0.0.1`) so it isn't accidentally exposed — +with host networking there's no bridge isolation to rely on. + +**Alternative (broader fix, requires explicit user sign-off)**: add +`ct state established,related accept` + `ip saddr 172.16.0.0/12 accept` to +`setup_wireguard_ssh.sh`'s forward chain. Fixes egress for *all* Docker +containers on the host (useful if `joplin-server`/`open-webui` ever need +outbound access too), but it's a change to shared, security-relevant +infrastructure beyond any single service's scope — ask before applying it, +don't default to it silently. + +## Bash gotcha hit while scripting the readiness check + +`docker logs | grep -q "ready-string"` can spuriously fail forever +under `set -o pipefail`: `grep -q` quits at the first match, SIGPIPE-ing +`docker logs` before it finishes writing, and `pipefail` propagates that +upstream non-zero exit even though `grep` itself succeeded. Fix: capture into a +variable first, then grep the variable — +```bash +logs=$(docker logs "$container" 2>&1) +if grep -q "ready-string" <<<"$logs"; then ... +``` + +## Credentials for anything the server needs to reach out to (e.g. pulling private repos) + +Default to the **least-privilege credential**, not whatever's already +authenticated in the shell (e.g. `gh auth token`). A publicly-exposed host +storing a broad-scope token is a bigger blast radius if compromised. If the +service needs to read from a third-party private resource, prefer a +purpose-scoped token (e.g. a GitHub fine-grained PAT, `Contents: Read-only`, +scoped to only the specific repos needed) even though it requires a one-time +manual step (can't be scripted — token creation UIs generally can't be +automated). **Ask the user which tradeoff they want** rather than silently +picking the convenient broad-scope option — this is a real security decision, +not an implementation detail. + +## Verification checklist before declaring a public deployment done + +1. `docker compose ps` — containers healthy. +2. `docker logs | grep -i "certificate obtained"` — a real + cert was issued. If it never appears, check egress (gotcha above) before + suspecting the router. +3. Let's Encrypt's own external validation succeeding (visible in the same log, + e.g. `served key authentication certificate ... remote: `) is + strong indirect proof that inbound 80/443 is already reachable from the + internet — a genuinely external validator reached this host. Don't stop + there, but it means the router forward is very likely already fine. +4. `curl` from the host itself only proves the app works — it hairpins through + loopback and does **not** prove external reachability. +5. **The real acceptance test is on the user**: open the URL from a phone with + WiFi off, on cellular data. + +## Boot persistence — confirm these are all `enabled`, not just running + +A service surviving a reboot needs all of: +```bash +systemctl is-enabled docker # containers won't come back without this +systemctl is-enabled nftables # firewall rules reload from /etc/nftables.conf +systemctl is-enabled cronie # or crond -- DuckDNS updater needs this +docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' # unless-stopped or always +``` +If all of the above hold, no boot-time script or systemd unit is needed for the +service itself — the Docker daemon restarts `unless-stopped` containers on its +own startup, independent of `docker compose` ever being re-invoked. + +## Reference implementation + +`linux_configuration/scripts/single_use/features/setup_gitea.sh` and +`migrate_github_to_gitea.sh` are working, idempotent examples of this whole +pattern end to end (firewall, Caddy, headless bootstrap, host networking +workaround, least-privilege external credential). Copy the shape, not +necessarily the Gitea specifics, for the next self-hosted service. diff --git a/docs/superpowers/evidence/self-hosted-gitea-2026-07-04.json b/docs/superpowers/evidence/self-hosted-gitea-2026-07-04.json new file mode 100644 index 0000000..f6cdf79 --- /dev/null +++ b/docs/superpowers/evidence/self-hosted-gitea-2026-07-04.json @@ -0,0 +1,67 @@ +{ + "intent": "Stand up a self-hosted, publicly-reachable Gitea instance (Docker+Caddy) at kuhy.duckdns.org, and mirror all of kuhyx's GitHub repos into it with ongoing sync, so repos are reachable from a phone off the home network.", + "scope": [ + "linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh (extended: allow-web subcommand)", + "linux_configuration/scripts/single_use/features/setup_gitea.sh (new)", + "linux_configuration/scripts/single_use/features/migrate_github_to_gitea.sh (new)", + "~/gitea/ (docker-compose.yml, Caddyfile -- outside the git repo, matching ~/.joplin-server/ convention)", + "Non-goal: did not touch ~/.joplin-server/duckdns-update.sh (pre-existing, out of scope); did not implement per-repo webhooks for near-instant sync (10-min mirror interval is the guaranteed baseline)" + ], + "changes": [ + "Removed an unrecognized, previously-running GitLab CE container + ~/gitlab data + ~/install_gitlab.sh that the user did not recognize as their own work", + "Added an 'allow-web' subcommand to setup_wireguard_ssh.sh: persists ALLOW_WEB flag, conditionally emits 'tcp dport { 80, 443 } accept' in the nftables input chain, reuses existing verify-then-apply-with-sshd-rollback safety logic", + "Deployed Gitea 1.22.3 + Caddy 2.8 via docker-compose with network_mode: host (not a bridge network) -- discovered mid-implementation that this host's custom nftables FORWARD chain (default-drop, zero rules) silently blocks all Docker bridge-network egress since both nf_tables and Docker's separate iptables-legacy rules hook the same netfilter point and a DROP verdict from either is terminal; host networking sidesteps this without touching the shared firewall script", + "Gitea bound to 127.0.0.1:3000 only (never exposed); Caddy is the only container bound to host 80/443, obtains a real Let's Encrypt cert automatically", + "Headless admin bootstrap via 'gitea admin user create'/'generate-access-token' (no web installer), all secrets (admin password, API token) written directly to 0600 files, never echoed to any transcript", + "Migrated 21 non-fork GitHub repos (5 private) as Gitea pull-mirrors via a single /api/v1/repos/migrate call each (mirror:true, 10m interval) -- one mechanism covers both initial import and ongoing sync", + "Private-repo credential is a dedicated fine-grained GitHub PAT (Contents: Read-only, scoped to the 5 private repos), stored in a 0600 file, applied only to private repos' migrate payload -- not the broader-scoped gh CLI token, given the host is now internet-facing" + ], + "verification": [ + { + "command": "bash -n on both new/edited scripts + shellcheck", + "result": "pass", + "evidence": "No warnings beyond a pre-existing SC1091 info note also present in the unedited parts of setup_wireguard_ssh.sh" + }, + { + "command": "sudo nft list ruleset | grep -A15 'chain input'", + "result": "pass", + "evidence": "tcp dport { 80, 443 } accept present alongside all pre-existing WireGuard/LAN-SSH rules; sshd stayed active through apply" + }, + { + "command": "docker logs gitea-caddy | grep 'certificate obtained successfully'", + "result": "pass", + "evidence": "Let's Encrypt tls-alpn-01 challenge completed from external LE validation IPs, confirming inbound 443 is reachable from the internet" + }, + { + "command": "curl -sI https://kuhy.duckdns.org", + "result": "pass", + "evidence": "HTTP/2 200, server: Caddy" + }, + { + "command": "git clone https://kuhy.duckdns.org/kuhyx/testsAndMisc.git and compare commit count", + "result": "pass", + "evidence": "714 commits on both Gitea mirror and GitHub original (main branch); CV (private) repo visibility confirmed private:true, mirror:true via API" + }, + { + "command": "./migrate_github_to_gitea.sh run twice", + "result": "pass", + "evidence": "First run: 21 migrated, 0 failed. Second run: 0 migrated, 21 already present, 0 failed -- confirms idempotency" + }, + { + "command": "POST /api/v1/repos/{owner}/wake-alarm/mirror-sync", + "result": "pass", + "evidence": "HTTP 200 -- on-demand sync endpoint confirmed functional" + } + ], + "risks": [ + "Public exposure: Gitea (including 5 private repos) is now reachable from the open internet -- user was explicitly warned and chose this over a VPN-gated alternative", + "Gitea/Caddy run with network_mode: host rather than an isolated bridge network, as a workaround for a pre-existing firewall/Docker interaction bug -- slightly less network isolation for these two containers specifically", + "The pre-existing forward-chain bug (nftables default-drop silently blocking all Docker bridge egress) was NOT fixed at the shared-script level per user's choice -- it may still affect other containers (joplin-server, open-webui) that need outbound access; not in scope for this task", + "Final phone-off-WiFi reachability check (the literal backlog 'done' condition) was not independently verified by the agent -- strong indirect evidence (external Let's Encrypt validation succeeded) but pending user confirmation" + ], + "rollback": [ + "Gitea/mirrors: cd ~/gitea && docker compose down -v && rm -rf ~/gitea", + "Firewall: sudo sed -i 's/ALLOW_WEB=\"true\"/ALLOW_WEB=\"false\"/' linux_configuration/scripts/single_use/features/.wireguard_ssh.conf && sudo ./linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh setup (regenerates ruleset without the web rule)", + "After rollback, validate: docker ps shows no gitea/gitea-caddy containers, curl to kuhy.duckdns.org times out, sudo nft list ruleset no longer shows tcp dport 80/443, sshd/WireGuard access still work" + ] +} diff --git a/linux_configuration/scripts/single_use/features/migrate_github_to_gitea.sh b/linux_configuration/scripts/single_use/features/migrate_github_to_gitea.sh new file mode 100755 index 0000000..5b45dd1 --- /dev/null +++ b/linux_configuration/scripts/single_use/features/migrate_github_to_gitea.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# Migrate all of kuhyx's GitHub repos to the self-hosted Gitea instance +# (see setup_gitea.sh) as pull-mirrors, so Gitea keeps them in sync on its +# own -- this single migration mechanism covers both the initial import and +# ongoing sync, no separate push step or webhook needed. +# +# Safely re-runnable: skips repos that already exist on Gitea, so re-running +# later also picks up any new GitHub repos for free. +# +# Scope: only non-fork repos owned by kuhyx (21 at last count, 5 private). +# Local-only repos with no GitHub remote (e.g. ~/guard-lib) are out of scope. +# +# Private-repo credential: a dedicated fine-grained GitHub PAT (Contents: +# Read-only, scoped to just these repos), NOT the broad-scope `gh auth token` +# -- this host is internet-facing, so the mirror credential stored in +# Gitea's config should carry the least privilege that works. Public repos +# need no credential at all and clone anonymously. +# +# Usage: ./migrate_github_to_gitea.sh + +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +# shellcheck source=../../lib/common.sh +source "$SCRIPT_DIR/../../lib/common.sh" + +readonly GITEA_DOMAIN="kuhy.duckdns.org" +readonly GITEA_OWNER="kuhyx" +readonly GITHUB_OWNER="kuhyx" +readonly GITEA_TOKEN_FILE="${HOME}/gitea/.admin_token" +readonly GITHUB_MIRROR_TOKEN_FILE="${HOME}/gitea/.github_mirror_token" + +die() { + log_error "$1" + exit 1 +} + +require_command gh || die "gh CLI is required." +require_command curl || die "curl is required." +[[ -f $GITEA_TOKEN_FILE ]] || die "No Gitea API token at ${GITEA_TOKEN_FILE} -- run setup_gitea.sh first." +[[ -f $GITHUB_MIRROR_TOKEN_FILE ]] || die "No GitHub mirror PAT at ${GITHUB_MIRROR_TOKEN_FILE} -- create a fine-grained PAT (Contents: Read-only) and save it there (chmod 600) first." + +GITEA_TOKEN="$(cat "$GITEA_TOKEN_FILE")" +readonly GITEA_TOKEN +GITHUB_MIRROR_TOKEN="$(cat "$GITHUB_MIRROR_TOKEN_FILE")" +readonly GITHUB_MIRROR_TOKEN + +repo_exists_on_gitea() { + local name="$1" status + status=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "https://${GITEA_DOMAIN}/api/v1/repos/${GITEA_OWNER}/${name}") + [[ $status == "200" ]] +} + +migrate_repo() { + local name="$1" is_private="$2" + local auth_field="" payload response http_code body + if [[ $is_private == "true" ]]; then + auth_field="\"auth_token\": \"${GITHUB_MIRROR_TOKEN}\"," + fi + payload=$( + cat <"$COMPOSE_FILE" <"$CADDYFILE" <&1) + if grep -q "Listen: http" <<<"$logs"; then + log_ok "Gitea is ready." + return 0 + fi + sleep 2 + done + die "Gitea did not become ready within 2 minutes -- check 'docker logs gitea'." +} + +bootstrap_admin() { + if docker exec -u git gitea gitea admin user list 2>/dev/null | grep -q "$GITEA_ADMIN_USER"; then + log_info "Admin user '${GITEA_ADMIN_USER}' already exists -- not recreating." + return 0 + fi + umask 077 + openssl rand -base64 24 >"$ADMIN_PASSWORD_FILE" + chmod 600 "$ADMIN_PASSWORD_FILE" + docker exec -u git gitea gitea admin user create \ + --username "$GITEA_ADMIN_USER" --password "$(cat "$ADMIN_PASSWORD_FILE")" \ + --email "$GITEA_ADMIN_EMAIL" --admin --must-change-password=false + log_ok "Created admin user '${GITEA_ADMIN_USER}'. Password saved to ${ADMIN_PASSWORD_FILE} (chmod 600)." +} + +mint_api_token() { + if [[ -f $ADMIN_TOKEN_FILE ]]; then + log_info "API token already exists at ${ADMIN_TOKEN_FILE} -- not regenerating." + return 0 + fi + umask 077 + docker exec -u git gitea gitea admin user generate-access-token \ + --username "$GITEA_ADMIN_USER" --token-name automation --scopes all --raw >"$ADMIN_TOKEN_FILE" + chmod 600 "$ADMIN_TOKEN_FILE" + log_ok "API token saved to ${ADMIN_TOKEN_FILE} (chmod 600)." +} + +open_firewall() { + if [[ -x $WIREGUARD_SCRIPT ]]; then + sudo "$WIREGUARD_SCRIPT" allow-web + else + log_warn "Could not find ${WIREGUARD_SCRIPT} -- open tcp/80 and tcp/443 manually." + fi +} + +attempt_upnp() { + has_cmd upnpc || { + log_warn "upnpc not installed -- skipping automatic port-forward attempt." + return 0 + } + local lan_ip + lan_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}') + [[ -n $lan_ip ]] || { + log_warn "Could not detect LAN IP -- skipping UPnP." + return 0 + } + if upnpc -e "gitea-https" -a "$lan_ip" 443 443 tcp >/dev/null 2>&1 && + upnpc -e "gitea-http" -a "$lan_ip" 80 80 tcp >/dev/null 2>&1; then + log_ok "UPnP port mapping succeeded for 80 and 443 -> ${lan_ip}." + else + log_warn "UPnP port mapping failed or unsupported by your router." + fi +} + +print_router_instructions() { + local lan_ip + lan_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}') + cat < ${lan_ip}:80 and TCP 443 -> ${lan_ip}:443. +4. Save (and reboot the router if it requires it). +5. Confirm from OUTSIDE your LAN (e.g. phone on cellular data): + https://${GITEA_DOMAIN} +EOF +} + +status_cmd() { + echo "=== Containers ===" + docker compose -f "$COMPOSE_FILE" ps 2>/dev/null || echo "(not deployed)" + echo + echo "=== Caddy certificate ===" + docker logs gitea-caddy 2>&1 | grep -i "certificate obtained" | tail -3 || echo "(no certificate log entry yet)" +} + +usage() { + cat < + +Commands: + setup Full first-time setup (idempotent, safe to re-run). + status Show container and certificate status. + help Show this message. +EOF +} + +main() { + local cmd="${1:-help}" + case "$cmd" in + setup) + write_compose_files + start_containers + wait_for_gitea + bootstrap_admin + mint_api_token + open_firewall + attempt_upnp + print_router_instructions + log_ok "Gitea setup complete." + ;; + status) + status_cmd + ;; + help | -h | --help) + usage + ;; + *) + log_error "Unknown command: $cmd" + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh b/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh index c10e246..6f4e4c6 100755 --- a/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh +++ b/linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh @@ -37,6 +37,7 @@ fi DUCKDNS_DOMAIN="${DUCKDNS_DOMAIN:-}" DUCKDNS_TOKEN="${DUCKDNS_TOKEN:-}" LAN_SUBNET="${LAN_SUBNET:-}" +ALLOW_WEB="${ALLOW_WEB:-false}" die() { log_error "$1" @@ -48,6 +49,7 @@ save_config() { DUCKDNS_DOMAIN="${DUCKDNS_DOMAIN}" DUCKDNS_TOKEN="${DUCKDNS_TOKEN}" LAN_SUBNET="${LAN_SUBNET}" +ALLOW_WEB="${ALLOW_WEB}" EOF chmod 600 "$CONFIG_FILE" } @@ -113,6 +115,10 @@ write_nftables_ruleset() { cp "$NFT_CONF" "${NFT_CONF}.bak.$(date +%s)" log_warn "Backed up existing ${NFT_CONF} before overwriting." fi + local web_rule="" + if [[ $ALLOW_WEB == "true" ]]; then + web_rule=$'\n\t\ttcp dport { 80, 443 } accept' + fi cat >"${NFT_CONF}.new" </dev/null || echo "(interface not up)" @@ -362,6 +376,7 @@ Usage: $0 [args] Commands: setup Full first-time setup (WireGuard, firewall, sshd, DuckDNS). add-peer Provision a new phone/laptop and print its QR code. + allow-web Open tcp/80 and tcp/443 in the firewall (for a web server on this host). status Show WireGuard/firewall/sshd status. revoke Remove a peer. help Show this message. @@ -375,7 +390,7 @@ main() { # off -- exec sudo "$0" "$@" inside require_root must re-launch with the # subcommand still present, or sudo would silently run with no args. case "$cmd" in - setup | add-peer | revoke) + setup | add-peer | revoke | allow-web) require_root "$@" ;; esac @@ -399,6 +414,9 @@ main() { add_phone_peer "${1:-}" print_android_instructions ;; + allow-web) + allow_web + ;; status) status_cmd ;;