Skip to content

feat(api): make the per-IP invite limit configurable at runtime - #227

Merged
sanity merged 3 commits into
mainfrom
feat/invite-per-ip-env
Nov 2, 2026
Merged

sanity merged 3 commits into
mainfrom
feat/invite-per-ip-env

Conversation

@sanity

@sanity sanity commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The per-IP invite allowance is a compile-time constant. GLOBAL_INVITES_PER_HOUR and INVITE_POW_DIFFICULTY can be changed by editing the service environment, but changing the per-IP limit means building and swapping a gkapi binary, and doing it again to put it back. People who share an address, such as phones behind a carrier NAT, reach this limit sooner than anyone else, so it needs to be adjustable around a traffic spike.

Approach

Add INVITES_PER_IP_PER_DAY (--invites-per-ip-per-day), wired the same way as GLOBAL_INVITES_PER_HOUR.

  • No behaviour change on deploy. Unset, the limit stays at 4, and test_rate_limiter_enforces_four_per_window still pins that default.
  • Accepted range is 1 to 100, well below the global ceiling, which still applies to every invite. On the global ceiling 0 means off. Here 0 would refuse everyone, so it stops the service at startup instead.
  • A value other than the default logs a startup warning, so a forgotten raise is visible.
  • The refusal message states the configured limit, and startup logs the limit in force.
  • The rate-limit file is written atomically (temporary file plus rename). Restarts become routine with this setting, and a truncated file makes every invite return 500.
  • retry_after counts down to below the limit. Lowering the limit after a raise leaves some IPs holding more than it, and the old calculation (oldest entry expires) would have told them to come back too early.

The value itself is a production setting and is not changed here. The README gains an "Invite limits" section covering all three variables, the verify step after a restart, what a bad value does, and how to undo a change.

Testing

  • tests/invite_limit_env.rs starts the real binary with a cleared environment. It checks the startup log for a set value, the top value and the default, and that 0, 101, abc and a blank value are refused. It reads the log, not a refused request; the log reports the limiter's own value, and the unit tests take over from there.
  • configured_per_ip_limit_is_enforced_and_reported covers both handlers and their refusal messages.
  • retry_after_counts_down_to_below_the_limit_when_over_it and admission_reopens_when_the_retry_time_says_it_does cover the rollback case, and that the retry time matches what admission does.
  • failed_save_leaves_the_existing_file_whole covers the atomic write.
  • Mutation checks, all restored afterwards: dropping the parsed value, using the oldest entry for the retry time, writing the live file directly, removing the zero guard and widening the range each fail a test.
  • cargo test -p ghostkey-api passes locally.

This is abuse-protection code, so it needs a Full-tier review before merge. Merging ships nothing: gkapi is deployed by hand (see rust/api/README.md).

[AI-assisted - Claude]
https://claude.ai/code/session_01VjxvYQUzmUE7o4fLzHUmpo

The global ceiling and proof-of-work difficulty can already be changed
with an environment variable, but the per-IP allowance is a constant, so
raising it for a launch and putting it back means building and swapping
a binary twice.

Add INVITES_PER_IP_PER_DAY, defaulting to the existing 4. 0 is refused at
startup because it would turn invites off, while 0 on the global ceiling
turns the ceiling off.

An IP can hold more than the limit after it is lowered, so retry_after
now counts down to below the limit rather than to the oldest entry.

Claude-Session: https://claude.ai/code/session_01VjxvYQUzmUE7o4fLzHUmpo
Cap the variable at 100, well under the global ceiling, and warn at
startup when it differs from the default, so a forgotten raise shows up.
with_max_per_window(0) now means 1 rather than refusing with no retry
time.

Write the rate-limit file to a temporary file and rename it. Restarts
become routine with this setting, and a truncated file makes every invite
return 500.

Document the operating traps in the README: the build requirement, the
verify step, that a bad value stops donations too, and that .env has no
effect. Comments now say 4 is only the default.

Tests tie the retry time to admission, cover the rejected and top values,
clear the child's environment, and check create's refusal message. The
startup test is renamed, since it reads the log and not a refused request.

Claude-Session: https://claude.ai/code/session_01VjxvYQUzmUE7o4fLzHUmpo
@sanity

sanity commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Review for this PR (Full tier, because it touches abuse protection on a live service).

Lenses run: code-first, skeptical, testing and big-picture, then code-first and skeptical again on the follow-up commits. All were independent Claude reviewers.

External model: Codex was tried twice and failed both times with "You've hit your usage limit" (resets Nov 4). The big-picture lens ran in its place.

Addressed: a lower upper bound (100, not 1000); a startup warning when the limit isn't the default; a zero guard; atomic saves of the limits file; a fuller README (build requirement, verify step, bad-value behaviour, dated comments, global ceiling, .env); stale comments; and more tests, including retry time against admission.

Accepted without change:

  • No directory fsync after rename: power loss can restore the previous whole file, which loses at most a few recent entries.
  • No warning when the per-IP limit is near a lowered global ceiling: the operator sets both in one place and the README covers it.
  • The `too_many_arguments` allow stays: a params struct is out of scope.
  • IPv6 keying is tracked in Add IPv6 support to the invite service (gkapi.freenet.org) #228.

[AI-assisted - Claude]

@sanity
sanity marked this pull request as ready for review November 2, 2026 17:04
@sanity
sanity merged commit f822a49 into main Nov 2, 2026
4 checks passed
@sanity
sanity deleted the feat/invite-per-ip-env branch November 2, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant