5 Commits
Author SHA1 Message Date
svesnav ad9c632a9d Update README.md 2026-07-30 20:07:36 +03:00
svesnav 3ad4854189 Update README.md 2026-07-30 20:02:59 +03:00
svesnav 45a012a6e4 Update README.md 2026-07-30 19:58:56 +03:00
svesnav 8ae2364cc0 README: screenshots, features, requirements and configuration
The public page was install instructions and little else. It now shows what
Runix actually looks like and says what it needs, so someone can decide
whether to run the install command before running it.
2026-07-27 13:27:24 +03:00
svesnav 0ae771786a README: install and update from object storage 2026-07-27 13:04:23 +03:00
12 changed files with 352 additions and 61 deletions
+293 -43
View File
@@ -1,44 +1,114 @@
<div align="center">
# Runix
Runix is an infrastructure management platform: one control plane manages a
fleet of hosts through lightweight agents that dial out over WebSocket
(NAT-friendly — nothing connects *in* to your servers). Manage Docker,
Compose, systemd units and native daemons; browse and edit files; open
terminals and consoles; schedule tasks; and update the whole fleet from the
UI.
**One console for your whole fleet.**
Containers, system services and custom daemons — managed the same way, on every host.
This repository holds the **release binaries and the installer**. The source
lives in a separate repository.
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Frunix-releases.s3-alpha-web.astra-dev.online%2Flatest.json&query=%24.version&label=release&color=3fb950" alt="Latest release">
<img src="https://img.shields.io/badge/platforms-linux%20amd64%20%7C%20arm64-555" alt="Platforms">
<img src="https://img.shields.io/badge/install-one%20command-3fb950" alt="One-command install">
<img src="https://img.shields.io/badge/dependencies-none-blue" alt="No runtime dependencies">
<br>
<img src="docs/images/dashboard.png" alt="Runix dashboard" width="100%">
</div>
---
This repository publishes the **release binaries and the installer**. The source
lives in a separate, private repository.
## What it is
Runix manages servers the way you'd want to: you say *restart that thing*, and
it doesn't matter whether "that thing" is a Docker container, a Compose
project, a systemd unit or a plain process you wrote yourself.
Everything is a **Runtime** with the same lifecycle — start, stop, restart,
logs, metrics, a shell — so the interface stays the same as your stack changes.
It ships as **two static binaries with no runtime dependencies**. The control
plane serves its own web UI from inside the executable; agents dial *out* to it
over WebSocket, so managed hosts need no inbound ports, no VPN and no public IP.
## Install
One command sets up a host. Run it as root; it asks what the host should be
(control plane, agent, or both), checks prerequisites, installs under
`/opt/runix`, and wires up systemd.
One command. It asks what the host should be, then does the rest:
```sh
curl -fsSL https://vcs.astra-dev.online/svesnav/runix/raw/branch/main/install.sh | sudo sh
curl -fsSL https://vcs.astra-dev.online/svesnav/runix/releases/download/latest/install.sh -o install.sh
chmod +x install.sh
./install.sh
```
Prefer to pin a version, or your instance still requires sign-in to read raw
files? Use the release asset instead — it downloads without a login:
```sh
curl -fsSL https://vcs.astra-dev.online/svesnav/runix/releases/download/v0.5.0/install.sh | sudo sh -s -- --role all-in-one
```
What should this host run?
1) Control plane + agent (single-host install)
2) Control plane only
3) Agent only — join a control plane running elsewhere
```
Pick **1** and you get a working system from an empty machine: it provisions
PostgreSQL in Docker, starts the control plane, registers the host with it, and
installs the agent with a token it mints itself. Then open the URL it prints
and log in.
Downloads come from object storage and need no account, no token and no login.
Every binary is checked against the release's `SHA256SUMS` before it is
installed — a mismatch aborts.
### Roles
| Command | What it installs |
|---|---|
| `sudo sh install.sh --role all-in-one` | Control plane **and** an agent on this host (single-host setup) |
| `sudo sh install.sh --role server` | Control plane only |
| `sudo sh install.sh --role agent --url https://runix.example.com --token <token>` | An agent that joins an existing control plane |
| `install.sh --role all-in-one` | Control plane **and** an agent on this host |
| `install.sh --role server` | Control plane only |
| `install.sh --role agent --url https://runix.example.com --token <token>` | An agent joining an existing control plane |
The all-in-one and server roles can provision PostgreSQL in Docker for you
(`--db docker`) or use one you already run (`--dsn postgres://…`). Add `-y`
for an unattended install that takes sensible defaults. `sh install.sh
--help` lists every flag.
<details>
<summary><b>Unattended installs and options</b></summary>
<br>
Every question is also a flag, so the same script works from Ansible or CI:
```sh
# Single-host install, no questions asked
curl -fsSL https://vcs.astra-dev.online/svesnav/runix/releases/download/latest/install.sh -o install.sh
chmod +x install.sh
./install.sh --role all-in-one --yes
# Agent joining an existing control plane
curl -fsSL https://vcs.astra-dev.online/svesnav/runix/releases/download/latest/install.sh -o install.sh
chmod +x install.sh
./install.sh --role agent --url https://runix.example.com --token rnx_agt_...
# Behind a reverse proxy: listen on loopback only
curl -fsSL https://vcs.astra-dev.online/svesnav/runix/releases/download/latest/install.sh -o install.sh
chmod +x install.sh
./install.sh --role server --bind 127.0.0.1 --public-url https://runix.example.com -y
```
`-y` takes the recommended default for anything you didn't pass. Values with no
safe default — the role, an agent's URL and token — fail loudly instead of
being guessed.
| Flag | Purpose |
|---|---|
| `--db docker` / `--dsn` | Provision PostgreSQL in Docker, or use one you already run |
| `--bind`, `--port` | The IP and port the control plane listens on |
| `--public-url` | Where browsers reach the UI (sets the CORS allow-list) |
| `--version` | Install a specific release instead of the latest |
| `--s3`, `--gitea` | Where to download from |
| `--prefix` | Install root (default `/opt/runix`) |
| `--no-start` | Configure everything, start nothing |
`install.sh --help` lists them all.
</details>
Everything lives under one directory:
@@ -49,40 +119,220 @@ Everything lives under one directory:
/opt/runix/agent/ supervised daemon state (agent)
```
## Update
## Updating
Re-running the installer upgrades in place — binaries are replaced and
services restarted, while your configuration (above all the JWT and
encryption secrets) is preserved:
Re-running the installer upgrades in place. Binaries are replaced and services
restarted, while your configuration — above all the JWT and encryption secrets
— is preserved:
```sh
curl -fsSL https://vcs.astra-dev.online/svesnav/runix/raw/branch/main/install.sh | sudo sh -- -y
curl -fsSL https://vcs.astra-dev.online/svesnav/runix/releases/download/latest/install.sh -o install.sh
chmod +x install.sh
./install.sh -y
```
Or update from the UI: an administrator sees an **Updates** page that checks
this repository for the latest release and updates the control plane and each
agent with one click. Point a control plane at this repository with:
Configuration files are edited a key at a time rather than rewritten, so
anything you added by hand survives, and an upgrade with no flags leaves them
byte for byte identical.
Or update from the UI: an administrator gets an **Updates** page that checks
for the latest release and moves the control plane and each agent to it with
one click. Point a control plane at this release channel with:
```
RUNIX_UPDATE_API_BASE=https://vcs.astra-dev.online/api/v1
RUNIX_UPDATE_REPO=svesnav/runix
RUNIX_UPDATE_MANIFEST=https://runix-releases.s3-alpha-web.astra-dev.online/latest.json
```
## What you get
### Every workload, one interface
Native daemons, Docker containers, Compose projects and systemd units, each in
its own tab, all with the same actions. Runix supervises native daemons itself
— restart policies, backoff, log capture — so a plain binary gets the same
treatment as a container.
<img src="docs/images/runtimes.png" alt="Runtimes" width="100%">
### A console that actually takes input
Not a log tail, and not a shell *next to* your process — the process's own
stdin. Type `stop` into a game server and the game server sees it. Copy and
paste work like they do in a terminal.
<img src="docs/images/console.png" alt="Interactive runtime console" width="100%">
### Per-host metrics and inventory
CPU, memory, disk and load, live and historical, plus the hardware and OS
details the agent reports on every heartbeat.
<img src="docs/images/server-overview.png" alt="Server overview" width="100%">
### A real file manager
Browse, edit with syntax highlighting, upload by drag-and-drop, download whole
directories as `tar.gz`, create archives, change permissions — with tabs, a
right-click context menu, sorting, search and multi-select. Transfers stream
frame by frame, so a large file is never buffered in the control plane's
memory.
<img src="docs/images/files.png" alt="File manager" width="100%">
### Terminals, into the host or into a container
A real PTY on the host, or a shell inside a running container, over the same
outbound connection the agent already holds open. Sessions survive a page
reload.
<img src="docs/images/terminal.png" alt="Host terminal" width="100%">
### Docker, beyond containers
Images, volumes and networks with disk usage and pruning — because they aren't
runtimes and pretending otherwise would have bent the model out of shape.
<img src="docs/images/docker.png" alt="Docker resources" width="100%">
### Scheduling, with a cron engine of its own
Restart something nightly, run a command weekly. Multiple control-plane
instances can share a database without ever double-running a task.
<img src="docs/images/schedule.png" alt="Scheduled tasks" width="100%">
### Access control down to a single container
Grant a user, a role or a group access to the whole fleet, one server, or one
specific runtime on one specific host — including file access scoped to just
that runtime's directory. Everything is a dropdown; nothing asks you to paste
a UUID.
<img src="docs/images/grants.png" alt="Permission grants" width="100%">
### Audit log
Who did what, to which resource, from which IP — recorded for every mutating
action.
<img src="docs/images/audit.png" alt="Audit log" width="100%">
### And the rest
- **Auth** — argon2id, JWT with rotating refresh tokens and reuse detection,
TOTP MFA with recovery codes, personal access tokens
- **Backup/restore** of configuration, deliberately without secrets
- **Self-update** for the control plane and agents, refusing any download
without a matching checksum
- **Plugins** — external processes speaking line-delimited JSON on stdio can
register entirely new runtime types
- **English and Russian** UI
## How it works
```
Browser ──────► runix-server ──────► PostgreSQL
(control plane) Redis (optional, multi-instance)
▲
│ agents dial OUT over WebSocket
│ (one connection: RPC + byte streams)
┌─────────────┼─────────────┐
│ │ │
runix-agent runix-agent runix-agent
docker systemd daemons
```
- **Agents connect outbound.** Managed hosts need no open ports. One connection
multiplexes correlated RPC calls and byte streams (logs, terminals, file
transfers) at once.
- **The runtime abstraction is small on purpose.** A core interface every
provider implements, plus optional capability interfaces it opts into. The
capability set travels to the UI, which renders only the actions that exist —
so no button is ever a "not supported" error waiting to happen.
## Requirements
| | |
|---|---|
| **OS** | Linux, `amd64` or `arm64` |
| **Install** | root (the installer writes to `/opt` and `/etc/systemd/system`) |
| **Service manager** | systemd (optional — without it the installer prints how to run the binaries) |
| **Database** | PostgreSQL 17, provisioned in Docker for you or one you already run (control plane only) |
| **Docker** | Optional. Needed to provision PostgreSQL and to manage Docker runtimes |
Agents have no dependencies at all: one static binary.
## Configuration
Both binaries are configured by environment, read from `/opt/runix/etc/*.env`.
<details>
<summary><b><code>runix-server</code> — the essentials</b></summary>
<br>
| Variable | Default | Description |
|---|---|---|
| `RUNIX_HTTP_ADDR` | `:8080` | Listen address; `127.0.0.1:8080` to sit behind a proxy |
| `RUNIX_DATABASE_DSN` | — | PostgreSQL connection string (required) |
| `RUNIX_JWT_SECRET` | — | Signing key for sessions (required; min 32 chars) |
| `RUNIX_ENCRYPTION_KEY` | — | Encrypts stored secrets such as TOTP (required) |
| `RUNIX_CORS_ORIGINS` | — | Browser origins allowed for API + WebSocket |
| `RUNIX_UPDATE_MANIFEST` | this release channel | Where the Updates page looks for new versions |
| `RUNIX_REDIS_ADDR` | — | Optional; shares events across control-plane instances |
The installer generates the secrets and preserves them across upgrades —
rotating them would invalidate every session and make stored MFA secrets
unreadable.
</details>
<details>
<summary><b><code>runix-agent</code></b></summary>
<br>
| Variable | Default | Description |
|---|---|---|
| `RUNIX_AGENT_SERVER_URL` | — | Control-plane URL to dial (required) |
| `RUNIX_AGENT_TOKEN` | — | Enrollment token, minted by the control plane (required) |
| `RUNIX_AGENT_DATA_DIR` | `/opt/runix/agent` | Where supervised daemon state lives |
| `RUNIX_AGENT_SHELL` | login shell | Shell used for host terminals |
</details>
## Releases
Each release publishes, for `linux/amd64` and `linux/arm64`:
- `runix-server_linux_<arch>` — the control plane
- `runix-agent_linux_<arch>` — the agent
- `install.sh` — this installer
- `SHA256SUMS` — checksums; the installer verifies every download against it
| File | |
|---|---|
| `runix-server_linux_<arch>` | The control plane, with the web UI embedded |
| `runix-agent_linux_<arch>` | The agent |
| `install.sh` | The installer |
| `SHA256SUMS` | Checksums for everything above |
Binaries are built automatically from source on tag by the CI runner and
published here and to the source repository.
Built automatically from source by CI on every version tag, and published here
and to object storage. `latest.json` names the newest version and each asset's
checksum — it is what the installer and the in-app updater read, and it is
written last so a half-finished upload is never advertised.
## Verify a download by hand
### Verify a download by hand
```sh
curl -fsSLO https://vcs.astra-dev.online/svesnav/runix/releases/download/v0.5.0/runix-server_linux_amd64
curl -fsSL https://vcs.astra-dev.online/svesnav/runix/releases/download/v0.5.0/SHA256SUMS | sha256sum -c --ignore-missing
BASE=https://runix-releases.s3-alpha-web.astra-dev.online
curl -fsSLO $BASE/v0.5.2/runix-server_linux_amd64
curl -fsSL $BASE/v0.5.2/SHA256SUMS | sha256sum -c --ignore-missing
```
## Security notes
- Agents dial **out**; nothing connects in to a managed host.
- Every downloaded binary is verified against a published SHA-256 before it
replaces anything. An unverified download is refused, not warned about.
- `/opt/runix/etc/*.env` holds the secrets and is written `0600`.
- Access is deny-by-default: a permission can be granted globally, per server
group, per server, or on a single runtime, and the UI only renders controls
the account can actually use.
- Runix runs workloads you define, as the user you configure. Treat access to
the control plane as equivalent to shell on every managed host.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+59 -18
View File
@@ -1,7 +1,7 @@
#!/bin/sh
# Runix installer — the one command that sets a host up.
#
# curl -fsSL https://vcs.astra-dev.online/svesnav/runix/raw/branch/main/install.sh | sudo sh
# curl -fsSL https://runix-releases.s3-alpha-web.astra-dev.online/install.sh | sudo sh
#
# Asks what this host should be (control plane, agent, or both), checks
# the prerequisites, installs under /opt/runix and wires up systemd.
@@ -19,9 +19,15 @@
set -eu
PREFIX=${RUNIX_PREFIX:-/opt/runix}
# Releases are served from a Gitea instance by default. Point GITEA at a
# GitHub-style host by clearing it (RUNIX_GITEA=) and setting a download
# base, if you mirror the release elsewhere.
# Releases are served from object storage by default: it needs no account,
# so a plain `curl … | sh` works on a fresh box. The layout is
#
# <base>/latest.json names the newest version
# <base>/<version>/<asset> the binaries, install.sh and SHA256SUMS
#
# Clear it (RUNIX_S3=) to fall back to the git forge below.
S3_BASE=${RUNIX_S3:-https://runix-releases.s3-alpha-web.astra-dev.online}
# The forge is the fallback source, used when S3_BASE is empty.
GITEA=${RUNIX_GITEA:-https://vcs.astra-dev.online}
REPO=${RUNIX_REPO:-svesnav/runix}
VERSION=${RUNIX_VERSION:-latest}
@@ -86,8 +92,9 @@ Options:
--server-binary PATH Install a local control-plane build
--agent-binary PATH Install a local agent build
--version VERSION Release to install (default: latest)
--repo OWNER/NAME Release repository (default: $REPO)
--gitea URL Gitea instance serving releases (default: $GITEA)
--s3 URL Object store serving releases (default: $S3_BASE)
--repo OWNER/NAME Release repository, when not using --s3
--gitea URL Gitea instance serving releases (fallback source)
--repo-token TOK Read token, if the instance requires sign-in
--prefix PATH Install root (default: $PREFIX)
--no-start Install and configure, but do not start services
@@ -95,7 +102,7 @@ Options:
-h, --help Show this help
Environment equivalents: RUNIX_PREFIX, RUNIX_VERSION, RUNIX_REPO,
RUNIX_GITEA, RUNIX_TOKEN, RUNIX_DATABASE_DSN, RUNIX_ADMIN_PASSWORD,
RUNIX_S3, RUNIX_GITEA, RUNIX_TOKEN, RUNIX_DATABASE_DSN, RUNIX_ADMIN_PASSWORD,
RUNIX_HTTP_HOST, RUNIX_AGENT_SERVER_URL, RUNIX_AGENT_TOKEN,
RUNIX_POSTGRES_PORT.
@@ -125,7 +132,8 @@ while [ $# -gt 0 ]; do
--agent-binary) AGENT_BIN="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--gitea) GITEA="$2"; shift 2 ;;
--s3) S3_BASE="$2"; shift 2 ;;
--gitea) GITEA="$2"; S3_BASE=""; shift 2 ;;
--repo-token|--github-token) DL_TOKEN="$2"; shift 2 ;;
--prefix) PREFIX="$2"; shift 2 ;;
--no-start) NO_START=1; shift ;;
@@ -136,7 +144,9 @@ while [ $# -gt 0 ]; do
done
if [ -z "$DOWNLOAD_BASE" ]; then
if [ -n "$GITEA" ]; then
if [ -n "$S3_BASE" ]; then
DOWNLOAD_BASE="$S3_BASE"
elif [ -n "$GITEA" ]; then
DOWNLOAD_BASE="$GITEA/$REPO/releases"
else
DOWNLOAD_BASE="https://github.com/$REPO/releases"
@@ -802,6 +812,16 @@ RESOLVED_TAG=""
resolve_tag() {
if [ "$VERSION" != latest ]; then RESOLVED_TAG="$VERSION"; return 0; fi
[ -n "$RESOLVED_TAG" ] && return 0
# Object storage serves no API, so the newest version is named in a
# small manifest published beside the binaries.
if [ -n "$S3_BASE" ]; then
_man=$(mktemp)
if fetch "$S3_BASE/latest.json" "$_man" "application/json"; then
RESOLVED_TAG=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$_man" | head -n1)
fi
rm -f "$_man"
[ -n "$RESOLVED_TAG" ] && return 0
fi
if [ -n "$GITEA" ]; then
_loc=$(redirect_of "$DOWNLOAD_BASE/latest" 2>/dev/null || true)
RESOLVED_TAG=$(printf '%s' "$_loc" | sed -n 's#.*/releases/tag/##p')
@@ -843,19 +863,26 @@ asset_id() {
# try_download NAME DEST — quiet, returns non-zero if the asset is absent.
try_download() {
_name=$1; _dest=$2
if [ -n "$GITEA" ]; then
# Distinct variable names: POSIX sh has no locals, and resolve_tag
# below calls fetch, which would otherwise overwrite the _dest we were
# handed — sending the binary to the manifest's temp file instead of
# where the caller expects it.
_tdname=$1; _tddest=$2
if [ -n "$S3_BASE" ]; then
resolve_tag || return 1
fetch "$S3_BASE/$RESOLVED_TAG/$_tdname" "$_tddest" || return 1
elif [ -n "$GITEA" ]; then
# Gitea assets download by tag; the token (if any) rides in fetch.
resolve_tag || return 1
fetch "$DOWNLOAD_BASE/download/$RESOLVED_TAG/$_name" "$_dest" || return 1
fetch "$DOWNLOAD_BASE/download/$RESOLVED_TAG/$_tdname" "$_tddest" || return 1
elif [ -n "$DL_TOKEN" ]; then
_id=$(asset_id "$_name") || return 1
_id=$(asset_id "$_tdname") || return 1
fetch "https://api.github.com/repos/$REPO/releases/assets/$_id" \
"$_dest" "application/octet-stream" || return 1
"$_tddest" "application/octet-stream" || return 1
elif [ "$VERSION" = latest ]; then
fetch "$DOWNLOAD_BASE/latest/download/$_name" "$_dest" || return 1
fetch "$DOWNLOAD_BASE/latest/download/$_tdname" "$_tddest" || return 1
else
fetch "$DOWNLOAD_BASE/download/$VERSION/$_name" "$_dest" || return 1
fetch "$DOWNLOAD_BASE/download/$VERSION/$_tdname" "$_tddest" || return 1
fi
}
@@ -1016,6 +1043,14 @@ install_server() {
mkdir -p "$PREFIX"
install_binary runix-server "$SERVER_BIN"
# Applying an update from the UI replaces the binary in place, which
# means writing a new file into this directory and renaming it over the
# old one — so the service user has to own the directory, not just the
# file. Without this the update fails with a bare permission error long
# after the download has succeeded.
chown "$SERVER_USER" "$BIN_DIR" 2>/dev/null || true
chown "$SERVER_USER" "$BIN_DIR/runix-server" 2>/dev/null || true
[ "$DB_MODE" = docker ] && provision_postgres
[ -n "$DSN" ] || fail "no database configured"
@@ -1094,7 +1129,11 @@ NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=$CONFIG_DIR
# The binary directory is writable so the control plane can replace its own
# binary when an administrator applies an update from the UI. That is the
# price of in-app updates: the service can rewrite the file it runs from.
# Drop \$BIN_DIR here to forbid it, and upgrade by re-running this script.
ReadWritePaths=$CONFIG_DIR $BIN_DIR
[Install]
WantedBy=multi-user.target
@@ -1376,7 +1415,9 @@ fi
if [ "$ROLE" = server ]; then
echo
echo " Add hosts from the UI (Servers → Add server), then run on each:"
if [ -n "$GITEA" ]; then
if [ -n "$S3_BASE" ]; then
say " curl -fsSL $S3_BASE/install.sh | sudo sh -s -- \\"
elif [ -n "$GITEA" ]; then
say " curl -fsSL $GITEA/$REPO/raw/branch/main/install.sh | sudo sh -s -- \\"
else
say " curl -fsSL $DOWNLOAD_BASE/latest/download/install.sh | sudo sh -s -- \\"