/api/stats embeds config.mqtt_brokers as stored, so every broker
password went out on an endpoint every page polls, into browser memory,
devtools captures and anything holding a token. The default config
export, which redacts the login passwords, the JWT secret, the modem
token and the identity keys, carried them as well.
Send each broker password as the existing "*** REDACTED ***" sentinel
in both. The settings UIs post the brokers back as they received them,
so update_mqtt_config turns the sentinel into the stored password of
the broker with that name, and asks for the password again when no
single stored broker has the name; config_import keeps the stored
password in place of a redacted one, as it already does for the
security secrets and identity keys.
A direct ACK that still had hops on its path was registered with the
dispatcher but never offered to the companion bridges. Firmware calls
onAckRecv on it before the next-hop check (Mesh.cpp), so a co-hosted
companion that sent the message is confirmed by the ACK it overhears on
the way back. Without it the companion waited for a 0-hop copy it may
never receive, and the send was reported as unconfirmed.
MULTIPART ACKs were likewise registered but not fanned out. The wrapper
is unwrapped as firmware does (route and path kept, 1-byte prefix
dropped) and handed to the bridges as a plain ACK, since bridges have no
MULTIPART handler.
Both paths go through _companion_bridges_for_packet, so policy drops and
origin exclusion still apply. Engine forwarding of the early ACK is
unchanged.
A room config without a "type" key authenticated as a repeater, so the
empty-guest-password rule admitted blank logins to a room with
allow_read_only off. The login callback now passes the registered type.
A blank-password newcomer on a full table could evict a stored read-only or
read-write grant and delete its row. It now evicts only entries that are not
stored, and is refused when none are left.
Firmware simple_repeater compares a blank password from a sender not in the
ACL with guest_password, which defaults to empty, so anyone may log in as a
guest and fetch base telemetry. The repeater refused it unless
allow_read_only was on. Room servers are unchanged.
A bare number 0-255 named the first hash byte before multi-byte modes and
still does. acl_clients compared its identity_hash filter against the first
key byte only; it now matches the same prefix as remove.
acl_clients lists identity_hash at the path_hash_mode width (2 or 3 bytes),
but _acl_targets compared the whole value against the key's first byte, so a
remove from the Sessions page selected no ACL in 2- and 3-byte modes and the
grant stayed. Treat the hash as a public-key prefix; a bare number is read at
the configured width.
The Waev preset's header described a router constant and a 1500 ms
broker-A-then-B failover that Waev's router no longer has, and called
mqtt-a.waev.app "force primary, no failover". Waev routes each
connection to one of two broker pools and fails over to the other; the
pool hostnames only choose which is tried first.
Describe that without naming Waev's internals. The override example in
config.yaml.example named a broker, waev-b, that the preset does not
have, so it did nothing; it now prefers pool A by overriding the host.
The two test docstrings that repeated the old claims follow.
mesh_cli is imported on every boot, so it reads openhop_core.__version__
with getattr rather than importing it, and /api/stats does the same instead
of failing the whole response. Registering the repeater version with the
companion CLI cannot stop a companion loading if the core's signature
changes.
MeshCLI's `ver` read config["version"], which nothing sets, so every node
answered the fallback "openHop_repeater v13". Report the installed packages
instead: "openHop_repeater v<repeater> (core v<core>)".
The virtual companion's CLI `ver` now names the repeater too: the bridge
registers its version with the core's companion CLI when that core has one.
build_radio_stack pops _radio_id before building each radio, so the
warning never named the entry on a real node. A _radio_label survives the
pop, and the warning now points at radios[<id>].radio, saying the value is
inherited only when it is.
config.yaml.example documents sync_word.
A modem on another sync word connects, answers pings and transmits, but hears
no MeshCore traffic, so the failure is silent. Since per-radio sections
overlay the top-level ones, a radios[] modem entry inherits the top-level
radio.sync_word, which the SX1262 driver ignores; a stray value there (e.g.
13380 / 0x3444) left a real node's TCP modem deaf.
Log a warning naming the radio, the value and the inheritance, and keep
passing the configured value through so a deliberate private network is not
overridden. Only the USB and TCP modem drivers push the sync word to the
radio, so only they are checked.
neighbor.remove now resolves its targets in SQL: zero-hop repeaters, the
nodes `neighbors` lists, all of them or those whose key starts with the
given prefix, with their cached scopes. A keyed remove no longer deletes a
companion's, room's or distant repeater's advert history that happens to
share the prefix, and a locked database leaves every neighbour in place
instead of some of them. The prefix is compared literally rather than
LIKE-matched.
The prefix delete's None branch, which cleared every advert, is gone along
with get_neighbors' raise_errors flag, which nothing uses now.
get_neighbors answers {} on a storage error, which `neighbor.remove all` took
for "no neighbours" and answered OK. Add an opt-in raise_errors to
get_neighbors (and the storage collector passthrough) and use it here, so a
failed read surfaces as an error.
The storage call behind a clear-all deletes every advert, so
`neighbor.remove all` also wiped companions, room servers and multi-hop
nodes, with their first-seen and advert counts. Firmware clears only its
neighbour table. Remove just the zero-hop repeaters that `neighbors` lists,
each by its full key, and cover it against real SQLite storage.
handle_command strips the line, so `neighbor.remove ` with nothing after it
never matched the "neighbor.remove " route and answered "Unknown command";
the empty and whitespace branches in _cmd_neighbor_remove were reachable only
from a unit test. Firmware (CommonCLI.cpp) clears every neighbour for an
empty key, which a stray trailing space can trigger.
- `neighbor.remove all` clears them all; a missing key answers
"ERR: Missing pubkey. Do you mean `neighbor.remove all`?" and deletes
nothing.
- An odd-length or over-32-byte key answers "ERR: bad pubkey", as firmware's
fromHex does. Non-hex stays refused.
- Tests go through handle_command, the real entry path.
Names are unique across the repeater, rooms and companions at boot, so
create_identity and update_identity now refuse a name another identity
uses (including "repeater"), and creating a room with another identity's
key is refused as updating one already was.
A refused room update restores the live entry in place rather than
emptying and refilling it, since the identity manager holds that dict and a
concurrent config save could catch it with no name or key. A bad settings
value is refused too, undoing a rename made earlier in the same request.
An ACL request's identity_hash may be a JSON number, and 0 means hash byte
0 rather than every ACL.
A table full of admins refused every newcomer, and now that entries outlive
a restart that lockout was permanent; the upgrade placeholder made it likely
with max_clients: 1. The placeholder is now 32 (firmware MAX_CLIENTS), and
an admin password login or setperm to an admin role evicts the admin seen
least recently, by its stored last login after a restart. Blank-password and
guest newcomers are still refused, so they cannot strip admins one by one.
An eviction now waits for the newcomer: a refused login (a replay) puts the
evicted entry back, and its stored row is deleted only once the newcomer's
grant is stored, or once that grant's next write succeeds after a failure.
Evicted clients' replay watermarks are kept (capped, in RAM) so an evicted
admin's captured login cannot be replayed when it comes back.
unregister_identity also detaches an ACL listed only by name, after a hash
collision displaced it from the hash index.
A setperm on a full ACL deleted the evicted client's stored entry before
trying to store the newcomer, so a failed write reported an error but still
revoked someone. The eviction is now deleted from the store only once the
grant is stored, and put back in the table if it isn't.
Deleting a room whose rename was not hot-reloaded looked up its live ACL by
the new name only, so the ACL listed under the old name stayed attached and
the next grant wrote the deleted room's rows back. Fall back to the store
key, as move_room_acl does.
Stored entries now record when they last logged in, so the web UI can
show whether a provisioned grant is still in use after a restart.
Firmware keeps this in RAM only; last_activity still starts at 0 on
load, so "not active since restart" keeps its meaning.
- acl_entries gains a nullable last_login column, added beside the
table's CREATE so a failed earlier migration cannot lock stored
admins out of a load that reads it.
- A login by a stored entry writes its time, best-effort, and not when
the stored second already matches it.
- A guest promoted by setperm keeps its login time; a row taken over
from another identity's label does not inherit that identity's.
- Key changes, renames and label adoption carry the time; a retried
load keeps the later of the live and stored times and writes a login
made while the store was unreadable.
/api/acl_clients adds client_name and client_type for each entry: the
name the node advertises, from an advert the repeater heard, or else a
companion's contact for it (a companion's own admin is often never heard
directly). Rebuilds the bundled UI (openHop_RepeaterUI feat/acl-management),
which shows the name in place of the one-byte address.
Codex confirmation round: the sweep of rows already under a name was
best-effort, so when it failed the rename or creation went ahead and the
leftovers became the room's grants. The sweep is now required: a rename
fails before the config is saved, and create_identity refuses before
adding the room.
Codex confirmation round: after a delete whose ACL cleanup failed, a room
created on that key and renamed to the deleted room's name took over its
leftover rows. Names are unique, so no live room holds the label a
rename takes or a new room gets; a rename (before taking the label) and
create_identity now drop every row already stored under it.
Codex confirmation round: rows a rekey's failed cleanup left at an old key
survived a later rename under the old label, which the rename did not
touch and the delete (by the new label) did not reach, so a room given
the old name and key could load them. A room owns only the rows under
its label at its current key; a rename, a rekey and a delete now drop
every other row under the labels involved. A failed sweep is retried by
the next one. Loading does not sweep: a room whose key was edited by hand
would otherwise lose rows that setting the key back recovers.
Codex confirmation round: deleting a room removed only its rows at its
current key. Rows a rekey's failed cleanup left at an old key kept its
label, so a room re-created with that name on that key loaded them. A
delete now drops every row under the room's label; names are unique, so
no other room holds it.
Codex round 5 (after the usage limit reset):
- A rekeyed or deleted room's old key kept answering logins, messages and
requests until a restart: its login, text and protocol handlers stayed
registered. The daemon now unregisters an identity from every packet
helper (_unregister_identity_everywhere), and update_identity and
delete_identity call it for the retired identity. Each helper removes
only its own handler, never another identity's on the same hash byte.
- Rows left at a key by a failed cleanup could grant their admins to a
later room on that key. A room loads only rows under its own label and
logs any others; moves copy, relabel and delete only the room's own
label's rows. A same-key rename relabels before the config is saved
and puts the label back if the save fails, so a room's own rows follow
its name. delete_identity reports an ACL cleanup that failed.
- Two overlapping hot reloads could start a superseded replacement
RoomServer whose sync loop then ran untracked. A swap starts its room
only if it is still the registered one.
A hot reload from PUT /api/update_identity re-runs TextHelper.register_identity,
which replaced self.room_servers[hash] and started a new sync loop without
stopping the old one, so the room pushed posts twice (the old loop with its
stale ACL). Take the RoomServer under the same hash, and any registered under
another hash by the same or previous name (a key change), out of room_servers,
and stop them before starting the replacement, on the running loop or via
run_coroutine_threadsafe from a request thread. update_identity passes the
original name through _register_identity_everywhere as previous_name.
Sixth review round (opencode: design still clean):
- A room whose passwords were cleared, then set again in a settings-only
update, was told no reload was needed, yet had no login handler or ACL
until a restart. Turning a password on counts as a reload.
- A restore refused because the new entry already holds the hash is logged.
- The no-login-helper move path says why it keeps its own store guard.
Fifth review round (opencode: "the ACL design is clean"; residual nits):
- The old identity entry is put back whenever the new registration did not
take its place, including when registration raises.
- A room whose update leaves it with no password is not hot-reloaded: it
is not registered for logins, so its ACL would stay under the old name.
The update waits for a restart and says so.
- delete_identity releases the room's name and hash through
unregister_identity, so the room can be re-created live rather than
conflicting with a room that no longer exists until a restart.
- LoginHelper.move_room_acl checks for a store before any path, the
superseded-ACL loop again skips the ACL being registered, and the
test daemon accepts the previous_name argument of the RoomServer
teardown fix on feat/quirky-lamarr-dfeae2, which should land with this
branch: the hot reload that now applies is what reaches that bug.
Fourth review round (opencode; codex hit its usage limit):
- update_identity's hot reload was always refused: the identity manager
rejects a name or hash already registered, which the running room's own
entry always is. The ACL name index then lagged the config, so a renamed
room was unmanageable by name, and a later rekey left a second live,
writable ACL. The old entry is now unregistered first and restored if
the new registration is refused (IdentityManager.unregister_identity),
and a move falls back to the store key when the name misses.
- A key change whose save and cleanup both failed left a copy at the new
key that blocked every retry. A copy under the room's own label is
replaced on retry; another identity's entries still refuse the move.
- A registration now detaches the ACL it replaces before loading, warns
when it displaces another identity sharing its hash byte, and the no-store
guard for a key change lives in LoginHelper.move_room_acl.
- /acl_stats is documented as it has always answered; store_error and
store_errors say they clear once a later read succeeds; the example
config says room servers keep their own security settings.
Third review round (codex, opencode):
- A failed save whose rollback also failed left the ACL under a key the
config did not name. A key change now copies the entries, saves the
config, then drops the old ones, all under the ACL's lock: any failure
leaves the key the saved config names holding its entries.
- Moves and deletes found the live ACL by scanning for its store key, and a
rekey left two ACL objects for one identity, so a second move or a delete
could act on the stale one. LoginHelper finds ACLs by name, and a new ACL
detaches and drops the one it replaces.
- A room deleted and re-added with the same key reused the detached ACL and
never stored again. A detached ACL is never reused.
- A failed read at startup was never retried; the next login or change
retries it, and acl_clients reports it as store_errors.
- A key change with no store anywhere was treated as moved. It is refused.
- The owner lookup fell back to the 1-byte hash for a room with no ACL,
attributing another identity's entries to it. acl_stats used the hash too.
- A refused update_identity left its merged settings in the live config;
every refusal now restores the entry. A key that yields no public key is
refused rather than skipping the shared-key check.
- Removing from every ACL stopped at the first failed write; it now reports
which failed and which succeeded. Room eviction carries on past one.
- Settings on a reused ACL are applied under its lock.
Second review round (codex, opencode):
- Moving a room's rows and pointing its live ACL at them happened in two
steps, so a grant could land under the old key in between.
ACL.move_store now does both under the ACL's lock.
- update_identity accepted a key another identity already uses, and the
move merged the two ACLs. It now refuses such a key before changing
anything, and the store refuses to move onto a key that has entries.
- update_identity saved the new key before moving the ACL, so a failed move
left a saved key with an empty ACL. The move now runs first, and a failed
save moves it back. It also runs when the daemon has no login helper.
- A failed read at startup looked like an empty ACL. The ACL records the
error and /api/acl_info reports it as store_error.
- A deleted room's handlers stay live until a restart, and a login there
wrote its entries back. Deleting now detaches the live ACL from storage.
- /api/acl_info looked ACLs up by the 1-byte hash, so identities sharing one
could show each other's counts. It uses the name lookup acl_clients does.
- The acl_clients filter is an object in the spec, as returned.
Review (codex, opencode) found:
- A room renamed and rekeyed in one update lost its ACL, and a room later
created under the old name could adopt it. Room servers are no longer
adopted by label; update_identity moves their rows to the new key and
name in one transaction and points the live ACL at them. Only the
repeater, which is unique, still adopts by label after a key change.
- A removal racing a grant could leave the grant stored but not listed.
Each ACL change and its write now happen under one lock.
- A failed write was reported as OK. The store now raises; setperm answers
"Err - failed to save" and leaves the table as it was, and the API
returns an error. A login still succeeds, as its session is valid.
- Re-registering an identity (a rename) built a new ACL, zeroing every
session's activity and replay watermark, so the room sync loop stopped
pushing to logged-in clients. The live ACL is now reused.
- The repeater's max_clients default rises to firmware's 32: admins are
never evicted and now persist, so 5 or 10 filled permanently. Stored
entries past max_clients all load, with a warning.
- atoi takes ASCII digits only; non-repeater identity types get their own
label; the sync-loop skip logs at debug; `get acl` has help.
API for the web UI:
- POST /api/acl_set_permissions sets an entry by identity name.
- /api/acl_clients lists entries by identity name rather than the 1-byte
hash two identities can share, with permissions_value, persisted and
identity_pubkey. /api/acl_info adds acl_entries and stored_entries, and
authenticated_clients counts entries that have been active.
- /api/acl_remove_client reads client_pubkey, as its schema always said
(the UI sent it, and the endpoint read only public_key), and takes
identity_name.
Firmware keeps its ACL in /s_contacts, so a key provisioned over serial with
`setperm <pubkey> 3` logs in with a blank password forever after. Ours was
RAM-only and setperm was a stub, so that workflow could not work.
- New acl_entries table, keyed by the local identity's full 32-byte public
key rather than its 1-byte hash, which collides between identities. An
identity_label ("repeater", "room_server:<name>") lets an identity adopt
its rows after a key change, as firmware's ACL survives a new private key.
Shared secrets are derived on load, never stored.
- The repeater stores every entry with permissions, a room server admins
only (firmware saveFilter). A write happens only when an entry's stored
permissions change, so a returning admin costs no I/O.
- setperm follows ClientACL::applyPermissions and firmware's replies: atoi
permissions stored as the whole byte, a guest role deletes by key prefix,
other roles need the full key. It refuses an empty prefix and a key that
is not a valid ed25519 point, both of which firmware accepts.
- `get acl` works from the web CLI (the serial stand-in, passed local=True)
and still errors remotely. It was unreachable: "get " matched it first.
- A full table evicts the least active non-admin, as firmware putClient,
instead of refusing; it still refuses when every entry is an admin.
- Room servers keep a stored admin through inactivity eviction and do not
push to entries that have not logged in since load, as firmware skips
clients with no activity.
- Deleting a room server drops its stored entries.