Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions rust/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,47 @@ id is in its URL), then tell each of them to reload that success page. Do not cl
with `notary_period`: the server cannot tell which signatures failed to unblind, and a
donor whose key worked would be able to mint a second one.

## Invite limits

These are set as `Environment=` lines in `/etc/systemd/system/gkapi.service`. Changing one
needs `sudo systemctl daemon-reload && sudo systemctl restart gkapi`, and no new binary. The
restart drops any unexpired proof-of-work challenges, and the hourly count is rebuilt from
`invite_rate_limits.json`. They must go in the unit file: `.env` is read after the arguments
are parsed, so a value there has no effect.

| Variable | Default | Meaning |
|---|---|---|
| `GLOBAL_INVITES_PER_HOUR` | 200 | Ceiling across everyone over a sliding hour. 0 disables it. |
| `INVITES_PER_IP_PER_DAY` | 4 | Allowance for one IP over a sliding 24 hours, 1 to 100. Phones behind a carrier NAT share one. The global ceiling still applies to every invite. |
| `INVITE_POW_DIFFICULTY` | 16 | Base proof-of-work bits. Adds 2 at 90 invites in the hour and 4 at 150. Those thresholds do not move with the ceiling. |

`INVITES_PER_IP_PER_DAY` needs a build containing freenet/web#227. An older binary ignores the
variable without complaint, and rolling back to one means the limit is 4 whatever the unit
says.

**Verify after every restart:**

```bash
systemctl is-active gkapi
sudo journalctl -u gkapi _SYSTEMD_INVOCATION_ID=$(systemctl show -p InvocationID --value gkapi) \
| grep 'Per-IP invite limit'
```

The log line is the authority on the limit in force. This command reads only the current run,
so no line means the binary is too old, or the invite endpoint is not configured.

**A bad or blank value stops the whole service, donations included.** Each variable is parsed
at startup, so gkapi exits on an invalid value. Under `Restart=always` it then loops every few
seconds without any alert, so don't rely on the restart command to report it.

**Leave a dated comment beside any raise in the unit file**, saying why and when it should come
back down, and update the existing comment there that explains the ceiling. A changed
`INVITES_PER_IP_PER_DAY` is logged as a warning at each start, and nothing else will remind
anyone.

To undo a temporary raise, delete or restore the line, do the same two commands and verify
again.

## Notary keys and the monthly schedule

The notary directory (`--notary-dir`, `/home/gkapi/delegate-keys` on nova) holds one
Expand Down
12 changes: 12 additions & 0 deletions rust/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ fn load_invite_config(matches: &clap::ArgMatches) -> Option<InviteState> {
.unwrap_or("/var/lib/gkapi/tor_exit_list.txt"),
));
let global_invites_per_hour = matches.get_one::<usize>("global-invites-per-hour").copied();
let invites_per_ip_per_day = matches
.get_one::<u32>("invites-per-ip-per-day")
.map(|n| *n as usize);
let pow_base_difficulty = matches
.get_one::<u8>("invite-pow-difficulty")
.copied()
Expand Down Expand Up @@ -151,6 +154,7 @@ fn load_invite_config(matches: &clap::ArgMatches) -> Option<InviteState> {
rate_limit_file,
tor_exit_cache,
global_invites_per_hour,
invites_per_ip_per_day,
pow_base_difficulty,
room_owner_vk,
inviter_signing_key,
Expand Down Expand Up @@ -309,6 +313,14 @@ async fn main() {
.value_parser(value_parser!(usize))
.help("Emergency hourly ceiling across all issued invitations (0 disables)."),
)
.arg(
Arg::new("invites-per-ip-per-day")
.long("invites-per-ip-per-day")
.value_name("N")
.env("INVITES_PER_IP_PER_DAY")
.value_parser(value_parser!(u32).range(1..=100))
.help("Invitations one IP may receive in 24 hours (default 4)."),
)
.arg(
Arg::new("invite-pow-difficulty")
.long("invite-pow-difficulty")
Expand Down
192 changes: 178 additions & 14 deletions rust/api/src/rate_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,25 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, VecDeque};
use std::fs;
use std::io::Write;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration as StdDuration, Instant};
use thiserror::Error;

/// Maximum number of invites allowed per IP within the time window.
/// Default number of invites allowed per IP within the time window.
///
/// Kept deliberately low as an anti-spam measure for the freenet.org/quickstart
/// This is only the default: `INVITES_PER_IP_PER_DAY` overrides it at startup,
/// so a launch can raise it and put it back without a new binary. The startup
/// log line `Per-IP invite limit: ...` is the authority on the deployed value.
///
/// The default is kept deliberately low as an anti-spam measure for the freenet.org/quickstart
/// flow, which mints invites into the shared "Freenet Official" room. A few
/// invites per day covers legitimate re-invites while still slowing bulk
/// account creation. Raising this without a matching anti-abuse story re-opens
/// the vector this limit exists to slow. See
/// `test_rate_limiter_enforces_four_per_window`.
/// account creation. Raising it, here or through the environment, without a
/// matching anti-abuse story re-opens the vector this limit exists to slow. See
/// `test_rate_limiter_enforces_four_per_window`, which pins the default.
///
/// NOTE: this limit is per-IP and therefore cannot bound an actor who rotates
/// IPs. The global emergency ceiling and proof of work provide that bound.
Expand Down Expand Up @@ -67,6 +72,7 @@ pub struct RateLimitData {
pub struct RateLimiter {
data_path: PathBuf,
window_hours: i64,
max_per_window: usize,
/// Mutex for thread-safe access to the file
lock: Mutex<()>,
}
Expand All @@ -81,10 +87,22 @@ impl RateLimiter {
Self {
data_path,
window_hours,
max_per_window: MAX_INVITES_PER_WINDOW,
lock: Mutex::new(()),
}
}

/// Replace the default per-IP allowance. At least 1: a limit of 0 could
/// refuse every request with no time at which asking again would work.
pub fn with_max_per_window(mut self, max_per_window: usize) -> Self {
self.max_per_window = max_per_window.max(1);
self
}

pub fn max_per_window(&self) -> usize {
self.max_per_window
}

/// Check if an IP is rate limited, and record the access if allowed
///
/// Returns Ok(true) if the request is allowed, Ok(false) if rate limited
Expand Down Expand Up @@ -117,7 +135,7 @@ impl RateLimiter {

// Check if IP has reached the limit
let timestamps = data.invites.entry(ip_str).or_default();
if timestamps.len() >= MAX_INVITES_PER_WINDOW {
if timestamps.len() >= self.max_per_window {
return Ok(false); // Rate limited
}

Expand All @@ -141,17 +159,22 @@ impl RateLimiter {

if let Some(timestamps) = data.invites.get(&ip_str) {
// Filter to only valid timestamps within window
let valid_timestamps: Vec<_> = timestamps
let mut valid_timestamps: Vec<_> = timestamps
.iter()
.filter_map(|ts| DateTime::parse_from_rfc3339(ts).ok())
.map(|t| -> DateTime<Utc> { t.into() })
.filter(|t| now - *t < window)
.collect();

// If at limit, return time until oldest expires
if valid_timestamps.len() >= MAX_INVITES_PER_WINDOW {
if let Some(oldest) = valid_timestamps.iter().min() {
let expires_at = *oldest + window;
// If at limit, return the time until the count drops below it.
// That is not always the oldest entry: an IP can hold more than
// the limit after the limit is lowered, which is how a temporary
// raise is rolled back.
if valid_timestamps.len() >= self.max_per_window {
valid_timestamps.sort_unstable();
let index = valid_timestamps.len() - self.max_per_window;
if let Some(frees_a_slot) = valid_timestamps.get(index) {
let expires_at = *frees_a_slot + window;
let remaining = expires_at - now;
return Ok(Some(remaining.num_seconds()));
}
Expand Down Expand Up @@ -207,7 +230,17 @@ impl RateLimiter {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(data)?;
fs::write(&self.data_path, content)?;

// Write to a temporary file and rename it into place, so a process
// stopped mid-write leaves the previous file whole. A truncated file
// fails to parse, which refuses every invite until it is repaired.
// `self.lock` is held by every caller, so one temporary path is enough.
let mut temp_path = self.data_path.clone().into_os_string();
temp_path.push(".tmp");
let mut temp_file = fs::File::create(&temp_path)?;
temp_file.write_all(content.as_bytes())?;
temp_file.sync_all()?;
fs::rename(&temp_path, &self.data_path)?;
Ok(())
}
}
Expand Down Expand Up @@ -528,7 +561,9 @@ mod tests {
assert!(limiter.check_and_record(ip).unwrap());
}

/// Pins the deployed anti-spam limit at exactly 4 invites / IP / window.
/// Pins the DEFAULT anti-spam limit at exactly 4 invites / IP / window. The
/// deployed value can differ: see `INVITES_PER_IP_PER_DAY` and the startup
/// log.
///
/// The other tests are parameterized on `MAX_INVITES_PER_WINDOW`, so they
/// pass at any value and would NOT catch an accidental bump of the
Expand All @@ -538,7 +573,7 @@ mod tests {
fn test_rate_limiter_enforces_four_per_window() {
assert_eq!(
MAX_INVITES_PER_WINDOW, 4,
"invite anti-spam limit must stay at 4/IP/window; raising it re-opens bulk account creation"
"default invite anti-spam limit must stay at 4/IP/window; raising it re-opens bulk account creation"
);

let dir = tempdir().unwrap();
Expand All @@ -558,6 +593,135 @@ mod tests {
);
}

#[test]
fn configured_limit_replaces_the_default_allowance() {
let dir = tempdir().unwrap();
let limiter =
RateLimiter::new(dir.path().join("rate_limits.json"), 24).with_max_per_window(6);
let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 8));

for i in 1..=6 {
assert!(
limiter.check_and_record(ip).unwrap(),
"invite {i} of 6 should be allowed"
);
}
assert!(
!limiter.check_and_record(ip).unwrap(),
"7th invite in window must be rejected"
);
}

/// Store one IP's invites, given as hours before now.
fn write_invites(path: &std::path::Path, ip: IpAddr, hours_ago: &[i64]) {
let now = Utc::now();
let timestamps = hours_ago
.iter()
.map(|h| (now - Duration::hours(*h)).to_rfc3339())
.collect();
let data = RateLimitData {
invites: HashMap::from([(ip.to_string(), timestamps)]),
};
fs::write(path, serde_json::to_string(&data).unwrap()).unwrap();
}

/// Lowering the limit after a raise leaves IPs holding more than the new
/// limit. They can ask again once enough entries have expired, not when
/// the oldest one does.
#[test]
fn retry_after_counts_down_to_below_the_limit_when_over_it() {
let dir = tempdir().unwrap();
let path = dir.path().join("rate_limits.json");
let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9));
write_invites(&path, ip, &[23, 20, 10, 5, 1, 0]);

let retry = RateLimiter::new(path, 24)
.with_max_per_window(4)
.get_retry_after(ip)
.unwrap()
.expect("an IP over the limit must have a retry time");

// Six held and a limit of 4: the third oldest (10h ago) has to expire,
// which is 14h away. The oldest alone would say 1h.
let hour = Duration::hours(1).num_seconds();
assert!(
retry > 13 * hour && retry <= 14 * hour,
"expected about 14h, got {retry}s"
);
}

/// The retry time above is only honest if admission agrees: the request
/// is refused until the third oldest entry has aged out, and not after.
#[test]
fn admission_reopens_when_the_retry_time_says_it_does() {
let dir = tempdir().unwrap();
let path = dir.path().join("rate_limits.json");
let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10));
let limiter = RateLimiter::new(path.clone(), 24).with_max_per_window(4);

write_invites(&path, ip, &[23, 20, 10, 5, 1, 0]);
assert!(!limiter.check_and_record(ip).unwrap(), "six held");

write_invites(&path, ip, &[25, 25, 10, 5, 1, 0]);
assert!(
!limiter.check_and_record(ip).unwrap(),
"four still held after the two oldest aged out"
);

write_invites(&path, ip, &[25, 25, 25, 5, 1, 0]);
assert!(
limiter.check_and_record(ip).unwrap(),
"three held after the three oldest aged out"
);
}

#[test]
fn zero_limit_is_treated_as_one() {
let dir = tempdir().unwrap();
let limiter =
RateLimiter::new(dir.path().join("rate_limits.json"), 24).with_max_per_window(0);
let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 11));

assert_eq!(limiter.max_per_window(), 1);
assert!(limiter.check_and_record(ip).unwrap());
assert!(!limiter.check_and_record(ip).unwrap());
assert!(limiter.get_retry_after(ip).unwrap().is_some());
}

#[test]
fn save_leaves_no_temporary_file_behind() {
let dir = tempdir().unwrap();
let limiter = RateLimiter::new(dir.path().join("rate_limits.json"), 24);

limiter
.check_and_record(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 12)))
.unwrap();

let names: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert_eq!(names, ["rate_limits.json"]);
}

/// A write that fails part way must not touch the live file. The directory
/// in the temporary file's place makes the write fail; writing the live
/// file directly would succeed and this would not see an error.
#[test]
fn failed_save_leaves_the_existing_file_whole() {
let dir = tempdir().unwrap();
let path = dir.path().join("rate_limits.json");
let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 13));
let limiter = RateLimiter::new(path.clone(), 24);
assert!(limiter.check_and_record(ip).unwrap());
let before = fs::read_to_string(&path).unwrap();

fs::create_dir(dir.path().join("rate_limits.json.tmp")).unwrap();

assert!(limiter.check_and_record(ip).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), before);
}

#[test]
fn test_rate_limiter_allows_up_to_max_requests() {
let dir = tempdir().unwrap();
Expand Down
Loading
Loading