API Documentation
Complete reference for the Town OS systemcontroller API — 77 endpoints across accounts, storage, repositories, packages, systemd, settings, audit, pages, DNS, monitoring, system services, locales, VM images, and status.
Overview
The systemcontroller is the central backend service for Town OS. It is built on
Echo v5 and listens on port 5309 (TCP) or a Unix domain
socket in production. All request and response bodies use JSON. Errors follow
RFC 9457
(application/problem+json).
CORS is enabled for development. In production the API is served behind the same origin as the UI.
Authentication
Authenticate by calling POST /account/authenticate with a username and
password. The response contains a Bearer token. Include it in subsequent requests:
Authorization: Bearer <token> Sessions expire after 7 days of inactivity. There are five auth levels:
| Level | Description |
|---|---|
| Public | No token required. |
| Authenticated | Any valid session token. |
| Admin | Session token belonging to an admin account. |
| Grant | A specific grant on the account admits a non-admin. Object storage endpoints take the object storage grant; peer enrollment takes the wireguard grant, with per-network scope and per-peer ownership enforced by the handler itself. |
| Localhost | Requests from loopback pass unauthenticated — reaching loopback already means being on the box — and every other origin needs the level named alongside the badge. Used by the systemd unit and log endpoints, which the controller's own tooling reads. |
Creating an object storage partition is reserved for administrators even though the endpoints inside one are not: a partition roots a permission tree and allocates a btrfs subvolume with a quota, so the grant admits you to the users inside a partition rather than to the decision that it should exist.
Pagination
All list endpoints accept the following query parameters and return a common envelope:
| Parameter | Type | Description |
|---|---|---|
sort_by | string | Field name to sort on. |
sort_order | string | asc or desc. |
limit | int | Page size (default 20). |
offset | int | Pagination offset. |
search | string | Case-insensitive substring match across all string fields. |
Response Envelope
{
"entries": [...],
"has_more": true,
"total_pages": 5,
"total_count": 97
} Status
/status/ping Public
Health check and system overview. Unauthenticated callers receive a minimal response
with status and needs_setup. Authenticated callers receive
the full dashboard payload including filesystem count, package counts, unit status
summary, disk usage, external/internal IP, and upgrade availability.
Accounts
/account/authenticate Public Authenticate with username and password. Returns a session token and the account object.
| Field | Type | Description |
|---|---|---|
username | string | Required. Account username. |
password | string | Required. Account password. |
/account/create Public
/
Admin Create a new account. In bootstrap mode (no enabled admin accounts exist), this endpoint is public. Otherwise, admin authentication is required. The first account created becomes the administrator. Password must be at least 8 characters. Email, phone, and real name are required.
| Field | Type | Description |
|---|---|---|
username | string | Required. |
password | string | Required. Minimum 8 characters. |
email | string | Required. |
phone | string | Required. |
real_name | string | Required. |
admin | boolean | Whether the account has admin privileges. |
/account Authenticated
Get a single account by username. Request body: {"username": "alice"}.
/account Authenticated List all accounts. Supports pagination parameters.
/account/update Authenticated
Update account fields. Send username to identify the account and a
fields object with any combination of password,
email, phone, real_name, and admin.
Only provided fields are changed.
/account/me Authenticated Returns the username associated with the token in the Authorization header.
/account/sessions Authenticated List all active sessions for the authenticated user. Each session includes its ID, username, creation time, and last-used time.
/account/session/revoke Authenticated
Revoke a session by ID. Request body: {"session_id": "..."}.
/account/disable Admin
Disable an account. Request body: {"username": "bob"}.
/account/enable Admin
Re-enable a disabled account. Request body: {"username": "bob"}.
Storage
/storage Authenticated
List filesystems. Accepts pagination parameters plus optional name
(prefix filter) and state (user, installed,
or uninstalled) in the request body.
/storage/create Authenticated
Create a new btrfs subvolume. Send name and optional quota
(bytes). If quota is 0 or omitted, the system default (50 GB) is used. Reserved names
(installed, uninstalled, archives) are rejected.
/storage/modify Authenticated
Modify an existing filesystem. Send name to identify it and a
filesystem object with the updated name and/or quota.
/storage/remove Authenticated
Remove a filesystem. Request body: {"name": "mydata"}.
/storage/upload-archive Admin
Upload and unpack an archive into a target subvolume. Accepts multipart/form-data
with a subvolume field and an archive file. Supports
.tar.gz, .tgz, .tar.bz2, .tbz2,
.tar.xz, .txz, .tar, .zip, and
.7z.
| Field | Type | Description |
|---|---|---|
subvolume | string | Required. Target subvolume path. |
archive | file | Required. Archive file to upload. |
subpath | string | Optional. Relative path within the volume for unpacking; created on demand. |
stop_service | string | Optional. Systemd unit name to stop before unpacking and restart after completion. |
| Setting | Default | Description |
|---|---|---|
max_archive_size | 1 GB | Maximum upload size. |
archive_unpack_timeout | 600 seconds | Maximum time for unpacking. |
/storage/download-archive Admin Download an archive of subvolume contents. Returns a streamed archive in the requested format.
| Field | Type | Description |
|---|---|---|
subvolume | string | Required. Source subvolume path. |
paths | string[] | Optional. Array of specific paths within the subvolume to include. |
stop_service | string | Optional. Systemd unit name to stop during archiving and restart after. |
format | string | Optional. Compression format: tar.gz (default), tar.bz2, or tar.xz. |
filename | string | Optional. Custom base name for the downloaded file. The server appends the appropriate extension. Defaults to download. |
/storage/package-volumes Authenticated List package volumes grouped by package, with optional inclusion of uninstalled volumes.
/storage/remove-package-volume Admin Delete a specific package volume by internal name.
/storage/remove-package-volume-group Admin Delete every volume belonging to one package in a single call, rather than removing them one internal name at a time.
Repositories
/repository Authenticated List all configured package repositories with name, URL, and any error status. Supports pagination parameters.
/repository/add Authenticated Add a new package repository. Triggers an immediate refresh.
| Field | Type | Description |
|---|---|---|
name | string | Required. Display name for the repository. |
url | string | Required. Git URL of the repository. |
username | string | Optional. Auth username for private repos. |
password | string | Optional. Auth password for private repos. |
/repository/remove Authenticated
Remove a repository by name. Triggers an immediate refresh.
Request body: {"name": "my-repo"}.
/repository/move Admin
Reorder a repository to a new zero-based position. Later repositories override earlier
ones when package names collide.
Request body: {"name": "my-repo", "position": 0}.
/repository/refresh Authenticated Force an immediate refresh of all repository metadata. Returns an empty body on success, or a JSON object mapping repository names to error strings if any fail.
Packages
/packages Authenticated List all available packages across all repositories. Each entry includes repo, name, version, description, supplies tags, installation status, and whether an upgrade is available. Supports pagination parameters.
/packages/by-repo Authenticated
List packages grouped by repository. Accepts an optional search query
parameter. Returns an array of {"repo": "...", "packages": [...]} groups.
/packages/installed Authenticated List installed package identifiers. Supports pagination parameters.
/packages/installed/info Authenticated
Get detailed info for an installed package. Send repo, name,
and version. Returns questions, user responses, notes, and note types.
/packages/responses Authenticated
Get the saved question responses for an installed package. Send repo,
name, and version. Returns a key-value map of responses.
/packages/versions Authenticated
List available versions for a package. Request body: {"name": "nginx"}.
Returns a string array of version identifiers.
/packages/children Authenticated
List child packages. Send repo and name. Returns a string array.
/packages/questions Admin
Get the installation questions for a package. Request body: {"name": "nginx"}.
Returns a map of question key to {"query": "...", "type": "..."}.
/packages/questions/identity Admin
Get questions for a specific package version. Send repo, name,
and version.
/packages/oauth/start Admin
Begin the OAuth device flow for an oauth question. Send repo,
name, version, and question. The system controller
runs the flow’s start step against the provider and returns
flow_id, approve_url (open this in the user’s browser),
an optional user_code, and interval_ms — how often to poll.
The provider’s URLs come from the package, not from Town OS, so they are checked
before they are called: https only, and never an address on the
host’s own network.
/packages/oauth/poll Admin
Poll a flow started above. Send flow_id. Returns status:
pending while the user has not approved yet, approved together
with the token, or expired once the flow has timed out or its
token has already been collected — a flow is single-use. The token is then submitted as
that question’s answer to /packages/install, exactly like a typed
response.
/packages/install-preview Admin
Preview what an installation will do before committing. Send repo,
name, and version. Returns volume details, port mappings,
disk usage, quota information, upgrade source version, and a human-readable summary.
/packages/install Admin Install a package.
| Field | Type | Description |
|---|---|---|
repo | string | Required. Repository name. |
name | string | Required. Package name. |
version | string | Required. Version to install. |
responses | object | Required. Key-value answers to installation questions. |
reuse_volumes | boolean | Reuse existing data volumes from a previous installation. |
import_from_version | string | Version to import volumes from during upgrade. |
/packages/uninstall Admin
Uninstall a package. Send repo, name, version,
and optional purge_volumes (boolean) to delete associated data.
/packages/disable Admin
Disable an installed package (stop its service). Send repo and name.
/packages/enable Admin
Re-enable a disabled package (start its service). Send repo and name.
/packages/purge-volumes Admin
Delete all data volumes for an installed package. Send repo and name.
/packages/uninstalled-volumes Admin
Check whether a package has leftover volumes from a previous installation. Send
repo and name. Returns has_uninstalled_volumes,
uninstalled_versions, and installed_versions.
/packages/purge-uninstalled-volumes Admin
Delete leftover volumes from previously uninstalled versions. Send repo
and name.
/packages/upgrades Authenticated
List available upgrades for installed packages. Each entry includes
installed_version, latest_version, and whether the package
definition has changed.
/packages/upgrades/dismiss Admin Dismiss the current upgrade notifications. Send an empty JSON object.
/packages/manifest Authenticated
Returns the raw YAML package definition. Send repo, name,
and version. Returns the file content with
Content-Type: text/x-yaml. Returns 404 if the package file does not exist.
/packages/featured Authenticated List featured packages across all repositories.
/packages/last-responses Authenticated
Retrieve cached last responses for a package. Send repo and
name. Returns the saved responses from a previous uninstall for reuse
during reinstallation.
/packages/clear-last-responses Admin
Delete the cached last responses file for a package. Send repo and
name.
/packages/rebuild-git Admin
Pull latest changes for git-seeded volumes of an installed package and restart
the dependent service. Send repo, name, and
version. Template variables are re-evaluated against saved responses
before rebuilding.
Systemd
/systemd/units Authenticated
/
Localhost List systemd units managed by Town OS. Each entry includes unit name, description, load/active/sub states, the associated package identifier and description, and a failure flag. Supports pagination parameters.
/systemd/units-tree Authenticated
/
Localhost
The same units as the flat listing, grouped into a dependency tree: root packages at
the top, dependencies nested under their parent, all the way down — the same shape
/storage/package-volumes uses. Rows carry the same status data the flat
endpoint returns, so a client does not need a second fetch to enrich them.
/systemd/status Admin
Control a systemd unit. Send name (unit name) and action
(start, stop, restart, enable,
or disable).
/systemd/status/tree Admin
Apply an action across a package and its dependency tree in one call, in dependency
order. enable and disable are rejected here for the same
reason they are on /systemd/status: cascading an enable would
double-enable dependencies that are already linked through their parent.
/systemd/logs Admin
/
Localhost
Stream journal entries for a unit in real time via Server-Sent Events. Pass the
unit query parameter; empty or __system__ returns system-wide
logs. Each SSE event contains a JSON-encoded journal entry with fields like
Message, Priority, RealtimeTimestamp, and
SystemdUnit.
/systemd/logs/tail Admin
/
Localhost Fetch a page of journal entries with cursor-based pagination and filtering.
| Parameter | Type | Description |
|---|---|---|
unit | string | Systemd unit name. Empty or __system__ for system-wide logs. |
lines | int | Number of entries to return (default 100). |
before | string | Cursor — return entries before this position. |
after | string | Cursor — return entries after this position. |
grep | string | Case-insensitive substring filter on message text. |
since | int | Unix timestamp — return entries from this time forward. |
until | int | Unix timestamp — stop collecting at this time. |
priority | int | Syslog severity filter (0 = no filter). |
Returns entries, cursor (first entry), and
end_cursor (last entry) for subsequent pagination.
/systemd/logs/tree Admin
/
Localhost
The tree equivalent of /systemd/logs: one Server-Sent Events stream
carrying the journal for a package and every unit beneath it, merged in chronological
order. An unknown root with no install record still gets an open stream with no
entries rather than a 404, so the journal viewer works the same for a single unit and
for a whole tree.
/systemd/logs/tree/tail Admin
/
Localhost
The paginated form of the merged tree journal, taking the same cursor, filter, and
time-range parameters as /systemd/logs/tail.
Settings
/settings Admin Get all settings as a key-value object.
/settings/get Admin
Get a single setting. Request body: {"key": "default_quota"}.
Returns key and value.
/settings/set Admin
Set a setting value. Request body: {"key": "default_quota", "value": "107374182400"}.
Default Settings
| Key | Default | Description |
|---|---|---|
default_quota | 53687091200 (50 GB) | Default quota for new filesystems. |
max_archive_size | 1073741824 (1 GB) | Maximum archive upload size. |
archive_unpack_timeout | 600 (seconds) | Maximum time for archive unpacking. |
locale | en-US | System-wide locale for internationalization. |
proton_image | quay.io/town/proton:latest | Proton/Wine runner container image. |
dns_tld | home | Top-level domain for local DNS resolution. |
Audit Log
/audit/log Admin List audit log entries. All fields in the request body are optional.
| Field | Type | Description |
|---|---|---|
before_id | int | Keyset pagination — return entries with ID less than this. |
account | string | Filter by account username. |
sort_by | string | Field to sort on. |
sort_order | string | asc or desc. |
limit | int | Page size. |
offset | int | Pagination offset. |
search | string | Search filter. |
Each audit entry contains id, account, action,
path, detail, success, error,
and created_at. Audited actions include: authenticate, create/update/disable
account, revoke session, install/uninstall/disable/enable package, create/modify/remove
filesystem, add/remove/move/refresh repository, upload/download archive, update setting,
dismiss upgrades, and purge volumes.
Pages
Static site hosting supporting three content source types: archive uploads, container images, and git repositories. Users assign a domain, and the system serves the content via a Caddy container. All mutation endpoints require admin authentication; the list endpoint requires regular authentication.
/pages Authenticated List all pages with sorting, search, and pagination. Sortable by name, repo URL, branch, domain, source type, status, and timestamps.
/pages/create Admin
Create a new page. Accepts name, source type (archive,
container_image, or git), repo URL, branch, domain,
container image, and image directory. Source type defaults to archive.
Git and container image pages are provisioned asynchronously.
/pages/upload Admin
Upload a tar archive of content for an archive-type page. Accepts multipart form with
name and archive file. Only valid for pages with source type
archive; returns 400 for other source types.
/pages/update Admin Partial update of a page's repo URL, branch, domain, source type, container image, or image directory. Only provided fields are changed.
/pages/remove Admin Delete a page from the database, remove the webroot symlink, and delete the btrfs subvolume.
/pages/rebuild Admin
Rebuild page content from source. Git pages pull latest changes; container image pages
re-extract from the image. Archive pages return 400 (re-upload via
/pages/upload instead).
Networks
A network is a named WireGuard overlay paired with a DNS TLD. Packages install into a network, peers join it, and the TLD is what partitions who can resolve what. Network names are DNS-label-safe and capped at 32 characters, because they are reused as WireGuard interface suffixes and systemd unit names.
The home network always exists — it is seeded with the database itself,
not created at boot — and it is special in three ways: it cannot be
removed or created a second time, it is DNS-only (no
WireGuard interface, no subnet, no peers), and peer enrollment on it is
refused with a 400. Every account belongs to the home network, so accepting
enrollment there would make membership alone a way onto a tunnel — and the stored peer
would describe a tunnel that does not exist.
Disabling a network takes down only the transport: the WireGuard interface is not brought up, which cuts remote access, while local DNS resolution and the containers themselves keep running.
/networks Authenticated List networks. Each entry carries the name, TLD, subnet, the box's own overlay address, public key, listen port, and enabled flag. The private key is never serialized.
/networks/create Admin
Create a network. The subnet is derived deterministically from a box-identity seed and
the network name, drawn from 10.64.0.0/10 to stay clear of the ranges
consumer routers hand out. Keying on box identity means two Town OS boxes both serving
peers pick distinct subnets, so a device joining both never sees a collision. Creating
home returns 409 from the TLD-collision check.
/networks/remove Admin
Remove a network. Refuses the home network.
/networks/enable Admin Bring the network's WireGuard transport up.
/networks/disable Admin Take the transport down while leaving DNS and the containers running.
Peers
/networks/peers Authenticated List the peers enrolled on a network.
/networks/peers/connected Admin List peers currently connected, as opposed to merely enrolled.
/networks/peers/add Grant
Enrol a peer. The wireguard grant is what admits a non-admin; per-network scope and
per-peer ownership are enforced by the handler. Returns 400 for the home
network, which is DNS-only.
/networks/peers/refresh Grant Renew a peer's enrollment before its TTL expires. Enrollments have a lifetime and a reaper removes the ones that lapse.
/networks/peers/remove Admin Remove a peer from a network.
The local CA
/tls/ca.crt Public Download the box's local certificate authority in PEM form. Town OS issues its own leaves for package names, so trusting this certificate is what makes those names work in a browser without warnings. It is deliberately public — a CA certificate is the part you are meant to distribute, and a client needs it before it holds any credential to authenticate with.
Object Storage
Town OS ships object storage through gfeh. A
partition is one btrfs subvolume, one gfehd process, one
admin socket, and its own set of users. There is exactly one partition
per Town OS network, so the object-storage namespace is split along the same boundary
that splits DNS and WireGuard: a principal, a grant, or an exposure in the
office partition means nothing in home.
Each partition serves four HTTP views on fixed container ports — S3 on 9000, HTTP on 9001, drive on 9002, IPFS on 9003 — and publishes no host port at all. That is what makes the fixed ports safe: every partition has its own network namespace and the ingress reaches it by container name, exactly as it reaches a package, so two partitions both serving S3 on 9000 cannot collide.
Partitions
These four routes exist separately from /storage/* because
/storage/create rewrites every submitted name to
user/<name> unconditionally and so cannot produce a volume under the
gfeh/ prefix. Their wire shapes are a published contract that gfeh's
client parses, not an internal detail.
Two details are load-bearing. The prefix is asymmetric — requests
carry a bare name, responses carry gfeh/<name>, because the prefix
is a Town OS namespace artifact rather than part of the partition's identity. And
the listing returns a bare JSON array, not a paginated envelope,
unlike every other list endpoint on this API: gfeh's client deserializes a plain list
directly and a pagination wrapper fails to decode.
| Route | Auth | Request | Response |
|---|---|---|---|
POST /gfeh/partitions/create | Admin | name (no prefix), quota | Filesystem, name gfeh/<n> |
POST /gfeh/partitions/modify | Admin | name, quota | Filesystem |
POST /gfeh/partitions/remove | Admin | name | 200, empty |
POST /gfeh/partitions | Authenticated | no body | plain array of Filesystem |
Status codes a client should branch on: 409 already exists (gfeh's
provisioning is a create-or-resize and tells the two apart by this status),
404 missing, 400 a bad name, 403 not
an administrator. A name containing a path separator is refused here because
gfehd refuses it at its own boundary — disagreeing about what a legal
partition name is would let a name like ../user/something address a volume
outside the object-storage root.
Creating a partition is admin-only and cannot be reached with a grant: it roots a permission tree and allocates a btrfs subvolume with a quota, so a grant-holding account is refused before any handler runs.
Browsing
/gfeh Authenticated The object-storage overview: which partitions exist and what state they are in.
Principals
A partition's users. Adding one takes a name, a parent, and a ceiling — and
no password, which is why the UI never asks for one. The ceiling
follows gfeh's projection rule: all for a Town OS administrator,
read/write otherwise.
/gfeh/principals Authenticated List the principals in a partition.
/gfeh/principals/add Grant Create a principal under a parent, with a ceiling.
/gfeh/principals/remove Grant Delete a principal.
Grants
The ACLs. A grant is clamped to the principal's ceiling by
gfehd, so a client should display the permissions that came
back rather than the ones it sent — an administrator has to be able to see
that a grant was narrowed.
/gfeh/grants Authenticated List grants, optionally for one principal.
/gfeh/grants/add Grant Grant a principal access. The response carries the permissions as actually stored.
/gfeh/grants/revoke Grant Revoke a grant by id.
Exposures
A published file link, served at /f/<token>.
/gfeh/exposures Authenticated List the published links in a partition.
/gfeh/exposures/withdraw Grant Withdraw a published link by token, so the URL stops resolving.
DNS
Integrated local DNS resolver powered by a rolodex-dns container. Manages
zone files and records for installed packages, providing local name resolution via a
gRPC Unix socket interface.
/dns/status Authenticated Returns DNS status including enabled flag, running state, TLD, and record count.
/dns/records Authenticated List all DNS records.
/dns/records/add Admin Add a DNS record. Accepts name, record type, value, and TTL.
/dns/records/remove Admin Remove a DNS record by name and type.
/dns/tld Authenticated Get the current top-level domain setting.
/dns/tld Admin Set the TLD. Changes the existing TLD and re-registers all installed packages.
/dns/setup Admin Initialize or restart the DNS server and register all installed packages.
Blocklists
Two independent lists. The DNSBL is subscription-based — upstream blocklists rolodex fetches and applies — with an allowlist that exempts names you want resolved regardless of what an upstream list says. The local blocklist (RBL) is the box's own list, edited entry by entry.
/dns/dnsbl Authenticated Get the DNSBL configuration: which upstream blocklists are subscribed and how they are applied.
/dns/dnsbl Admin Replace the DNSBL configuration.
/dns/dnsbl/allowlist Authenticated List the names exempted from the subscribed blocklists.
/dns/dnsbl/allowlist/add Admin Exempt a name from the subscribed blocklists.
/dns/dnsbl/allowlist/remove Admin Drop an allowlist entry, letting the subscribed blocklists apply to that name again.
/dns/rbl/local Authenticated List the box's own blocklist entries.
/dns/rbl/local/add Admin Add a name to the local blocklist.
/dns/rbl/local/remove Admin Remove a name from the local blocklist.
Per-service DNS publishing
/dns/services Authenticated List installed services along with whether each one publishes a DNS name.
/dns/services/set Admin Turn DNS publishing on or off for one service, so a package can run without claiming a name on the network.
Monitoring
Integrated Prometheus, Node Exporter, and Grafana stack for system monitoring. The
stack runs as systemd-supervised podman containers with Restart=always.
/monitoring/status Authenticated
Returns container status (name, image, running state, port) for each monitoring
service. Returns {"status": "disabled"} when monitoring
is not configured.
Reaching the dashboard data
There is no reverse proxy through the system controller. Monitoring
data is served on its own dedicated port, 5308, and the browser talks
to that port directly; the controller's own port (5309) carries only
/monitoring/status. What listens on 5308 depends on the configured
backend:
- uPlot mode (the default) — a socat forwarder exposes the Prometheus
HTTP API on 5308, and the UI queries
/api/v1/query_rangedirectly, rendering the charts itself. - Grafana mode — Grafana listens on 5308 directly through a podman port mapping, and the UI embeds it in an iframe.
TOWN_OS_MONITORING_PORT relocates the dashboard port;
TOWN_OS_PROMETHEUS_PORT and TOWN_OS_NODE_EXPORTER_PORT do
the same for the two loopback ports.
System Services
System services are systemd-managed infrastructure containers (distinct from
user-installed package services). They use the town-os-system-- unit
name prefix.
/system-services Public
/
Authenticated List system services with live unit status. Accessible from localhost without authentication. Each entry includes key, display name, image, port, and systemd unit status fields.
/system-services/status Admin
Control a system service. Accepts key and action
(start, stop, or restart).
/system-services/refresh Admin Refresh system service unit files and status.
Locales
Internationalization locale information for the system.
/locales Authenticated Returns the current locale, list of populated locales, common languages (with native-script names), and extended locales. Uses BCP 47 locale codes.
VM Images
Management of cached VM disk images used by VM packages. Remote images are downloaded
and converted to raw format via qemu-img convert; the converted image is
cached in the vm-images subvolume.
/vm-images Authenticated List cached VM disk images. Returns name and file size for each image.
/vm-images/upload Admin
Download a VM image from a URL and convert it to raw format. Accepts a URL and
optional name. The name defaults to the URL's filename with a .raw
extension. Downloads have a 30-minute timeout.
/vm-images/delete Admin Remove a cached VM image by name.
Object Storage Admin API (gfeh)
Everything above is the Town OS API, which is what an application should normally use.
Underneath it, each gfehd partition has an administrative surface of its
own: JSON over HTTP on its Unix socket only, never a port.
There is no token and no authentication on this surface. The
filesystem permissions on the socket are the access control, so being able to
reach it already means being root on the box. The socket lives on the btrfs volume
because that is the one filesystem both the gfehd container and the system
controller container can see.
| Call | Method and path | Purpose |
|---|---|---|
Health | GET /v1/health | Liveness, and the readiness probe. |
Names | GET /v1/names | The names this partition wants published. |
ListPrincipals | GET /v1/principals | The partition's user forest. |
CreatePrincipal | POST /v1/principals | Takes name, parent, ceiling — and no password. |
DeletePrincipal | DELETE /v1/principals/<name> | Remove a principal. |
ListGrants | GET /v1/grants?principal= | The ACLs, optionally for one principal. |
CreateGrant | POST /v1/grants | Grant access; clamped to the principal's ceiling. |
RevokeGrant | DELETE /v1/grants/<id> | Revoke a grant. |
ListExposures | GET /v1/exposures | Published /f/<token> links. |
WithdrawExposure | DELETE /v1/exposures/<token> | Stop serving a published link. |
gfehd maps its internal errors onto HTTP status codes — 404, 409, 400 —
and the Go client maps those back onto sentinel errors, so errors.Is works
across the socket boundary.
Where a partition's files live, for a network named <network>:
| Thing | Location |
|---|---|
| Partition data | <btrfsBase>/gfeh/<network>, mounted at /data/<network> |
| Config | <btrfsBase>/gfeh-control/<network>/gfehd.yaml |
| Admin socket | <btrfsBase>/gfeh-control/<network>/run/admin.sock |
| Unit | town-os-system--gfeh-<network>.service |
DNS gRPC API (rolodex)
The /dns/* endpoints above are the Town OS view of DNS. Rolodex itself is
managed over gRPC, exposed on a Unix socket
(/var/run/rolodex-dns.sock by default) and optionally on TCP. Out of the
box the socket is the only management path — grpc.tcp_bind is empty.
There is one service, rolodex_dns.RolodexDnsService, carrying 74 methods.
Every path is /rolodex_dns.RolodexDnsService/<Method>. The full
message definitions live in proto/rolodex_dns.proto in the rolodex-dns
repository; the groupings below are what those methods cover.
Records and resolution
| Method | Purpose |
|---|---|
AddRecord | Add a DNS record to the local database. |
RemoveRecord | Remove records from the local database. |
ListRecords | Query the local database with optional filters. |
SetForwarders | Configure the upstream forwarders. |
SetResolutionMode / GetResolutionMode | Change and read the upstream resolution mode at runtime. |
GetSearchDomains | The search domains for a client IP. |
FlushCache | Clear the DNS and blocklist caches. |
Authoritative zones
| Method | Purpose |
|---|---|
AddAuthoritativeZone | Declare a zone authoritative. |
RemoveAuthoritativeZone | Drop a zone from the authoritative list. |
ListAuthoritativeZones | List the authoritative zones. |
Network scopes
Scopes are how rolodex partitions who can resolve what, and they are what Town OS networks map onto. An IP-to-scope association carries a TTL and has to be refreshed.
| Method | Purpose |
|---|---|
CreateNetworkScope / DeleteNetworkScope / ListNetworkScopes | Manage scopes. Deleting one takes its records and associations with it. |
JoinNetwork / LeaveNetwork | Associate a client IP with a scope, or remove the association. |
GetNetworkAssociations | Read the IP-to-scope associations. |
AddScopedRecord / RemoveScopedRecord / ListScopedRecords | Records that exist only within one scope. |
Scope TLDs
Per-network owned zones, partitioned across networks.
| Method | Purpose |
|---|---|
AddScopeTld / RemoveScopeTld / ListScopeTlds | Register a globally-unique TLD as owned by a scope. |
SetScopeTldForwarders / ListScopeTldForwarders | The peer forwarders for a scope's TLD. |
ListScopeTldListeners | The ingress DNS listeners bound to a scope's TLDs. |
Blocklists
| Method | Purpose |
|---|---|
SetDnsblConfig / GetDnsblConfig | The subscription-based domain blocklist configuration. |
AddDnsblAllowlistEntry | Exempt a name and its subdomains from the name-based blocklist check. |
RemoveDnsblAllowlistEntry / ListDnsblAllowlistEntries | Manage the allowlist. |
AddLocalBlocklistEntry / RemoveLocalBlocklistEntry / ListLocalBlocklistEntries | The box's own blocklist. |
Encrypted transports
Each transport has a matching setter and getter. DoH serves HTTP/2 and, when
enable_h3 is on, HTTP/3 on the same address, port, and certificate.
| Method | Purpose |
|---|---|
SetDotConfig / GetDotConfig | DNS over TLS. |
SetDohConfig / GetDohConfig | DNS over HTTPS, including HTTP/3. |
SetDoqConfig / GetDoqConfig | DNS over QUIC. |
SetProxyConfig / GetProxyConfig | The HTTP proxy configuration. |
DNSSEC, DANE, and ACME
| Method | Purpose |
|---|---|
GenerateDnssecKey / ListDnssecKeys / DeleteDnssecKey | Per-zone DNSSEC key material. |
GetDsRecords | The DS records for a zone. |
SignZone | Sign a zone with its DNSSEC keys. |
GenerateTlsaRecord / ListTlsaRecords | TLSA records, generated from a certificate. |
GenerateDaneRootCa | Generate a DANE root CA certificate. |
EnsureZoneCa | Ensure a zone has a CA. |
RequestAcmeCert / GetAcmeStatus | Request a certificate over ACME DNS-01, and read its status. |
CreateEabCredential / RemoveEabCredential | Mint an External Account Binding (kid plus HMAC) scoped to a zone, for an ACME client's newAccount. |
ListAcmeAccounts / ListAcmeCertificates | Registered ACME accounts and issued certificates. |
DHCP
| Method | Purpose |
|---|---|
AddDhcpPool / RemoveDhcpPool / ListDhcpPools | Address pools for allocation within a scope. |
ListDhcpLeases / DeleteDhcpLease | Leases, deleted by MAC address. |
SetDhcpCertOption / RemoveDhcpCertOption / ListDhcpCertOptions | A certificate delivered to clients over DHCP for a scope. |
Diagnostics and tuning
| Method | Purpose |
|---|---|
GetCacheStats / FlushDnsCache | Cache statistics, and clearing the response cache. |
GetQueryLatencyStats | Upstream query latency. |
SetTtlDriftConfig / GetTtlDriftConfig | TTL drift configuration. |
SetTrackedTlds / ListTrackedTlds | The tracked-TLD list behind the per-TLD metrics, stored and effective. |
SetDns64Config / GetDns64Config | DNS64 configuration. |
Client Libraries
Town OS ships with Go and JavaScript client libraries that provide full API coverage. Both clients throw typed errors on non-200 responses using RFC 9457 problem detail.
Go Client
The Go client lives in src/svc/systemcontroller/client.go and implements
the Client interface. It supports both Unix socket and HTTP connections.
// Connect via Unix domain socket (production)
client := systemcontroller.InitClient("/run/town-os/systemcontroller.sock")
// Connect via HTTP (development / testing)
client := systemcontroller.FromClient(http.DefaultClient, "http://localhost:5309")
Set client.Token after authenticating. All methods accept a
context.Context as their first parameter.
Storage
| Method | Description |
|---|---|
CreateFilesystem(ctx, fs) | Create a new btrfs subvolume. |
ModifyFilesystem(ctx, name, fs) | Rename or resize a filesystem. |
RemoveFilesystem(ctx, name) | Delete a filesystem by name. |
ListFilesystems(ctx, prefix, state, params) | Paginated list filtered by name prefix and state ("user", "installed", "uninstalled"). |
Repositories
| Method | Description |
|---|---|
AddRepository(ctx, name, rawURL, username, password) | Register a package repository with optional credentials. |
RemoveRepository(ctx, name) | Remove a repository by name. |
MoveRepository(ctx, name, position) | Change priority (0 = highest). |
RefreshRepositories(ctx) | Refresh all metadata. Returns map of errors. |
ListRepositories(ctx, params) | Paginated list of repositories. |
Packages
| Method | Description |
|---|---|
ListPackages(ctx, params) | Paginated list of available packages. |
ListPackagesByRepo(ctx, params) | Packages grouped by repository. |
ListPackageVersions(ctx, name) | Available versions of a package. |
GetPackageQuestions(ctx, name) | Configuration questions by name. |
GetPackageQuestionsByIdentity(ctx, repo, name, version) | Questions for a specific version. |
ListChildren(ctx, repo, name) | Child package names. |
InstallPreview(ctx, repo, name, version) | Preview volumes and ports without installing. |
InstallPackage(ctx, name, version, responses, reuseVolumes, importFromVersion, skipResponseReuse) | Install a package. Name uses "repo/package" format. |
UninstallPackage(ctx, repo, name, version, purgeVolumes) | Remove an installed package. |
DisablePackage(ctx, repo, name) | Stop services without uninstalling. |
EnablePackage(ctx, repo, name) | Re-enable a disabled package. |
PurgeVolumes(ctx, repo, name) | Delete all data volumes for a package. |
ListUninstalledVolumes(ctx, repo, name) | Check for leftover volumes. |
PurgeUninstalledVolumes(ctx, repo, name) | Delete leftover volumes. |
ListInstalled(ctx, params) | Installed packages as "repo/name@version". |
GetResponses(ctx, repo, name, version) | Stored configuration responses. |
GetInstalledInfo(ctx, repo, name, version) | Detailed info including questions, responses, and notes. |
Systemd
| Method | Description |
|---|---|
ListUnits(ctx, params) | Paginated list of systemd units. |
SetUnitStatus(ctx, name, action) | Apply "start", "stop", or "restart". |
LogReplay(ctx, name) | Stream journal entries via SSE. Returns a channel. |
LogTail(ctx, params) | Page of journal entries with cursor-based pagination, grep, time range, and priority filtering. |
Accounts
| Method | Description |
|---|---|
Authenticate(ctx, username, password) | Returns session token and account. |
CreateAccount(ctx, username, password, email, phone, realName, admin) | Create a user. Password minimum 8 characters. |
GetAccount(ctx, username) | Retrieve account by username. |
UpdateAccount(ctx, username, fields) | Modify account fields (password, email, phone, real_name, admin). |
ListAccounts(ctx, params) | Paginated list of accounts. |
DisableAccount(ctx, username) | Prevent authentication. |
EnableAccount(ctx, username) | Re-enable a disabled account. |
ListSessions(ctx, token) | Active sessions for the token's user. |
SessionUsername(ctx, token) | Username for a session token. |
RevokeSession(ctx, sessionID) | Invalidate a session. |
Audit, Settings & Upgrades
| Method | Description |
|---|---|
ListAuditLog(ctx, opts, token) | Paginated audit log with filters. |
GetSettings(ctx) | All settings as key-value map. |
GetSetting(ctx, key) | Single setting by key. |
SetSetting(ctx, key, value) | Update a setting. |
ListUpgrades(ctx) | Packages with newer versions available. |
DismissUpgrades(ctx) | Mark pending upgrades as dismissed. |
Archives
| Method | Description |
|---|---|
UploadArchive(ctx, subvolume, archiveReader, filename, subpath, stopService) | Upload and extract an archive into a subvolume. Formats: tar.gz, tar.bz2, tar.xz. |
DownloadArchive(ctx, subvolume, paths, stopService, format) | Create an archive of subvolume contents. Returns an io.ReadCloser. |
Health
| Method | Description |
|---|---|
Ping(ctx) | Service health and summary counts. |
JavaScript Client
The JavaScript client lives in ui/src/api/ and is used by the Town OS
dashboard UI. It is built as a modular set of mixins on the
SystemControllerClient class. Non-200 responses throw ApiError
with the parsed RFC 9457 problem detail.
import SystemControllerClient from './api/client.js';
const client = new SystemControllerClient('http://localhost:5309');
// After authentication
const result = await client.authenticate('admin', 'password');
client.setToken(result.token); Storage
| Method | Description |
|---|---|
createFilesystem(fs) | Create a new btrfs subvolume. |
modifyFilesystem(name, fs) | Rename or resize a filesystem. |
removeFilesystem(name) | Delete a filesystem by name. |
listFilesystems(prefix, sortBy, sortOrder, state, limit, offset, search) | Paginated list with filtering. |
Repositories
| Method | Description |
|---|---|
addRepository(name, url, username?, password?) | Register a repository with optional credentials. |
removeRepository(name) | Remove a repository by name. |
moveRepository(name, position) | Change priority (0 = highest). |
refreshRepositories() | Refresh all metadata. Returns error map or null. |
listRepositories(sortBy, sortOrder, limit, offset, search) | Paginated list. |
Packages
| Method | Description |
|---|---|
listPackages(sortBy, sortOrder, limit, offset, search) | Paginated list of available packages. |
listPackagesByRepo(search) | Packages grouped by repository. |
listPackageVersions(name) | Available versions of a package. |
getPackageQuestions(name) | Configuration questions by name. |
getPackageQuestionsByIdentity(repo, name, version) | Questions for a specific version. |
installPreview(repo, name, version) | Preview volumes and ports without installing. |
installPackage(repo, name, version, responses, reuseVolumes?, importFromVersion?) | Install a package with configuration answers. |
uninstallPackage(repo, name, version, purgeVolumes?) | Remove an installed package. |
disablePackage(repo, name) | Stop services without uninstalling. |
enablePackage(repo, name) | Re-enable a disabled package. |
purgeVolumes(repo, name) | Delete all data volumes for a package. |
listUninstalledVolumes(repo, name) | Check for leftover volumes. |
purgeUninstalledVolumes(repo, name) | Delete leftover volumes. |
listInstalled(sortBy, sortOrder, limit, offset, search) | Installed packages as "repo/name@version". |
getResponses(repo, name, version) | Stored configuration responses. |
getInstalledInfo(repo, name, version) | Detailed info including questions, responses, and notes. |
Systemd
| Method | Description |
|---|---|
listUnits(sortBy, sortOrder, limit, offset, search) | Paginated list of systemd units. |
setUnitStatus(name, action) | Apply "start", "stop", or "restart". |
logReplay(unit) | Stream journal entries via SSE. Returns an AsyncGenerator. |
logTail(unit, lines?, before?, after?, grep?, since?, until?, priority?) | Page of journal entries with cursor-based pagination, grep, time range, and priority filtering. |
Accounts
| Method | Description |
|---|---|
authenticate(username, password) | Returns session token and account. |
createAccount(username, password, email, phone, realName, admin) | Create a user. Password minimum 8 characters. |
getAccount(username) | Retrieve account by username. |
updateAccount(username, fields) | Modify account fields. |
listAccounts(sortBy, sortOrder, limit, offset, search) | Paginated list of accounts. |
disableAccount(username) | Prevent authentication. |
enableAccount(username) | Re-enable a disabled account. |
listSessions(token) | Active sessions for the token's user. |
sessionUsername(token) | Username for a session token. |
revokeSession(sessionID) | Invalidate a session. |
Audit, Settings & Upgrades
| Method | Description |
|---|---|
listAuditLog(opts) | Paginated audit log with filters. |
getSettings() | All settings as key-value object. |
getSetting(key) | Single setting by key. |
setSetting(key, value) | Update a setting. |
listUpgrades() | Packages with newer versions available. |
dismissUpgrades() | Mark pending upgrades as dismissed. |
Archives
| Method | Description |
|---|---|
uploadArchive(subvolume, file, subpath?, stopService?) | Upload and extract an archive via FormData. Returns {needs_restart, message}. |
downloadArchive(subvolume, paths?, stopService?, format?) | Download a subvolume archive. Returns raw Response for streaming. |
Health
| Method | Description |
|---|---|
ping() | Service health and summary counts. |
Development Reference
The Town OS backend runs on port 5309 with a Vite dev server on port 5173.
Use make dev to start the full development environment.
Core Targets
| Target | Description |
|---|---|
make dev | Start the full dev environment (backend + Vite dev server). |
make dev-stop | Stop and remove the dev backend container. |
make dev-logs | Tail journalctl inside the running dev container. |
make dev-clean | Stop the container and tear down the dev btrfs volume. |
Testing Targets
| Target | Description |
|---|---|
make test | Run lint, Go unit tests, and JS unit tests. |
make test-integration | Run Go integration tests in a privileged Podman container. |
make test-ui-integration | Run Bun UI integration tests against a backend container. |
make test-full | Run all test suites in sequence. |
make auto-test | Watch for file changes and re-run tests automatically. |
Build Targets
| Target | Description |
|---|---|
make production-image | Build the production container image. |
make test-image | Build the test container image. |
make pull-images | Pull base container images from Docker Hub. |
Prerequisites
- Go 1.25+
- Bun — JavaScript runtime
- Podman — rootful, with
sudo - btrfs-progs —
mkfs.btrfs - golangci-lint
Create a .env file with repository credentials:
TOWN_OS_REPO_USERNAME=<username>
TOWN_OS_REPO_PASSWORD=<password>
After installing prerequisites, run make pull-images before any other targets.