Release 8.18.0: app ownership and tenant isolation, placement diversity, and the first gated release - #1799
Merged
Conversation
A node that lost one sync request never asked again, stayed SYNCING and never reached READY. Permanently, from a five millisecond window. The orchestrator recorded a peer as asked and then sent. A send into a socket that is closing does not throw - it is accepted and discarded - so the catch around it never fired and nothing noticed. The peer stayed in #askedPeers, every later pass filtered it out, and the node logged "No new eligible sync peers to ask" and asked nobody. Cleared only by #resetSyncState, which runs on degraded, so in ordinary operation never. Found on a real fleet by the residential-dos session: one direction of a 3-node fleet answered in 74ms while the other timed out at 30s with zero completions, and node 0's outbound socket closed with code 4019 five milliseconds after the four requests went into it. That same log line appears 15 times in another suite's node, which had been read as noise. The fix is a deletion. FluxPeerManager already tracks this - markSyncRequested, isSyncRequested, completeSyncRequest - and remove() already drops a peer's mark when the peer goes. The orchestrator was keeping a SECOND set of the same fact, and only the peer manager's copy followed reality. It now reads the owner's copy instead of keeping one, so a peer whose connection went away becomes askable again by construction rather than by a listener kept in step. No new event, no un-ask handler, nothing to keep in sync: the duplicate was the bug, so removing the duplicate is the fix. The test harness now models that ownership the way production has it, which is what makes the regression expressible: a peer asked, then removed, must be asked again. Restoring the private set fails that test and the same-cycle one. appSyncMinCompletions is 1 under test against 3 in production, which is why a single lost request is fatal there and merely wasteful in the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erdict The address a peer is asked to test is no longer an input. A caller that names it picks where every Flux node connects, and the echo hands back the first bytes of what answered - so the body form is a fetch primitive aimed at anything a signed peer likes, loopback and the RFC1918 side of its own router included. The range guard was never one: portMin is 1 and portMax is 65535. The address now comes from the socket, which is the rule /flux/addpeer already applies and the identity every inbound peer is already held to; this node's own operator may still name one by hand. A request is bounded at 50 ports, which is maxComponents times ports per component. Running out is a decision, and it is made once. A verdict and "nobody has decided" were the same value, so a last attempt whose peer never answered fell out of the bottom of the loop onto the false it started with and refused the install having said nothing - on one witness, which is what the corroboration rule exists to prevent. Every way of running out now resolves in one place after the loop, which also deletes the two copies of that paragraph inside it. A peer that will not answer is not a witness. `status: 'error'` carried two different things: a peer that NAMES the port it could not reach has read something at this address, and a peer that rejected the request outright - a stale node list, a clock, a key briefly unavailable at its end - has not. The first joins the same corroboration rule everything else answers to; the second is asked past, and logged as what it was rather than as a closed port. A sibling's answer is signed, as the node at that address. The request side had always established who was asking; the reply established nothing, and a node's own record of what it has installed is the whole reason to act on it. The verifier is extended rather than copied - `socketAddress` binds a signer to a place, which an answer needs and a request does not. Also: the picker's reason for excluding a node at our own address is that it is not outside it, which holds whatever the router does; siblingHoldingPort records its dependency on NAT hairpinning, and says so when siblings exist and none of them answered. The port test loop had no unit coverage of any kind - suite 98 was the only thing that had ever run it. Eight tests now, five of which fail against the previous code, and the address derivation has five more of which one does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nchmark The self-connection guard added on this branch needs a CURRENT address, and got one by calling getLocalSocketAddress on every dial. That is an uncached benchd RPC (executeCall), paid by discovery's deterministic loop, the reconnect queue, the random draw, the manual add and /flux/addpeer - and it made all five hard dependent on benchd answering, where only discovery already was. The form it replaced was not right either: `if (!myPort)` read the port once per process and never again, so after an address change a node announced a stale one for as long as it ran. That is the bug the guard exists to catch. The peer manager already holds this fact. fluxNetworkHelper pushes every refresh into it through setOwnSocketAddress, from the one place the node learns what it is, and it already answers this same question for the sync draw - never ourselves. So the dial reads the owner's copy, and asks only when the node has not been told yet, which a dial arriving before the first refresh genuinely is. Freshness is bounded rather than unbounded: fluxDiscovery refreshes it once per cycle before it dials anything, as do the availability checker and the address-change handler. Three tests, two of which fail when the dial bypasses the owner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e question The signed ask was a constant body - a key and a fixed word - so one captured signature was a bearer token for /flux/portsinuse on every node in the network, for ever. It carries the time now, and a node refuses one outside siblingAskValidityMs. Refused as STALE rather than as unauthentic: the two want different fixes, and a node whose clock has drifted should be able to read which from one line. Not asked of an operator, who authenticated as themselves and has no signature for anyone to capture. The same field binds the ANSWER. The sibling signs the ask's time back, so a captured answer cannot be replayed into a later question either - a signed reply with no question attached is a recording, and a recording says what was true then. One field, both directions. A sibling REFUSING the question is now said out loud rather than joining the silence a sibling with nothing to report makes. Also: portNotOurs gets its documentation back. The block had drifted above PORT_TEST_CORROBORATION, so the constant had two doc comments and the function none. Three tests, all three red without the window and the binding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The idempotency check matched the tag by name alone, so a tag holding the release version but pointing at another commit -- one cut by hand, or one master was later rolled back behind -- was read as "this workflow already ran". Every remaining step was skipped, including the merged-tree verification that is the only check covering what master actually holds, and the job reported success with no tag cut and nothing verified. The tag is now resolved to a commit and compared to the one being released. A match is a genuine re-run and still skips; anything else stops the job and names both commits. Both tag forms are read, because an annotated tag advertises the commit through a peeled ref and a lightweight tag does not -- and every tag in this repository today is lightweight, so reading one form finds neither reliably. ls-remote answers empty when nothing matches, so pipefail is what separates "no such tag" from a transport failure. tests/ci/release-tag.sh runs the step as it ships, extracted from the workflow rather than retyped, across both tag forms and a dead remote; it also asserts the rest of the job still reads the output this step writes, since an output that is never written compares equal to nothing and silently disables the release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes to release-gate, and three small ones to release-tag.
A version the comparison cannot read is refused by name rather than judged by
whichever part happens to differ. Splitting on dots and comparing the results let
"8.18" pass with no third part at all, and "8.18.0-rc1" pass on its minor while
"8.17.2-rc1" was refused on a NaN -- opposite answers for the same kind of input.
A release whose merged tree exists on no branch is refused at the gate. The merge
preview is a ref nowhere, so its hash is in the signed document only because some
branch tip already holds the same ZelBack subtree. When master carries ZelBack
changes development lacks, nothing will ever sign the merged tree, and the
membership check below would tell the reader to wait for a signing run that never
comes. This refuses first and names the remedy. It compares the ZelBack subtree,
not the whole tree, because master diverging under helpers/ is routine and cannot
change the hash.
release-tag now runs only on this repository, reads its last two values through
env rather than interpolating them into the shell, and logs the poll failure's
message rather than its whole stack trace -- fifteen lines by thirty attempts.
tests/ci/release-workflows.sh covers all three steps, extracted from the workflow
files verbatim: 20 cases, and 11 mutations each reintroducing one defect, all
caught by a case asserting the cause. It also asserts the steps carry no ${{ }},
so a step that becomes uninspectable breaks the tests rather than silently
skipping them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What a releaser does, what each check means, and what every red message is asking for. The one change to the ritual is that the helpers/hashes.json entry now rides the release PR instead of being committed to master after the merge -- which works because the fingerprint covers ZelBack only, so adding the entry cannot change it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes to release-gate, three small ones to release-tag, and the tests for all of them. a9ed2f0 carried the test file's rename without this content; the two belong together. A version the comparison cannot read is refused by name rather than judged by whichever part happens to differ. Splitting on dots and comparing the results let "8.18" pass with no third part at all, and "8.18.0-rc1" pass on its minor while "8.17.2-rc1" was refused on a NaN -- opposite answers for the same kind of input. A release whose merged tree exists on no branch is refused at the gate. The merge preview is a ref nowhere, so its hash is in the signed document only because some branch tip already holds the same ZelBack subtree. When master carries ZelBack changes development lacks, nothing will ever sign the merged tree, and the membership check below would tell the reader to wait for a signing run that never comes. This refuses first and names the remedy. It compares the ZelBack subtree, not the whole tree, because master diverging under helpers/ is routine and cannot change the hash. release-tag now runs only on this repository, reads its last two values through env rather than interpolating them into the shell, and logs the poll failure's message rather than its whole stack trace -- fifteen lines by thirty attempts. tests/ci/release-workflows.sh covers all three steps, extracted from the workflow files verbatim: 20 cases, and 11 mutations each reintroducing one defect, all caught by a case asserting the cause. It also asserts the steps carry no ${{ }}, so a step that becomes uninspectable breaks the tests rather than silently skipping them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(ci): gate release PRs on the signed hash list, and cut the tag after merge
… write in full A peer asked to read a port hands back a bounded prefix of what it found, and the requester's only evidence is finding its secret inside that prefix. The secret was last in the body, finishing 48 bytes short of the cap - and almost none of what preceded it was ours. Node writes Date, the transfer framing, and a Connection it copies from the READING peer, so the margin was settled by a request string in another service and moved between 20 and 80 bytes with it: dropping `Connection: close` from portAnswered spends 28 of the 48. Losing the secret does not read as a truncation. It reads as a neighbour holding the port, identically on every peer - so refusedPort's two-witness rule, which exists to filter what does not reproduce, corroborates it rather than catching it, and no node installs anything. Every header is now written in fluxHttpTestServer, including the three Node would otherwise append on its own, and the secret is the first of them: the same 187 bytes, secret ending at byte 67, under Connection: close, keep-alive, no Connection header, HTTP/1.0 and HEAD. MAX_ECHO_BYTES stays 256 and goes back to answering only what it is for - how much of a stranger's response a peer may relay - instead of also having to exceed the length of an answer nobody was measuring. isPortOpen's JSDoc had drifted above portAnswered, leaving isPortOpen with none. Three tests, two of which fail against the previous code: the secret sits in the header block, and the answer is byte-identical whatever the peer asks for. The third states the contract the two files never mention to each other, and holds either way. The suite's tokens were 8 and 12 characters against a real 32. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… once routes.js carried eighteen lines calling /flux/portsinuse unauthenticated and reachable by anyone, bounded by a thirty-second apicache, protected by a rejectQueryParameters guard that is not on the route - with three sentences appended at the end saying it is a signed POST with no route cache at all. The first thing the next reader met was the design that had been replaced, and the correction was at the bottom. It went stale because it was a second copy. portsInUseApi already documents what the endpoint is for, that it is signed rather than open and why - an anonymous caller could make this node decrypt its own specifications as often as it liked - and that an operator may still ask by hand. The route line owns none of that, so when the handler changed the prose describing it sat in a file the change never opened. Appending a correction is what produced a comment that contradicts itself; the copy is deleted instead. What is left in routes.js is what the route line decides: POST because the ask carries a signature and is therefore a body, no cache() because apicache keys on the URL alone and answers before the handler, no rejectQueryParameters because there is no URL surface for a query string to reach. portsInUse had taken that comment as its authority - "which routes.js says in as many words must never happen" - so a second file was already resting on it. It gives its own reason now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ine breaks siblingHoldingPort promises in its own doc comment, and again at its call site, that it answers rather than throws - because the spawner reads a throw as the APPLICATION having failed and files it in the six-hour pre-install error cache. Nothing about a question this node could not ask is the application's fault. That promise was kept a line at a time, and the guess was wrong. The dial carried .catch(() => null); the verification immediately after it carried nothing, and deterministicFluxList sits inside that. So one sibling failing to verify left the function, passed through appSpawner's catch, and stood a healthy application down for six hours. Three layers now, saying three different things. The dial keeps its quiet catch: where a router does not hairpin, every sibling is silent on every cycle, and a line each would bury everything else - the count is already said once at the end. Around the rest of the per-sibling work, a catch that loses THAT sibling and no other, and names it. Around the whole body, the contract itself, so that no line added here later can break it and no future reader has to notice. Two tests, both red before: a sibling that cannot be verified no longer costs the answers of the siblings that replied, and a failure before any sibling is reached answers no information rather than throwing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y platform has The check that a body-supplied address cannot aim this node's probe - the blocking finding of the second review - could only run on Linux, and nothing about it is a platform question. It used 127.0.0.2 as the address the caller did NOT connect from. Linux assigns the whole 127 block to the loopback and refuses there in 3ms; macOS assigns only 127.0.0.1, so the dial hangs until the port test's own five-second timeout and the test dies on mocha's two. ::1 instead: a real local address on both, with nothing on that port, refusing in 3ms - and a better "somewhere else" than a second address in the same block. A host with IPv6 switched off errors immediately too, so the assertion holds either way. Skipping it off Linux was the alternative, and that convention exists here for adjustFirewall and initiateAndHandleConnection. Those need Linux. This needed a different constant, and skipping would have silenced a security check on the platform it is developed on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n Error
getFluxNodePublicKey answered a failure with the Error object, and signMessage
did the same. That is the worst value either could return: truthy, so a guard on
the value passes; not a string, so nothing type-based notices; and `{}` the
moment JSON.stringify reaches it. Nine callers took the public key and two
checked it, so a node whose key was briefly unavailable did not fail - it went on
broadcasting messages carrying "pubKey":{} that every peer silently refused,
having logged nothing at the point the key went missing.
appSyncOrchestrator is the proof the contract was wrong rather than the callers:
it wrapped both accessors in a try/catch and logged "Failed to sign sync
requests" from a catch that could not fire, because neither of them throws.
Both now answer null, and getFluxNodePublicKey says so in the log - it was silent
where it happened and loud only at the far end.
The question "can this node speak as itself" gets one place to live.
signCheckAppData existed twice, byte for byte, in portManager and
availabilityChecker; both are gone. nodeSigner answers { pubKey, sign } or
nothing, and the five sites that took a public key without ever checking it -
portsInUseApi, siblingHoldingPort, checkInstallingAppPortAvailable,
callOtherNodeToKeepUpnpPortsOpen, checkMyAppsAvailability - each ask once. It
answers rather than throws: its callers sit on paths with very different
tolerances, one of which must never throw at all, and a primitive cannot know
which it is being called from.
No message shape moves. pubKey keeps its position in all five bodies, so nothing
a peer verifies changes; the broadcast envelope signs version+message+timestamp
and the node-to-node asks sign the stringified body, and unifying those is a wire
change that is not this.
enterpriseNetwork and nodeConfirmationService keep the type checks they already
had. A consumer refusing a value it cannot use is a stronger guarantee than a
producer promising not to send one, and they are the only reason a bad key was
ever caught.
Five tests for nodeSigner including a key that arrives as an Error. Three tests
that pinned the old contract now assert null; one of them was called "should
throw error" for something that never threw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ery operator holds Two gates stood here and they answer the same question. The first lets a caller skip the Fluxnode signature; the second, added by this branch, lets a caller name the address to probe. But skipping the signature is only ever a PERSON driving this endpoint by hand, and the sole thing a person gets from it is naming the address: asking whether the ports are open on the machine they happen to be sitting at answers nothing anyone wants. Signed-in-but-pinned-to-your-own-address is a state with no purpose. One question, asked once. And it is Flux team's. NODE_OPERATOR_OR_FLUX_TEAM reads node-local and is not - verifyNodeOperatorOrFluxTeamSession takes fluxTeamFluxID and fluxSupportTeamFluxID alongside the operator - so the comment beside it, claiming the carve-out was already node-local, was wrong in the direction that matters. What it actually described was a request-forgery primitive, aimed anywhere including loopback and the private side of a node's own router, held by thousands of separate credentials, one per node, for what is a Flux team diagnostic. An operator who wants to dial out of their own box has a shell on it. Their node still reaches this endpoint the way every node does, by signing: both product callers - checkInstallingAppPortAvailable and checkMyAppsAvailability - post a signed body and never touch this privilege. What is lost is an operator calling it by hand with zelidauth and no signature, which development allowed. Two tests. One that Flux team may still name an address; one that pins the decision rather than the behaviour - the privilege asked for is FLUX_TEAM and NODE_OPERATOR_OR_FLUX_TEAM is never asked for at all, so the old constant cannot drift back unnoticed. The second is red against the previous gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported in round two as done, and they were not: the runs removed then were in portManager.js, and none of that round's three commits touched this file. Still here, moved by the response rewrite from 14-15 and 56-58 to 38-39 and 96-98. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
assignedPortsGlobalApps read the global specifications straight out of mongo. A version 8 specification seals contacts and compose, and every port an application holds lives inside compose - so an enterprise application came back with no ports, was dropped by the length filter, and the pre-install check concluded its ports were free. The exact collision this branch exists to prevent, invisible for every enterprise application on the network. The key was never the problem. checkAndDecryptAppSpecs opens the blob, assignedPortsInstalledApps sixty lines above already decrypts, and the spawner decrypts an application it has not installed at all before deciding whether to take it. Nothing anywhere refuses on entitlement: fluxbench forwards the ask verbatim. The global reader simply never asked. Both readers now go through one appsWithPorts, which decrypts and then derives ports through getAppPorts - the one place that derivation lives, which the review noted these two were hand-rolling around. What each does about a specification that will not open is now a decision rather than an accident, and they differ on purpose: - installed refuses, because portsInUse publishes that list to a Flux node sharing our address, so a hole there tells a sibling a port is free when it is not. A node holding an enterprise application can always read it - they only install on ArcaneOS. - global answers short and says so, because every node stores every global specification including ones sealed to keys it was never meant to hold. Refusing would stop installation on every node not running ArcaneOS. The comment at the sibling ask claimed an enterprise application "does not carry" its ports. It carries them sealed, and that wording is what kept the missing decrypt looking like a law of physics. It now says the true reason the ask is needed: the broadcast list only shows what is already RUNNING, and a sibling that has installed an application without starting it still holds the router's forward. isPortAvailable and findNextAvailablePort deleted. No caller anywhere - findNextAvailablePort was called by nothing and isPortAvailable only by it. Being exported was all that made them look alive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… what they hold extractIp(localSocketAddr) was computed in four places inside trySpawningGlobalApplication, under three names - adjustedIP, localSocketAddrAddress and localIp - with nothing telling a reader they were the same value. localSocketAddr is set once and never reassigned, so one const after the null check serves all four. The name that made this visible was localSocketAddrAddress, which says its own type twice and its meaning not at all. It is the node's public IP without the port, which is what the callers want it for: the apps the network reports running at the address this node shares with its siblings. Two more in the same function, same convention: - runningAppsOnThisIP -> appsRunningAtOurIp - filterAppsWithNyNodeIP -> appsPinnedToThisNode, which is what it holds. It named a filter rather than the result, spelt My as Ny, and gave no hint that these are the applications whose owner named this node - pinned being the word isNodePinnedHere already uses for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
isNodePinnedHere answers whether the app's own nodes list identifies this node, by socket address or by collateral outpoint. "Here" carried none of that, and "pinned" was a word the codebase used without ever defining, so the surrounding code borrowed it for two more names that then had to be read back against this function to be understood. placementFeasibility.isNodePinnedHere -> specNamesThisNode appSpawner pinnedHere -> ownerNamedThisNode appSpawner appsPinnedToThisNode -> appsNamingThisNode Names only; no behaviour, and the stubs and assertions move with them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e comments say what actually differs The hash is filed in the spawn cache at selection, and the spawner's catch adds nothing for a hash already there. A sibling check that threw would therefore leave the app exactly where standing down leaves it. What differs is how it is told: an answer is one log line and a published deferral, a throw is an error with a stack trace and no event. Three comments said a throw would file the app in the six-hour pre-install error cache. It cannot. They now describe the distinction that exists. The test guarding that distinction asserted a cache neither path writes, so it could not go red on a throw. It asserts the deferral event instead, which a throw does not publish - red against the branch turned into a throw. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hing it did not announce The broadcast signer read both key accessors and checked neither, so a node whose key was unavailable put a message on the wire with null where the key and signature go, and every peer refused it without a word. It signs through nodeSigner now, answers null when the node cannot sign as itself, and each of its callers sends nothing on null. The running-apps announcement is one fact recorded twice - the location table, and the event log peers sync from - and sent once. A node that cannot sign records it nowhere, asked before the first write, so its own view of where it runs stays the network's view rather than diverging from it. The Flux Storage request is signed the same way and refuses rather than sending a header carrying null. Each guard has a test that is red without it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…es a stale ask before verifying it The handler's comment said its only real caller is a sibling. Which ports a node holds is a fact about that node, and a sibling is one caller for it; the endpoint answers any listed Fluxnode and the comment now says so, along with what a captured ask yields inside its window: the port list, signed to the ask's own time, and nothing else. The window is checked before the signature. An ask outside it is refused for nothing, whoever sent it, rather than after a signature check it would fail anyway. Two tests assert the order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… same rule as the availability endpoint /flux/keepupnpportsopen took the address to poke from the request body, so a signed peer could point any node at any address and any ports. The honest caller is asking about its own ports and posts from the address it means, so the address comes from the socket, and naming one is the same single Flux team privilege as skipping the signature. The rule is one function now, shared with /flux/checkappavailability, rather than two copies of it. The port list is bounded by what an honest one can hold - every installed application's ports plus the four node service ports the caller adds - and is deliberately not filtered by range or by the banned list: the caller's own service ports sit inside the banned block, and the filter that is right on the availability endpoint would silently end the keep-alive here. A test pokes them. A node operator can no longer drive this endpoint with a login alone. Both product callers sign; the frontend does not call it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A node with no public key cannot sign whatever its private key says, so the test that proves nothing is sent has no use for one. The line that supplied it repeated the suite's test key a thirty-fourth time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ddress that asked No node in the fleet is behind UPnP, so nothing sends this ask itself. The runner signs one as node 5 and sends it from where it is not: a real FluxOS answers 503, because the connect-back goes to the runner, where nothing listens - the body's address would have reached node 5 and answered 202. The control, the same body with Flux team auth, gets the 202. What a unit test cannot prove and this does: express.json(), a real socket, and a refusal that is a bare status code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…address rule is what decides it
…on the harness bus
…s a value and not a broadcast
…swers the contract the caller reads
A node writes its OWN location row when it announces, and that row expires RUNNING_EXPIRY_MS after the announcement that carried it. So the interval is a contract: announce later than the row lives and the node stops being a holder of its own apps, on its own reading and on every peer's. The timer was recreated in the finally, so the real period was `interval + the cycle's duration` - the work sat inside the thing it was being timed against. Nothing reported it and nothing measured it, so a node whose cycle took 40s against a 30s interval announced every 70s while every config file said 30. It is scheduled from the cycle's START now, monotonically, clamped at zero so a cycle that outran its interval runs again immediately rather than being scheduled into the past. The debounce the finally was written for survives: any call still postpones the next scheduled announcement. The interval is capped at half the row's expiry. Both configurations in use are already inside it - production 3600000ms against a 3750000ms cap, the harness 30000ms against 31500ms - so nothing changes today. What it stops is the pairing that deletes the property in silence, which is how this was found: the harness compresses the interval and the expiry together by 120x and the ratio survived that, because the term it could not compress was the cycle's own duration. An overrun is said once on the transition and once when it clears. A node whose cycle no longer fits its interval decays its presence on the network, and until now nothing anywhere reported it. The work stays inside the announcement deliberately. A node that cannot determine what it is running announces nothing, peers expire its rows, and the network draws the right conclusion. Separating the timer from the payload would make the cadence exact and the content stale - a wedged read would announce a stale snapshot punctually and forever, which is worse than being late. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eld by the feed it joined A subscription remembered a container id, and asked whether anything was filed under it. Ids survive a container's restart, so a viewer whose feed had ended read the feed another viewer opened for the same container as its own, and every later subscribe on that connection was refused for the life of the connection. An entry now holds the feed record itself, and a feed leaves the map only through closeFeed, which marks it - so "not filed" and "marked closed" always agree, and a dead subscription is replaced on sight rather than mistaken for a live one. One slot becomes a map, because nothing about a log stream is exclusive. The terminal takes one container per connection because `cmd` and `resize` name no session; everything here is addressed by room and keyed by container, and one docker stream already serves every viewer of a container however they are connected. Measured on a node: ten containers followed by one connection cost what ten connections following one each cost, and save nine sockets. Ten, because an app is capped at ten components; refused before the signature check, so a connection at its limit cannot spend the node's verification on a subscribe it cannot have. The rest follows from a connection being able to follow more than one: every message names its container, or a viewer of two cannot tell whose lines arrived; unsubscribe takes the name or id of one of them, and no argument still means all of them. Three defects in the same file went with it. A failed open now empties its room - a viewer left behind was handed the lines of whatever feed opened for that id next - and does it only when the feed it claimed is still the container's, so a stale failure cannot empty a live feed's room. A pass whose feed was closed while the daemon answered says the stream failed instead of reporting itself subscribed to nothing. And a pass that fails releases only what it still holds, rather than taking down the subscription a later pass on the same connection has taken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a stop of it is final Two numbers decided one thing. A node writes its own location row when it announces, that row expires locationTtlS later, and peerNotifyIntervalMs was a second, hand-set number that had to stay under it - with a clamp and a ceiling in the scheduler to catch whoever forgot. Both configurations had already been set to the same ratio by hand: two announcements to a lifetime, with a little slack. That is now the derivation, and the second knob is deleted. Two, because a node must be able to miss one announcement and still be refreshed before the row lapses; four percent, the slack that miss needs so the refresh lands before the row expires rather than exactly on it. Production's 7500s gives 3600s and the harness's 63s gives 30s - the values both files were carrying - so nothing changes on any node, and there is no longer a pairing an edit can put wrong in silence. The announcement loop also gets a lifecycle. It ended each cycle by arming the next one, so a stop taken while a cycle was running was undone a moment later by the work it was trying to end; and the stop returned before that cycle had finished. Whether the node announces is one flag now, consulted by every path that ends a cycle, and the stop holds the cycle's lock so it returns when the cycle has actually let go. An announcement asked for directly - an install completing, a container starting - is still answered, because that is a request to announce and not a request to resume announcing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hat it owns An AbortSignal fires once and has to be replaced to run again, so it cannot also carry whether a loop is wanted: abort() reissued it as soon as the lock freed, and loop() read it after the runner had had a chance to yield. Whether a stop held came down to which of the two resumed first, and for a runner that took no lock it did not hold at all - measured: the loop ran twice more after the abort, and the timer it armed kept the process alive. The controller now has a state of its own, taken at the first line of the stop, and that is what decides whether an iteration schedules another. A generation goes with it, so an iteration already in flight when the controller was stopped cannot arm a timer beside the loop a later start has begun. The signal goes back to being what it is - a cancellation for the work in flight - and is reissued when the abort finishes, because it is also handed to clients that have no loop and are rebuilt on stop. `abort` waits on the default lock and on any lock added with blocksAbort. A named lock usually holds a caller's own coordination - the network state manager's `fetcher` is how its readers wait for a fetch - and a stop that waited on every lock deadlocks against the work it is stopping. `running` covers stopping as well as running, since a caller guarding its start on it must not read a stop in progress as stopped and reset its own state for a loop that startLoop then refuses to begin. `active` is the new question: is this run still wanted. It is the condition for a runner that loops on its own rather than returning a delay, which the signal cannot answer - and it is false outside a run, so a function called both as the runner and directly must still ask the signal. The syncthing events consumer is the one loop of that shape. A runner that throws ends the loop with a log rather than a rejection nobody handles, which in this process is an exit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it counts what is in flight A feed and the records naming it are two halves of one subscription, and closing a feed tore down only one. The record stayed behind, counting against its connection's limit for the life of the socket while naming a container it no longer followed - and the id it named may since have been filed to another viewer's feed. It is only replaced when the same id is subscribed again, which a redeploy never is: the container comes back with a new one. A feed remembers its viewers as their own records now, so closing one forgets them where it already empties their room. Nothing at module scope can see a connection's map, so the record carries the way to forget it, guarded by identity: a later pass may have taken that container over and its record is not an older feed's to remove. The limit counts the passes still in setup as well as the settled ones. A pass reaches the map only after the signature check and the docker lookup, so counting the settled ones alone let a burst arriving in one tick run both in parallel and be refused afterwards - 60 verifications against a limit of 10, measured. That is the cost the refusal exists to avoid, taken as many times as the subscribes were sent. 'subscribed' says one thing: you are receiving this container. It is answered now only for a record that is a member of its feed's subscribers, so a repeat arriving while the first pass is still opening is left to that pass to answer for both. Answering it early told a client it was subscribed to a feed that had not opened yet, and nothing corrected it if the open then failed. And every refusal names what the client asked with, as the batches already name their container. A connection following several of them could not attribute 'Not authorized.', 'Container not found.', a refusal at the limit, or 'Error following logs.' to any of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ainst `tail` bounds the window over the FILE and `since` trims that window afterwards - measured on a live daemon: asked for the last 1884 lines at-or-after a timestamp it answered 1883, having dropped a leading line stamped before it. The read was bounded at one more than a page on the opposite premise, which the file asserted in one comment and contradicted in the one above it. A position's `count` is a place in the sequence docker returns for a timestamp, so that sequence has to start at the same line each time. A window bounded over the file slides forward as the container writes, and when it slid past the reader's overlap the count was measured from a different first line and the lines between were dropped - with `skipped` and `rolledOver` both false, because an answer shorter than a page reads as the whole set having fitted, which is exactly what a trimmed window produces. The window carries the overlap as well as the page, and the overflow test measures the new lines rather than new-plus-overlap. Every stub in the suite answered a fixed buffer whatever it was asked for, so `tail` and `since` had no effect on any answer and no test could see the order they were applied in. There is a fake daemon now that honours both, built to what was measured, and a two-poll test through it: against the old arithmetic it loses two lines of three and reports success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four comments said a redeploy that could not reinstall had touched nothing, so the app was as it was and the reconciler would converge it. Neither half is true: the removal above has already taken the app's containers AND its local row, and with no row there is nothing to converge. The installer's own FAILED return said it had told the network, and it removes with `sendMessage` false. So both outcomes end with the node holding no containers, no row, and peers keeping a location record until it expires on its own. That is recorded where it happens now, along with why it is not answered here: v9 replaces this path with the operation registry, broadcasting the removal would cost the app's data, and a retry belongs to something that owns the whole redeploy rather than to its last step. The tests stubbed `registerAppLocally` with `true` and `undefined`, which stopped meaning anything when it began answering an InstallOutcome - anything that is not INSTALLED is read as a failed reinstall. So a test asserting that a SUCCESSFUL component redeploy does not uninstall the app was asserting it of the failure branch, which also happens not to. The stubs answer an outcome, and that test now proves it reached the success path by the event that is published on success only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…og holds A positioned read is bounded by `tail`, and `tail` was sized from `position.count` - a value the caller writes into an unsigned token. The bound the docblock rests on was the caller's to choose. Bounded by what the log can physically hold rather than by the page: `count` grows past a page whenever a burst lands inside one millisecond, and a window that does not reach back over the overlap is answered entirely by lines the reader already holds - it is handed nothing, its position never moves, and neither `skipped` nor `rolledOver` says so. `max-file` and `max-size` move out of appDockerCreate into constants both it and the read derive from, so what a log holds and what a read may ask for stay one fact. The decode also declares that it asked docker for timestamps, which is what lets the decoder tell the stamp on a line from the copies docker puts on each 16KB chunk of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ts the node Catching it kept the node up and left the loop stopped for the life of the process - nothing reads `running`, and nothing starts these loops again after boot. What a node does with an unexpected fault is already decided at the process level: apiServer's uncaughtException handler logs it and exits, and systemd brings every subsystem back thirty seconds later. Every runner this serves guards the failures it expects, so a throw arriving here is one nobody predicted, and the crash policy for those belongs to the application rather than to a generic utility. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…what it lost A line is held until its newline arrives, which nothing obliges a container to send. Unbounded, a process writing a large blob to stdout decided how much of the node's memory a viewer cost - measured, 10MB of newline-free output holds 10MB and emits nothing, growing to V8's 512MB ceiling where every further chunk throws and the feed is finished still holding it - and then how much crossed the socket, because a line that completes is delivered whole. The decoder cuts every line to a bound and drops the rest of a cut line rather than splitting it into lines the container never wrote, so delivery is bounded as well as memory. What was cut is reported on a `truncated` event: its own, because `skipped` counts lines that never arrived and this is a line the viewer HAS, missing its tail. Counted per line rather than per batch, and sent after the lines rather than before them. Docker splits a message longer than 16KB into 16KB chunks and copies the message's stamp onto every one - measured on a live daemon, three chunks of len=16415 all carrying the first one's. Joined as they arrived, any line over 16KB came back with docker's timestamps spliced through its body, which is a line the container never wrote and one a reader cannot tell from one it did. A continuation's leading token is now dropped when it is byte-identical to the stamp its line began with, so content that merely looks like a timestamp is left alone and a daemon that stops repeating it falls back to passing the body through. Two more the review found. The limit refused the tenth container to a connection holding nine: a pass is in `pending` until its setup ends and in `following` from the moment it takes one, so adding both charged an open twice. And a stream outlives the feed it was opened for - the events after a destroy still reach its handlers, by which time a later viewer's feed is filed under the same id, so a dead subscription's lines went to the live one and a dead stream's error closed a subscription it never had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suites 59 and 84 required lines to arrive in the container's own numbering. Docker merges stdout and stderr as it reads them and can serialise a line behind the one that follows it - measured on the gate box over 40,000 alternating lines: 38,078 order breaks, 2,817 backwards timestamp steps, nothing missing. The fixture alternates the two streams deliberately, so the suites forbade the property they exist to produce, and 59 went red on a loaded gate for it. Both now assert the set is complete - no duplicates, no gaps once sorted - with the window closed by what arrives after it, so a line docker merged late is not read as one the node never delivered. The fixture also writes a line with no newline in it on demand, and 84 covers the cut end to end on a container of its own: the line comes back at the bound and carrying only its own stamp, every notice reports a whole number of cut lines, and the numbered lines continue across it. Docker frames that blob into eighty pieces, which is the part no crafted buffer can stand in for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-and-spurious-uninstall fix(apps): a missing container must not crash FluxOS, a failed redeploy must not uninstall the app, and a log poll must lose nothing
2919141 was the chain tip the fork was written at, which is the one height it could not be: a node on an older FluxOS reads only the fork at 1851659, so a message signed by any of the four new addresses is rejected until it updates. A fork at the tip gives the network no window to update in. It moves to 2951000 - about a week past the tip as of this commit (2931010, 7th September 2026), at the ~30s blocks the chain is producing. The addresses and the shape of the entry are unchanged; only when it takes effect is. The harness chain start moves with it. tests/unit/harnessChainStart.test.js fails if any gate in the production config sits at or above it, and for the same reason it fails: a chain starting at 2920000 would run every suite on the pre-fork branch of the rule this PR adds. 2952000 is above the new fork and crosses no other gate - the next highest in the config is 2176519. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167SV41toNw53WkGcHJ3cE2
feat(auth): the support team is a list of Flux IDs, not one
Brings back the two commits that landed directly on master after the v8.17.1 release: the v8.17.1 fingerprint in helpers/hashes.json, and the tampering blocklist additions. Both are under helpers/, so neither touches the hashed ZelBack tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ooCPUCraGLFGvtFh35ZA5
Steps 1 and 2 of RELEASING.md for the development -> master release PR. package.json moves 8.17.1 -> 8.18.0, which is what release-gate's "version moves forward" check reads. helpers/hashes.json gains f91c1a1ec2604642ddd69edc121d18b6, the md5 over ./ZelBack at this tip. Neither file is inside ZelBack, so committing them does not move the fingerprint. It is already carried by the signed document central serves (seq 41), so release-gate's signed-document check has its answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ooCPUCraGLFGvtFh35ZA5
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 10071586 | Triggered | Generic High Entropy Secret | c053c1e | tests/unit/fluxCommunicationMessagesSender.test.js | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
Cabecinha84
requested review from
MorningLightMountain713,
TheTrunk,
XK4MiLX and
alihm
and removed request for
MorningLightMountain713 and
XK4MiLX
September 7, 2026 22:03
TheTrunk
approved these changes
Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release PR,
development→master. 20 merged PRs since v8.17.1, 470 files, +89k/-17k, 57 new test files.Cut following
RELEASING.md— the process introduced by #1788 in this very release, so this is the first PR the gate judges.mastermerged back intodevelopment(13e92bf2a)masterZelBackis the PR head'sZelBackmaster's two extra commits are both underhelpers/, outside the hashed treepackage.json8.17.1 → 8.18.0 (0b7d1a390)helpers/hashes.jsoncarriesf91c1a1ec2604642ddd69edc121d18b6./ZelBackat this tiphashes.runonflux.io/hashlist, seq 41, valid Ed25519 signaturenpm run lintgate,build (ubuntu-22.04, 20.x, 7.0),build,deploy,publishall green; GitGuardian red — see belowGitGuardian is red on a known false positive, and it needs a maintainer dismissal rather than a code change. The flagged string is the WIF test key
5JTeg79...intests/unit/fluxCommunicationMessagesSender.test.js; #1795 added one more occurrence of a constant that already appears 20+ times indevelopment. It is the same red that stood on #1795 itself. Every other release PR since 8.15.0 passed this check, so it is new with this content, not with this PR.flux-domain-manager#154 must merge and be deployed to every FDM server first. #1777 in this release retires
apppause, and a paused container was the only reason an operator-stoppedg:master kept its load-balancer primary. FDM#154 replaces that with an explicitGET /apps/heldcomponentsprobe — an endpoint this release adds. Cutting the release before FDM has it means an operator-stoppedg:app loses its primary and can come back on a different instance's copy of the shared volume.The other coordinated repositories are already in: fluxos-frontend#140 (monitoring controls) and fluxos-frontend#151 (encrypted specifications) merged 2026-08-20 and 2026-09-03.
Ownership, authorisation and tenant isolation
#1787 — an app owner is validated as an identity a signature can verify against.
ownerwas only ever checked for being a string. A signed update setting it to a username or a mistyped address did not transfer the app, it destroyed it: no key on earth signs for"TrippleCore", so nobody — not the former owner, not FluxOS, not us — could update, renew, restart or reassign it again. It happened on 2026-08-14 to a customer app with 18 days of paid life left, and was recoverable only by accident.#1791 — hosting an app is not owning it. The node operator is admitted to no app-scoped endpoint.
appowneraboveis removed from the tree entirely, dispatch case and verifier both, so the string authorises nobody rather than waiting to be reached for again. A specification decrypts for its owner alone. Underneath, the check that decides who is asking is rebuilt: a privilege is a named member rather than a spelled string, and the check takes the identity it is judging rather than the request it arrived in.#1778 — every write to a tenant volume moves into a throwaway container, with that volume as the only thing mounted, including
createfolder,renameobject,removeobjectand upload. It closes a remotely-triggerable root compromise (the boot sweep fed a tenant-writable file's contents to a rootmvdestination —sudo mv -T <volume>/.flux-old-pwn /etc/cron.d/pwnon the next restart), an app owner's ability to take a node down by opening their own file manager (symlink-following folder measurement, unbounded, as the FluxOS process), and two download endpoints that left every downloaded file world-writable.#1796 — the support team is a list of Flux IDs, not one.
fluxSupportTeamFluxIDandfluxapps.teamSupportAddressboth name lists; four privilege checks that compared with===now match against the set.#1795 — two Flux nodes on one public address could install onto the same port and nothing refused it. Four nodes behind one home router is ordinary. Each binds its own host port successfully, the router forwards to exactly one, and every other node's app is unreachable from the moment it starts while being broadcast as live. The port test now asks the peer to bring back what the port actually said and checks it against a secret only this node could have put there, and the other Flux nodes at the address are asked which ports they already hold.
Data safety
#1779 — restore acquires before destroying. On 2026-08-04 a customer's Palworld world was destroyed by a restore: one unrepairable volume jammed a node-wide gate in the syncthing monitor, so no folder was ever configured for any app on that node, and the restore deleted appdata before fetching anything and then hard-redeployed every other instance —
rm -rfon the 35 GB volume of the one node that held the world. Five product fixes and six new harness suites, one of which boots a real syncthing daemon.#1794 — a missing container must not crash FluxOS, a failed redeploy must not uninstall the app, and a log poll must lose nothing. Two live-node failures three hours apart from the same line, five more found in the same paths. The log endpoint is rebuilt: it answered every poll on a 1500 ms timer, lost lines written between polls, and made docker re-read the whole log to do it. Additive — a client that knows nothing about it gets the old behaviour exactly.
#1780 — the reconciler is the only actuator for every operator command, and the crash-recovery ladder paces faults rather than operators. A customer restarting their Palworld server six times in twenty-one minutes reached the fifteen-minute rung, which from outside is indistinguishable from the app refusing to come back.
Placement, DOS and the fleet's view of itself
#1774 — placement diversity. Fault domains are provider organisations from a published IP table rather than
/16arithmetic, so Hetzner's twelve Finnish/16s count as one domain and a/16spanning ten countries stops cross-blocking. Synced apps reach their full instance count instead of stranding (a 3-instance WordPress sat at 1 of 3, returning 503, permanently). A provably unsatisfiable spec is refused at registration, before payment. A dead node no longer holds a synced app down for the 125-minute broadcast expiry. Nineg:primary-election defects fixed. Network policy moves out of this repository intoRunOnFlux/fluxos-network-policy— blocked and vetted repositories, enterprise nodes, the tampering blocklist and the IP location table — so merging application code no longer carries the power to change network policy.#1784 — a residential node holds, evacuates, and only then goes to DOS.
DOS >= 100is not a mark: it deletes every app directory and volume on the node. Staging means the destructive step is only ever reached on a node holding nothing. Enforcement reads aRESIDENTIALverdict from the published table, so it can be ended fleet-wide within a day without a release.#1797 — a node answers a state-sync request for itself. A node that has just started answered with whatever it held, and an empty answer looked identical to a complete survey, so a node could finish its sync, mark itself ready and start placing apps having learned nothing. A response now carries
refusedwhen the responder is not authoritative, and every outstanding request has its own clock.#1786 — a node it has not heard of yet is not a node that is absent. Three
networkStateManagerlookups awaited the indexing lock, which is free before the first fetch has ever run, so every node in the fleet came back absent and nothing distinguished that from not being a node.#1783 — a peer we just heard from is alive, whatever it sent. Liveness was credited only by a pong, an ordinary frame queued behind everything else that peer sent, so the heartbeat fired first on the connections with the most evidence of being alive.
Memory and monitoring
#1775 — bound the sync path and take heavy dependencies off the boot path. One sync response cost 676 MB of RSS in two seconds, measured on a live node. Separately, the verification pool started
os.cpus().length - 1worker isolates at boot and never stopped them — fifteen V8 heaps on a 16-core node, growing for the life of the process, to serve 18 batches in 10.5 hours.#1777 — one store of extracted samples, dead endpoints fixed, and the on/off and pause controls retired:
/apps/startmonitoring,/apps/stopmonitoringand the stats stream answer410naming what to poll instead, rather than an anonymous 404. The routes go at the next major version.Release process and CI
One note on the merge-back: from this release the tampering blocklist is read from
fluxos-network-policy(config.policy.baseUrl), not fromhelpers/tamperingblockednodes.json. The entriesbea9e1bdfadded tomasterare already present in the policy repository — both lists hold the same 14 — so nothing is lost by the file becoming inert, and future additions go to the policy repository.#1789 — hash publication becomes a dispatch. This repository's CI loses write access to
fluxhashes; a contents-write PAT exposed to every branch build is gone, and the org-scoped classic token it used was revoked.#1788 — release PRs are gated on the signed hash list, and the tag is cut after merge. A release cannot merge until the tree it ships is already in the signed document; the tag and GitHub Release become CI outputs.
RELEASING.mdis the process doc from here on.#1781 — the integration fleet installs from itself and reaches nothing outside. ~1.2 million packets left the box per gate run, to five third parties. The fleet now runs with its network sealed and tcpdump reporting
0 packets received by filter— and four production fixes the work turned up.#1782 — the tree lints clean, 45 pre-existing errors fixed by hand rather than with
--fix, separated out so it cannot hide a review of anything else.API surface
New:
Removed:
GET /flux/rebuildhome. Deprecated behind410:/apps/startmonitoring,/apps/stopmonitoring, the stats stream.Behaviour changes a caller can be broken by on release day:
appowneraboveauthorises nobody. fluxos-frontend#151 is what stops the UI offering controls that now refuse; it is merged./flux/checkappavailabilityand/flux/keepupnpportsopenrequirePrivilege.FLUX_TEAMon thezelidauthpath, where they tookNODE_OPERATOR_OR_FLUX_TEAMbefore, and the probed address otherwise comes from the socket rather than the body (Two Flux nodes on one public address can install onto the same port, and nothing refuses it #1795).g:master is the FDM dependency named at the top.Known and open, not fixed by this release
31
/syncthingGET routes are unauthenticated and cached, including/syncthing/db/browse/:folderand/syncthing/db/file, which hand any anonymous caller a listing or the contents of an app's synced volume. That count is unchanged by #1791 — it was 31 before that PR and is 31 at this tip — so this release does not make it worse, but it does contradict #1791's thesis. The gating work is tracked as a blocker for the next release, not this one.Merged PRs
#1774 · #1775 · #1777 · #1778 · #1779 · #1780 · #1781 · #1782 · #1783 · #1784 · #1786 · #1787 · #1788 · #1789 · #1791 · #1794 · #1795 · #1796 · #1797
🤖 Generated with Claude Code
https://claude.ai/code/session_014ooCPUCraGLFGvtFh35ZA5