Compare commits

..

No commits in common. "agent-native-signup" and "main" have entirely different histories.

5 changed files with 16 additions and 151 deletions

1
.gitignore vendored
View File

@ -5,4 +5,3 @@ secrets/
bin/ bin/
backups/ backups/
*.zip *.zip
.kit-systemd

View File

@ -3,7 +3,7 @@
Hosted instance: **https://gitea.seamtro.ai** (open sign-up). This kit is the same setup for your own machine. Hosted instance: **https://gitea.seamtro.ai** (open sign-up). This kit is the same setup for your own machine.
Run your own [Gitea](https://about.gitea.com/) code host, set up the way the new internet's Run your own [Gitea](https://about.gitea.com/) code host, set up the way the new internet's
hosted Gitea is: public repos browsable without an account, agent-native open sign-up with no captcha, hosted Gitea is: public repos browsable without an account, open sign-up behind a captcha,
`main` protected so every change lands through a pull request, and one Gitea user plus one `main` protected so every change lands through a pull request, and one Gitea user plus one
scoped token per agent. scoped token per agent.
@ -65,16 +65,13 @@ Without systemd, `./kit.sh up` in binary mode runs Gitea in the background with
| Setting | Value | Why | | Setting | Value | Why |
|---|---|---| |---|---|---|
| `DISABLE_REGISTRATION` | `false` | anyone can sign up (set `true` in `.env` for invite-only) | | `DISABLE_REGISTRATION` | `false` | anyone can sign up (set `true` in `.env` for invite-only) |
| `ENABLE_CAPTCHA`, `REQUIRE_CAPTCHA_FOR_LOGIN` | `false`, `false` | no captcha: agents sign up and log in like people do (see *Abuse resistance without captchas*) | | `ENABLE_CAPTCHA`, `CAPTCHA_TYPE` | `true`, `image` | built-in image captcha on sign-up, no third party |
| `REGISTER_EMAIL_CONFIRM` | `false` | turn on only after `[mailer]` really delivers mail | | `REGISTER_EMAIL_CONFIRM` | `false` | turn on only after `[mailer]` really delivers mail |
| `REQUIRE_SIGNIN_VIEW` | `false` | public repos are browsable without an account | | `REQUIRE_SIGNIN_VIEW` | `false` | public repos are browsable without an account |
| `DEFAULT_ALLOW_CREATE_ORGANIZATION` | `false` | new users cannot create organizations | | `DEFAULT_ALLOW_CREATE_ORGANIZATION` | `false` | new users cannot create organizations |
| `MAX_CREATION_LIMIT` | `10` | repos per regular user (admins are exempt) | | `MAX_CREATION_LIMIT` | `10` | repos per regular user (admins are exempt) |
| `DISABLE_MIGRATIONS`, `[mirror] DISABLE_NEW_PULL` | `true` | no importing or mirroring from arbitrary URLs | | `DISABLE_MIGRATIONS`, `[mirror] DISABLE_NEW_PULL` | `true` | no importing or mirroring from arbitrary URLs |
| `[security] EGRESS_MODE` (Gitea 28) | `lax` | webhooks may call public hosts only, not loopback, LAN or link-local (`strict` = only `[security] ALLOWED_HOST_LIST`). On Gitea 1.23 use `[webhook] ALLOWED_HOST_LIST = external` | | `[security] EGRESS_MODE` | `lax` | webhooks may call public hosts only, not your LAN or loopback |
| `ENABLE_BASIC_AUTHENTICATION` | `false` | API and git-over-HTTP take tokens only; password basic auth is refused |
| `[packages] ENABLED`, `[actions] ENABLED` | `false`, `false` | no package blobs, runner jobs or artifacts to fill the disk |
| `[attachment] MAX_SIZE` / `MAX_FILES`, `[repository.upload] FILE_MAX_SIZE` / `MAX_FILES` | `4` MB / `3` | small uploads only |
| `DEFAULT_KEEP_EMAIL_PRIVATE` | `true` | new users' emails hidden by default | | `DEFAULT_KEEP_EMAIL_PRIVATE` | `true` | new users' emails hidden by default |
| `DEFAULT_BRANCH` | `main` | | | `DEFAULT_BRANCH` | `main` | |
| `DISABLE_SSH` | `true` | Git over HTTPS with a token; nothing to expose but one HTTP port | | `DISABLE_SSH` | `true` | Git over HTTPS with a token; nothing to expose but one HTTP port |
@ -173,61 +170,9 @@ tests/e2e-binary.sh [PORT]
Runs the whole flow in a throwaway directory on a spare port (default 3990): download and verify the Runs the whole flow in a throwaway directory on a spare port (default 3990): download and verify the
binary, start, create the admin, create a repo, protect `main`, check that a direct push to `main` is binary, start, create the admin, create a repo, protect `main`, check that a direct push to `main` is
rejected (even for the admin) while a PR merges, create an agent token (own repo yes, admin API no), rejected (even for the admin) while a PR merges, create an agent token (own repo yes, admin API no),
check the new-user defaults (no org creation, 10-repo limit), migrations off, no captcha on sign-up, check the new-user defaults (no org creation, 10-repo limit), migrations off, captcha on sign-up,
backup. Then it stops Gitea and deletes everything. backup. Then it stops Gitea and deletes everything.
## Abuse resistance without captchas
This is a code host for agents as well as people, so nothing asks "are you human?". Bots may
sign up and log in. Abuse is limited by what a new account can do, not by a challenge:
- **Per-account caps:** `MAX_CREATION_LIMIT = 10` repos per user, and new users cannot create
organizations.
- **No server-side fetches into your network:** migrations and new mirrors are off, and
`[security] EGRESS_MODE = lax` (Gitea 28; `[webhook] ALLOWED_HOST_LIST = external` on 1.23) keeps webhooks off loopback, private and link-local addresses.
- **Tokens, not passwords, on the API:** `ENABLE_BASIC_AUTHENTICATION = false` refuses
username/password basic auth on the API and git-over-HTTP, so stolen or guessed passwords can't be
scripted against it. Personal access tokens (as header or as the basic-auth password) still work.
Note: Gitea's `POST /users/{name}/tokens` needs password basic auth, so mint tokens with
`kit.sh agent` (Gitea CLI) instead.
- **Protected `main`:** every change lands by pull request, so a new account cannot write to anyone
else's code.
- **Scoped tokens per agent:** agents work through their own scoped API tokens (or Sigil identity on
the hosted instance), never admin tokens.
- **Bounded storage:** packages and Actions are off; attachments and web uploads are capped at 4 MB,
3 files. Also do the following outside Gitea:
- cap request bodies at your proxy (e.g. nginx `client_max_body_size 50m`; Cloudflare's free plan caps at 100 MB);
- alert on disk use (e.g. a cron job that warns above 80% on the Gitea data volume);
- remove abusive accounts with `gitea admin user delete --purge --username NAME` (deletes their repos too).
- **Registration switches** (in `.env` / `app.ini`):
- `DISABLE_REGISTRATION=true`: invite-only.
- `REGISTER_EMAIL_CONFIRM=true`: only once `[mailer]` works.
- `REGISTER_MANUAL_CONFIRM = true`: holds new web sign-ups until an admin activates them. It does
not hold accounts created through the admin API and does not slow password guessing.
- `[service] EMAIL_DOMAIN_ALLOWLIST` / `EMAIL_DOMAIN_BLOCKLIST`: restrict sign-up email domains.
- **Edge rate limit:** a rate-limit rule at your proxy or CDN on `POST /user/sign_up` and
`POST /user/login` (for example, a few requests per minute per IP). Gitea 1.23 has no built-in
request rate limit.
Known gaps:
- The sign-up and password-reset forms reveal whether a username or email is taken (email
enumeration).
- Names reserved by the hosted instance's `/join` flow are not reserved in Gitea itself.
### Rollout order (existing instance with a captcha)
1. Add the edge rate limit on `POST /user/sign_up` and `POST /user/login`, and verify it: a burst
from one IP gets 429.
2. **Gate while password basic auth is still on.** If `ENABLE_BASIC_AUTHENTICATION` must stay `true`
(for example because a service mints or uses agent tokens with passwords), do not remove the
captcha until one of these is true:
- edge rate limits also cover requests carrying `Authorization: Basic` to `/api/v1/` and to git
HTTP (`*/info/refs`, `*/git-upload-pack`, `*/git-receive-pack`), each verified by a burst that
returns 429; or
- that service has moved to tokens and `ENABLE_BASIC_AUTHENTICATION = false` is live.
3. Then set `ENABLE_CAPTCHA = false` (and the settings above) and restart Gitea.
4. Rollback: set `ENABLE_CAPTCHA = true` and restart.
## Contributing ## Contributing
`main` is protected: every change lands through a pull request on `main` is protected: every change lands through a pull request on

View File

@ -49,31 +49,10 @@ SECRET_KEY = ${SECRET_KEY}
INTERNAL_TOKEN = ${INTERNAL_TOKEN} INTERNAL_TOKEN = ${INTERNAL_TOKEN}
MIN_PASSWORD_LENGTH = 10 MIN_PASSWORD_LENGTH = 10
PASSWORD_CHECK_PWN = false PASSWORD_CHECK_PWN = false
; Outgoing requests (webhooks etc.) may reach public hosts only: no loopback, private or ; Outgoing requests (webhooks, OAuth2 clients) may reach public hosts only; loopback / LAN
; link-local addresses (SSRF guard). Gitea 28: lax = block private/loopback, strict = only hosts ; targets need an explicit [security] ALLOWED_HOST_LIST entry. Blocks SSRF into your network.
; in [security] ALLOWED_HOST_LIST. (Gitea 1.23 has no EGRESS_MODE; use [webhook] ALLOWED_HOST_LIST = external.)
EGRESS_MODE = lax EGRESS_MODE = lax
[packages]
; Package registry off: no unbounded blob uploads.
ENABLED = false
[actions]
; Actions off: no runner jobs or artifact storage.
ENABLED = false
[attachment]
; Issue/PR attachments capped at 4 MB, 3 files per upload.
ENABLED = true
MAX_SIZE = 4
MAX_FILES = 3
[repository.upload]
; Web-UI file uploads capped at 4 MB, 3 files.
FILE_MAX_SIZE = 4
MAX_FILES = 3
[oauth2] [oauth2]
JWT_SECRET = ${JWT_SECRET} JWT_SECRET = ${JWT_SECRET}
@ -81,16 +60,11 @@ JWT_SECRET = ${JWT_SECRET}
; Public self-registration (set DISABLE_REGISTRATION=true in .env for invite-only). ; Public self-registration (set DISABLE_REGISTRATION=true in .env for invite-only).
DISABLE_REGISTRATION = ${DISABLE_REGISTRATION} DISABLE_REGISTRATION = ${DISABLE_REGISTRATION}
SHOW_REGISTRATION_BUTTON = true SHOW_REGISTRATION_BUTTON = true
; Agent-native: no captcha on sign-up or login. Abuse is limited by the per-user ENABLE_CAPTCHA = true
; defaults (repo cap, no orgs, no migrations/mirrors, webhooks to external hosts only, CAPTCHA_TYPE = image
; no packages/Actions, small attachments, no password basic auth), not by a challenge.
ENABLE_CAPTCHA = false
REQUIRE_CAPTCHA_FOR_LOGIN = false REQUIRE_CAPTCHA_FOR_LOGIN = false
; API and git-over-HTTP accept tokens only; password basic auth is refused.
ENABLE_BASIC_AUTHENTICATION = false
; Only turn this on once [mailer] really delivers mail. ; Only turn this on once [mailer] really delivers mail.
REGISTER_EMAIL_CONFIRM = ${REGISTER_EMAIL_CONFIRM} REGISTER_EMAIL_CONFIRM = ${REGISTER_EMAIL_CONFIRM}
; Holds new web sign-ups until an admin activates them (not admin-API-created accounts; no effect on password guessing).
REGISTER_MANUAL_CONFIRM = false REGISTER_MANUAL_CONFIRM = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false ALLOW_ONLY_EXTERNAL_REGISTRATION = false
; Public repos stay browsable without an account. ; Public repos stay browsable without an account.

24
kit.sh
View File

@ -73,13 +73,7 @@ install_binary() {
local url="https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-${arch}" local url="https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-${arch}"
mkdir -p bin; log "downloading $url" mkdir -p bin; log "downloading $url"
curl -fsSL -o bin/gitea.tmp "$url" curl -fsSL -o bin/gitea.tmp "$url"
# sha256 pinned per release (from dl.gitea.com/<ver>/*.sha256); update with GITEA_VERSION. local want got; want="$(curl -fsSL "$url.sha256" | awk '{print $1}')"; got="$(sha256sum bin/gitea.tmp | awk '{print $1}')"
local want got; case "$GITEA_VERSION-$arch" in
28.1.0-amd64) want=ea93cfa4a300564ac457d3903469fea49ec5a4da37d0cebaa435f8e329ea64d0 ;;
28.1.0-arm64) want=47bf18a8e4602b17910cd660a902541e2c13b67c732f2607da8326fa3cd46805 ;;
28.1.0-arm-6) want=2bf4d222be997103b6668cdf241a83bb26ec365ee6d182faa67ec9f7f021ef39 ;;
*) rm -f bin/gitea.tmp; die "no pinned sha256 for gitea $GITEA_VERSION/$arch; add it to install_binary" ;;
esac; got="$(sha256sum bin/gitea.tmp | awk '{print $1}')"
[ -n "$want" ] && [ "$want" = "$got" ] || { rm -f bin/gitea.tmp; die "sha256 mismatch for gitea binary"; } [ -n "$want" ] && [ "$want" = "$got" ] || { rm -f bin/gitea.tmp; die "sha256 mismatch for gitea binary"; }
chmod +x bin/gitea.tmp; mv bin/gitea.tmp bin/gitea; log "gitea $GITEA_VERSION verified (sha256 $got)" chmod +x bin/gitea.tmp; mv bin/gitea.tmp bin/gitea; log "gitea $GITEA_VERSION verified (sha256 $got)"
} }
@ -99,7 +93,7 @@ cmd_init() {
start() { start() {
if [ "$KIT_MODE" = docker ]; then compose up -d if [ "$KIT_MODE" = docker ]; then compose up -d
elif [ -f "$KIT_DIR/.kit-systemd" ]; then sudo systemctl start gitea elif systemctl is-enabled gitea >/dev/null 2>&1; then sudo systemctl start gitea
else else
mkdir -p "$WORK_PATH/log" mkdir -p "$WORK_PATH/log"
if [ -f "$WORK_PATH/gitea.pid" ] && kill -0 "$(cat "$WORK_PATH/gitea.pid")" 2>/dev/null; then log "already running"; return; fi if [ -f "$WORK_PATH/gitea.pid" ] && kill -0 "$(cat "$WORK_PATH/gitea.pid")" 2>/dev/null; then log "already running"; return; fi
@ -181,17 +175,10 @@ cmd_backup() {
chmod 600 "backups/$f"; log "backup: backups/$f (contains secrets; store it encrypted)" chmod 600 "backups/$f"; log "backup: backups/$f (contains secrets; store it encrypted)"
} }
cmd_down() { # stops only what this kit started: its compose project, its own pid, or the unit it installed cmd_down() {
if [ "$KIT_MODE" = docker ]; then compose down if [ "$KIT_MODE" = docker ]; then compose down
elif [ -f "$WORK_PATH/gitea.pid" ]; then elif systemctl is-active gitea >/dev/null 2>&1; then sudo systemctl stop gitea
local p; p="$(cat "$WORK_PATH/gitea.pid")" elif [ -f "$WORK_PATH/gitea.pid" ]; then kill "$(cat "$WORK_PATH/gitea.pid")" 2>/dev/null || true; rm -f "$WORK_PATH/gitea.pid"; log "stopped"
local cmd=""; cmd="$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null || true)"
if kill -0 "$p" 2>/dev/null && [[ "$cmd" == *"--work-path $WORK_PATH "* ]]; then
kill "$p"; log "stopped pid $p"
else log "pid $p is not this kit's gitea; not touching it"; fi
rm -f "$WORK_PATH/gitea.pid"
elif [ -f "$KIT_DIR/.kit-systemd" ]; then sudo systemctl stop gitea
else log "nothing started by this kit is running"
fi fi
} }
@ -211,7 +198,6 @@ cmd_install_service() { # binary mode, as root: git user, binary, config, system
sed -e "s#/var/lib/gitea#$WORK_PATH#g" -e "s#/etc/gitea/app.ini#$CONF_HOST#g" -e "s#/etc/gitea#$(dirname "$CONF_HOST")#g" \ sed -e "s#/var/lib/gitea#$WORK_PATH#g" -e "s#/etc/gitea/app.ini#$CONF_HOST#g" -e "s#/etc/gitea#$(dirname "$CONF_HOST")#g" \
systemd/gitea.service > /etc/systemd/system/gitea.service systemd/gitea.service > /etc/systemd/system/gitea.service
systemctl daemon-reload; systemctl enable --now gitea systemctl daemon-reload; systemctl enable --now gitea
echo /etc/systemd/system/gitea.service > "$KIT_DIR/.kit-systemd"
wait_healthy; cmd_setup wait_healthy; cmd_setup
log "systemd service 'gitea' enabled; run later kit commands with sudo" log "systemd service 'gitea' enabled; run later kit commands with sudo"
} }

View File

@ -8,7 +8,7 @@ TMP="$(mktemp -d)"; WORK="$TMP/kit"; CLONE="$TMP/clone"
pass=0; fail=0 pass=0; fail=0
ok() { echo "PASS $*"; pass=$((pass+1)); } ok() { echo "PASS $*"; pass=$((pass+1)); }
bad() { echo "FAIL $*"; fail=$((fail+1)); } bad() { echo "FAIL $*"; fail=$((fail+1)); }
cleanup() { [ -n "${LPID:-}" ] && kill "$LPID" 2>/dev/null || true; (cd "$WORK" 2>/dev/null && ./kit.sh down >/dev/null 2>&1) || true; rm -rf "$TMP"; } cleanup() { (cd "$WORK" 2>/dev/null && ./kit.sh down >/dev/null 2>&1) || true; rm -rf "$TMP"; }
trap cleanup EXIT trap cleanup EXIT
ss -ltn 2>/dev/null | grep -q ":$PORT " && { echo "port $PORT is busy"; exit 2; } ss -ltn 2>/dev/null | grep -q ":$PORT " && { echo "port $PORT is busy"; exit 2; }
@ -40,8 +40,7 @@ H=(-H "Authorization: token $T" -H "Content-Type: application/json")
curl -fsS "${H[@]}" -X POST "$A/user/repos" -d '{"name":"demo","auto_init":true,"default_branch":"main"}' >/dev/null && ok "admin created repo kitadmin/demo" || bad "create repo" curl -fsS "${H[@]}" -X POST "$A/user/repos" -d '{"name":"demo","auto_init":true,"default_branch":"main"}' >/dev/null && ok "admin created repo kitadmin/demo" || bad "create repo"
./kit.sh protect kitadmin/demo >/dev/null && ok "branch protection applied to main" || bad "protect" ./kit.sh protect kitadmin/demo >/dev/null && ok "branch protection applied to main" || bad "protect"
# Password basic auth is off (ENABLE_BASIC_AUTHENTICATION=false); git over HTTP uses the token. git -c credential.helper= clone -q "http://kitadmin:$P@127.0.0.1:$PORT/kitadmin/demo.git" "$CLONE"
git -c credential.helper= clone -q "http://kitadmin:$T@127.0.0.1:$PORT/kitadmin/demo.git" "$CLONE"
cd "$CLONE"; git config user.name e2e; git config user.email e2e@example.com cd "$CLONE"; git config user.name e2e; git config user.email e2e@example.com
echo direct > direct.txt; git add direct.txt; git commit -qm direct echo direct > direct.txt; git add direct.txt; git commit -qm direct
if git -c credential.helper= push -q origin main 2>/dev/null; then bad "direct push to main was ACCEPTED"; else ok "direct push to main rejected (even for the admin)"; fi if git -c credential.helper= push -q origin main 2>/dev/null; then bad "direct push to main was ACCEPTED"; else ok "direct push to main rejected (even for the admin)"; fi
@ -58,7 +57,7 @@ curl -fsS -H "Authorization: token $AT" -H "Content-Type: application/json" -X P
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" "$A/admin/users"); [ "$code" = 403 ] && ok "agent token has no admin access (403)" || bad "agent admin access: $code" code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" "$A/admin/users"); [ "$code" = 403 ] && ok "agent token has no admin access (403)" || bad "agent admin access: $code"
code=$(curl -s -o /dev/null -w '%{http_code}' "${H[@]}" -H "Sudo: e2e-agent" -X POST "$A/orgs" -d '{"username":"e2e-org"}'); [ "$code" = 403 ] && ok "new users cannot create organizations (403)" || bad "org creation by new user: $code" code=$(curl -s -o /dev/null -w '%{http_code}' "${H[@]}" -H "Sudo: e2e-agent" -X POST "$A/orgs" -d '{"username":"e2e-org"}'); [ "$code" = 403 ] && ok "new users cannot create organizations (403)" || bad "org creation by new user: $code"
st=$(curl -fsS "${H[@]}" "$A/settings/repository"); grep -q '"migrations_disabled":true' <<<"$st" && ok "migrations disabled" || bad "migrations" st=$(curl -fsS "${H[@]}" "$A/settings/repository"); grep -q '"migrations_disabled":true' <<<"$st" && ok "migrations disabled" || bad "migrations"
page=$(curl -fsS "http://127.0.0.1:$PORT/user/sign_up"); ! grep -qi 'captcha' <<<"$page" && ok "public sign-up page has no captcha (agent-native)" || bad "sign-up still shows a captcha" page=$(curl -fsS "http://127.0.0.1:$PORT/user/sign_up"); grep -q 'captcha' <<<"$page" && ok "public sign-up page shows a captcha" || bad "sign-up captcha"
./kit.sh backup >/dev/null 2>&1 && ls backups/*.zip >/dev/null && ok "backup (gitea dump) written" || bad "backup" ./kit.sh backup >/dev/null 2>&1 && ls backups/*.zip >/dev/null && ok "backup (gitea dump) written" || bad "backup"
./kit.sh protect-all >/dev/null && ok "protect-all ran" || bad "protect-all" ./kit.sh protect-all >/dev/null && ok "protect-all ran" || bad "protect-all"
@ -67,45 +66,7 @@ made=1; for i in $(seq 2 11); do
[ "$c" = 201 ] && made=$((made+1)) [ "$c" = 201 ] && made=$((made+1))
done done
[ "$made" = 10 ] && ok "repo limit: regular user stops at 10 repos" || bad "repo limit: user made $made repos" [ "$made" = 10 ] && ok "repo limit: regular user stops at 10 repos" || bad "repo limit: user made $made repos"
c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/user/repos" -d '{"name":"r-eleventh"}')
[ "$c" = 403 ] && ok "11th repo refused (403)" || bad "11th repo: $c"
# --- adversarial checks (agent-native sign-up, no captcha) ---
c=$(curl -s -o /dev/null -w '%{http_code}' -u "kitadmin:$P" "$A/user"); [ "$c" = 401 ] && ok "password basic auth refused on the API (401)" || bad "password basic auth: $c"
c=$(curl -s -o /dev/null -w '%{http_code}' -u "kitadmin:$T" "$A/user"); [ "$c" = 200 ] && ok "token as basic-auth password still works" || bad "token basic auth: $c"
c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $T" "$A/user"); [ "$c" = 200 ] && ok "token header works" || bad "token header: $c"
(cd "$CLONE" && git checkout -qb pw-push && if git -c credential.helper= push -q "http://kitadmin:$P@127.0.0.1:$PORT/kitadmin/demo.git" pw-push 2>/dev/null; then exit 1; fi) && ok "git push over HTTP refuses password basic auth" || bad "git HTTP push accepted a password"
c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/orgs" -d '{"username":"e2e-org2"}'); [ "$c" = 403 ] && ok "agent token cannot create an org (403)" || bad "agent org create: $c"
c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -X PUT --data-binary 'blob' "http://127.0.0.1:$PORT/api/packages/e2e-agent/generic/p/1.0/f.bin"); [ "$c" = 404 ] && ok "package upload refused (404, registry off)" || bad "package upload: $c (want 404)"
LPORT=$((PORT+7)); HIT="$TMP/webhook-hit"
python3 -c "import http.server,sys
class H(http.server.BaseHTTPRequestHandler):
def do_POST(s): open(sys.argv[2],'w').write('hit'); s.send_response(200); s.end_headers()
def log_message(s,*a): pass
http.server.HTTPServer(('127.0.0.1',int(sys.argv[1])),H).serve_forever()" "$LPORT" "$HIT" >/dev/null 2>&1 & LPID=$!
sleep 0.5
HID=$(curl -s -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/repos/e2e-agent/agent-repo/hooks" \
-d "{\"type\":\"gitea\",\"active\":true,\"config\":{\"url\":\"http://127.0.0.1:$LPORT/\",\"content_type\":\"json\"}}" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)
[ -n "$HID" ] && ok "webhook to 127.0.0.1 created (id $HID); delivering test" || bad "webhook create failed"
[ -n "$HID" ] && curl -s -o /dev/null -H "Authorization: token $AT" -X POST "$A/repos/e2e-agent/agent-repo/hooks/$HID/tests"
LOGS=("$TMP/home")
denied=0; for _ in $(seq 1 30); do
grep -rqs 'denied by egress' "${LOGS[@]}" 2>/dev/null && { denied=1; break; }; sleep 0.5; done
kill $LPID 2>/dev/null || true
[ "$denied" = 1 ] && ok "Gitea logged the webhook as denied by egress" || bad "no 'denied by egress' line within 15s"
[ ! -e "$HIT" ] && ok "webhook to 127.0.0.1 not delivered ([security] EGRESS_MODE=lax)" || bad "webhook reached 127.0.0.1"
# 4 MB attachment cap: a small file is accepted, a 5 MB file is refused
NUM=$(curl -s -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/repos/e2e-agent/agent-repo/issues" -d '{"title":"attach"}' | python3 -c 'import json,sys;print(json.load(sys.stdin).get("number",""))')
head -c 1024 /dev/urandom > "$TMP/small.txt"; head -c $((5*1024*1024)) /dev/urandom > "$TMP/big.txt"
c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -F "attachment=@$TMP/small.txt" "$A/repos/e2e-agent/agent-repo/issues/$NUM/assets"); [ "$c" = 201 ] && ok "1 KB attachment accepted" || bad "small attachment: $c"
c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -F "attachment=@$TMP/big.txt" "$A/repos/e2e-agent/agent-repo/issues/$NUM/assets"); [ "$c" != 201 ] && ok "5 MB attachment refused ($c, MAX_SIZE=4)" || bad "5 MB attachment accepted"
# Actions off: the Actions API is not served
ha=$(curl -s -H "Authorization: token $AT" "$A/repos/e2e-agent/agent-repo" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("has_actions"))')
c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" "http://127.0.0.1:$PORT/e2e-agent/agent-repo/actions")
[ "$ha" = False ] && [ "$c" = 404 ] && ok "Actions disabled (has_actions false, /actions page 404)" || bad "Actions: has_actions=$ha, /actions page $c"
echo "PENDING sign-up burst: throttling is the edge rate-limit rule (Cloudflare/proxy), not Gitea; test once the rule exists"
if grep -q "Deprecation" "$TMP/home/log/gitea.out"; then bad "config uses deprecated keys: $(grep -o 'config option `[^`]*`' "$TMP/home/log/gitea.out" | sort -u | tr '\n' ' ')"; else ok "no deprecated config keys in the Gitea log"; fi if grep -q "Deprecation" "$TMP/home/log/gitea.out"; then bad "config uses deprecated keys: $(grep -o 'config option `[^`]*`' "$TMP/home/log/gitea.out" | sort -u | tr '\n' ' ')"; else ok "no deprecated config keys in the Gitea log"; fi
if grep -E '\[E\]|invalid entry' "$TMP/home/log/gitea.out" | grep -v 'denied by egress' | grep -q .; then bad "Gitea logged errors: $(grep -E '\[E\]|invalid entry' "$TMP/home/log/gitea.out" | grep -v 'denied by egress' | head -3)"; else ok "no [E] or 'invalid entry' lines in the Gitea log"; fi
echo; echo "$pass passed, $fail failed" echo; echo "$pass passed, $fail failed"
[ "$fail" = 0 ] [ "$fail" = 0 ]