We've finally reached our first beta. Please ensure you are using our DNS server.

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 three auth levels:

LevelDescription
PublicNo token required.
AuthenticatedAny valid session token.
AdminSession token belonging to an admin account.

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.

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/timezones Authenticated

List all available IANA timezone names for use in package configuration.

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

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.

POST /systemd/status Admin

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

GET /systemd/logs Authenticated

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 Authenticated

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.

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).

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.

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.

GET /monitoring/grafana/* Public

Reverse proxy to the local Grafana instance, stripping the /monitoring/grafana prefix from the URL path. Returns 503 if monitoring is not configured. This endpoint bypasses authentication.

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.

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.
ListTimezones(ctx)Available IANA timezone names.

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.