systemd-gateway is a Go/Gin HTTP API for inspecting and controlling systemd units remotely. It uses github.com/coreos/go-systemd/v22/dbus to talk to systemd over D-Bus.
⚠️ Warning: this project exposes service-control operations over HTTP. It is currently an early prototype. Do not expose it to untrusted networks until authentication, authorization, unit allowlisting, request timeouts, and a privilege model are implemented. Anyone who can reach an unprotected instance may be able to inspect host services, start/stop/restart/kill units, change unit-file state, trigger denial of service, or use the service's systemd privileges as a path to broader host impact.
The API provides basic systemctl-style functionality over HTTP:
- list units and unit files
- inspect unit status and properties
- start, stop, restart, reload, and kill units
- reset failed units
- inspect dependencies and reverse dependencies
- enable, disable, mask, unmask, and link unit files
- reload systemd manager configuration
- list systemd jobs and inspect a job by ID
- choose system or user systemd scope with
scope=system|user
This is an early implementation. Many endpoints are functional, but the project still needs hardening before real remote deployment.
Not implemented yet:
- authentication
- unit allowlisting
- configuration loading
- request timeouts
- stable response structs for every endpoint
- job cancellation
- production privilege/polkit guidance
- Linux with systemd
- reachable system or user D-Bus/systemd manager
- Go
1.26.2or compatible - appropriate privileges or polkit rules for mutating operations
Read-only endpoints may work as an unprivileged user depending on host configuration. Mutating endpoints usually require elevated privileges or polkit authorization.
From the repository root:
go run .By default, the HTTP server listens on 127.0.0.1:8080.
You can choose another localhost port with PORT:
PORT=19080 go run .To bind a different address, set ADDR to the full listen address:
ADDR=0.0.0.0:8080 go run .Be careful with non-localhost addresses. Binding to 0.0.0.0 or a public interface makes the API reachable by other machines; without an external access-control layer, remote users can invoke the same systemd operations that the service process is allowed to perform.
Health check:
curl localhost:8080/healthExpected response: empty 200 OK.
systemd-gateway sends systemd service notifications with github.com/coreos/go-systemd/v22/daemon when NOTIFY_SOCKET is available:
READY=1after the HTTP socket is successfully boundWATCHDOG=1pings whenWatchdogSec=is configured by systemdSTOPPING=1during graceful shutdown
Example unit files are provided in systemd/:
systemd/systemd-gateway.servicefor a system service installed at/usr/local/bin/systemd-gatewaysystemd/systemd-gateway-user.servicefor a user service installed at~/.local/bin/systemd-gateway
The system service runs without a User= setting so it can inspect and control the system manager. Treat that as privileged access to the host. The user service talks to the user's systemd manager by default only when requests use scope=user; system-scope mutating operations usually need root or polkit authorization.
go build -o systemd-gateway .sudo install -D -m 0755 systemd-gateway /usr/local/bin/systemd-gateway
sudo install -D -m 0644 systemd/systemd-gateway.service /etc/systemd/system/systemd-gateway.service
sudo systemctl daemon-reload
sudo systemctl enable --now systemd-gateway.serviceCheck status and logs:
systemctl status systemd-gateway.service
journalctl -u systemd-gateway.service -f
curl localhost:8080/healthTo change the bind address or port, edit the unit or create an override:
sudo systemctl edit systemd-gateway.service[Service]
Environment=ADDR=127.0.0.1:19080Then restart:
sudo systemctl daemon-reload
sudo systemctl restart systemd-gateway.serviceinstall -D -m 0755 systemd-gateway ~/.local/bin/systemd-gateway
install -D -m 0644 systemd/systemd-gateway-user.service ~/.config/systemd/user/systemd-gateway.service
systemctl --user daemon-reload
systemctl --user enable --now systemd-gateway.serviceCheck status and logs:
systemctl --user status systemd-gateway.service
journalctl --user -u systemd-gateway.service -f
curl localhost:8080/healthTo keep the user service running after logout, enable lingering:
loginctl enable-linger "$USER"The supplied units use Type=notify so systemd waits for the readiness notification:
[Unit]
Description=systemd-gateway systemd control API
[Service]
Type=notify
ExecStart=/usr/local/bin/systemd-gateway
Environment=ADDR=127.0.0.1:8080
Restart=on-failure
WatchdogSec=30s
[Install]
WantedBy=multi-user.targetWatchdogSec= is optional. If omitted, watchdog pings are disabled automatically.
Most endpoints accept an optional scope query parameter:
scope=system
scope=user
Default:
scope=system
Examples:
curl 'localhost:8080/systemd/units?scope=system'
curl 'localhost:8080/systemd/units?scope=user'Internally:
scope=systemusesNewSystemConnectionContextscope=userusesNewUserConnectionContext
User scope may require a running user systemd manager and a reachable user/session bus.
GET /health
GET /systemd/state
POST /systemd/daemon-reload
GET /systemd/state is equivalent-ish to:
systemctl is-system-runningPOST /systemd/daemon-reload is equivalent to:
systemctl daemon-reloadGET /systemd/units
GET /systemd/units/:name
GET /systemd/units/:name/properties
GET /systemd/units/:name/properties/:property
GET /systemd/units/:name/dependencies
GET /systemd/units/:name/reverse-dependencies
GET /systemd/units/:name/cat
Examples:
curl localhost:8080/systemd/units
curl localhost:8080/systemd/units/nginx.service
curl localhost:8080/systemd/units/nginx.service/properties/ActiveState
curl localhost:8080/systemd/units/nginx.service/dependencies
curl localhost:8080/systemd/units/nginx.service/reverse-dependencies
curl localhost:8080/systemd/units/nginx.service/catGET /systemd/units/:name/cat is similar to systemctl cat. It reads the unit file paths reported by systemd properties such as FragmentPath, SourcePath, and DropInPaths, then returns each file as a fragment:
{
"unit": "nginx.service",
"fragments": [
{
"path": "/usr/lib/systemd/system/nginx.service",
"contents": "[Unit]\nDescription=..."
}
]
}/systemd/units supports state, pattern, and type filters:
GET /systemd/units?state=active
GET /systemd/units?state=failed
GET /systemd/units?pattern=*.service
GET /systemd/units?pattern=nginx*
GET /systemd/units?type=service
Filters may be repeated or comma-separated:
GET /systemd/units?state=active,failed
GET /systemd/units?state=active&state=failed
GET /systemd/units?pattern=nginx*&pattern=ssh*
type=service is translated to pattern=*.service.
POST /systemd/units/:name/start
POST /systemd/units/:name/stop
POST /systemd/units/:name/restart
POST /systemd/units/:name/reload
POST /systemd/units/:name/try-restart
POST /systemd/units/:name/reload-or-restart
POST /systemd/units/:name/reload-or-try-restart
POST /systemd/units/:name/reset-failed
POST /systemd/units/:name/kill
Examples:
curl -X POST localhost:8080/systemd/units/nginx.service/restart
curl -X POST localhost:8080/systemd/units/nginx.service/reset-failedJob-based actions support a mode query parameter:
curl -X POST 'localhost:8080/systemd/units/nginx.service/restart?mode=replace'Supported modes:
replace
fail
isolate
ignore-dependencies
ignore-requirements
Default:
mode=replace
POST /systemd/units/:name/kill supports:
target=all|main|control
signal=<number>
Defaults:
target=all
signal=15
Examples:
curl -X POST 'localhost:8080/systemd/units/nginx.service/kill'
curl -X POST 'localhost:8080/systemd/units/nginx.service/kill?target=main&signal=15'
curl -X POST 'localhost:8080/systemd/units/nginx.service/kill?signal=9'GET /systemd/unit-files
GET /systemd/unit-files?state=enabled
GET /systemd/unit-files?pattern=*.timer
GET /systemd/unit-files?type=service
/systemd/unit-files supports the same repeated or comma-separated state, pattern, and type filter style as /systemd/units.
Unit file operations:
POST /systemd/unit-files/enable
POST /systemd/unit-files/disable
POST /systemd/unit-files/mask
POST /systemd/unit-files/unmask
POST /systemd/unit-files/link
Request body:
{
"files": ["nginx.service"],
"runtime": false,
"force": false
}Examples:
curl -X POST localhost:8080/systemd/unit-files/enable \
-H 'Content-Type: application/json' \
-d '{"files":["nginx.service"],"runtime":false,"force":false}'
curl -X POST localhost:8080/systemd/unit-files/disable \
-H 'Content-Type: application/json' \
-d '{"files":["nginx.service"],"runtime":false}'GET /systemd/jobs
GET /systemd/jobs/:id
Examples:
curl localhost:8080/systemd/jobs
curl localhost:8080/systemd/jobs/123Job cancellation is not implemented yet. go-systemd/v22 v22.7.0 does not expose a CancelJob helper, so DELETE /systemd/jobs/:id would require upstream support or a small raw D-Bus call.
Errors are returned as JSON:
{
"error": "human-readable error message"
}When the error comes from D-Bus, the raw D-Bus error name is included:
{
"error": "Access denied",
"dbus_error": "org.freedesktop.DBus.Error.AccessDenied"
}HTTP status mapping aims to follow this shape:
400 invalid request input
403 permission/auth failure
404 missing unit/job/object
409 conflict-style systemd state
422 valid request, but systemd could not complete the operation
503 systemd/D-Bus/transport unavailable
500 unexpected fallback error
This project can control host services. Treat it as sensitive infrastructure.
The default bind address is 127.0.0.1 to reduce accidental exposure. If you override ADDR to bind beyond localhost, put the service behind a trusted control plane such as a VPN, SSH tunnel, firewall allowlist, or authenticated reverse proxy. Do not rely on obscurity, port choice, or systemd/polkit prompts as the only protection for a remotely reachable HTTP API.
Exposure risk depends on the privileges of the service process and host policy. A reachable unprotected instance may allow callers to:
- enumerate services, jobs, dependencies, and unit-file contents;
- start, stop, restart, reload, kill, mask, unmask, enable, or disable units;
- disrupt availability by stopping critical services or exhausting queued jobs;
- trigger privileged systemd actions when the service runs as root or has permissive polkit rules.
Current limitations:
- no authentication
- no authorization
- no unit allowlist
- no request timeout policy
- no TLS handling
- no production privilege/polkit policy
Until those are implemented, keep the default localhost bind or run it only in trusted environments, such as behind a VPN, SSH tunnel, or authenticated reverse proxy. Do not expose it directly to the public internet.
Recommended hardening priorities:
- Add configuration loading.
- Add a unit allowlist.
- Add API token authentication.
- Add request timeouts.
- Define a privilege/polkit model.
Run tests:
go test ./...Format Go files:
gofmt -w *.goRun on a temporary port:
PORT=19080 go run .Run on a custom bind address:
ADDR=127.0.0.1:19080 go run .Check route registration without leaving the server running:
timeout 3s env PORT=19080 go run .