Open-source endpoint client for IT-AI — a self-hosted, LAN-first + reverse-tunnel remote-administration hub. This repo holds the three pieces that run on your machines (the hub itself is separate and private):
| Binary | Crate | What it is |
|---|---|---|
IT-AI (it-ai) |
crates/agent |
the endpoint agent — connects out to a hub over an HTTPS relay tunnel, serves screen/input/shell/camera/telemetry |
it-ai-mcp |
crates/mcp |
an MCP server your coding agent (Claude Code, etc.) talks to, to drive the fleet |
itai |
crates/cli |
a small command-line client for the hub |
Everything here is MIT and built in public CI. Nothing phones home to a hardcoded host:
the hub URL and any token are runtime parameters (--relay <url> with --relay-token htok_…
or HIVE_RELAY_TOKEN for the agent, HAIVE_HUB for the MCP and CLI) — there are no embedded
endpoints or secrets.
So you (and your tools) don't have to trust an opaque binary. A coding agent should refuse to download and run an unknown executable — here the source is auditable, and every released binary carries signed build provenance (see below) tying it to the exact commit + workflow that produced it.
Downloads are anonymous from the GitHub Release. Pick the asset for your OS/arch:
it-ai-linux · it-ai-linux-arm64 · it-ai-macos · it-ai-windows.exe
(same suffixes for it-ai-mcp-* and itai-*).
macOS (Apple Silicon):
curl -fsSL -o ~/.it-ai/it-ai-mcp https://github.com/gitayg/haive-agent/releases/latest/download/it-ai-mcp-macos && chmod +x ~/.it-ai/it-ai-mcpLinux x86-64 / arm64: swap the asset name (it-ai-mcp-linux or it-ai-mcp-linux-arm64).
Register the MCP with Claude Code (fill in your own hub + token + owner):
claude mcp add-json itai '{
"command": "'"$HOME"'/.it-ai/it-ai-mcp",
"env": {
"HAIVE_HUB": "https://your-hub.example.com",
"HIVE_MCP_TOKEN": "<your-mcp-token>",
"HIVE_OWNER": "<your-owner-id>"
}
}'/mcp → approve once. The agent then calls the tools and downloads nothing at runtime.
itai CLI on macOS / Linux, one command (from a checkout of this repo):
export HAIVE_HUB=https://your-hub.example.com
export HIVE_MCP_TOKEN=... # from your secret manager; the script never prompts for it
sh scripts/setup-itai.shscripts/setup-itai.sh fails before downloading anything if HAIVE_HUB or HIVE_MCP_TOKEN is
unset, and warns when HAIVE_HUB is not https:// (localhost excepted). It picks the asset for
the machine (itai-linux, itai-linux-arm64, or itai-macos on Apple Silicon, including a shell
under Rosetta; Intel macOS and Windows are refused), downloads it with the release SHA256SUMS,
and refuses to install on a missing, malformed or mismatched checksum. It installs itai into
$INSTALL_DIR, notes when that directory is not on PATH, then runs itai list as a read-only
connectivity check with any mtok= value in its output replaced by REDACTED. The token is read
from the environment only: never put on a command line, printed, or written to disk.
| Variable | Default | |
|---|---|---|
HAIVE_HUB |
(required) | hub URL |
HIVE_MCP_TOKEN |
(required) | token for the hub's /m API |
HIVE_OWNER |
(unset) | owner id, when the hub serves several users |
ITAI_VERSION |
latest | release tag to install, with or without the leading v |
INSTALL_DIR |
$HOME/.local/bin |
install directory |
ITAI_BASE_URL |
https://github.com/gitayg/haive-agent/releases |
releases base URL |
The hub dashboard's Register a device panel shows the exact command. Its shape:
HIVE_RELAY_TOKEN=htok_… ./it-ai --relay https://your-hub.example.com --name <device> --persist--relay-token htok_… works too, but the environment variable keeps the token off the command
line. --persist adds a per-user autostart entry; --install (run as root/admin) installs a boot
service instead.
From agent 3.7.0, with hub 3.16.0 or later:
- The enrollment token is used only to enroll. On its first hello the agent asks the hub for
its own credential, a device secret (
hdev_…), logsrelay: device credential issued, and uses that secret for every relay call from then on. The enrollment token is dropped. - Where it lives:
~/.it-ai/relay.cred(%USERPROFILE%\.it-ai\relay.credon Windows), mode 0600 in a 0700 directory; on Windows it inherits the profile's ACL. It is tied to the hub URL, and a file written for another hub is ignored. For--installon macOS and Linux the file goes in the home directory of the service user from the passwd entry (/var/root,/root), not theHOMEof thesudothat ran it, and thatHOMEis pinned in the LaunchDaemon plist / systemd unit. - The token is not kept on any command line. Autostart and service entries written by
--persist,--installorPOST /persistno longer contain--relay-token. Until the device has its own secret, the enrollment token is saved inrelay.credinstead, so the entry still enrolls after a reboot.--backgroundand the restart after a self-update pass the token to the new process inHIVE_RELAY_TOKEN, not in its arguments. - At startup the agent uses the device secret in
relay.credif there is one for this hub; otherwise--relay-token, thenHIVE_RELAY_TOKEN, then an enrollment token saved inrelay.cred. With none of these it exits withrelay mode requires an enrollment token. - Existing installs keep working. An entry written by an older agent still carries
--relay-token; the agent enrolls with it and gets its own secret on the first hello. Against a hub older than 3.16.0 the agent simply stays on the enrollment token. - Existing installs clean themselves up (agent 3.8.2+). At every start, once the agent holds a
device secret for this hub, it removes
--relay-tokenfrom its own old entry: the.desktopautostart and theit-ai.serviceunit on Linux, thecom.itai.agentLaunchAgent and LaunchDaemon on macOS, theIT-AIRun value and theIT-AIscheduled task on Windows. So the first start on 3.8.2, which is usually the restart after the self-update, takes the token off the command line.-
It rewrites an entry only if the rewritten entry will find the same
relay.cred. The agent works out the HOME the entry runs with from the entry itself and compares thatrelay.credwith the file it loaded its secret from. An old systemd unit has no HOME, so it looks in/.it-ai. When the secret is elsewhere, the agent pinsEnvironment=HOME=in the unit, orEnvironmentVariables/HOMEin a plist, but only for a root service when the agent is root, and only to a directory that root (or the agent's own user) alone can change, checked from/down, symlinks included. That is the service account's passwd home when the secret is there, otherwise the directory that holds it. A root service is never pointed at a directory a user could plant a credential, certificate or job in. No credential is ever moved or copied. A.desktopentry, a Run value and a scheduled task cannot pin HOME, so they are rewritten only when they already find the file. A task is changed only when it runs as this user with no stored password (InteractiveToken). Every other case is left as it is, with a log line saying why. -
It only rewrites a file nobody else can change. It never follows a symlink: the entry's directory is opened once, and the entry is read, checked and replaced through that directory with
O_NOFOLLOW,O_EXCLandrenameat. The file and its directory must belong to the agent's own user, every directory above them to that user or root, and none of them may be writable by others, as with ssh'sStrictModes. So a root agent never rewrites a file in a directory a user can change. An entry that fails these checks is left as it is, with a log line. -
Group-write is allowed only in the user's private group (agent 3.8.3+). Debian and Ubuntu give each user a group of their own and umask 002, so a desktop user's
~/.config/autostartis 0775 andit-ai.desktop0664, bothuser:user. 3.8.2 refused any group-writable file or directory, so on most Ubuntu desktops the clean-up never ran and the token stayed in the file. Group-write on a path owned by the agent's user now counts as safe only when every one of these is proven, and the log line names the first that is not:-
the path's gid is the user's primary gid, and the group's name is the user's name;
-
the group lists no supplementary members. No other passwd entry has that gid as its primary group or among its groups at login (
getgrouplist, which is how login computes them). Since 3.9.1 this also catches a group that only an nss-systemd membership (/etc/userdb/<user>:<group>.membership) or aninitgroups:source grants. Measured on Debian 12: theregetent groupshows no member, butidshows the group; -
/etc/nsswitch.confprovably takes users and groups only from this machine, where all of them can be listed, so "no other user has this group" can be checked. The file must exist and be readable. Since 3.9.1 it is read the way glibc reads it, or refused. That covers both glibc 2.33+ and the 2.31/2.32 parser, each case measured on glibc 2.31 and 2.36:- The file must be ASCII with no NUL, and must end in a newline (glibc 2.33+ skips a last line without one).
- A
#afterpasswd:,group:,initgroups:orgshadow:is refused. glibc 2.33+ has no comments: it readspasswd: files # ldapas the sourcesfiles,#andldap. 3.9.0 dropped the# ldap. - Database names are case-sensitive to glibc, so
PASSWD:is not apasswd:line. 3.9.0 took it for one, and a case variant of these names is now refused. - The name must be followed directly by
:, and there may be no[...]action item. - It needs exactly one
passwd:and onegroup:line, and at most oneinitgroups:andgshadow:line (3.9.0 ignored those two). - Their sources may only be
filesandsystemd.passwd:andgroup:may also usecompatwhen there is nopasswd_compat:/group_compat:line and/etc/passwd(/etc/group) has no+/-NIS entry.
Any other source (
sss,ldap,winbind,nis,cache, an unknown or differently-cased name), a missing or duplicated line, or an unreadable file means the group is not proven private, and the entry is left alone. So does an existing nscd socket (/var/run/nscd/socket), since glibc then asks nscd's cache first. Withsystemdlisted (Ubuntu's and Debian's default), two more conditions apply. The gid must be 1–60000, because nss-systemd's users thatgetpwentdoes not list (synthesized root/nobody, foreign-range users) have ids outside that range. And noSYSTEMD_NSS_*,SYSTEMD_BYPASS_USERDBorSYSTEMD_ONLY_USERDBvariable may be set in the agent's environment. A vendor file such as openSUSE's/usr/etc/nsswitch.confis not read: without/etc/nsswitch.confthe rule never applies, and neither does it on macOS, which has none.
This is the rule Debian patches into OpenSSH's
StrictModes(debian/patches/user-group-modes.patch, "Allow harmless group-writability", Debian bug #314347), made stricter. Debian also accepts the owner listed as the group's only member, and it trusts NSS enumeration from any source. World-writable is always refused, so is group-write in any shared group, and root-owned paths never get the exception (no group-write at all). The same rule applies to the HOME a root service would be pinned to, which must still be root's own. -
-
It never starts, stops or loads anything. Files are replaced atomically and keep their mode and owner. A unit is followed by
systemctl daemon-reloadonly. A plist takes effect at its next load. The task is changed in place withschtasks /Change /TN IT-AI /TR …and no/RU,/RPor/RL, so its principal and run level are not part of the change. -
It logs one
autostart:line per entry it rewrites or leaves, and nothing once the entries are clean. Each hello reportsautostart_token: true|false: whether any of the agent's own entries still carries the token. A hub that does not know the field ignores it. -
scripts/entryclean-e2e.shchecks this in Docker against old.desktopand unit entries, and starts each rewritten entry to confirm it finds the device secret. It also checks that a root agent leaves a user-owned entry and a symlinked unit alone, and never pins a unit's HOME to a user-owned directory. Since 3.8.3 it also strips a 0664 entry in a 0775 directory of a user whose private group has no members, and leaves the same entry alone in a shared group (another member, or another user's primary group), when the group is not named for the user, when nsswitch.conf takes users fromsss, has aPASSWD:line or a# ldapafterpasswd:, when an nss-systemd membership drop-in gives another user the group at login (in an image with libnss-systemd), and when it is world-writable. It also checks that a root agent never follows anagent.logor~/.it-aisymlink another user planted.
-
- After a revoke. When the hub refuses the device secret, the agent logs
relay: device credential rejected — re-enroll this deviceand retries every 60 s; it never exits. If an enrollment token was passed on this start (flag orHIVE_RELAY_TOKEN), it re-enrolls with it at once. Otherwise re-run the enrollment command (--persistor--installwith a current--relay-token): a token given on the command line replaces the stored credential, and the next hello issues a fresh secret. A self-update restart andPOST /persistnever do this — they keep a working secret even though they carry the original start's arguments.
Integrity — every release ships a SHA256SUMS:
curl -fsSL -O https://github.com/gitayg/haive-agent/releases/latest/download/SHA256SUMS
shasum -a 256 it-ai-mcp-macos # compare against the matching lineProvenance — each binary has a Sigstore-signed attestation:
gh attestation verify it-ai-mcp-macos --repo gitayg/haive-agentAn enrolled agent keeps itself on the build its hub serves. Every two minutes it fetches the hub's
small /bin/SHA256SUMS and compares the published hash for its platform with its own executable;
only when they differ does it download the binary, check its ed25519 signature against the pinned
key, and replace itself. A hub that publishes no checksum file gets the older behaviour (download
and compare bytes). A hub can also push an update (POST /update); if the pushed binary is
byte-identical to the one running, the agent answers "already running this build" instead of
reinstalling and restarting. The checksum file is not signed — a hub lying about it can only
delay an update, never install one, because every installed binary must carry a valid signature.
From agent 3.7.1 only one update runs at a time. A pushed POST /update that arrives while the
two-minute check is installing (or the other way round) answers 409 an update is already in progress and the check skips that cycle. Each update writes its own temp file, and the agent
refuses to install a 0-byte binary or one whose size on disk does not match what it downloaded.
It stays on the current version instead. Before 3.7.1 the two updaters shared one temp file and
could install an empty binary, which left the device dead until it was reinstalled by hand.
On macOS, an agent started by launchd (--persist LaunchAgent or --install LaunchDaemon) now
writes stdout and stderr to ~/.it-ai/agent.log. For the LaunchDaemon that is the service account's
home, /var/root/.it-ai/agent.log. A --background agent already logged there. The installer
creates the file as 0600 in a 0700 directory. At startup the agent empties it in place if it has passed 1 MB.
Re-run --persist or --install to add logging to an existing install.
On Windows, from agent 3.8.2, the agent that a self-update starts appends its stdout and stderr to
the same %USERPROFILE%\.it-ai\agent.log as a --background start, with the same 0600 file and
1 MB cap. Before 3.8.2 that output went nowhere, so after an update the log stopped at the old
version. On macOS and Linux an update execs the new binary, which keeps the old process's stdout
and stderr. A test checks this.
On Linux and macOS, from agent 3.8.3, an agent started without a terminal points its own stdout and
stderr at ~/.it-ai/agent.log (0600, appending, the same 1 MB cap) at startup. This is the Linux
desktop autostart: the .desktop entry's Exec= has no --background, so the agent used to write
wherever the session sent its output, and agent.log stopped at the last --background start
(measured on Ubuntu 26.04 GNOME: the log stopped at 3.3.1 while the agent ran 3.8.2). It applies
when stdout is a pipe, a socket, /dev/null or closed. It does not apply when stdout is a terminal,
or a regular file: launchd's StandardOutPath is already agent.log, so a launchd agent is not
redirected a second time, and neither is a --background child or a > file. A systemd service
keeps logging to the journal (journalctl -u it-ai): the agent stays on it when $JOURNAL_STREAM
names the device and inode of its own stdout or stderr, as systemd.exec(5) says to check, and
$SYSTEMD_EXEC_PID, when set, is the agent's own pid. A variable merely inherited from a service
further up the tree (a desktop session started by systemd) does not count. A Linux autostart agent
therefore logs to ~/.it-ai/agent.log of the user it runs as. The unix restart after an update
execs with the same descriptors, so it keeps logging there too.
On Linux and macOS the agent never opens its log through a link (3.8.3). ~/.it-ai is opened
O_DIRECTORY|O_NOFOLLOW and must be a directory of the agent's own user. agent.log is opened
relative to it with O_NOFOLLOW and must be a regular file of that user with one link. A mode an
older agent left loose is tightened through those descriptors (0700 and 0600), and the 1 MB trim
truncates that descriptor, never a path. Otherwise the agent does not log there and says why on
stderr. This covers the --background relaunch, the trim at every start and the redirect above. So
a root agent whose ~/.it-ai/agent.log, or ~/.it-ai, another user turned into a link never
appends to, chmods or truncates the file it points at. Windows opens the path as before.
When a controller and an agent share a network, traffic goes straight over the LAN instead of round-tripping through the cloud hub — screen frames, file transfer, shell, input and exec all ride the same shortcut, because it is chosen once at the transport layer rather than per feature.
It is automatic. The agent listens on 0.0.0.0:8765 serving the hub-signed leaf cert whose SANs are
its own LAN IPs, so the controller validates it against the hub CA — no self-signed exception. The
controller probes that address and the probe succeeding is the detection; any failure (no LAN
route, refused, TLS, timeout) falls back to the relay, so nothing breaks off-LAN.
A direct call is still governed. Reaching an agent over the LAN does not bypass the hub: before each direct call the controller mints a short-lived (60 s) ed25519 capability from the hub, and the hub issues it only after running the same authorization, command deny-list and audit it runs on the relay path. The agent verifies the capability — bound to the device, the operation, and a hash of the arguments — in addition to its own token gate, and refuses the call without one. So the audit trail and policy apply wherever the command came from. The cost is one hub round-trip per operation, which is why the win here is bandwidth (screen, files) rather than latency on tiny commands.
Set HIVE_LAN=0 on the agent to opt out and bind loopback only.
A privileged action (see below) is never sent LAN-direct: the hub answers the capability
request with cap: null, because a 60 s grant would expire long before a human approves, and the
controller sends the call over the relay, where the hub files the approval request.
itai talks to the hub's /m/* API, so it needs a token: --mtok / HIVE_MCP_TOKEN, plus
--owner / HIVE_OWNER when a hub serves several users.
The token travels as ?mtok= in the request URL, and transport errors quote that URL. itai
and it-ai-mcp mask the value (mtok=***) in every error they print or return.
From hub 3.18.0 the hub sorts every /m action into a tier before anything reaches a device, and
itai / it-ai-mcp 3.10.0 understand its answers:
| Tier | Hub answer | it-ai-mcp |
itai |
|---|---|---|---|
| read, change | runs (change also notifies the owner) | the result | the result |
| privileged | 202 {pending_approval, approval_id, poll}; nothing runs until a human approves it in the dashboard |
waits up to 60 s, polling GET /m/approval?id= every 2 s; returns the result if approved, else Waiting for approval in the IT-AI dashboard (approval id apr_…) and how to check with approval_status |
says Waiting for approval … on stderr, waits --wait SECS (default 60); the result if approved, else the id and exit 75 |
| blocked | 403 |
Blocked by the hub's policy: … (tool error) |
the same on stderr, exit 1 |
A denied or expired approval is reported as such (MCP tool error; itai exit 1). Check one later
with the approval_status(id) tool or itai approval <id> (which waits only with --wait). An
approval runs the one stored request, once; re-sending the action files a new request.
-
Fleet runs (
fleet_run,run_script_fleet) takemaxandcanary, passed to the hub, which refuses a fleet bigger thanmax(capped at itsfleet_max_devices) and runscanarydevices first. A canary failure is shown asSTOPPED: …with the devices that were skipped; a refusal is shown as an error, not as "no devices". -
Dry run: every MCP tool that changes something takes
dry_run: true, anditaitakes--dry-runonexec,put,job startandjob stop. The request goes to the hub relay withdry_run=1(never LAN-direct, andputsends no file), and the hub's plan is printed: tier, whether it needs approval, devices, and what it would do. -
Attribution: both binaries send
X-IT-AI-Client: cliormcpon every hub request, so a call made with the sharedMCP_TOKENis recorded as a CLI or an AI agent. A scoped token carries its own kind. -
MCP tool annotations: every tool carries
readOnlyHint/destructiveHintfrom the hub's default tier for the action it performs (crates/mcp/src/policy.rs, mirroring the hub's/m/policy): read = read-only, privileged = destructive. An admin can change tiers on the hub; the hub's answer at call time decides. -
MoorAI approval receipts (
it-ai-mcp3.11.0): any tool input may carry_moorai: {"receipt": "<receipt>"}, which MoorAI adds through Claude Code.it-ai-mcpremoves the key before the tool runs, so the tool never sees it, and adds three headers to every hub request that tool call makes (lookups, LAN-direct capability mints, relay calls, fleet calls, approval polls):Header Value X-MoorAI-Receiptthe receipt, verbatim X-MoorAI-Toolthe tool name, e.g. run_commandX-MoorAI-Argsbase64url, no padding, of the tool input as received with only _moorairemoved: no defaults from the typed arguments are added. Keys come out in sorted order (rmcp hands the server a sorted map), and numbers are re-rendered by serde_jsonNo headers are sent for a dry run, when the encoded args exceed 16384 bytes, or when
_mooraiis not an object with a stringreceipt; the call then runs exactly as it does without one. The headers go to the hub only, never to a device on the LAN-direct leg. A receipt-carrying call uses its own hub client, so it probes the LAN-direct route afresh rather than reusing the cached one.it-ai-mcpnever logs the receipt or the args; the hub decides what a receipt is worth.
A job is a long-running command on a device (a dev server, a build) that outlives the ~65 s
limit of a single exec. Start it, read its output as it grows, stop it; each is a separate short
call. The wire contract is docs/JOBS-API.md.
itai job start <device> [--cwd DIR] -- <command…> # prints the job id
itai job logs <device> <id> [--offset N] [--follow]
itai job stop <device> <id>
itai job list <device>- The words after
--are joined with spaces and run by the device's shell, so inner quoting is lost:-- sh -c 'a; b'does not work. Pass a compound command as one quoted argument:itai job start dev -- 'for i in 1 2 3; do echo $i; sleep 1; done'. (itai execbehaves the same.) job logsprints the output from--offset(default 0), then[next offset N · running]or[next offset N · exited <code>]on stderr; pass that offset back to read only what is new.--followkeeps reading from the returned offset, waiting 2 s whenever it has caught up, until the job has exited and all output is read, then printsexit code: <n>.job stopends the job and its child processes.
MCP tools, same operations:
| Tool | Returns |
|---|---|
job_start(device, command, cwd?, dry_run?) |
job id, pid and log path (a command off the hub's allowlist waits for approval first) |
job_logs(device, id, offset?) |
a JSON line with the next offset, running, exit_code, eof, size, then the output text |
job_stop(device, id) |
whether it was running, and the exit code |
job_list(device) |
id, running or exit code, pid, start time (unix secs), command |
On the agent:
- Four endpoints,
POST /jobs/start(body{"cmd","cwd"?}),GET /jobs/logs?id=&offset=&max=,POST /jobs/stop?id=,GET /jobs/list. All are privileged like/exec, and/jobs/startanswers 403remote exec disabledwhen the agent runs withSCREEN_EXEC=0. - The command runs through the same shell as
/exec(sh -c/cmd /S /C) in its own process group, stdin closed, stdout and stderr appended to one file,~/.it-ai/jobs/<id>.log. - Ids are
j+ unix milliseconds + 4 hex chars; anything outside[a-z0-9]gets 400, an unknown id 404unknown job. - A logs read returns at most
maxbytes (default 64 KiB, capped at 1 MiB).eofis true only once the job has exited and the read reached the end of the log. - Stop: unix sends SIGTERM to the process group and SIGKILL after 5 s; Windows runs
taskkill /T /F /PID. - The job list is in memory. It keeps the 50 most recent jobs, dropping the oldest finished ones (running jobs are never dropped); an agent restart forgets it. Log files stay on disk.
The hub authorizes, deny-list-checks (as a launch) and audits job/start exactly like an exec;
a read-only MCP token cannot start or stop a job. Jobs are relay-only in this version: the CLI
and MCP call the hub's /m/job/* routes, not the LAN-direct path. A device needs agent 3.6.0 or
later; for an older one the hub returns the agent does not support jobs — update it.
A device can act as a WireGuard exit, so pass holders browse with its public IP even when it sits
behind CGNAT: the agent dials out to the hub's UDP relay, and the relay only carries WireGuard
ciphertext. It is enabled per device from the hub dashboard, which drives the privileged
/vpn/status, /vpn/apply and /vpn/disable endpoints.
- Linux only. Other platforms answer
/vpn/applywith 400. - Requirements: root,
/dev/net/tun, and iptables, which the agent installs. No WireGuard package is needed. Root means the service install (--install); the agent checks for uid 0, soCAP_NET_ADMINalone is not enough. Wheniptablesis missing it installs it withapt-get,dnf,pacmanorapk, whichever is present. If it cannot, or no WireGuard interface can be created,/vpn/applyfails with the reason, and the hub dashboard shows it. - WireGuard is the kernel module when the kernel has it and
wg(wireguard-tools) is already installed. Otherwise it is the agent's built-in userspace WireGuard (Cloudflare's boringtun, BSD-3-Clause), which needs only/dev/net/tun. That covers kernels without the module, such as Jetson (Tegra) and some WSL2 and container kernels.HIVE_VPN_USERSPACE=1forces the built-in one./vpn/statusreports which one is running asbackend(kerneloruserspace). - No IPv6 needed. The built-in WireGuard listens on IPv4 and IPv6. On a kernel booted with
ipv6.disable=1, which refuses IPv6 sockets, boringtun 0.7.1 failed to start at all; the agent builds it fromvendor/boringtunwith a patch that listens on IPv4 alone then. IPv6 turned off by sysctl (net.ipv6.conf.all.disable_ipv6=1) never broke it. - The built-in WireGuard is watched and rebuilt when it dies. boringtun runs on one worker
thread inside the agent. If that thread panics, only the thread dies, and the exit would
otherwise stop forwarding while everything else still looks fine. Every 10 seconds, and right
after every apply, a watchdog checks three things: that the worker thread is still running, that
a UAPI
get=1answers witherrno=0within 2 seconds, and thatitai-wgstill exists. If any check fails, the agent tears the device down, releasing every fd it held, and rebuilds it with the same key, port and passes, then checks it again. Clients reconnect on their own; nothing is re-sent from the hub. Rebuilds back off at 1s, 2s, 4s and so on, up to 60s. After 5 failures within 10 minutes the watchdog stops: the device stays down,healthisfailed, anderrorcarries the reason until the next apply from the hub. A disable or a newer apply ends a recovery that is still running, so a disabled exit is never brought back. The kernel backend is not watched, since the agent runs no threads for it.- A worker that is stuck, not dead, cannot be killed. Its TUN queue and UDP sockets are released
(their fd numbers are kept on
/dev/null) so that the new device can take the interface and port. The thread itself stays parked until the agent restarts. /vpn/statusreportshealth(ok,recoveringorfailed),restarts(rebuilds since the exit was enabled), andlast_failure({at, reason}, whereatis in Unix seconds). For a panic,reasonincludes boringtun's panic message and location. A panic hook records it only for boringtun's worker threads, and passes every panic on to the previous hook.
- A worker that is stuck, not dead, cannot be killed. Its TUN queue and UDP sockets are released
(their fd numbers are kept on
/vpn/applyfails instead of answering 200 over a broken exit. It fails when the interface cannot be created, when a pass cannot be added, changed or removed, or when the built-in device is unhealthy right after the apply. In those cases the applied state is still saved, and the watchdog rebuilds the device with it. The hub dashboard shows the device'serrorin the exit's status line. It does not showhealth,restartsorlast_failureyet, although they reach the browser unchanged.- It creates the
itai-wginterface (10.77.0.1/24), turns on IPv4 forwarding, and adds iptables NAT and filter rules taggedit-ai-vpn. Those rules block the device itself, private and CGNAT ranges, and peer-to-peer traffic, and drop any other forwarded traffic to or fromitai-wg. Every apply, and every uplink change (Wi-Fi to Ethernet), first deletes all tagged rules whatever interface they named, then inserts the full set at fixed positions at the top of each chain, so the peer-to-peer and private-range drops always sit above the accepts. Disabling removes the interface and every tagged rule, and stops the built-in WireGuard's threads. - MSS clamping is optional. The two
manglerules that clamp TCP MSS to the path MTU need the kernel's TCPMSS target, which some kernels lack (a Jetson Orin's Tegra 5.15 build has# CONFIG_NETFILTER_XT_TARGET_TCPMSS is not set). There the agent logsno MSS clampingand brings the exit up without them; before, the apply failed. Every other rule must still go in. - IPv4 forwarding is put back. Before turning it on, the agent records the previous
ip_forwardvalue in~/.it-ai/vpn/ip_forward.before. If it was0, the agent is the reason forwarding is on: a tagged rule then drops every forwarded packet that does not involveitai-wg, and disabling setsip_forwardback to0. If it was already1(a Docker host, a router), the agent adds no such rule and leaves it on when disabled. The FORWARD chain policy is never changed. - The agent only receives each pass's public key and preshared key; it never sees a client's private key. Pass expiry is enforced on the device too, and the applied state is restored after a reboot.
- Apply, disable, the boot resume and the expiry ticker run one at a time. A disable stops a resume that is still retrying, and the ticker cannot write back a pass list that a disable or a newer apply replaced.
scripts/vpn-e2e.sh [userspace|kernel|noipv6]runs the exit in Docker against a stock WireGuard client and checks the handshake, traffic through the NAT, and a clean disable. On the userspace backend it also kills boringtun's worker (with the real 0.7.1 panic) and later holds it stuck. Each time it checks that/vpn/statusgoesrecoveringand thenok, that the client's traffic flows again with no apply in between, and what is left after disable: nothing after the panic, and only the parked thread after the hang.noipv6is the userspace run on a box without IPv6:scripts/seccomp-no-ipv6.jsonmakes every IPv6socket()fail, asipv6.disable=1does. The hub relay is not part of that test.
skills/it-ai/SKILL.md is a Claude Code skill for operating a fleet
through it-ai-mcp or itai: which tool fits which task (short command vs job, upload_file vs
push_file), how to treat hub refusals (report them, never route around the deny-list), which
actions to confirm first, reading update_agent replies, and keeping the token out of command
lines and output. Install it by copying skills/it-ai into ~/.claude/skills/.
cargo build --release # produces target/release/{IT-AI,it-ai-mcp,itai}Linux release binaries are cross-linked against glibc 2.31 via cargo-zigbuild (see
.github/workflows/build.yml) so one binary runs on Debian 12, Ubuntu 22.04+, and ARM SBCs.
MIT. See LICENSE.