viiwork authenticates nothing. Every API endpoint and every dashboard is open to any client that can reach the server. Reachability is the authorization model, and the fleet is expected to sit on a tailnet or another trusted network. If you expose viiwork to an untrusted network, put a reverse proxy (Caddy, nginx) or firewall rules in front of it.
What that exposes
- Prompt and response text is readable over the API. The history
(
activity.prompt_historyrequests per node, 1000 by default, in memory) is served unauthenticated like everything else. If either side of the traffic on your fleet is sensitive, restrict access at the network layer. There is currently no switch to disable the history. See dashboards.md. - Client names and energy. With a
clientssection,/v1/statusand/v1/clusterlist each named client and the energy spent on it. A client is named by source address, so anything that can send from a named address is counted as that client.X-Viiwork-Clientfrom a client is removed; a node accepts it only on a forward it has verified, and it is not covered by the forward's signature, so any mesh member can attach a label, as any member can already forward a request. A client cannot name itself only once every node that clients call runs v2.10 or later: a node still on v2.9.x does not know the header, so it does not strip it from a client's request and copies it onto its signed forward, where a v2.10 node honours it. Addclientsonly after that upgrade. In an open mesh a verified forward is the source address of an alive member, so any process on a member machine can name itself. - Aliases change what every client gets. In a secured mesh a write needs the mesh secret's signature; in an open mesh it is accepted only from the node's own machine. A reverse proxy on that machine in front of the API would make every request look local, so put one only in front of a secured mesh. See mesh.md.
- Membership is the mesh secret's job. Without a secret, any viiwork node that reaches 7946 joins — which on a tailnet means the tailnet's ACLs are the control. See mesh.md.
- Chassis power control is off by default and has no wildcard. The allowlist is the whole authorization story; anyone who can reach the API can use what it names. See power-and-energy.md.
Two endpoints are deliberately narrowed, because an unauthenticated API on a network that also carries IPMI would otherwise be a probe primitive:
/v1/mesh/promptonly forwards to mesh members. Theaddrparameter is checked against the API addresses of alive members before anything is fetched.?host=is compared, never dialled. The value only filters routing candidates by node name; it never becomes an address. The same holds for the names in?prefer=andX-Viiwork-Prefer, and that header is removed before a request goes to a member or an engine.
Mesh headers are stripped from every incoming request right after verification, so no client can smuggle a forwarding claim through and no engine ever sees one.
Browser origins (CORS)
Server-side callers — curl, a backend proxying on behalf of its own UI — are unaffected by any of this. It matters only when a page served from somewhere else fetches viiwork directly from the browser.
Because viiwork authenticates nothing, an origin allowlist is not protecting the API from anyone who can already reach it. What it stops is a page in some browser on your network quietly driving your fleet through that browser's network position. Treat the list as a real control and keep it short:
api:
cors:
allow_origins: ["*.tail1234.ts.net", "localhost", "127.0.0.1", "*.your-app.example"]
allow_tailnet_ips: true # also 100.64.0.0/10 and fd7a:115c:a1e0::/48
*.example.com matches subdomains only, never the bare apex; every other entry
must match the host exactly. allow_origins: [] sends no CORS header at all,
which is how viiwork behaved before v1.1.0.
Leave allow_origins out and the node derives it at startup: it asks
tailscaled (over mesh.tailnet.socket) for this tailnet's MagicDNS domain and
allows *.<that domain> — *.tail1234.ts.net, say — plus localhost,
127.0.0.1 and, unless allow_tailnet_ips: false, tailnet IP literals. The log
line api.cors: allowing browser origins … shows the result. If tailscaled does
not answer, the node allows only the local origins and IP literals and logs how
to add the domain by hand. An explicit list, the empty one included, is used
exactly as written and tailscaled is not consulted.
Older releases shipped *.ts.net as the default. Do not write it back:
ts.net is every Tailscale customer's domain, and Tailscale Funnel serves
arbitrary public pages from it, so *.ts.net trusts pages anyone on the
internet can publish. Your own application's origin is deployment-specific: add
it in your viiwork.yaml, never in the repo's defaults.
Two behaviours are pinned by tests: SSE streams carry the header on their own
GET response (EventSource sends no preflight), and OPTIONS is answered
before routing, with a refused preflight returning 403.
Where the consumer has a backend of its own, prefer a server-side proxy over CORS: it needs no allowlist entry, and it can put authentication in front of an API that has none. api-integration.md covers both modes.
Cross-site writes (CSRF)
CORS decides which pages may read a response; it does not stop a browser from
sending one. A POST with a text/plain or form body is a "simple request"
that goes out with no preflight, so through v2.3.1 any web page a tailnet
member opened could switch a host off with
POST /v1/mesh/power {"host":"gb2","action":"off"} through that member's
browser. Two guards now run before routing:
- A write from a refused origin is 403. A
POST,PUT,PATCHorDELETEcarrying anOriginheader that is neither the node's own (the dashboards, same host and port) nor allowed byapi.corsis refused — on every endpoint, inference included, so a foreign page cannot spend GPU time either. Withallow_origins: []only the node's own pages may write. Clients that are not browsers send noOriginand are unaffected: curl, SDKs, the alias CLI, member forwards and pipeline steps. - Control writes must be JSON.
/v1/power,/v1/mesh/powerand every write under/v1/aliases/(set, delete, revert) requireContent-Type: application/json, else 415. A browser cannot send that cross-origin without a preflight, and a refused preflight is 403. Theviiwork aliasCLI sends it on bodiless deletes and reverts too. A write with no body and noContent-Typeis let through, so an older CLI'sdeleteandrevertkeep working against an upgraded node; a browser cannot use that gap, since a cross-origin write always carriesOrigin.
What neither guard can stop is DNS rebinding — a page that points its own name at a node's address is same-origin by every check a server can make. The answer to that is the same as to everything else here: keep the API off networks whose browsers you do not trust.
Listening address
api.host defaults to 0.0.0.0, every interface: local health checks and the
compose examples expect it. With host networking that includes every LAN, VPN
and public interface the machine has, and the API behind them authenticates
nothing. The node logs api: listening on every network interface … at startup
when that is the case. To narrow it, set api.host to the machine's tailnet IP
(or its LAN address) — the node's own callers, pipeline steps included, follow
the address the listener actually bound. Health checks then have to use that
address instead of localhost.