Skip to content

About

Network policy documents FluxOS nodes fetch and enforce at runtime

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

fluxos-network-policy

The documents the Flux network enforces at runtime. Every FluxOS node fetches these directly and applies them without a release, so a merge here changes the behaviour of the whole fleet immediately. There is no staged rollout and no canary.

This repo contains policy and nothing else. No application code lives here, which is the point: it used to live in RunOnFlux/flux under helpers/, where any merge touching those files was a fleet policy change as a side effect, and where changing policy meant committing to the application's default branch.

The documents

file governs shape
blocklist.json everything that may not run anywhere: app hashes, app names, owners, images, namespaces array of typed entries
blockedrepositories.json generated from blocklist.json, for releases that predate typed entries array of strings
enterprisenodes.json which app owners may install on which enterprise nodes object of node pubkey → array of owner addresses
tamperingblockednodes.json collateral txhashes DOSed for tampering, above the score threshold array of strings
vettedrepositories.json owners, app hashes and repos whose apps bypass user-level blocks array of strings
cputhrottleexempt.json games-hub game servers the CPU throttle leaves at their full specified cpu array of lowercase app name prefixes
iplocation.bin.gz the IP → (organisation, country, region) table placement uses to count fault domains generated binary artifact, see below

blocklist.json

Every entry names the kind of thing it refuses, and a kind names the one field it is compared against:

{ "kind": "name", "value": "dowz", "reason": "why this is blocked", "added": "2026-09-12" }
kind compared against covers
hash the app message hash one version of one application, and nothing after its next update
name the application name that application for as long as it exists, across every update
owner the owner identity every application that identity registers, present and future
image a component repository, tag removed every application running that exact image
org a registry namespace every application running any image published under it

Prefer name over hash for an application you want off the network. A non-extending update is free to its owner and mints a new hash: of the 149 hashes added on 2026-09-11, 23 had left the list within a day that way while keeping their names. Escaping a name ban costs a whole new registration.

A kind exists so that an entry can never reach a field it was not written for. In the flat document an entry is a bare string tested against the image, the namespace, the owner and the hash at once, so grafana would refuse both the application called grafana and every image published under the grafana namespace, with no way to say which was meant.

cputhrottleexempt.json

FluxOS lowers an app with more than 2 cpus to 80% of its spec once it runs above 92% for most of its window, and then measures it against that reduced share, so a saturated app never gets the 20% back. For a game server that is lag: its tick is a latency budget, and the ticks that overrun it arrive late for every player.

Each entry is a prefix the games hub writes for a game server: the lowercased marketplace name for every game it deploys today, plus minecraftj and minecraftb, the Java and Bedrock prefixes the standalone Minecraft site wrote before the hub took it over (those servers are still running). Every one names a server <prefix><Date.now()>, and a node exempts an app only when its name splits into a listed prefix followed by exactly that 13-digit millisecond timestamp, dated from 2023 to a day past the node's clock. enshrouded1792177003381 qualifies; enshrouded, myenshrouded1792177003381 and enshroudedserver do not. The match is on the whole prefix, so rustserver does not cover rustserveroxide: every marketplace app is listed on its own.

An exempt app is held at the cpu its owner specified, never more, so a name that qualifies without being a game gains nothing it was not already paying for. The match ignores case, so the same game deployed straight from the marketplace (MinecraftServer1761055063053) is exempt too.

When a new game launches on the hub, add its marketplace name here as part of the launch. The prefixes match the prefixes of the game sites in FluxView's src/constants/dedicatedSites.js, which is the other place that has to know them.

Read by the FluxOS release that carries RunOnFlux/flux#1868. A node that has no copy, or finds the document malformed, exempts nothing and throttles as it always has.

blockedrepositories.json is generated

It is an artifact of blocklist.json, kept because every release before typed entries fetches that name and would otherwise stop enforcing anything. Do not edit it — run node scripts/build-blockedrepositories.js; validate.js fails if the two drift apart.

The projection is lossy on purpose: name entries are omitted, because a reader without kinds would test that bare string against the image and the namespace as well. A name ban is enforced only by a node that understands kinds, and is simply not in force on older releases.

There is no image whitelist here. RunOnFlux/flux still carries a helpers/repositories.json, but nothing has enforced it since the method that read it was written without a caller in July 2024, and the file has not been edited since September 2024. It is not part of this repo and should not be added back without the enforcement to go with it.

How nodes read them

FluxOS fetches each document on its own schedule — 6 hours for blocked and vetted repositories and for enterprise nodes, 12 hours for the tampering blocklist — and validates its shape before using it. No node ever writes a fetched document to disk: every copy it holds is in memory. What a node does when this repo is unreachable therefore differs per document, and the differences matter.

  • Blocked and vetted repositories sit in a six-hour cache. Without a readable list a node refuses to install or spawn anything at all, but the hourly sweep that removes already-running blocked apps does nothing that tick. The door fails closed; the sweep pauses, and resumes at the next successful fetch.
  • The tampering blocklist is fetched fresh each tick and never cached. An unreadable one skips the tick rather than clearing anything, and a DOS already applied is the node's own state, so an outage postpones enforcement instead of undoing it.
  • Enterprise nodes are held as a map that only a successful fetch replaces, so a failed fetch keeps the previous one. At boot it is seeded from helpers/enterprisenodes.json in the FluxOS release — the one document a release still ships a copy anything reads.

A restart does not preserve what a node last read. It begins from that release seed for enterprise nodes and from nothing for the other three, and the first successful fetch decides.

Two consequences worth holding on to:

  • An empty document is a real answer and an unreachable one is not. [] means nothing is listed. No node reads unreachable as empty, so an outage can pause enforcement but never lifts it.
  • Removing an entry takes effect on a node's next successful fetch, so unblocking is not instant. Adding one is subject to the same delay.

The IP location table

iplocation.bin.gz is different from the other documents: it is generated, never hand-edited, and it is binary. FluxOS uses it to answer one question locally on every node: how many distinct fault domains does an app's eligible candidate set span? — which is what turns the synced-app placement rule from a blind veto into arithmetic (an app pinned to a one-provider country converges instead of sticking below its instance count forever).

The wire format is format 2, specified in fluxModels workstreams/placement-and-election/GEO_TABLE_BASELINE_FORMAT.md: a gzipped FLXGEO container holding a JSON header (generation timestamp, source serials, and the country, continent, organisation and region tables) followed by varint-encoded rows of (gap, length, organisation, country, region). About 2.0M rows and 4.6 MB on the wire. The publisher here and the reader in FluxOS implement exactly that document; change neither alone.

Regenerate it with:

node scripts/build-iplocation.js

The build has two sources, each authoritative for what it knows:

  1. The five RIRs' delegated-extended files — allocation boundaries and the registry-scoped organisation id. An allocation is the block rung of the fault-domain ladder, so these boundaries are the artifact's structure.
  2. DB-IP City Lite — country and region per address range, collapsed from city granularity and resolved to ISO 3166-2 through data/region-map.json. Country and region come from here alone: measured against the fleet's self-reports it wins 178 disagreements to 10 against the registry-plus-RDAP machinery it replaced, and it carries region data for 99% of the fleet, which the registries do not have at all.

This product includes IP address to city data from DB-IP, used under the Creative Commons Attribution 4.0 International Licence. The attribution is a licence condition: it must stay with any redistribution of this artifact.

Three sources are deliberately absent, each measured against the fleet and rejected. RDAP object country corrects a block whose delegated country the nodes inside it contradict; DB-IP is right more often than that correction is, and needs no per-block queries. RFC 8805 geofeeds and their RFC 9632 discovery were beaten or tied by DB-IP everywhere they were measured, at the cost of a network fetch per operator and a dependency on operators publishing at all.

data/region-map.json maps a (country, DB-IP region name) pair to its ISO 3166-2 code. It is generated too, from the iso-codes dataset, the fleet's own self-reports, and data/region-aliases.json — a small curated table for names no rule reaches:

node scripts/build-region-map.js

Regenerate it when DB-IP's region names change or the fleet moves into a region the table does not cover; the script prints its unmatched names with fleet presence, which is what an alias entry is for. A name with no mapping is not an error: its addresses answer at country granularity, and a missing region can only make placement more conservative.

The build joins the result against the live fleet's self-reported geolocation and writes data/iplocation-build-report.json with country and region agreement numbers and every remaining disagreement. Read the report before merging a regeneration; country agreement is ~98.5% and materially below that is a regression, not drift.

The artifact is self-describing (format version, generated, per-registry source serials and the DB-IP vintage). A node that cannot resolve an address in the table falls back to /16 arithmetic, which errs toward refusing placement — the failure mode is the pre-table status quo, never over-concentration.

Two deliberate compactions, both revisitable: the artifact carries IPv4 only (no Flux node has an IPv6 address; a v6 lookup falls back strict; add a v6 section when v6 nodes can exist), and organisation identity is a 12-hex-char token of the registry-scoped org id — nothing reads the id's content, distinctness is all that placement needs. Adjacent rows agreeing on organisation, country and region collapse into one: with all three equal there is no boundary between them that placement can see.

Which organisations run access networks

The artifact also answers is this address on a consumer connection, or in a data centre? FluxOS needs it to enforce anything against a residential node, and a node cannot decide it well enough alone: the strongest evidence is the registry's own record of what a block was assigned for, and six thousand nodes cannot each query the RIRs.

So it is decided here and carried in the header as orgClasses — a sparse map from the 12-hex organisation token to 1 (residential) or 2 (hosting). The rows are untouched; the unit is the organisation, which is what a row already names and what the evidence actually describes.

node scripts/build-orgclasses.js     # gather evidence, write data/orgclasses.json
node scripts/build-iplocation.js     # embed the ledger in the header

data/orgclasses.json is the reviewable ledger, in the same spirit as the overrides file: every entry carries the evidence it was decided on, the number of fleet hosts that evidence came from, and a date. A malformed entry stops the build — publishing a verdict nobody can check is worse than publishing none, and none is a state the readers already handle.

The ledger is written to stay diffable, because a ledger nobody can review is a ledger nobody has reviewed. Evidence is sorted and capped per kind rather than sampled, and decided is carried forward from the existing entry whenever the verdict is unchanged, so it means the day the verdict was reached rather than the day the file was last written. Two consecutive builds against the same fleet leave 1,566 of 1,599 entries byte-identical; before that, an unsorted eight-host sample and a fresh date on every entry rewrote all 1,600 of them and buried the handful that actually moved.

The ledger keys ADDRESS RANGES, not organisation tokens, and that is load-bearing. The token is a hash of the registries' opaque-id, and the registries regenerate those on every publication: measured across eleven days, 1,919 of 2,510 fleet hosts changed token. It groups ranges correctly within one artifact and means nothing between two — so a ledger keyed on it loses its verdicts at the next build, silently, because a token that no longer exists is indistinguishable from an organisation the table does not carry. Keyed on ranges, the same ledger resolved cleanly against a registry vintage eleven days older: 160 organisations in, 160 out, nothing unresolved. A range is the thing itself rather than a name for it, and it is something a reviewer can check.

The organisation grouping is therefore a build-time derivation: build-iplocation.js looks up which organisation holds each ledger range in today's registry files and tags it. An organisation whose ledger ranges disagree gets no verdict — that is an operator running consumer lines and a hosting arm, and nothing is the honest answer for it. A ledger range that resolves to no allocation is named in the build output and recorded in the report; it is stale, not absent evidence, and silence is how verdicts vanish unnoticed.

The rule (scripts/orgclasses.js, the single definition both the gatherer and the build read): each fleet host in the organisation is decided on its own evidence — residential when something positively says access network and nothing contradicts, hosting when something contradicts and nothing says access, and no vote at all when its own signals disagree or it has none. The organisation then takes the verdict its decided hosts agree on: unanimous decides at any size, which keeps the many one- and two-host organisations answerable, and short of unanimous it needs at least three decided hosts and 80% agreement — the thresholds ADJUDICATION.md already uses to settle a location dispute. Evidence is reverse DNS, the RDAP object's netname and registrant, the ip-api hosting/proxy/mobile flags, and whether the operator is a known host — read from isp/as and never from org, which is the block registrant and frequently a reseller.

Unanimity was the first rule here, and it has a size bias that runs exactly backwards. Across a real consumer ISP's customers something always trips a flag, so four of Free SAS's 55 addresses flagged proxy vetoed a verdict its other 51 plainly supported. The bigger the access network, the less classifiable it became: what unanimity left undecided was Cogent, Free SAS, Frontier, Bouygues and Comcast — the large consumer and transit networks, which is precisely the population this table exists to name. Under the vote each of those is now decided, and each the obvious way: Cogent, Hetzner, Contabo, OVH, netcup and Linode datacenter; Free SAS, Frontier, Bouygues and Comcast residential.

Where the vote cannot reach, a person decides. Two populations are unreachable by gathering more of the same evidence: the minority tail of a mixed organisation, which the vote is designed to outvote, and an allocation with too few fleet hosts to hold a vote at all. data/orgclass-overrides.json carries hand-entered verdicts for those, keyed by CIDR range — never by organisation, whose tokens are reissued on every publication — under the same evidence discipline as the location ledger. "class": "none" withdraws a verdict rather than asserting one - a range we no longer stand behind returns to unclassified, which enforces nothing, without having to re-gather the evidence and hope an 80% vote comes out differently. An overridden range carries "override": true in the ledger, so a hand-entered verdict never reads as something the evidence decided. The procedure is data/ADJUDICATION_CLASS.md.

Link asymmetry is recorded but decides nothing. A bench figure is a speed test's result, not a property of the link, and Hetzner nodes measure 183/621 often enough that counting it as evidence let one noisy benchmark veto an operator whose own PTR, registry object and vendor flag all said hosting — 1,102 hosts left unclassified by an instrument reading. It can support a verdict the real signals already reached; it can never reach one, and never fight one.

The contradiction rule is what earns the accuracy, and it is measured, not asserted: against the 1,569 fleet hosts ip-api positively calls hosting, spanning all 29 hosting ASNs the fleet uses, the per-host form of this rule calls none of them residential — and the 39 that do trip an access signal are each caught by a contradiction. Applying it across the organisation rather than per host is deliberate: an operator running both consumer lines and a hosting arm reaches no majority either way and comes out unclassified, which is the honest answer for a block whose addresses are not all the same kind of thing.

Because the majority no longer has to be unanimous, up to a fifth of an organisation's addresses may be the other kind, and the table alone cannot tell which. FluxOS closes that on the node: one holding a published residential verdict that can see hosting evidence about its OWN address declines it. Measured on the 2026-08-19 fleet, exactly one of the 1,587 hosts ip-api positively calls hosting carries a published residential verdict — 213.44.137.57, in Bouygues' consumer space — and the node-side veto is what stops it being acted on, along with six others. Local evidence only ever removes a node from enforcement; it can never impose one the table did not give.

Only organisations the fleet actually occupies are considered. The artifact names over 103,000; enforcement will only ever ask about the few hundred holding Flux nodes, and a verdict nobody will read is a verdict nobody has checked. On the 2026-08-19 fleet that is 260 organisations behind 2,514 hosts, written as 1,599 ranges: 112 residential, 51 hosting, 97 unclassified.

Checked against the deterministic node list on the day it was built — 2,514 host addresses, 21% residential, 71% hosting, 8% unclassified:

  • Of the 1,587 hosts ip-api positively calls hosting, one came out residential, and the node declines it (above).
  • Hetzner (1,099), OVH, Contabo, netcup and Linode nodes carry no residential verdict.
  • The hosts PR #1784's classifier wrongly targeted — the University of Latvia, Infomaniak, the *.dedicated.static.tds.net block — all come out datacenter or unclassified.

Every one of the 97 organisations left unclassified is unclassified for want of evidence, not because its signals fought — the conflicted bucket is empty. The largest are YWNET (27 hosts), TalkTalk (20) and Consolidated Telephone (7): addresses with no PTR, no access or hosting vocabulary in their registry object, and no vendor flag either way. Nothing is the honest answer for them, and it costs only enforcement.

orgClasses is optional, like regionNames, and for the same reason: an organisation absent from the map has no verdict, and nothing enforces without one. A build carrying none costs enforcement rather than misdirecting it. Do not raise the format version to add a section — a bump makes every node on the current release reject the artifact and fall back to /16 arithmetic until it upgrades, degrading placement fleet-wide across the rollout.

Correcting the location table

DB-IP is right about far more of the address space than anything measured against it, but it is not right about all of it, and where it is wrong it is usually wrong about a whole block: a dozen nodes inside one allocation all self-reporting a country the table disagrees with is the vendor mis-attributing that allocation, not a dozen nodes lying. data/iplocation-overrides.json is the ledger of those corrections. They are applied build-side, between the merge and the artifact, so every node fetches a corrected table and there is nothing to override node-side.

The build report is what an entry is written from. validation.disagreementBlocks and validation.regionDisagreementBlocks name the address ranges the fleet contradicts, with the organisation token and the node count — the country and region pair tables say what is disputed, these say where.

An entry looks like this:

{
  "range": "203.0.113.0/24",
  "country": "FR",
  "region": null,
  "evidence": "12 nodes self-report FR; RIPE db inetnum country: FR — https://…",
  "added": "2026-08-02"
}
  • range is an IPv4 CIDR on its own boundary. Entries are sorted ascending by range start and may not overlap.
  • At least one of country and region must say something. country is an ISO 3166-1 alpha-2 code that the build knows a continent for; region is an ISO 3166-2 code whose country prefix matches. A region-only entry keeps whatever country the table already has, and the prefix is checked against it when the entry is applied — a mismatch fails the build.
  • country set with region: null clears the region on every row the range covers. A block in the wrong country was also given the wrong region, and there is nothing to salvage; leave region out and it means the same thing.
  • evidence is required and is meant to be read by whoever finds the entry in a year. Say what showed the vendor wrong — node self-reports, the RIR record, an operator's own statement — with a URL where there is one. added is the ISO date the entry went in.
  • Organisation is never overridden. The allocation boundary is the registries' fact; it is the vendor's attribution that is in dispute.

Entries are effectively write-once. There is no expiry and no review date, because the build detects retirement on its own: when the vendor catches up and every row a range covers already carries the entry's values, the build marks it noop and it is then deleted, nothing more. A range no table row covers at all is marked noop too, which is what a typo'd range looks like. Both counts land in overrides in the build report, node scripts/validate.js prints a WARNING line per retirable entry, and the monthly PR body carries the block — so the signal to write one and the signal to delete one both arrive in the same place, once a month.

A stale entry never fails CI. It is inert by definition, and blocking an unrelated policy merge on somebody else's tidy-up is the rule that gets bypassed.

Changing policy

To block something, add an entry to blocklist.json and run node scripts/build-blockedrepositories.js. Both files go in the commit. Never edit blockedrepositories.json by hand — it is generated, and validation fails when it drifts.

Open a PR. main refuses direct pushes, including from admins, because the validate check has to pass first and it cannot run against a commit that has not been pushed anywhere.

No reviewer is required — merge your own PR once CI is green, which takes about fifteen seconds. That is deliberate: the moment you most need to change policy is during an incident, and a rule requiring a second person is one that gets bypassed at 3am. A rule one person can satisfy alone does not.

The check is the point of the rule, not the PR. A document that fails validation is ignored by every node on the network, which for enterprisenodes.json means no owner can install on any enterprise node — so the one mistake worth preventing here is a shape error, which four trusted people can make as easily as anyone.

There is deliberately no review requirement. Write access here is already limited to the org owners — narrower than the application repo this was split out of — and everyone who has it is an admin, so a review rule among them would be a convention rather than a control. The protection that mattered was the split itself: a merge to RunOnFlux/flux can no longer change what the fleet enforces as a side effect of unrelated work, which is exactly how a tidy-up commit once removed a policy document from under the whole network.

What is not here

These documents are served over plain HTTPS and are not signed. Their authenticity rests on GitHub and on who can merge to this repo — which is why the ownership rules matter, and why moving to signed documents with a rollback-resistant sequence number is the intended next step rather than a finished one.

About

Network policy documents FluxOS nodes fetch and enforce at runtime

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages