66-web¶
66-web is a web interface to the 66 service manager. A single binary serves an embedded web UI and a JSON API to list services and trees, read their state, follow their changes live, and act on them.
Interface¶
66-web [ -h ] [ -v verbosity ] serve [ -h ] [ -l host:port ] [ -L host:port ] [ -a file ]
66-web [ -h ] [ -v verbosity ] passwd [ -h ] [ -a file ]
66-web manages exactly the services of the uid that runs it. Launched as root, it manages the system services; launched as a user, it manages the services of that user. It never changes its privileges, never reaches outside that scope, and there is no separate root daemon behind it.
Nothing is served without a credential. Run 66-web passwd once, as the uid that will run the server, before the first 66-web serve. See Credential file.
The HTML, CSS and JavaScript of the UI are compiled into the binary. There is nothing else to install or deploy.
Exit codes¶
- 0 : success.
- 100 : wrong usage, bad argument, or no usable credential file.
- 111 : a system call failed.
Options¶
These options are global and come before the command, as for 66.
-
-h, --help : print the help and exit. Every command also accepts -h.
-
-v, --verbosity verbosity : set the verbosity level, from 0 to 5.
- 0 : only print error messages.
- 1 : also print informative messages. This is the default.
- 2 : also print warning messages.
- 3 : also print tracing messages.
- 4 : also print the function name and line of the messages.
- 5 : also display the sequence of the current process function by function.
Called without a command, 66-web prints the help and exits 100.
Commands¶
serve¶
66-web serve [ -h ] [ -l host:port ] [ -L host:port ] [ -a file ]
Runs the HTTP server until it receives SIGINT or SIGTERM.
-
-l, --listen host:port : address to bind. Default
127.0.0.1:8066. The host must be a loopback address, that is a127.0.0.0/8address. Any other host is refused with exit code 100, pointing at --insecure-listen. -
-L, --insecure-listen host:port : same as --listen, but accepts a non-loopback host. The server speaks plain HTTP, so credentials and every response cross the network unencrypted. A warning is logged at bind time, visible from
-v 2on. Use it only behind an HTTPS reverse proxy. It also turns off theHostandOriginchecks, which become the job of the proxy, see Security. -
-a, --auth-file file : credential file to load. See Credential file.
For both options, the host must be an IPv4 address in numeric form. A name such as localhost, or an IPv6 address such as ::1, is refused with exit code 100. The port must be between 1 and 65535, with nothing after it.
When --listen and --insecure-listen are both given, the last one wins.
Before binding, 66-web checks that the resolve format on disk is the one it was built for, by comparing the .version marker of the 66 system directory with the version of 66 it was compiled against. On a mismatch it exits 111: rebuild 66-web against the installed 66, or run 66 once to migrate the state. When the marker is absent, it logs a warning and serves anyway.
passwd¶
66-web passwd [ -h ] [ -a file ]
Prompts for a user name, a password and its confirmation on stdin, then writes the credential file and exits.
- -a, --auth-file file : credential file to write. See Credential file.
When stdin is a terminal, echo is disabled while the password is typed. When stdin is a pipe, the three lines are read as they come, which allows a scripted setup:
printf 'admin\nsecret\nsecret\n' | 66-web passwd
An empty user name, a user name containing :, an empty password or a confirmation that does not match is refused. The password is never printed nor logged.
Running passwd again replaces the credential. A running server keeps the credential it loaded at startup: restart it to apply the new one.
Credential file¶
66-web holds a single user name and password, used through HTTP Basic authentication.
The file is chosen in this order:
- the --auth-file option;
- the
66_WEB_AUTH_FILEenvironment variable, when set and not empty; /etc/66/web/authwhen running as root,$HOME/.config/66-web/authotherwise.
serve and passwd apply the same rule, so both find the same file when they run as the same uid with the same environment.
The file holds one line:
user:<salt_hex>:<hash_hex>
The password itself is never stored. The salt is 16 random bytes read from /dev/urandom. The hash is SHA-256 iterated 100000 times: h = SHA256(salt || password), then h = SHA256(salt || h) repeated. The file is created mode 0600, its parent directory 0700 if it does not exist, and it is written under a temporary name then renamed into place, so a reader never sees a partial credential.
66-web serve refuses to start, exit code 100, when the file is absent, empty or malformed. This holds on loopback too: there is no anonymous mode.
Security¶
Every request is authenticated, the UI assets, the JSON API and the event stream alike.
-
Authentication : a request without a valid
Authorization: Basicheader gets401withWWW-Authenticate: Basic realm="66-web", charset="UTF-8", which makes the browser show its login prompt. The user name and the password are both compared in constant time, so a wrong user and a wrong password cannot be told apart by timing. -
Host check : when bound with --listen, the
Hostheader must name this server: the listen host,127.0.0.1,localhostor[::1], with the listen port or no port. Anything else gets403. This defeats DNS rebinding, where an attacker's domain resolves to127.0.0.1: the browser still sends the attacker's name, which matches nothing. -
Origin check : when bound with --listen, a
POSTcarrying anOriginheader must name this server, with anhttporhttpsscheme and the same set of names as theHostcheck. AnOriginwithout a scheme,nullincluded, gets403. APOSTwithoutOriginis accepted. -
CSRF header : every
POSTmust carry anX-66-Webheader, whatever its value, in every listen mode, or it gets403. A cross-site form cannot set a custom header, and a script that tries forces a CORS preflight, which 66-web answers405. NoAccess-Control-Allow-Originis ever sent.GETrequests and the event stream do not need it. -
Behind a reverse proxy : with --insecure-listen, the
HostandOriginchecks are off, since the proxy legitimately rewrites them. The proxy then owns TLS, theHostfiltering and theOriginfiltering. Basic authentication and theX-66-Webheader still apply.
66-web has no TLS of its own, no multiple users, no roles and no sessions. One credential gives full control over the scope of the uid running the server.
Scope¶
The running uid decides everything:
| root | user | |
|---|---|---|
| services and trees | system ones | the user's ones |
| new frontends written to | /etc/66/service/ |
$HOME/.66/service/ |
| frontends listed from | /etc/66/service/, then /usr/share/66/service/ |
$HOME/.66/service/, then /etc/66/service/user/, then /usr/share/66/service/user/ |
| default credential file | /etc/66/web/auth |
$HOME/.config/66-web/auth |
Frontends under /usr/share/66/service/ are owned by the package manager: 66-web lists them but never deletes them.
Web interface¶
Point a browser at the listen address, http://127.0.0.1:8066/ by default, and log in with the credential set by 66-web passwd.
The UI offers four views:
- services : every service of every tree, with its state, and a detail panel giving its resolve information, its dependencies, the tail of its log, its frontend and its configuration. The panel runs the service actions.
- trees : every tree, its flags, its dependencies, and the tree actions.
- graph : the dependency graph of the services.
- available : the frontends found in the source directories that are not parsed yet, to enable, start or delete them.
+ new service opens an editor to write a new frontend, either a single service or a module, and optionally enable or start it right away.
The views refresh by themselves when the state of a service changes, through the event stream described below.
HTTP API¶
Conventions¶
- Only
GETandPOSTare accepted. Any other method gets405. - Every response closes the connection (
Connection: close), except the event stream. - API responses are
application/json; charset=utf-8. An API error carries{"error":"<message>"}. Errors detected by the HTTP layer itself, before routing, carry a one-linetext/plainbody such as404 Not Found. - A query string is ignored. The path is percent-decoded before routing; a malformed escape gets
400. - Names in a path or a body must be non-empty, shorter than 256 bytes, must not start with
-, and must not contain/,.., whitespace or control characters.@is allowed for instances and templates. - Actions are never run inside 66-web. Each one execs the
66binary of the bindir 66-web was built for, with-v 2added so that its warnings reach the captured output. The server keeps serving while the command runs; the HTTP response is sent when the command has exited.
Read routes¶
GET /api/trees¶
{
"base": "/var/lib/66/",
"trees": [
{
"name": "global",
"enabled": true,
"current": true,
"init": true,
"supervised": true,
"group": "admin",
"depends": [],
"requiredby": [],
"allow": []
}
]
}
base is the 66 base directory of the running uid.
GET /api/services¶
Every service of every tree:
{
"base": "/var/lib/66/",
"services": [
{
"name": "ntpd",
"tree": "global",
"type": 0,
"typestr": "classic",
"inns": null,
"logger": "ntpd-log",
"status": "up",
"pid": 812,
"who": "user",
"since": 1790000000,
"depends": [],
"requiredby": [],
"contents": [],
"provide": [],
"conflict": []
}
]
}
typestris one ofclassic,oneshot,module,event.innsis the module holding the service, ornull.loggeris the name of the logger service, ornull.statusis the word 66 uses for the state of the service.whotells what caused it:self,user,event,bootorshutdown.sinceis the date of the last state change in seconds since the epoch, ornull.contentslists the services a module bundles.
A service listed by its tree whose resolve file cannot be read is still present, with null in place of its type, links, status and date, and empty arrays.
GET /api/services/name¶
The detail of one service, with more keys than its entry in GET /api/services:
{
"name": "ntpd",
"description": "ntpd daemon",
"version": "0.0.1",
"type": 0,
"typestr": "classic",
"source": "/etc/66/service/ntpd",
"intree": "global",
"treename": "global",
"enabled": true,
"islog": false,
"earlier": false,
"config": true,
"status": "up",
"pid": 812,
"who": "user",
"since": 1790000000,
"depends": [],
"requiredby": [],
"contents": [],
"provide": [],
"conflict": []
}
description,version: theDescriptionandVersionkeys of the frontend.source: path of the frontend file the service was parsed from.intree: the tree the frontend asks for with itsInTreekey, empty when it has none.treename: the tree the service actually belongs to.enabled:truewhen the service is enabled.islog:truewhen the service is itself a logger, such asntpd-log.earlier:truewhen the frontend carries theearlierflag, starting the service as soon as the scandir is up.config:truewhen the service has an environment configuration, readable throughGET /api/services/name/config.type,typestr,status,pid,who,sinceand the five arrays : same meaning as inGET /api/services.
404 when the service is unknown.
GET /api/services/name/log¶
{ "name": "ntpd", "log": "2026-10-03 10:00:00.000000000 ...\n" }
The last 5 lines of the log of the service, with TAI64N stamps converted to local time. log is empty when the service logs nowhere 66-web can read, or has never run.
GET /api/services/name/frontend¶
{ "name": "ntpd", "path": "/etc/66/service/ntpd", "content": "[Main]\n..." }
The frontend the service was parsed from. When that file cannot be read, content is (unable to read frontend).
GET /api/services/name/config¶
{ "name": "ntpd", "config": "..." }
The output of 66 configure -L name. Always 200, whatever the exit code of the command.
GET /api/available¶
The frontends of the source directories that are not parsed yet, listed in the precedence order of Scope. A name present in several directories is listed once, from the first.
{
"base": "/var/lib/66/",
"available": [
{
"name": "sshd",
"kind": "service",
"type": "classic",
"description": "OpenSSH daemon",
"instanced": false,
"source": "/usr/share/66/service/",
"removable": false
}
]
}
kindisserviceormodule.instancedistruefor a template, a name ending in@, and for every module.removableistruewhen the directory is not package-managed and the running uid can write in it.
GET /api/frontend/name¶
{ "name": "sshd", "source": "/usr/share/66/service/", "content": "[Main]\n..." }
The content of a frontend found in the source directories, parsed or not. For an instance, name@instance, it answers the content of the template name@. 404 when no directory holds it.
Action routes¶
Every POST needs the X-66-Web header, see Security. Bodies are JSON, 256 KiB at most.
POST /api/services/name/action¶
Runs 66 action name. action is one of start, stop, restart, reload, enable, disable, reconfigure, free, remove. Any other verb gets 400.
{ "name": "ntpd", "action": "start", "exit": 0, "output": "..." }
200 when 66 exits 0, 503 otherwise. output holds its stdout and stderr.
POST /api/trees/name/action¶
Runs 66 tree action name. action is one of start, stop, free, enable, disable, current, init, remove. Any other verb gets 400.
{ "tree": "global", "action": "start", "exit": 0, "output": "..." }
200 when 66 exits 0, 503 otherwise.
Machine-level commands, boot, halt, poweroff, reboot, are not reachable through the API.
POST /api/services¶
Writes a new frontend file into the writable source directory, see Scope.
{
"name": "myservice",
"content": "[Main]\nType = classic\n...",
"action": "enablestart",
"tree": "global",
"overwrite": false
}
name,content: required.action: what to run once the file is written.none: nothing. This is the default, and the value for any unknown word.enable:66 enable, which parses the frontend first.enablestart:66 enable -S.start:66 start.
tree: tree to enable into, passed as66 -t. When absent, 66 decides.overwrite: replace an existing file of that name. Without it, an existing file gets409.
{ "name": "myservice", "path": "/etc/66/service/myservice", "action": "enablestart", "exit": 0, "output": "..." }
201 when the file is written and the action, if any, succeeded. 503 when 66 failed; the file is kept, so a parse error can be fixed and the request sent again with overwrite.
POST /api/modules¶
Writes a new module directory into the writable source directory.
{
"name": "mymodule",
"content": "[Main]\nType = module\n...",
"members": [ { "local": "worker", "content": "[Main]\n..." } ],
"activated": [ "worker" ],
"depends": [ "network" ],
"requiredby": [],
"configure": "#!/bin/sh\n...",
"instance": "one",
"action": "enable",
"tree": "global",
"overwrite": false
}
name: module name, without@. The module frontend is written asname/name@.content: the module frontend, required.members: services bundled by the module, written underfrontend/.activated,depends,requiredby: written underactivated/,activated/depends/andactivated/requiredby/.configure: theconfigure/configurescript, made executable.action,tree: as for/api/services, applied toname@instance. Any action other thannonerequiresinstance, without@.overwrite: replace an existing module directory. Without it, an existing one gets409.
When writing the directory fails halfway, it is removed. The response is the same as for /api/services, path naming the module directory.
POST /api/enable¶
Enables or starts a frontend already present in the source directories.
{ "name": "sshd", "tree": "global", "action": "enable" }
action is enable, the default, enablestart or start.
{ "name": "sshd", "exit": 0, "output": "..." }
200 when 66 exits 0, 503 otherwise.
POST /api/remove/name¶
Deletes an unparsed frontend, file or module directory, from the first source directory that holds it, is not package-managed, and is writable by the running uid. No body.
{ "name": "myservice", "source": "/etc/66/service/", "removed": true }
404 when no such directory holds it. To remove a parsed service, use POST /api/services/name/remove.
Event stream¶
GET /api/events¶
A Server-Sent Events stream, text/event-stream. It opens with a : connected comment, then sends:
data: {"type":"state_changed"}
each time something changes in the live state directory of the running uid under the 66 live directory. The event carries no detail: a client re-fetches what it shows. A client too slow to read misses a frame rather than receiving a late one.
At most 64 streams are served at once; one more gets 503. When the live state directory cannot be watched, a warning is logged and the stream stays open but never emits.
Status codes¶
| Code | When |
|---|---|
200 |
success |
201 |
frontend or module written |
400 |
malformed request, bad name, unknown verb, invalid JSON |
401 |
missing or wrong credential |
403 |
Host, Origin or X-66-Web check failed |
404 |
unknown route, service, tree or frontend |
405 |
method other than GET or POST |
408 |
the request header was not complete within 15 seconds |
409 |
the frontend or module already exists and overwrite is not set |
413 |
body larger than 256 KiB |
414 |
path of 1024 bytes or more |
431 |
header block larger than 16 KiB, or more than 64 header fields |
500 |
internal failure |
503 |
the 66 command exited non-zero, or the event stream is full |
Limits¶
- 256 connections at once.
- 15 seconds to send the request header block.
- 30 seconds without the client reading one more byte of a response, and 10 minutes in all to deliver it.
Examples¶
The examples use curl and jq, with the server on its default address and the credential admin / secret. With -u admin alone, curl prompts for the password instead of leaving it in the shell history.
List the trees:
curl -s -u admin:secret http://127.0.0.1:8066/api/trees | jq .
Show the services that are not up, one per line:
curl -s -u admin:secret http://127.0.0.1:8066/api/services | jq -r '.services[] | select(.status != "up") | "\(.name) \(.status)"'
numlockx waiting
printenv down
Show one service, then the tail of its log:
curl -s -u admin:secret http://127.0.0.1:8066/api/services/clipit | jq .
curl -s -u admin:secret http://127.0.0.1:8066/api/services/clipit/log | jq -r .log
2026-10-01 02:01:20.661064816 (clipit:16578): Gtk-WARNING **: 02:01:20.661: cannot open display: :0
2026-10-02 00:13:40.658753078 Clipboard is null, recovering ...
List the frontends not parsed yet, with the directory holding them:
curl -s -u admin:secret http://127.0.0.1:8066/api/available | jq -r '.available[] | "\(.name)\t\(.kind)\t\(.source)"'
Print a frontend:
curl -s -u admin:secret http://127.0.0.1:8066/api/frontend/clipit | jq -r .content
Restart a service, then stop a tree. A POST needs the X-66-Web header; -i shows the status line, 200 on success and 503 when 66 failed:
curl -s -i -u admin:secret -H 'X-66-Web: 1' -X POST http://127.0.0.1:8066/api/services/clipit/restart
curl -s -u admin:secret -H 'X-66-Web: 1' -X POST http://127.0.0.1:8066/api/trees/global/stop | jq -r .output
Without the header, the server answers 403 Forbidden; with a verb outside the whitelist, 400:
{"error":"unknown action or invalid service name"}
Create a service from a local frontend file myservice, enable it in the tree global and start it. jq --rawfile builds the JSON body and escapes the content of the file, --data-binary @- sends it unchanged:
jq -n --rawfile c myservice '{name: "myservice", content: $c, action: "enablestart", tree: "global"}' | curl -s -u admin:secret -H 'X-66-Web: 1' -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:8066/api/services | jq .
Enable a frontend already present in a source directory:
jq -n '{name: "sshd", tree: "global", action: "enable"}' | curl -s -u admin:secret -H 'X-66-Web: 1' --data-binary @- http://127.0.0.1:8066/api/enable | jq .
Follow the event stream. -N turns off the buffering of curl, so each event shows as it arrives:
curl -s -N -u admin:secret http://127.0.0.1:8066/api/events
: connected
data: {"type":"state_changed"}
Running it under 66¶
An example frontend for the system instance:
[Main]
Type = classic
Description = "66-web interface"
[Start]
Execute = ( /usr/bin/66-web serve )
Run 66-web passwd as root first, otherwise the service fails at start. For a user instance, write the same frontend into $HOME/.66/service/, run 66-web passwd as that user, and pick a different port with --listen if the system instance is also running.
Logging¶
66-web logs through oblibs to stderr, at the verbosity set by -v. Under 66, that stream goes to the logger of the service. The listen address, the watched live state directory, shutdown, and every failure path are reported there. Request bodies and credentials are never logged.
See also¶
- 66 : the service manager 66-web drives.