Add self-hosted Gitea deployment, mirroring all GitHub repos publicly
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5jnu99ZuENSkuFQKLcSdh
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-07-04 07:45:00 +02:00
parent f9664561ac
commit ea94435c4f
5 changed files with 583 additions and 2 deletions

View File

@ -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 <container> 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:<port>` 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 <container> | 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 <caddy-container> | 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: <external IP>`) 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 <container> --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.

View File

@ -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"
]
}

View File

@ -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 <<JSON
{
"clone_addr": "https://github.com/${GITHUB_OWNER}/${name}.git",
"repo_name": "${name}",
"repo_owner": "${GITEA_OWNER}",
"private": ${is_private},
"mirror": true,
"mirror_interval": "10m0s",
${auth_field}
"wiki": true,
"issues": false,
"pull_requests": false,
"releases": true
}
JSON
)
response=$(curl -s -w '\n%{http_code}' -X POST "https://${GITEA_DOMAIN}/api/v1/repos/migrate" \
-H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
-d "$payload")
http_code="${response##*$'\n'}"
body="${response%$'\n'*}"
if [[ $http_code == "201" ]]; then
log_ok "Migrated ${name} (private=${is_private})."
else
log_error "Failed to migrate ${name} (HTTP ${http_code}): ${body}"
return 1
fi
}
main() {
log_info "Enumerating non-fork GitHub repos owned by ${GITHUB_OWNER}..."
local failures=0 total=0 skipped=0 migrated=0
while IFS=$'\t' read -r name is_private; do
[[ -n $name ]] || continue
total=$((total + 1))
if repo_exists_on_gitea "$name"; then
log_info "Skipping ${name} -- already exists on Gitea."
skipped=$((skipped + 1))
continue
fi
if migrate_repo "$name" "$is_private"; then
migrated=$((migrated + 1))
else
failures=$((failures + 1))
fi
done < <(gh repo list "$GITHUB_OWNER" --limit 200 --json name,isPrivate,isFork \
--jq '.[] | select(.isFork==false) | [.name, .isPrivate] | @tsv')
log_ok "Done: ${total} repos considered, ${migrated} migrated, ${skipped} already present, ${failures} failed."
[[ $failures -eq 0 ]] || exit 1
}
main "$@"

View File

@ -0,0 +1,239 @@
#!/bin/bash
# Self-hosted Gitea git server, mirroring GitHub repos, exposed publicly via
# DuckDNS + Caddy automatic HTTPS.
#
# Companion to setup_wireguard_ssh.sh, which owns /etc/nftables.conf and
# provides the 'allow-web' firewall rule this script depends on.
#
# DNS note: kuhy.duckdns.org is kept updated by an existing cron job installed
# by install_joplin.sh (~/.joplin-server/duckdns-update.sh) -- this script
# does not manage DuckDNS itself.
#
# Networking note: Gitea and Caddy run with `network_mode: host`, not a
# Docker bridge network. This host's custom nftables ruleset has a
# default-drop FORWARD chain with no rules; Docker's own forwarding rules
# (separate iptables-legacy tables) don't override it, since both hook the
# same netfilter point and a DROP verdict from either is terminal. That
# silently blocks all bridge-networked container egress -- breaking both
# Caddy's ACME renewal and Gitea's GitHub pull-mirroring. Host networking
# sidesteps the FORWARD chain entirely (uses INPUT/OUTPUT only, both already
# permissive) without touching the shared firewall script. Gitea binds to
# 127.0.0.1:3000 only -- never exposed directly, only reachable via Caddy.
#
# Usage:
# ./setup_gitea.sh setup - full first-time setup (idempotent, re-runnable)
# ./setup_gitea.sh status - show container + certificate status
# ./setup_gitea.sh help
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_ADMIN_USER="kuhyx"
readonly GITEA_ADMIN_EMAIL="krzysztofrudnicki0@gmail.com"
readonly GITEA_DATA_DIR="${HOME}/gitea"
readonly COMPOSE_FILE="${GITEA_DATA_DIR}/docker-compose.yml"
readonly CADDYFILE="${GITEA_DATA_DIR}/Caddyfile"
readonly ADMIN_PASSWORD_FILE="${GITEA_DATA_DIR}/.admin_password"
readonly ADMIN_TOKEN_FILE="${GITEA_DATA_DIR}/.admin_token"
readonly WIREGUARD_SCRIPT="${SCRIPT_DIR}/setup_wireguard_ssh.sh"
die() {
log_error "$1"
exit 1
}
write_compose_files() {
ensure_dir "$GITEA_DATA_DIR"
cat >"$COMPOSE_FILE" <<EOF
services:
gitea:
image: gitea/gitea:1.22.3
container_name: gitea
restart: unless-stopped
network_mode: host
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__server__DOMAIN=${GITEA_DOMAIN}
- GITEA__server__ROOT_URL=https://${GITEA_DOMAIN}/
- GITEA__server__HTTP_ADDR=127.0.0.1
- GITEA__server__HTTP_PORT=3000
- GITEA__server__DISABLE_SSH=true
- GITEA__service__DISABLE_REGISTRATION=true
- GITEA__service__REGISTER_EMAIL_CONFIRM=false
- GITEA__security__INSTALL_LOCK=true
- GITEA__mirror__ENABLED=true
- GITEA__mirror__DEFAULT_INTERVAL=10m
- GITEA__mirror__MIN_INTERVAL=10m
volumes:
- ./gitea-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
caddy:
image: caddy:2.8
container_name: gitea-caddy
restart: unless-stopped
network_mode: host
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes:
caddy-data:
caddy-config:
EOF
cat >"$CADDYFILE" <<EOF
${GITEA_DOMAIN} {
reverse_proxy 127.0.0.1:3000
}
EOF
log_ok "Wrote ${COMPOSE_FILE} and ${CADDYFILE}."
}
start_containers() {
docker compose -f "$COMPOSE_FILE" up -d
}
wait_for_gitea() {
log_info "Waiting for Gitea to become ready..."
local attempt logs
for ((attempt = 1; attempt <= 60; attempt++)); do
# Capture into a variable rather than piping straight into
# `grep -q`: grep quits at the first match, SIGPIPE-ing `docker
# logs` before it finishes writing, and under `pipefail` that
# upstream non-zero exit clobbers grep's own success -- the `if`
# never sees a match even though one is right there in the log.
logs=$(docker logs gitea 2>&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 <<EOF
=== Manual step: verify port forwarding on your router (cannot be automated) ===
1. Log into your router admin page (often http://192.168.1.1).
2. Find "Port Forwarding" / "Virtual Server" / "NAT" settings.
3. Forward TCP 80 -> ${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 <<EOF
Usage: $0 <command>
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 "$@"

View File

@ -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" <<EOF
#!/usr/sbin/nft -f
flush ruleset
@ -131,7 +137,7 @@ table inet filter {
udp dport ${WG_PORT} accept
iifname "${WG_IFACE}" tcp dport 22 accept
ip saddr ${LAN_SUBNET} tcp dport 22 accept
ip saddr ${LAN_SUBNET} tcp dport 22 accept${web_rule}
}
chain forward {
type filter hook forward priority 0; policy drop;
@ -341,6 +347,14 @@ print_android_instructions() {
EOF
}
allow_web() {
ALLOW_WEB=true
save_config
write_nftables_ruleset
verify_nftables_then_apply
log_ok "Opened tcp/80 and tcp/443 in the input chain (persisted -- future 'setup' re-runs keep this rule)."
}
status_cmd() {
echo "=== WireGuard ==="
wg show 2>/dev/null || echo "(interface not up)"
@ -362,6 +376,7 @@ Usage: $0 <command> [args]
Commands:
setup Full first-time setup (WireGuard, firewall, sshd, DuckDNS).
add-peer <name> 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 <name> 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
;;