diff --git a/README.md b/README.md index 4acbd8f..3de7e98 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) | -| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | +| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/services/gitea.sh b/services/gitea.sh new file mode 100644 index 0000000..30761f3 --- /dev/null +++ b/services/gitea.sh @@ -0,0 +1,441 @@ +#!/bin/bash +# services/gitea.sh — Self-hosted Gitea (lightweight Git server), with an +# optional two-way GitHub mirror sync (gitea-github-sync.sh, vendored from +# the ai-stack bundle but genuinely standalone here — this does NOT pull in +# Ollama/ComfyUI/InvokeAI/any of the rest of that stack, just the one +# Gitea container + the sync script). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash gitea.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } + + _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _COMMON="$_SELF_DIR/../lib/common.sh" + + if [[ -f "$_COMMON" ]]; then + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } + log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } + log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } + log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } + + require_docker() { + command -v docker &>/dev/null || { + log_error "Docker not found. Install it first:" + log_error " curl -fsSL https://get.docker.com | sudo sh" + return 1 + } + docker compose version &>/dev/null || { + log_error "Docker Compose plugin missing:" + log_error " sudo apt-get install -y docker-compose-plugin" + return 1 + } + } + + ensure_docker_dir_ownership() { + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true + } + + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_yn() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_reinstall_mode() { + local _var="$1" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='cancel'"; return; } + echo "" + echo " 1) Update — refresh the image only, leave config/data as-is" + echo " 2) Full reinstall — wipe and reconfigure from scratch" + echo " 3) Cancel — leave the existing install untouched" + read -r -p " Choice [3]: " _r + case "$_r" in + 1) eval "$_var='update'" ;; + 2) eval "$_var='fresh'" ;; + *) eval "$_var='cancel'" ;; + esac + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" + local _display_port="${_upstream##*:}" + log_info "Access $_name directly on port $_display_port (no Caddy in standalone mode)." + CADDY_SERVICE_CONFIGURED=false + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" + ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service gitea utilities "Self-hosted Git server (Gitea) — raw local clones plus optional two-way GitHub mirror sync" 3001 + +_gitea_sync_vendor_src() { + local _self_dir + _self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + echo "$(cd "$_self_dir/.." && pwd)/vendor/ai-stack/gitea-github-sync.sh" +} + +# ── Own systemd timer, not gitea-github-sync.sh's built-in --install-timer ── +# The vendor script's own timer installer always runs the script bare (no +# --pull-only/--push-only), i.e. always both directions — there's no way to +# hand it a fixed sync direction. Since this installer asks up front which +# direction to run automatically, the timer's ExecStart bakes that flag in +# directly instead of delegating to the vendor script's own (less flexible) +# --install-timer/--remove-timer modes. +_gitea_write_sync_timer() { + local DIR="$1" RUN_USER="$2" RUN_HOME="$3" FLAG="$4" INTERVAL="$5" + local _service="/etc/systemd/system/gitea-github-sync.service" + local _timer="/etc/systemd/system/gitea-github-sync.timer" + + cat > "$_service" << UNIT +[Unit] +Description=Gitea-GitHub Mirror Sync +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=oneshot +User=${RUN_USER} +Environment=HOME=${RUN_HOME} +Environment=SYNC_ENV=${DIR}/.env +ExecStart=/bin/bash ${DIR}/gitea-github-sync.sh ${FLAG} +UNIT + + cat > "$_timer" << UNIT +[Unit] +Description=Gitea-GitHub Sync Timer + +[Timer] +OnBootSec=5min +OnUnitActiveSec=${INTERVAL} +Persistent=true + +[Install] +WantedBy=timers.target +UNIT + + systemctl daemon-reload + systemctl enable --now gitea-github-sync.timer +} + +_gitea_remove_sync_timer() { + systemctl disable --now gitea-github-sync.timer 2>/dev/null || true + rm -f /etc/systemd/system/gitea-github-sync.service /etc/systemd/system/gitea-github-sync.timer + systemctl daemon-reload 2>/dev/null || true +} + +# Ask sync direction + autosync, apply to either a fresh setup or a +# reconfigure of an existing one. Always asked (matches pstn-trunk.sh's +# international-calling step reasoning: a live-editable extra, not a +# structural setting tied exclusively to fresh installs). +_gitea_run_sync_direction_step() { + local DIR="$1" + + echo "" + echo " Sync direction:" + echo " 1) GitHub -> Gitea only (cloud to local — backup your GitHub repos here)" + echo " 2) Gitea -> GitHub only (local to cloud — push repos created here up to GitHub)" + echo " 3) Both directions" + local _DIR_CHOICE="" + prompt_text " Choice [1]:" "1" _DIR_CHOICE + local FLAG="" DIR_DESC="" + case "$_DIR_CHOICE" in + 2) FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;; + 3) FLAG=""; DIR_DESC="both directions" ;; + *) FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;; + esac + log_info "Sync direction: $DIR_DESC" + + _gitea_remove_sync_timer + + echo "" + local AUTOSYNC="" + prompt_yn "Enable automatic sync on a schedule? (y/n):" "y" AUTOSYNC + if [[ "$AUTOSYNC" =~ ^[Yy]$ ]]; then + local INTERVAL="" + prompt_text " Sync interval (e.g. 1h, 6h, 1d) [6h]:" "6h" INTERVAL + _gitea_write_sync_timer "$DIR" "$ACTUAL_USER" "$ACTUAL_HOME" "$FLAG" "$INTERVAL" + log_success "Timer installed: syncs every $INTERVAL ($DIR_DESC)." + log_info "Check status: systemctl status gitea-github-sync.timer" + log_info "Run now: sudo systemctl start gitea-github-sync.service" + log_info "Logs: ~/.config/gitea-github-sync/sync.log" + else + log_info "Automatic sync not enabled. Run it yourself whenever you want:" + log_info " cd $DIR && bash gitea-github-sync.sh $FLAG" + [[ -z "$FLAG" ]] && log_info " (no flag needed for both directions)" + fi +} + +install_gitea() { + log_info "Setting up self-hosted Gitea..." + + local DIR="$DOCKER_DIR/gitea" + local SYNC_SRC + SYNC_SRC="$(_gitea_sync_vendor_src)" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with docker-compose.yml (gitea/gitea:latest)" + echo "[DRY-RUN] Would scan for free host ports (web + SSH) to avoid collisions" + echo "[DRY-RUN] Would auto-create a Gitea admin account and API token (no manual web wizard)" + echo "[DRY-RUN] Would prompt for a GitHub token and copy in gitea-github-sync.sh" + echo "[DRY-RUN] Would ask sync direction (GitHub->Gitea / Gitea->GitHub / both) and whether" + echo "[DRY-RUN] to install a systemd timer for automatic sync, or print manual instructions" + echo "[DRY-RUN] Would write $DIR/README.md" + return 0 + fi + + if [[ ! -f "$SYNC_SRC" ]]; then + log_error "Vendored gitea-github-sync.sh not found at $SYNC_SRC" + return 1 + fi + + require_docker || return 1 + + # ── Existing install? ─────────────────────────────────────────────────── + if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then + echo "" + log_info "Existing Gitea install found at $DIR." + local MODE="" + prompt_reinstall_mode MODE + case "$MODE" in + update) + cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh" + chmod +x "$DIR/gitea-github-sync.sh" + ensure_docker_dir_ownership "$DIR" + (cd "$DIR" && docker compose up -d) \ + && log_success "Gitea refreshed and restarted." \ + || log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs" + _gitea_run_sync_direction_step "$DIR" + log_success "Existing .env (tokens) and web/SSH ports were left untouched." + return 0 + ;; + cancel) + log_info "Leaving the existing install as-is." + return 0 + ;; + fresh) log_info "Reconfiguring from scratch — every prompt below runs again." ;; + esac + fi + + mkdir -p "$DIR" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + # ── Port scan — web (default 3001->3000) and SSH (default 2222->22) ──── + local WEB_PORT=3001 SSH_PORT=2222 + find_free_port WEB_PORT "$WEB_PORT" + find_free_port SSH_PORT "$SSH_PORT" + [[ "$WEB_PORT" != 3001 ]] && log_info "Port 3001 was taken — Gitea's web UI will use ${WEB_PORT}." + [[ "$SSH_PORT" != 2222 ]] && log_info "Port 2222 was taken — Gitea's SSH clone port will use ${SSH_PORT}." + + cat > docker-compose.yml << EOF +name: gitea +services: + gitea: + image: gitea/gitea:latest + container_name: gitea + restart: unless-stopped + ports: + - "${WEB_PORT}:3000" + - "${SSH_PORT}:22" + volumes: + - ./data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=sqlite3 + - GITEA__database__PATH=/data/gitea/gitea.db + - GITEA__security__INSTALL_LOCK=true +EOF + + ensure_docker_dir_ownership "$DIR" + docker compose up -d \ + && log_success "Gitea container started." \ + || { log_error "docker compose up failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; } + + # ── Wait for Gitea to actually be ready before touching its CLI ──────── + log_info "Waiting for Gitea to finish starting..." + local _tries=0 _ready=false + while [[ $_tries -lt 30 ]]; do + if docker exec gitea gitea admin user list &>/dev/null; then + _ready=true + break + fi + sleep 2 + _tries=$((_tries + 1)) + done + if [[ "$_ready" != true ]]; then + log_error "Gitea didn't come up in time — check: docker compose -f $DIR/docker-compose.yml logs" + return 1 + fi + + # ── Auto-create an admin account + API token — no manual web wizard ──── + local GITEA_ADMIN_USER="$ACTUAL_USER" + local GITEA_ADMIN_PASS + GITEA_ADMIN_PASS="$(generate_password 24)" + docker exec -u git gitea gitea admin user create --admin \ + --username "$GITEA_ADMIN_USER" --password "$GITEA_ADMIN_PASS" \ + --email "${GITEA_ADMIN_USER}@localhost" --must-change-password=false \ + &>/dev/null \ + && log_success "Admin account created: $GITEA_ADMIN_USER" \ + || { log_error "Failed to create the Gitea admin account — check: docker logs gitea"; return 1; } + + local GITEA_TOKEN="" + GITEA_TOKEN="$(docker exec -u git gitea gitea admin user generate-access-token \ + --username "$GITEA_ADMIN_USER" --token-name sync \ + --scopes write:repository,write:user --raw 2>/dev/null)" + if [[ -z "$GITEA_TOKEN" ]]; then + log_warning "Automatic token generation didn't work (older Gitea image?) — generate one" + log_warning "by hand: log into http://localhost:${WEB_PORT} as $GITEA_ADMIN_USER, then" + log_warning "Settings -> Applications -> Generate New Token (repo + user write access)." + prompt_text " Paste the Gitea token here:" "" GITEA_TOKEN + fi + + # ── GitHub token ───────────────────────────────────────────────────────── + echo "" + log_info "Needs a GitHub Personal Access Token (not an SSH key — this talks to GitHub's" + log_info "REST API too, which SSH can't do). Generate one at https://github.com/settings/tokens" + log_info "with 'repo' scope if you don't already have one handy." + local GITHUB_TOKEN="" + prompt_text " GitHub token:" "" GITHUB_TOKEN + if [[ -z "$GITHUB_TOKEN" ]]; then + log_warning "No GitHub token entered — Gitea itself is still up, but the sync script won't" + log_warning "work until you add one to $DIR/.env and re-run this installer (update mode)." + fi + + cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh" + chmod +x "$DIR/gitea-github-sync.sh" + + cat > "$DIR/.env" << ENV +# Written by services/gitea.sh — re-run that (update mode) to change any of this. +GITEA_URL='http://localhost:${WEB_PORT}' +GITEA_TOKEN='${GITEA_TOKEN}' +GITHUB_TOKEN='${GITHUB_TOKEN}' +ENV + chmod 600 "$DIR/.env" + chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" "$DIR/gitea-github-sync.sh" + + # ── Discover GitHub/Gitea usernames + scope prefs (the sync script's own + # first-time setup) — runs as the real user, not root, so its config + # lands under the real user's home, not /root. + if [[ -n "$GITHUB_TOKEN" && -n "$GITEA_TOKEN" ]]; then + echo "" + if [ "$UNATTENDED" = true ]; then + log_info "Unattended mode — skipping the interactive sync setup. Run it yourself later:" + log_info " cd $DIR && sudo -u $ACTUAL_USER bash gitea-github-sync.sh --init" + else + sudo -u "$ACTUAL_USER" env HOME="$ACTUAL_HOME" SYNC_ENV="$DIR/.env" \ + bash "$DIR/gitea-github-sync.sh" --init + fi + fi + + _gitea_run_sync_direction_step "$DIR" + + # ── Caddy (Gitea has its own built-in login — no Authelia needed) ────── + configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git" + + write_readme "$DIR" << MD +# Gitea + +Self-hosted Git server. Raw, real working-copy clones are just normal +\`git clone\` commands against it (or against GitHub directly) — Gitea's own +storage is separate from that, used for the web UI and the mirror sync +below. + +- Web UI: http://localhost:${WEB_PORT} +- Admin login: \`${GITEA_ADMIN_USER}\` / see \`.env\` if you need the generated + password again (\`docker exec -u git gitea gitea admin user change-password\` + to rotate it) +- SSH clone port: ${SSH_PORT} (e.g. \`git clone ssh://git@localhost:${SSH_PORT}/user/repo.git\`) + +## GitHub mirror sync + +\`gitea-github-sync.sh\` (in this directory) mirrors repos between this Gitea +and GitHub. Tokens live in \`.env\` (chmod 600) — treat them like passwords. + +\`\`\`bash +cd $DIR +bash gitea-github-sync.sh --list # preview what would sync, no changes +bash gitea-github-sync.sh --pull-only # GitHub -> Gitea only +bash gitea-github-sync.sh --push-only # Gitea -> GitHub only +bash gitea-github-sync.sh # both directions +\`\`\` + +Config (which repos, private/forks handling) lives at +\`~/.config/gitea-github-sync/config\` — edit directly, or re-run +\`bash gitea-github-sync.sh --init\` to redo it interactively. + +## Manage + +\`\`\`bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose up -d +sudo ./setup.sh gitea # re-run to change sync direction/schedule, or refresh +\`\`\` +MD + + echo "" + log_success "Gitea installed at $DIR" + echo " Web UI: http://localhost:${WEB_PORT} (login: ${GITEA_ADMIN_USER})" + echo " Details, sync commands: $DIR/README.md" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_gitea