Add gitea: standalone self-hosted Git server with GitHub mirror sync
Gitea previously only existed bundled inside the full ai-stack service (Ollama/ComfyUI/InvokeAI/etc. all together) — no way to get just a git server without the rest of that heavy stack. This adds it as its own lightweight service, reusing the vendored gitea-github-sync.sh but not any of ai-stack's other components. - Auto-creates a Gitea admin account and API token via the container's own CLI (no manual web setup wizard). - Asks GitHub token, sync direction (GitHub->Gitea / Gitea->GitHub / both), and whether to install a systemd timer for automatic sync — prints manual commands instead if declined. - Own systemd unit for the timer rather than the vendor script's built-in --install-timer, since that always runs both directions with no way to pin a single direction.
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user