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:

LevelDescription
PublicNo token required.
AuthenticatedAny valid session token.
AdminSession token belonging to an admin account.
GrantA 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.
LocalhostRequests 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:

ParameterTypeDescription
sort_bystringField name to sort on.
sort_orderstringasc or desc.
limitintPage size (default 20).
offsetintPagination offset.
searchstringCase-insensitive substring match across all string fields.

Response Envelope

{
  "entries":     [...],
  "has_more":    true,
  "total_pages": 5,
  "total_count": 97
}

Status

GET /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

POST /account/authenticate Public

Authenticate with username and password. Returns a session token and the account object.

FieldTypeDescription
usernamestringRequired. Account username.
passwordstringRequired. Account password.
POST /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.

FieldTypeDescription
usernamestringRequired.
passwordstringRequired. Minimum 8 characters.
emailstringRequired.
phonestringRequired.
real_namestringRequired.
adminbooleanWhether the account has admin privileges.
POST /account Authenticated

Get a single account by username. Request body: {"username": "alice"}.

GET /account Authenticated

List all accounts. Supports pagination parameters.

POST /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.

GET /account/me Authenticated

Returns the username associated with the token in the Authorization header.

GET /account/sessions Authenticated

List all active sessions for the authenticated user. Each session includes its ID, username, creation time, and last-used time.

POST /account/session/revoke Authenticated

Revoke a session by ID. Request body: {"session_id": "..."}.

POST /account/disable Admin

Disable an account. Request body: {"username": "bob"}.

POST /account/enable Admin

Re-enable a disabled account. Request body: {"username": "bob"}.

Storage

POST /storage Authenticated

List filesystems. Accepts pagination parameters plus optional name (prefix filter) and state (user, installed, or uninstalled) in the request body.

POST /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.

POST /storage/modify Authenticated

Modify an existing filesystem. Send name to identify it and a filesystem object with the updated name and/or quota.

POST /storage/remove Authenticated

Remove a filesystem. Request body: {"name": "mydata"}.

POST /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.

FieldTypeDescription
subvolumestringRequired. Target subvolume path.
archivefileRequired. Archive file to upload.
subpathstringOptional. Relative path within the volume for unpacking; created on demand.
stop_servicestringOptional. Systemd unit name to stop before unpacking and restart after completion.
SettingDefaultDescription
max_archive_size1 GBMaximum upload size.
archive_unpack_timeout600 secondsMaximum time for unpacking.
POST /storage/download-archive Admin

Download an archive of subvolume contents. Returns a streamed archive in the requested format.

FieldTypeDescription
subvolumestringRequired. Source subvolume path.
pathsstring[]Optional. Array of specific paths within the subvolume to include.
stop_servicestringOptional. Systemd unit name to stop during archiving and restart after.
formatstringOptional. Compression format: tar.gz (default), tar.bz2, or tar.xz.
filenamestringOptional. Custom base name for the downloaded file. The server appends the appropriate extension. Defaults to download.
POST /storage/package-volumes Authenticated

List package volumes grouped by package, with optional inclusion of uninstalled volumes.

POST /storage/remove-package-volume Admin

Delete a specific package volume by internal name.

POST /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

GET /repository Authenticated

List all configured package repositories with name, URL, and any error status. Supports pagination parameters.

POST /repository/add Authenticated

Add a new package repository. Triggers an immediate refresh.

FieldTypeDescription
namestringRequired. Display name for the repository.
urlstringRequired. Git URL of the repository.
usernamestringOptional. Auth username for private repos.
passwordstringOptional. Auth password for private repos.
POST /repository/remove Authenticated

Remove a repository by name. Triggers an immediate refresh. Request body: {"name": "my-repo"}.

POST /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}.

POST /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

GET /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.

GET /packages/by-repo Authenticated

List packages grouped by repository. Accepts an optional search query parameter. Returns an array of {"repo": "...", "packages": [...]} groups.

GET /packages/installed Authenticated

List installed package identifiers. Supports pagination parameters.

POST /packages/installed/info Authenticated

Get detailed info for an installed package. Send repo, name, and version. Returns questions, user responses, notes, and note types.

POST /packages/responses Authenticated

Get the saved question responses for an installed package. Send repo, name, and version. Returns a key-value map of responses.

POST /packages/versions Authenticated

List available versions for a package. Request body: {"name": "nginx"}. Returns a string array of version identifiers.

POST /packages/children Authenticated

List child packages. Send repo and name. Returns a string array.

POST /packages/questions Admin

Get the installation questions for a package. Request body: {"name": "nginx"}. Returns a map of question key to {"query": "...", "type": "..."}.

POST /packages/questions/identity Admin

Get questions for a specific package version. Send repo, name, and version.

POST /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.

POST /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.

POST /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.

POST /packages/install Admin

Install a package.

FieldTypeDescription
repostringRequired. Repository name.
namestringRequired. Package name.
versionstringRequired. Version to install.
responsesobjectRequired. Key-value answers to installation questions.
reuse_volumesbooleanReuse existing data volumes from a previous installation.
import_from_versionstringVersion to import volumes from during upgrade.
POST /packages/uninstall Admin

Uninstall a package. Send repo, name, version, and optional purge_volumes (boolean) to delete associated data.

POST /packages/disable Admin

Disable an installed package (stop its service). Send repo and name.

POST /packages/enable Admin

Re-enable a disabled package (start its service). Send repo and name.

POST /packages/purge-volumes Admin

Delete all data volumes for an installed package. Send repo and name.

POST /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.

POST /packages/purge-uninstalled-volumes Admin

Delete leftover volumes from previously uninstalled versions. Send repo and name.

GET /packages/upgrades Authenticated

List available upgrades for installed packages. Each entry includes installed_version, latest_version, and whether the package definition has changed.

POST /packages/upgrades/dismiss Admin

Dismiss the current upgrade notifications. Send an empty JSON object.

POST /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.

GET /packages/featured Authenticated

List featured packages across all repositories.

POST /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.

POST /packages/clear-last-responses Admin

Delete the cached last responses file for a package. Send repo and name.

POST /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

GET /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.

GET /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.

POST /systemd/status Admin

Control a systemd unit. Send name (unit name) and action (start, stop, restart, enable, or disable).

POST /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.

GET /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.

GET /systemd/logs/tail Admin / Localhost

Fetch a page of journal entries with cursor-based pagination and filtering.

ParameterTypeDescription
unitstringSystemd unit name. Empty or __system__ for system-wide logs.
linesintNumber of entries to return (default 100).
beforestringCursor — return entries before this position.
afterstringCursor — return entries after this position.
grepstringCase-insensitive substring filter on message text.
sinceintUnix timestamp — return entries from this time forward.
untilintUnix timestamp — stop collecting at this time.
priorityintSyslog severity filter (0 = no filter).

Returns entries, cursor (first entry), and end_cursor (last entry) for subsequent pagination.

GET /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.

GET /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

GET /settings Admin

Get all settings as a key-value object.

POST /settings/get Admin

Get a single setting. Request body: {"key": "default_quota"}. Returns key and value.

POST /settings/set Admin

Set a setting value. Request body: {"key": "default_quota", "value": "107374182400"}.

Default Settings

KeyDefaultDescription
default_quota53687091200 (50 GB)Default quota for new filesystems.
max_archive_size1073741824 (1 GB)Maximum archive upload size.
archive_unpack_timeout600 (seconds)Maximum time for archive unpacking.
localeen-USSystem-wide locale for internationalization.
proton_imagequay.io/town/proton:latestProton/Wine runner container image.
dns_tldhomeTop-level domain for local DNS resolution.

Audit Log

POST /audit/log Admin

List audit log entries. All fields in the request body are optional.

FieldTypeDescription
before_idintKeyset pagination — return entries with ID less than this.
accountstringFilter by account username.
sort_bystringField to sort on.
sort_orderstringasc or desc.
limitintPage size.
offsetintPagination offset.
searchstringSearch 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.

GET /pages Authenticated

List all pages with sorting, search, and pagination. Sortable by name, repo URL, branch, domain, source type, status, and timestamps.

POST /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.

POST /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.

POST /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.

POST /pages/remove Admin

Delete a page from the database, remove the webroot symlink, and delete the btrfs subvolume.

POST /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.

GET /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.

POST /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.

POST /networks/remove Admin

Remove a network. Refuses the home network.

POST /networks/enable Admin

Bring the network's WireGuard transport up.

POST /networks/disable Admin

Take the transport down while leaving DNS and the containers running.

Peers

GET /networks/peers Authenticated

List the peers enrolled on a network.

GET /networks/peers/connected Admin

List peers currently connected, as opposed to merely enrolled.

POST /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.

POST /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.

POST /networks/peers/remove Admin

Remove a peer from a network.

The local CA

GET /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.

RouteAuthRequestResponse
POST /gfeh/partitions/createAdminname (no prefix), quotaFilesystem, name gfeh/<n>
POST /gfeh/partitions/modifyAdminname, quotaFilesystem
POST /gfeh/partitions/removeAdminname200, empty
POST /gfeh/partitionsAuthenticatedno bodyplain 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

GET /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.

GET /gfeh/principals Authenticated

List the principals in a partition.

POST /gfeh/principals/add Grant

Create a principal under a parent, with a ceiling.

POST /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.

GET /gfeh/grants Authenticated

List grants, optionally for one principal.

POST /gfeh/grants/add Grant

Grant a principal access. The response carries the permissions as actually stored.

POST /gfeh/grants/revoke Grant

Revoke a grant by id.

Exposures

A published file link, served at /f/<token>.

GET /gfeh/exposures Authenticated

List the published links in a partition.

POST /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.

GET /dns/status Authenticated

Returns DNS status including enabled flag, running state, TLD, and record count.

GET /dns/records Authenticated

List all DNS records.

POST /dns/records/add Admin

Add a DNS record. Accepts name, record type, value, and TTL.

POST /dns/records/remove Admin

Remove a DNS record by name and type.

GET /dns/tld Authenticated

Get the current top-level domain setting.

POST /dns/tld Admin

Set the TLD. Changes the existing TLD and re-registers all installed packages.

POST /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.

GET /dns/dnsbl Authenticated

Get the DNSBL configuration: which upstream blocklists are subscribed and how they are applied.

POST /dns/dnsbl Admin

Replace the DNSBL configuration.

GET /dns/dnsbl/allowlist Authenticated

List the names exempted from the subscribed blocklists.

POST /dns/dnsbl/allowlist/add Admin

Exempt a name from the subscribed blocklists.

POST /dns/dnsbl/allowlist/remove Admin

Drop an allowlist entry, letting the subscribed blocklists apply to that name again.

GET /dns/rbl/local Authenticated

List the box's own blocklist entries.

POST /dns/rbl/local/add Admin

Add a name to the local blocklist.

POST /dns/rbl/local/remove Admin

Remove a name from the local blocklist.

Per-service DNS publishing

GET /dns/services Authenticated

List installed services along with whether each one publishes a DNS name.

POST /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.

GET /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_range directly, 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.

GET /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.

POST /system-services/status Admin

Control a system service. Accepts key and action (start, stop, or restart).

POST /system-services/refresh Admin

Refresh system service unit files and status.

Locales

Internationalization locale information for the system.

GET /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.

GET /vm-images Authenticated

List cached VM disk images. Returns name and file size for each image.

POST /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.

POST /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.

CallMethod and pathPurpose
HealthGET /v1/healthLiveness, and the readiness probe.
NamesGET /v1/namesThe names this partition wants published.
ListPrincipalsGET /v1/principalsThe partition's user forest.
CreatePrincipalPOST /v1/principalsTakes name, parent, ceiling — and no password.
DeletePrincipalDELETE /v1/principals/<name>Remove a principal.
ListGrantsGET /v1/grants?principal=The ACLs, optionally for one principal.
CreateGrantPOST /v1/grantsGrant access; clamped to the principal's ceiling.
RevokeGrantDELETE /v1/grants/<id>Revoke a grant.
ListExposuresGET /v1/exposuresPublished /f/<token> links.
WithdrawExposureDELETE /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>:

ThingLocation
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
Unittown-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

MethodPurpose
AddRecordAdd a DNS record to the local database.
RemoveRecordRemove records from the local database.
ListRecordsQuery the local database with optional filters.
SetForwardersConfigure the upstream forwarders.
SetResolutionMode / GetResolutionModeChange and read the upstream resolution mode at runtime.
GetSearchDomainsThe search domains for a client IP.
FlushCacheClear the DNS and blocklist caches.

Authoritative zones

MethodPurpose
AddAuthoritativeZoneDeclare a zone authoritative.
RemoveAuthoritativeZoneDrop a zone from the authoritative list.
ListAuthoritativeZonesList 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.

MethodPurpose
CreateNetworkScope / DeleteNetworkScope / ListNetworkScopesManage scopes. Deleting one takes its records and associations with it.
JoinNetwork / LeaveNetworkAssociate a client IP with a scope, or remove the association.
GetNetworkAssociationsRead the IP-to-scope associations.
AddScopedRecord / RemoveScopedRecord / ListScopedRecordsRecords that exist only within one scope.

Scope TLDs

Per-network owned zones, partitioned across networks.

MethodPurpose
AddScopeTld / RemoveScopeTld / ListScopeTldsRegister a globally-unique TLD as owned by a scope.
SetScopeTldForwarders / ListScopeTldForwardersThe peer forwarders for a scope's TLD.
ListScopeTldListenersThe ingress DNS listeners bound to a scope's TLDs.

Blocklists

MethodPurpose
SetDnsblConfig / GetDnsblConfigThe subscription-based domain blocklist configuration.
AddDnsblAllowlistEntryExempt a name and its subdomains from the name-based blocklist check.
RemoveDnsblAllowlistEntry / ListDnsblAllowlistEntriesManage the allowlist.
AddLocalBlocklistEntry / RemoveLocalBlocklistEntry / ListLocalBlocklistEntriesThe 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.

MethodPurpose
SetDotConfig / GetDotConfigDNS over TLS.
SetDohConfig / GetDohConfigDNS over HTTPS, including HTTP/3.
SetDoqConfig / GetDoqConfigDNS over QUIC.
SetProxyConfig / GetProxyConfigThe HTTP proxy configuration.

DNSSEC, DANE, and ACME

MethodPurpose
GenerateDnssecKey / ListDnssecKeys / DeleteDnssecKeyPer-zone DNSSEC key material.
GetDsRecordsThe DS records for a zone.
SignZoneSign a zone with its DNSSEC keys.
GenerateTlsaRecord / ListTlsaRecordsTLSA records, generated from a certificate.
GenerateDaneRootCaGenerate a DANE root CA certificate.
EnsureZoneCaEnsure a zone has a CA.
RequestAcmeCert / GetAcmeStatusRequest a certificate over ACME DNS-01, and read its status.
CreateEabCredential / RemoveEabCredentialMint an External Account Binding (kid plus HMAC) scoped to a zone, for an ACME client's newAccount.
ListAcmeAccounts / ListAcmeCertificatesRegistered ACME accounts and issued certificates.

DHCP

MethodPurpose
AddDhcpPool / RemoveDhcpPool / ListDhcpPoolsAddress pools for allocation within a scope.
ListDhcpLeases / DeleteDhcpLeaseLeases, deleted by MAC address.
SetDhcpCertOption / RemoveDhcpCertOption / ListDhcpCertOptionsA certificate delivered to clients over DHCP for a scope.

Diagnostics and tuning

MethodPurpose
GetCacheStats / FlushDnsCacheCache statistics, and clearing the response cache.
GetQueryLatencyStatsUpstream query latency.
SetTtlDriftConfig / GetTtlDriftConfigTTL drift configuration.
SetTrackedTlds / ListTrackedTldsThe tracked-TLD list behind the per-TLD metrics, stored and effective.
SetDns64Config / GetDns64ConfigDNS64 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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

TargetDescription
make devStart the full dev environment (backend + Vite dev server).
make dev-stopStop and remove the dev backend container.
make dev-logsTail journalctl inside the running dev container.
make dev-cleanStop the container and tear down the dev btrfs volume.

Testing Targets

TargetDescription
make testRun lint, Go unit tests, and JS unit tests.
make test-integrationRun Go integration tests in a privileged Podman container.
make test-ui-integrationRun Bun UI integration tests against a backend container.
make test-fullRun all test suites in sequence.
make auto-testWatch for file changes and re-run tests automatically.

Build Targets

TargetDescription
make production-imageBuild the production container image.
make test-imageBuild the test container image.
make pull-imagesPull base container images from Docker Hub.

Prerequisites

  • Go 1.25+
  • Bun — JavaScript runtime
  • Podman — rootful, with sudo
  • btrfs-progsmkfs.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.