diff --git a/README.md b/README.md
index 80eef89..bc50138 100644
--- a/README.md
+++ b/README.md
@@ -17,15 +17,11 @@ One command sets up a host. Run it as root; it asks what the host should be
`/opt/runix`, and wires up systemd.
```sh
-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
```
-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
-```
+Binaries are served from object storage, so this needs no account and no
+token. To pin a version, add `--version v0.5.0`.
### Roles
@@ -56,16 +52,15 @@ 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://runix-releases.s3-alpha-web.astra-dev.online/install.sh | sudo sh -s -- -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
+object storage for the latest release and updates the control plane and each
agent with one click. Point a control plane at this repository 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
```
## Releases
@@ -78,11 +73,13 @@ Each release publishes, for `linux/amd64` and `linux/arm64`:
- `SHA256SUMS` — checksums; the installer verifies every download against it
Binaries are built automatically from source on tag by the CI runner and
-published here and to the source repository.
+published to object storage as well as to this repository. `latest.json`
+beside them names the newest version and every asset's checksum — that is
+what the installer and the in-app updater read.
## 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
+curl -fsSLO https://runix-releases.s3-alpha-web.astra-dev.online/v0.5.0/runix-server_linux_amd64
+curl -fsSL https://runix-releases.s3-alpha-web.astra-dev.online/v0.5.0/SHA256SUMS | sha256sum -c --ignore-missing
```
diff --git a/install.sh b/install.sh
index c8eeeb1..4efcc15 100755
--- a/install.sh
+++ b/install.sh
@@ -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
+#
+# /latest.json names the newest version
+# // 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 -- \\"