diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 00000000..3aae6dc2 --- /dev/null +++ b/ops/README.md @@ -0,0 +1,26 @@ +# Scripts de operação da instância + +Rodam na VPS, em `/opt/ops`, onde os arquivos são links simbólicos para esta +pasta — o repositório é a única cópia de verdade. Antes disso eles existiam só +na VPS, sem versão nem backup: uma reinstalação do servidor levaria junto todo +o processo de publicação. + +| script | o que faz | +| --- | --- | +| `call-check.sh` | Diz se tem alguém em call. Roda antes de reiniciar o servidor. | +| `release-desktop.sh` | Espelha o fork no GitHub e dispara a compilação dos instaladores de desktop. | +| `release-android.sh` | Compila o APK no GitHub, assina na VPS e publica no Gitea. | + +## O que estes scripts leem e não está aqui + +Nada de segredo entra no repositório. Os arquivos abaixo ficam só na VPS, com +permissão `600`: + +- `/root/.github-token` — dispara e baixa os builds do GitHub. +- `/root/.gitea-token` — publica os anexos das releases. +- `/root/android-signing/` — a chave que assina o APK, mais a senha dela. + +A chave do Android merece atenção especial: ela é o que prova ao Android que +uma atualização veio de nós. Se for perdida, ninguém consegue atualizar por +cima — todo mundo tem que desinstalar e instalar de novo, e não existe +recuperação. Mantenha uma cópia fora da VPS. diff --git a/ops/call-check.sh b/ops/call-check.sh new file mode 100755 index 00000000..552efd24 --- /dev/null +++ b/ops/call-check.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Reports how many people are currently in a call on this instance. +# +# Asks LiveKit directly (ListRooms over its twirp API) rather than inferring +# from the app: LiveKit is the authority on who is actually connected to media. +# Prints the participant count and exits 0 when the instance is idle, 1 when +# someone is in a call — so it can gate a deploy. +set -euo pipefail + +ENV_FILE=${ENV_FILE:-/opt/backspace/.env} +# `|| true`: under `set -o pipefail` a grep with no match aborts the script +# before the check below, exiting 1 — which in this script's contract means +# "someone is in a call" rather than "I could not tell". +KEY=$(grep -E '^LIVEKIT_API_KEY=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true) +SECRET=$(grep -E '^LIVEKIT_API_SECRET=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true) +[ -n "$KEY" ] && [ -n "$SECRET" ] || { echo "LiveKit credentials not found in $ENV_FILE" >&2; exit 2; } + +b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } + +now=$(date +%s) +header=$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | b64url) +payload=$(printf '{"iss":"%s","sub":"%s","nbf":%d,"exp":%d,"video":{"roomList":true}}' \ + "$KEY" "$KEY" "$now" "$((now + 60))" | b64url) +sig=$(printf '%s.%s' "$header" "$payload" \ + | openssl dgst -sha256 -hmac "$SECRET" -binary | b64url) +jwt="$header.$payload.$sig" + +resp=$(curl -sS --max-time 10 -w '\n%{http_code}' -X POST http://127.0.0.1:7880/twirp/livekit.RoomService/ListRooms \ + -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' -d '{}') || { + echo "LiveKit unreachable — refusing to report idle" >&2; exit 2; } + +code=$(printf '%s' "$resp" | tail -n1) +resp=$(printf '%s' "$resp" | sed '$d') + +# A failed query returns no participant counts, which would read as "idle" and +# green-light a deploy on top of a live call. Anything but a well-formed 200 is +# treated as unknown, not as empty. +if [ "$code" != "200" ] || ! printf '%s' "$resp" | grep -q '"rooms"'; then + echo "LiveKit answered HTTP $code without a room list — status unknown, not deploying" >&2 + exit 2 +fi + +# numParticipants is omitted by protobuf JSON when zero, so absence means empty. +total=$(printf '%s' "$resp" | grep -oE '"numParticipants":[0-9]+' | cut -d: -f2 | paste -sd+ - | bc 2>/dev/null || true) +total=${total:-0} + +if [ "$total" -gt 0 ]; then + echo "EM CALL: $total participante(s) — nao deployar" + exit 1 +fi +echo "OCIOSO: ninguem em call" +exit 0 diff --git a/ops/release-android.sh b/ops/release-android.sh new file mode 100755 index 00000000..24b8eeeb --- /dev/null +++ b/ops/release-android.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Baixa o APK compilado no GitHub, assina aqui e publica no Gitea. +# +# A chave de assinatura fica nesta VPS e nao sobe para o GitHub. Ela e o que +# prova que uma atualizacao veio de nos: um APK assinado com outra chave nao +# instala por cima do instalado, o Android recusa. Por isso o runner entrega o +# APK sem assinatura e a assinatura acontece aqui. +# +# Uso: release-android.sh [run_id] +# sem run_id, dispara um build novo e espera terminar. +set -euo pipefail + +REPO=${REPO:-/opt/backspace} +GH_REPO=${GH_REPO:-syncwrld/resenhacord} +TOOLS=/opt/ops/android-tools/build-tools +KEYSTORE=/root/android-signing/resenhacord.keystore +PASSFILE=/root/android-signing/password.txt +OUT=/opt/ops/android-out +GITEA_API=https://git.resenha.website/api/v1/repos/devsyncwrld/backspace + +TOKEN=$(cat /root/.github-token) +gh() { curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json" "$@"; } + +# apksigner e um script que chama java, e esta VPS nao tem JDK instalado (nem +# deve ter: ela roda a producao, nao compila nada). Roda num container, com as +# ferramentas, a chave e a pasta de saida montadas. zipalign e binario nativo e +# roda direto no host. +apksigner() { + docker run --rm \ + -v "$TOOLS":/tools:ro \ + -v /root/android-signing:/ks:ro \ + -v "$OUT":/out \ + eclipse-temurin:21-jdk /tools/apksigner "$@" +} + +for f in "$TOOLS/apksigner" "$TOOLS/zipalign" "$KEYSTORE" "$PASSFILE"; do + [ -e "$f" ] || { echo "faltando: $f" >&2; exit 1; } +done + +RUN_ID=${1:-} +if [ -z "$RUN_ID" ]; then + echo "==> Disparando o build no GitHub" + gh -o /dev/null -X POST \ + "https://api.github.com/repos/$GH_REPO/actions/workflows/android.yml/dispatches" \ + -d '{"ref":"main"}' + sleep 10 + RUN_ID=$(gh "https://api.github.com/repos/$GH_REPO/actions/workflows/android.yml/runs?per_page=1" \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['workflow_runs'][0]['id'])") + echo " run=$RUN_ID" + + echo "==> Esperando (uns 4 min)" + for _ in $(seq 1 90); do + read -r status conclusion < <(gh "https://api.github.com/repos/$GH_REPO/actions/runs/$RUN_ID" \ + | python3 -c "import sys,json;r=json.load(sys.stdin);print(r['status'],r['conclusion'])") + [ "$status" = "completed" ] && break + sleep 20 + done + [ "${conclusion:-}" = "success" ] || { echo "build falhou: ${conclusion:-timeout}" >&2; exit 1; } +fi + +echo "==> Baixando o APK sem assinatura" +mkdir -p "$OUT" +URL=$(gh "https://api.github.com/repos/$GH_REPO/actions/runs/$RUN_ID/artifacts" \ + | python3 -c "import sys,json;a=json.load(sys.stdin)['artifacts'];print(a[0]['archive_download_url'] if a else '')") +[ -n "$URL" ] || { echo "o run $RUN_ID nao tem artefato" >&2; exit 1; } +curl -sL -H "Authorization: Bearer $TOKEN" "$URL" -o "$OUT/apk.zip" +rm -f "$OUT/resenhacord-unsigned.apk" +unzip -o -q "$OUT/apk.zip" -d "$OUT" + +VERSION=$(python3 -c "import json;print(json.load(open('$REPO/package.json'))['version'])") +SIGNED="$OUT/Resenhacord-$VERSION.apk" + +echo "==> Alinhando e assinando $VERSION" +# --key-pass omitido de proposito: sem ele o apksigner reusa a senha do +# keystore, que e a mesma. Passar o mesmo arquivo nas duas opcoes falha — ele le +# os arquivos de senha em sequencia e bate no fim do arquivo na segunda leitura. +# zipalign antes do apksigner, nao depois: o alinhamento move bytes dentro do +# zip e invalidaria a assinatura se viesse por ultimo. +"$TOOLS/zipalign" -p -f 4 "$OUT/resenhacord-unsigned.apk" "$OUT/aligned.apk" +apksigner sign \ + --ks /ks/resenhacord.keystore --ks-pass file:/ks/password.txt \ + --ks-key-alias resenhacord \ + --out "/out/$(basename "$SIGNED")" /out/aligned.apk +rm -f "$OUT/aligned.apk" "$OUT/apk.zip" + +echo "==> Conferindo a assinatura" +apksigner verify --print-certs "/out/$(basename "$SIGNED")" | head -5 + +if [ "${1:-}" = "--no-publish" ] || [ "${PUBLISH:-1}" = "0" ]; then + echo "APK pronto: $SIGNED" + exit 0 +fi + +echo "==> Publicando no Gitea" +GITEA_TOKEN=$(cat /root/.gitea-token 2>/dev/null || true) +if [ -z "$GITEA_TOKEN" ]; then + echo "sem /root/.gitea-token — APK pronto localmente em $SIGNED" + exit 0 +fi +api() { curl -s -w '\n%{http_code}' -X "$1" -H "Authorization: token $GITEA_TOKEN" "$GITEA_API$2" "${@:3}"; } +body() { sed '$d' <<<"$1"; } +code() { tail -n1 <<<"$1"; } + +R=$(api GET "/releases/tags/latest") +ID=$(body "$R" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "") +[ -n "$ID" ] || { echo "release 'latest' nao encontrada no Gitea" >&2; exit 1; } + +# Remove o APK anterior: dois anexos com o mesmo nome fazem o Gitea servir o +# mais antigo, e quem baixasse pegaria a versao velha sem erro nenhum. +api GET "/releases/$ID/assets" | sed '$d' \ + | python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \ + | while read -r aid; do + [ -n "$aid" ] && api DELETE "/releases/$ID/assets/$aid" >/dev/null && echo " removido anexo $aid" + done + +NAME=$(basename "$SIGNED") +R=$(api POST "/releases/$ID/assets?name=$NAME" -F "attachment=@$SIGNED") +[ "$(code "$R")" = "201" ] || { echo "falha no upload — HTTP $(code "$R"): $(body "$R")" >&2; exit 1; } +echo "publicado: $NAME" +echo "https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/$NAME" diff --git a/ops/release-desktop.sh b/ops/release-desktop.sh new file mode 100755 index 00000000..2511c337 --- /dev/null +++ b/ops/release-desktop.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Espelha o fork no GitHub e dispara a compilacao dos instaladores. +# +# O GitHub e usado apenas como maquina de build: ele fornece runners Windows +# prontos, e o modulo nativo do audio (WASAPI) so compila la. O Gitea continua +# sendo o repositorio principal. +set -euo pipefail + +REPO=${REPO:-/opt/backspace} +TAG=${1:-} +cd "$REPO" + +if [ -z "$TAG" ]; then + echo "uso: $0 vX.Y.Z" >&2 + exit 2 +fi + +# `ssh -T git@github.com` always exits 1 (GitHub refuses shell access), and +# under `pipefail` that status wins over grep's — so piping straight into the +# `if` reported failure even on a working key. Capture, then test. +SSH_PROBE="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -T git@github.com 2>&1 || true)" +if ! printf '%s' "$SSH_PROBE" | grep -q "successfully authenticated"; then + echo "GitHub ainda nao aceita a chave desta VPS." >&2 + echo "Adicione esta chave em https://github.com/settings/keys :" >&2 + cat ~/.ssh/id_ed25519.pub >&2 + exit 1 +fi + +echo "==> Espelhando main no GitHub" +git push github main + +echo "==> Marcando $TAG (e a tag que dispara o workflow release.yml)" +git tag -f "$TAG" +git push -f github "$TAG" + +echo +echo "Compilacao disparada. Acompanhe em:" +echo " https://github.com/syncwrld/resenhacord/actions" +echo "Os instaladores aparecem em Releases quando terminar (~15 min)."