Metadata-Version: 2.4
Name: necce
Version: 0.1.9
Summary: Network Conditional Configuration Engine
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.12
Requires-Dist: pyroute2>=0.9.1
Requires-Dist: tomli-w>=1.0
Provides-Extra: dbus
Requires-Dist: dbus-fast>=2.21; extra == 'dbus'
Provides-Extra: gui
Requires-Dist: pyside6>=6.7; extra == 'gui'
Requires-Dist: qasync>=0.27; extra == 'gui'
Description-Content-Type: text/markdown

NeCCE — Network Conditional Configuration Engine
===============================================

A Linux toolkit, graphical and command-line, for reconfiguring network settings —
routes, firewalld policies and zones, NetworkManager zone assignment — either by
hand or driven by **conditions** such as whether a VPN is up or an interface
exists.

The idea in one sentence: your configuration, gated by conditions, is what
*should* be there; the machine is what *is*; and a plan is the difference.

Technical design and the reasoning behind it: [design.md](design.md). Open work:
[TODO.md](TODO.md).

What it does
------------

- **Routes**, managed against the kernel through netlink and tagged with a private
  `proto` so `ip route show proto 201` audits NeCCE with standard tools. A route
  may take its device and gateway from a NetworkManager connection, or follow the
  system's own default route.
- **firewalld policies and conditional zone binding** — the two things
  `firewall-config` cannot do. Policies (which services are permitted between which
  zones) are otherwise reachable only through `firewall-cmd` or root-owned XML;
  bindings can be made to follow a condition, so a laptop can drop into a
  locked-down zone off the home network.
- **Conditions**, set by hand or sampled automatically: `nm-state` (a
  NetworkManager connection is active) and `iface-up` (an interface, or a glob of
  them, is up). Any route, policy or binding may name one, and `!name` inverts it.
- **Multiple machines.** Every machine is a *target* reached over SSH; `localhost`
  is just one of them. Objects and conditions are shared, routes and firewall
  entries are per target.
- **A safety model built into the apply path**: a diff you read first, a revert
  watchdog armed on the machine being changed, connectivity guards, snapshot
  rollback, and an append-only audit log.

Reading and planning need neither root nor a running daemon. Writing needs two
independent opt-ins, described in *Let it write* below.

Getting started
---------------

A walkthrough from nothing installed to one route you can switch on and off.
**Steps 1–4 cannot change your network** — no daemon, no privilege, nothing
written to the kernel — so the whole idea is visible before anything is at stake.

Commands are shown as `uv run necce …` because they run from a checkout. With an
activated venv they are just `necce` and `necce-gui`.

### 1. Install

#### From RPMs (Fedora)

```sh
sudo dnf install necce          # CLI, engine, privileged helper, user units
sudo dnf install necce-gui      # the desktop client, if you want it
```

Two packages on purpose: `necce` pulls in no Qt at all, which is what makes it
usable on a server. `necce-gui` adds the window and requires the base package.

The RPM is the better install if you can use it, and not only for convenience.
It puts `necce-helper` on `/usr/bin`, which is a root-owned path — a helper under
`~/.local/bin` can never be reached through `sudo`. It vendors the `systemd
--user` units, so section 5 below becomes a single `systemctl --user enable`. And
it installs the desktop entry system-wide, where `Exec=necce-gui` resolves
without the workaround section 8 otherwise needs.

**It also stops the password prompt on every apply.** From a checkout, `pkexec`
has no registered action for the helper and falls back to a generic
"run a program as another user" prompt each time. The package registers
`sk.coders.necce.helper`, and a rule grants it without a password to members of
`wheel` on a local, active seat — which is to say, to an administrator sitting at
the machine. Everyone else still authenticates.

That is a real trade for a real convenience: `necce-helper` is a root executor
for routes, firewall rules and DNS, and administrators get to it without typing
anything. If you would rather keep the prompt, delete
`/usr/share/polkit-1/rules.d/50-sk.coders.necce.rules` — the action's own defaults
require admin authentication, so removing that one file restores prompting for
everybody.

To build the packages yourself, see [`packaging/rpm/README.md`](packaging/rpm/README.md).

#### From a checkout

Python 3.12 or newer. `uv` is a convenience, not a requirement:

```sh
# with uv
uv sync --all-extras
uv run necce --version
```

```sh
# without uv
python3 -m venv .venv
source .venv/bin/activate
pip install '.[gui,dbus]'
necce --version
```

**Take the extras seriously in either form.** `gui` is PySide6 and qasync; `dbus`
is dbus-fast, which is how firewalld and NetworkManager are read and written. A
bare `uv sync` or `pip install .` installs neither, and then reading
firewalld/NetworkManager state fails with a message naming the remedy while
`necce-gui` is missing altogether. Routes need no extra — pyroute2 is a hard
dependency, since a helper without it can read nothing. Only uv reads `uv.lock`,
so a pip install resolves current versions rather than the pinned ones.

### 2. Look at what your machine already has

```sh
uv run necce show --all      # your live routing table, as NeCCE sees it
uv run necce target          # what this machine can do
```

`show` reads the real routing table through a short-lived helper. With no
configuration yet, every route comes back `foreign` — present in the kernel, not
ours:

```
 ? default           foreign  -  metric 600   via 192.168.1.1 dev wlp0s20f3
     not managed by NeCCE — adopt to bring it under management
```

`target` reports which backend answered and whether writes are possible. They are
not, yet — and it says what that would take:

```
backend:     netlink
writable:    False
read-only: set 'writable = true' on this target and give it a privileged
transport such as pkexec to apply changes.
```

### 3. Write one route with a switch on it

The configuration lives at `~/.config/necce/necce.toml` (override with `-c`). This
is the smallest thing that shows what NeCCE is for — a named network, a condition,
and a route gated on it:

```toml
[conditions.at-home]
source = "manual"           # you flip this one by hand
value = true

[objects.networks.office]
prefix = "10.20.0.0/16"

[routes.office]
dst = "office"              # the object above, by name
gateway = "192.168.1.1"     # substitute your own router
condition = "at-home"
metric = 500
```

```sh
uv run necce validate
# necce.toml: valid — 1 route(s), 1 condition(s), 1 object(s)
```

`docs/examples/necce.toml` is a fuller, commented version to crib from.

### 4. Preview it, then flip the switch

```sh
uv run necce plan
```

```
Plan for localhost: 1 to add, 0 to change, 0 to remove

 + add     10.20.0.0/16 via 192.168.1.1 metric 500
     route 'office' is not present
```

That is a *plan*, not a change — nothing has been applied. `plan` exits 1 while
changes are pending, so it doubles as a drift check in a script.

Now turn the condition off and ask again:

```sh
uv run necce condition set at-home off
# at-home = false  (engine not running)

uv run necce plan
# Nothing to do: the target already matches the configuration.

uv run necce show
# . 10.20.0.0/16  off  office  …  [at-home=false]
```

Same configuration, different desired state. Turn it back on before continuing.

### 5. Start the engine

Everything so far ran and exited. The engine is the part that stays: it watches
netlink, re-plans when the network or a condition changes, samples automatic
conditions, and holds pending changes so they can be confirmed from anywhere. It
is unprivileged and needs no root.

```sh
# in the foreground, for a first look
uv run necce engine &

uv run necce targets
# * localhost  local  ro  backend netlink
```

For everyday use install the user units instead — socket-activated, so connecting
the GUI starts the engine on demand and it survives the GUI closing:

```sh
# from the RPM the units are already there; from a checkout install them
# first — see packaging/user/README.md
systemctl --user enable --now necce-engine.socket
```

Some commands change behaviour once it is up. `necce condition set` no longer just
edits the file — it hands the change to the engine, which re-plans at once, which
is why the `(engine not running)` note from step 4 disappears. `targets`, `adopt`,
`apply`, `confirm` and `rollback` need the engine and say so if it is absent;
`show`, `plan`, `validate`, `condition list` and `route list` never do.

### 6. Import what you already have (optional)

Rather than retyping an existing setup, adopt it. This needs the engine, because
the engine holds the observed state:

```sh
uv run necce adopt home-lan 192.168.1.0/24
# adopted 192.168.1.0/24 as 'home-lan' (was proto 2)
```

Nothing changes in the kernel — the route is already there. It becomes part of
your configuration and shows as `unclaimed`: exactly what you want, but still
tagged as somebody else's. The next apply rewrites only that ownership tag.

Adopting a route the kernel maintains itself (a DHCP address's own subnet, say) is
allowed but warned about, since the kernel will keep recreating its version.

### 7. Let it write, then apply

Applying needs **two** independent opt-ins: `writable` on the target, and a
transport that can carry the write. Either one missing and the helper refuses — it
enforces this itself, so a caller cannot talk past it. `pkexec` and `ssh` acquire
privilege; `local` and `host-spawn` run as you, so they only work where you could
already change the table by hand; `fake` needs none, being in memory.

```toml
[targets.localhost]
transport = "pkexec"     # polkit prompt, once per session
writable = true
```

```sh
# prints the plan, asks, then applies — exits 2 while unconfirmed
uv run necce apply

uv run necce confirm     # make it permanent
uv run necce rollback    # or undo it right now
```

`apply` asks before touching anything, and refuses outright when stdin is not a
terminal — pass `--yes` from a script. Exiting 2 afterwards is not a failure: the
change is **live but not yet permanent** and undoes itself unless confirmed. *How
applying is kept safe*, below, explains what that protects you from.

### 8. The graphical interface

```sh
# uses the engine if it is running; read-only if not
uv run necce-gui

# a constructed example — no engine, no network needed
uv run necce-gui --demo
```

The window shows the same merged view as `necce show`, grouped by configuration
entry, with the conditions panel on the right, a diff to read before applying, and
a countdown bar afterwards while you check that connectivity survived.

**Steps 3 to 7 are all available in it**, so the walkthrough above is the CLI half
of the same story rather than a prerequisite: the Targets page sets `transport` and
`writable`, the Routes page adds, edits and removes entries, the Firewall page does
the same for policies, zones and bindings, the Objects page maintains hosts,
networks and groups, and the conditions panel creates the switches — right-click one
to edit or remove it, which is the only way to reach an automatic condition's
parameters, since its switch is deliberately inert. Everything is
written through the engine, which validates the whole document before saving — so
an edit the CLI would reject on load cannot be produced here, and a rejected one
changes nothing. The file it writes is ordinary TOML you can keep editing by hand.

Removing a firewall policy or zone asks which kind you mean, because there are two.
**Delete** takes it out of the configuration *and* deletes it from firewalld, so
whatever it was forwarding stops. **Disown** takes it out of the configuration and
leaves it in firewalld exactly as it is, with NeCCE's `[necce:<name>]` marker
removed from its description — it keeps working, it is simply no longer ours. Either
way the firewalld half is a change you read in the plan and then apply, not
something that happens while you are still deciding; the entry stays in the file,
marked `removal = "delete"` or `removal = "disown"`, until that has happened.

Fields that name something are **pickers over what is already there**: the objects
you have defined (with what each one expands to shown beside it), the interfaces
observed on the target, the zones firewalld has (with what each one currently
holds). They stay typeable — a literal prefix is still the fastest thing to write —
but a name you meant to reuse no longer has to be remembered exactly, a group is
refused where a single address is required, and an entry that fans out says so
before you save it.

#### Stopping NeCCE

Closing the window does not stop NeCCE: the engine is a separate service and keeps
applying your conditions, which is the point of it being separate. When you want
the deciding itself to stop — you are about to reconfigure the network by hand, or
you are done with it for now — **Quit and stop the engine…** in the toolbar, and
the same item in the tray menu, closes the window and stops `necce-engine.service`.

**Nothing has to be started by hand afterwards.** `necce-engine.socket` is left
listening, so opening NeCCE again — or running any `necce` command — gets a fresh
engine that systemd starts on the spot, exactly as at login. This is "stop it now",
not "stop it coming back".

Nothing already applied is undone by it either. The routes, policies and bindings
stay exactly as they are; only the thing that would change them again stops.

One case is not so quiet: a change applied and not yet confirmed is being held by a
helper whose watchdog reverts on losing its connection, so stopping the engine
*undoes* it rather than leaving it to time out. The confirmation says so, with the
number of changes it means. And if the engine turns out not to be stoppable that
way — one you started by hand with `necce engine`, say — NeCCE does not quit: it
stays open and says what is still running.

#### In the system tray

On a desktop with a tray — Plasma 6, GNOME with an extension, anything speaking
StatusNotifier — NeCCE puts an icon in the panel. Its tooltip is the whole window in
four lines: which machine, whether the engine is answering, how much is outstanding,
and how long an unconfirmed change has left. Right-click it for the window, both
ways of leaving (above), and two settings, which are also behind **Preferences…**
in the toolbar:

- **Keep running here when the window closes.** Off by default. With it on, closing
  the window hides it instead of quitting, and the icon stays. This does *not* keep
  NeCCE reconciling — the engine is a separate service that was never going to stop
  when you closed the window. What it keeps reachable is the live view and the
  countdown that undoes an unconfirmed change.
- **Start automatically at login.** Writes
  `~/.config/autostart/sk.coders.necce.desktop`, which starts `necce-gui --hidden`:
  no window, just the icon. Your desktop's own Autostart settings can remove that
  entry, and the checkbox follows it — NeCCE reads the file rather than remembering
  separately.

The icon can also carry up to three **condition dots** along its bottom edge —
green while a condition is on, red while it is off, grey while that is not known (the
condition is gone, or it is automatic and no engine is evaluating it). Pick a dot in
the condition's **Tray icon** field when adding or editing it; dots keep their place,
first on the left, and the tooltip names each one in words. Choosing a dot another
condition already has asks first, then moves it. The assignment is a setting of this
window, stored as `tray_dots` in `~/.config/necce/gui.toml`, not in `necce.toml`.

Both need a tray, and a tray needs the artwork: `Icon=sk.coders.necce` is a theme
*name*, so without the two SVGs installed (the `necce-gui` RPM does it; from a
checkout see *Desktop integration* below) the icon would be blank. NeCCE says so and
declines rather than putting an invisible item in your panel. `necce-gui --hidden`
with no tray shows the window after ten seconds instead of running unreachable.

To try all of that with nothing at stake, give yourself an in-memory machine:

```toml
[targets.practice]
transport = "fake"       # a routing table that lives in the helper's memory
writable = true
```

It is the one target where applying, confirming and watching the watchdog revert
costs nothing — the routes are a dict, not your kernel's.

Install the desktop entry and its icons alongside the units, or every start logs
a `Failed to register with host portal` warning and the taskbar shows a generic
icon. **The `necce-gui` RPM already installed them** — the rest of this section is
for a checkout install only.

Run this as the user who runs the GUI, and note the substituted `Exec`: GLib
discards an entry whose program it cannot find, which is the usual reason that
warning survives the install. That is also why the RPM does not need the
substitution: `/usr/bin/necce-gui` is on every session's `PATH`.

```sh
NECCE_GUI=$(command -v necce-gui || echo "$PWD/.venv/bin/necce-gui")

mkdir -p ~/.local/share/applications
sed "s|^Exec=.*|Exec=$NECCE_GUI|" packaging/user/sk.coders.necce.desktop \
  > ~/.local/share/applications/sk.coders.necce.desktop

# Icon=sk.coders.necce is a theme name, so the artwork has to be somewhere the
# icon loader looks. ~/.local/share/icons is that place for a per-user install.
install -Dm0644 packaging/user/sk.coders.necce.svg \
  ~/.local/share/icons/hicolor/scalable/apps/sk.coders.necce.svg
install -Dm0644 packaging/user/sk.coders.necce-symbolic.svg \
  ~/.local/share/icons/hicolor/symbolic/apps/sk.coders.necce-symbolic.svg
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor 2>/dev/null || :
```

[`packaging/user/README.md`](packaging/user/README.md) has the rest, including how
to check what GLib makes of the entry when the warning persists.

Conditions that set themselves
------------------------------

A `manual` condition is one you flip. An **automatic** one is sampled from the
machine each time the engine re-plans:

```toml
# true while the NetworkManager connection named "work-vpn" is active
[conditions.on-vpn]
source = "nm-state"
[conditions.on-vpn.params]
connection = "work-vpn"

# true while any interface matching the glob is administratively up
[conditions.tunnelled]
source = "iface-up"
[conditions.tunnelled.params]
interface = "wg*"
```

Each source's own setting goes under `params`. Both take an optional `target`
there too, naming which machine's state to watch; absent means `localhost`. A
condition flipping on one machine re-plans every machine with something gated on
it.

Reference a condition inverted with `!`:

```toml
[routes.fallback]
dst = "10.20.0.0/16"
gateway = "192.168.1.1"
condition = "!on-vpn"      # only while the VPN is *not* up
```

An automatic condition holds its last value when the machine cannot be read —
NetworkManager restarting must not look like every VPN in the fleet disconnecting
at once. Where the silence *is* the answer, say so with `when_unavailable`:

```toml
# false the moment the office router stops answering, rather than staying
# true until it is heard from again
[conditions.at-office]
source = "iface-up"
when_unavailable = "off"
[conditions.at-office.params]
interface = "enp*"
target = "office-router"
```

`hold` (the default), `off` and `on` are the three choices, and they cover both
ways a condition can go unsampled: the reading it needs was missing this cycle,
and the machine it watches could not be reached at all — including one you have
parked with `enabled = false`. The GUI offers the same three as *While
unreadable*, both when you add an automatic condition and when you right-click it
in the conditions panel to edit it. The name is the one field an edit cannot change:
routes and policies refer to the condition by it.

The firewall
------------

```sh
uv run necce show --firewall
```

The Firewall page and this command merge your configured policies, zones and zone
bindings with what firewalld actually has, including what a reload would discard.
Reading firewalld's *permanent* configuration needs authorization, so an
unprivileged run says what it could not see rather than showing an empty firewall.

A policy is which services are permitted between which zones, and it may be gated
on a condition:

```toml
[firewall.zones.locked-down]
verdict = "DROP"           # what the zone does with traffic no policy permits

[firewall.policies.lan-services]
ingress = ["locked-down"]
egress = ["HOST"]
verdict = "ACCEPT"
services = ["ssh"]
condition = "at-home"

[firewall.bindings.wifi]
connection = "home-wifi"
zone = "locked-down"
condition = "!at-home"
```

`verdict` is what firewalld itself calls a container's `target`; NeCCE spells it
differently because in this file `target` names the *machine*, in every table.

Two things about that are worth knowing:

- **Structure is permanent, gating is runtime.** Creating or changing a policy
  writes firewalld's permanent configuration and costs one reload; a condition
  flipping just sets one runtime flag and costs nothing. That is also why each
  governed policy has an explicit *baseline* — what holds at boot, during a reload,
  and after NeCCE is stopped — shown separately from its current state.
- **Zone bindings read NetworkManager too**, because on an ordinary Fedora machine
  the zone a connection lands in comes from its NM profile and not from firewalld.
  So each binding shows three things side by side: the zone it is in now, the zone
  its profile will assert on the next reconnect, and the zone you asked for. A
  connection that is in the right zone today and saved as the wrong one is one
  reconnect from coming undone, which nothing else tells you.

Firewall entries are authored in the GUI or by hand in TOML; there is no CLI verb
for them yet.

DNS per interface
-----------------

```sh
uv run necce show --dns
```

Which resolvers an interface uses, held in systemd-resolved — and, unlike
`resolvectl`, able to follow a condition:

```toml
[objects.hosts.corp-dns1]
address = "10.20.0.53"

[dns.work-vpn]
interface = "wg0"
servers   = ["corp-dns1", "10.20.0.54"]   # a host object, or a literal address
domains   = ["~corp.example", "corp.example"]
condition = "on-vpn"
```

- **`interface` is one device**, by name. A DNS list belongs to exactly one link,
  so there is no glob here as there is on a zone binding.
- **`servers` are tried in the order given.** Each is a literal address or the name
  of a *host* object — a network or a group is refused, because a resolver is one
  address.
- **`domains` uses `resolvectl`'s own spelling.** A plain domain is a *search*
  domain: it is appended to single-label lookups and routes queries for that suffix
  to this link. A `~` prefix makes it routing-only — queries go here, but the
  domain is not appended. `~.` routes everything no other link claims, which is
  what a full-tunnel VPN wants.
- **Switching an entry off hands the interface back**, rather than restoring what
  was there before: systemd-resolved keeps no memory of what NetworkManager last
  pushed, so the link is cleared until something pushes again. That is the
  fail-closed direction — no resolver rather than the wrong one.
- **This is written to systemd-resolved's runtime**, which has no permanent scope
  at all. NeCCE's configuration plus a reconcile on start is what makes it survive
  a reboot, exactly as it is for routes — and NetworkManager pushing its own list
  when a connection reactivates shows up as drift that the next apply corrects.

An entry whose interface is not present, or that names a device systemd-resolved
does not manage, reads `unresolved`: it is neither applied nor reverted, and the
row says which of the two it is.

Unattended apply
----------------

By default nothing is written without someone confirming it. A target can opt in
to having its own plan applied automatically whenever a condition flips:

```toml
[targets.localhost]
transport = "pkexec"
writable = true
auto_apply = true        # refused unless writable
```

It uses a shorter revert window than the interactive path (10 seconds), waits at
least a minute between attempts on one target, and stops retrying a plan the
connectivity guard keeps refusing until that plan's *content* changes.

One engine-wide switch freezes every target's unattended writes at once, for when
something is going wrong and you want it to stop now:

```sh
uv run necce auto-apply disable
uv run necce auto-apply enable
```

Reading other machines
----------------------

Every target is the same helper code, differing only in how it is launched, and
all of them are read-only unless that target opts in to writes:

```sh
uv run necce show                  # spawn a helper here (the default)
uv run necce --ssh router show     # over ssh; nothing is installed on the router
uv run necce --host show           # via flatpak-spawn, from a container or Flatpak
uv run necce --offline plan        # contact nothing: plan against an empty table
```

`--offline` is how to check a configuration for a machine you cannot reach —
including before that machine exists.

For an SSH target, the far helper runs as whoever logged in. That reads routes and
firewalld's *runtime* state; writes and the permanent firewall configuration need
root, so either log in as root or escalate on the far end:

```toml
[targets.router]
transport = "ssh"
host = "router.lan"
sudo = true
```

It runs `sudo -n`, which never prompts — the same stdin carries the protocol — so
the remote needs a `NOPASSWD` rule and the helper has to live on a path sudo
searches (`/usr/local/bin`, not `~/.local/bin`).

A target that is gone for good can be parked with `enabled = false` rather than
deleted, which stops the engine retrying it without losing its routes and settings.

Capture and replay
------------------

State can be read once and re-examined later, which is also how to file a useful
bug report — attach the capture and the plan is reproducible:

```sh
uv run necce dump-observed > observed.json
uv run necce --observed observed.json show --all

# or start from iproute2's own output
ip -json route show > routes.json
uv run necce --ip-routes routes.json show --all
```

How applying is kept safe
-------------------------

The watchdog behind step 7's exit code. `apply` arms it **on the machine being
changed**, not here, and two things trigger the revert:

- **nobody confirms** within `--revert-after` seconds (default 30);
- **the control connection drops** — whoever applied it can no longer confirm, and
  the reason may well be the change itself.

That second trigger is why the watchdog lives on the far end. Break the route to a
router and you lose the SSH path *and* the ability to undo; armed on the router,
the drop is itself the signal to revert. It applies just as well to a firewall
change that locks out the session that made it.

Changes that can strand a machine are refused outright unless you say otherwise,
each acknowledged separately rather than under one blanket override:

```sh
uv run necce apply --allow-default-route            # can remove all connectivity
uv run necce apply --allow-cutting-control-path     # can strand a remote target
```

If an apply fails part way, the whole transaction is rolled back from a snapshot
taken beforehand — never by inverting each change, because firewalld expands one
settings write into many calls with no atomicity, and a half-written container is
a state no sequence of inverses describes.

Every apply is recorded — intent, outcome, and who asked — in
`$XDG_STATE_HOME/necce/audit.jsonl`, with credentials masked.

Multiple machines, one snapshot
-------------------------------

Routes are authored per target and share objects and conditions, so a path across
two machines is two entries that move together:

```toml
[targets.router]
transport = "ssh"
host = "router.lan"

[objects.networks.office]
prefix = "10.20.0.0/16"

[objects.hosts.router-lan]
address = "192.168.1.1"

[objects.hosts.vpn-gw]
address = "10.8.0.1"

[conditions.at-home]
source = "manual"

[routes.office-hop]         # the laptop sends office traffic at the router
dst = "office"
gateway = "router-lan"
condition = "at-home"
target = "localhost"

[routes.office-onward]      # the router forwards it onward
dst = "office"
gateway = "vpn-gw"
condition = "at-home"
target = "router"
```

```sh
# applies the far end first, then the near end
uv run necce apply --all-targets
uv run necce confirm --all
```

**Order matters and the default gets it right.** Remote targets are configured
before `localhost`, because adding a path means the far end must know where to
forward before the near end starts sending at it — and the near end is the machine
you are sitting at. `depends_on` on a target refines this when two remotes sit
behind each other.

A batch needs no distributed coordinator to be safe: each target arms its own
watchdog, so if the engine dies mid-batch every target reverts independently. If
one target fails, the ones already applied are rolled back before the error is
reported.

The merged view
---------------

`necce show` combines the configuration with live state into one table, and the
GUI renders the same states. The state of each row determines the action offered:

| Mark | State | Meaning |
|---|---|---|
| `=` | `active` | configured, present, matching |
| `+` | `pending` | configured, condition true, not in place yet |
| `~` | `drifted` | present but its parameters differ |
| `-` | `stale` | condition false, but still in place |
| `.` | `off` | condition false or disabled, correctly absent |
| `?` | `foreign` | present, not ours — DHCP, NetworkManager, `firewall-cmd`, a manual `ip route add` |
| `^` | `unclaimed` | configured and present, but not tagged as ours; claim it |
| `!` | `conflicting` | something else owns it **and** its settings differ — left alone |
| | `unresolved` | its NetworkManager connection could not be looked up — left alone |

Firewall entries share this vocabulary, minus `conflicting`: without an ownership
tag, "someone else owns this" and "it happens to be present" cannot be told apart,
so both read as `foreign`.

Architecture in one diagram
---------------------------

```
   necce (CLI)  /  necce-gui          transient, unprivileged
            |
            |  JSON-RPC 2.0 over NDJSON, on a unix socket
            |  (authenticated by SO_PEERCRED — no password, nothing stored)
            v
     necce-engine                     persistent, UNPRIVILEGED, sole writer
            |                         conditions, config, reconcile decisions
            |  same protocol, over each helper's stdio
   +--------+--------+-------------+
   v                 v             v
 necce-helper   pkexec …      ssh router …     privileged, transient, DUMB
```

The helper is deliberately tiny: no config parsing, no condition evaluation, no
shell. That keeps the component which runs as root auditable in one file, and is
why user-supplied condition commands will execute in the *engine*, unprivileged,
in the user's session.

[design.md](design.md) covers all of this properly.

Development
-----------

```sh
uv sync --all-extras           # both extras, or the GUI and D-Bus tests cannot be collected
uv run pytest                  # the whole suite
uv run pytest -m netns         # only the ones using real netlink, in a namespace
uv run ruff check src tests
uv run mypy src tests
uv run lint-imports            # the architectural boundaries
```

Without uv the dev tools are a PEP 735 dependency group — `pip install --group dev`
(pip 25.1 or newer). The full suite passes either way.

The helper speaks a greppable, hand-testable protocol, which is the main reason
NDJSON was chosen over length-prefixed framing:

```sh
echo '{"jsonrpc":"2.0","id":1,"method":"observe","params":{}}' | uv run necce-helper
```

`lint-imports` enforces the boundaries the design depends on: `model/` imports
nothing from the package, `helper/` must not import `engine/` (keeping the
root-privileged surface small), `engine/` must not import backends directly, and
`gui/` and `cli/` must not own the engine's process role.

[design.md](design.md) is the reference for how it all fits together;
[CLAUDE.md](CLAUDE.md) covers the development environment and the working rules;
[docs/dev-environment.md](docs/dev-environment.md) has the longer setup recipe.

License
-------

MIT. See [LICENSE](LICENSE).
