mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 15:03:01 +02:00
Add self-hosted Gitea deployment, mirroring all GitHub repos publicly
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
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:
parent
f9664561ac
commit
ea94435c4f
142
.github/skills/self-hosted-service-exposure/SKILL.md
vendored
Normal file
142
.github/skills/self-hosted-service-exposure/SKILL.md
vendored
Normal 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.
|
||||
67
docs/superpowers/evidence/self-hosted-gitea-2026-07-04.json
Normal file
67
docs/superpowers/evidence/self-hosted-gitea-2026-07-04.json
Normal 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"
|
||||
]
|
||||
}
|
||||
115
linux_configuration/scripts/single_use/features/migrate_github_to_gitea.sh
Executable file
115
linux_configuration/scripts/single_use/features/migrate_github_to_gitea.sh
Executable 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 "$@"
|
||||
239
linux_configuration/scripts/single_use/features/setup_gitea.sh
Executable file
239
linux_configuration/scripts/single_use/features/setup_gitea.sh
Executable 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 "$@"
|
||||
@ -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
|
||||
;;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user