first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:59:58 +01:00
commit 0efaae1715
27 files changed
+1455

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
.git
.env
data/
gpg/
*.zip
*.sha256
**/__pycache__/
**/.pytest_cache/
+12
View File
@@ -0,0 +1,12 @@
SOURCE_REPO=https://git.example.com/rustbox/RustBox.git
SOURCE_BRANCH=master
REPOSITORY_URL=http://127.0.0.1:18765
HTTP_PORT=18765
POLL_INTERVAL=300
REPO_DISTRIBUTION=stable
REPO_COMPONENT=main
PACKAGE_RELEASE=auto
RUN_LINUX_TESTS=compile
MAX_GLIBC_VERSION=2.34
BUILD_SUSE=1
RELEASE_RETENTION=10
+5
View File
@@ -0,0 +1,5 @@
.env
data/
gpg/
*.zip
__pycache__/
+54
View File
@@ -0,0 +1,54 @@
FROM rockylinux:9
ARG RUST_TOOLCHAIN=stable
ARG NFPM_VERSION=2.47.0
ARG NFPM_RPM_SHA256=5ad3287a0b1d0c068e556bd84965a9866ab6451ef861506a3c09b8971ca5326f
ARG APTLY_VERSION=1.6.3
ARG APTLY_ZIP_SHA256=535ed24eb706bc25a316b59d709a7e74e10165f2ffab8552097ba7e4816a3e7e
RUN dnf -y install --allowerasing \
bash binutils ca-certificates coreutils createrepo_c curl file findutils \
gcc gcc-c++ git gnupg2 gzip make openssl openssl-devel pkgconf-pkg-config python3 python3.11 \
rpm rsync shadow-utils tar unzip xz bzip2 zstd \
&& dnf clean all \
&& rm -rf /var/cache/dnf
RUN set -eux; \
curl -fsSL -o /tmp/nfpm.rpm \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm-${NFPM_VERSION}-1.x86_64.rpm"; \
echo "${NFPM_RPM_SHA256} /tmp/nfpm.rpm" | sha256sum -c -; \
rpm -Uvh /tmp/nfpm.rpm; \
rm -f /tmp/nfpm.rpm; \
nfpm --version
RUN set -eux; \
curl -fsSL -o /tmp/aptly.zip \
"https://github.com/aptly-dev/aptly/releases/download/v${APTLY_VERSION}/aptly_${APTLY_VERSION}_linux_amd64.zip"; \
echo "${APTLY_ZIP_SHA256} /tmp/aptly.zip" | sha256sum -c -; \
mkdir -p /tmp/aptly; \
unzip -q /tmp/aptly.zip -d /tmp/aptly; \
install -m 0755 "$(find /tmp/aptly -type f -name aptly | head -n1)" /usr/local/bin/aptly; \
rm -rf /tmp/aptly /tmp/aptly.zip; \
aptly version
ENV RUSTUP_HOME=/opt/rustup \
CARGO_HOME=/opt/cargo \
PATH=/opt/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN set -eux; \
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs -o /tmp/rustup-init.sh; \
sh /tmp/rustup-init.sh -y --no-modify-path --profile minimal --default-toolchain "${RUST_TOOLCHAIN}"; \
rm -f /tmp/rustup-init.sh; \
rustc --version; \
cargo --version
ENV DATA_DIR=/var/lib/rustbox-package-builder \
CARGO_HOME=/var/lib/rustbox-package-builder/cargo \
CARGO_TARGET_DIR=/var/lib/rustbox-package-builder/cargo/target
COPY scripts/ /opt/rustbox-package-builder/scripts/
COPY packaging/ /opt/rustbox-package-builder/packaging/
RUN chmod 0755 /opt/rustbox-package-builder/scripts/*.sh
VOLUME ["/var/lib/rustbox-package-builder"]
ENTRYPOINT ["/opt/rustbox-package-builder/scripts/entrypoint.sh"]
+218
View File
@@ -0,0 +1,218 @@
# rustbox-package-builder
Linux-only builder i repozytorium pakietów dla RustBox. Projekt jest uproszczoną wersją pipeline'u używanego dla RustProxy: nie zawiera FreeBSD, OPNsense ani dodatkowego agenta.
## Co buduje
Z jednego commita RustBox dla `amd64/x86_64`:
- podpisany DEB i repo APT,
- podpisany RPM i repo DNF/YUM,
- opcjonalny osobny RPM dla SUSE/openSUSE i repo Zypper,
- panel WWW repozytorium w stylistyce RustBox,
- publiczny JSON `GET /api/v1/packages`.
Builder obserwuje `SOURCE_BRANCH`. Nowy commit lub zmiana `PACKAGING_REVISION` uruchamia testy, release build i publikację nowego snapshotu. `current` jest przełączany atomowo dopiero po udanym pakowaniu i podpisaniu metadanych.
## Wymaganie po stronie źródeł RustBox
Repo RustBox musi obsługiwać natywne logowanie plikowe przez `log_dir`. Pakiet ustawia:
```toml
log_dir = "/var/log/rustbox"
```
Builder celowo przerywa build, jeśli w źródłach nie ma obsługi `log_dir`. Dzięki temu logi aplikacji trafiają do `/var/log/rustbox/rustbox.log`, a nie do stdout usługi systemd.
## Szybki start
```bash
cp .env.example .env
nano .env
./start.sh
```
Minimalnie ustaw:
```env
SOURCE_REPO=https://git.example.com/rustbox/RustBox.git
SOURCE_BRANCH=master
REPOSITORY_URL=https://repo.example.com
```
Dla lokalnego HTTP można użyć:
```env
REPOSITORY_URL=http://192.168.1.100
HTTP_PORT=18765
```
`start.sh` utworzy trwały klucz GPG w `gpg/`, zbuduje obrazy i uruchomi:
- `rustbox-package-builder` — watcher + build + publikacja,
- `repository-dashboard` — read-only panel/API,
- `repository-http` — Nginx udostępniający panel oraz artefakty APT/RPM.
Dane buildera są trwałe w `data/`.
## Domyślne ustawienia builda
```env
POLL_INTERVAL=300
REPO_DISTRIBUTION=stable
REPO_COMPONENT=main
PACKAGE_RELEASE=auto
RUN_LINUX_TESTS=compile
MAX_GLIBC_VERSION=2.34
BUILD_SUSE=1
RELEASE_RETENTION=10
```
`RUN_LINUX_TESTS=compile` uruchamia `cargo test --release --locked --no-run`. Ustaw `full`, aby uruchamiać wszystkie testy, albo `0`, aby je wyłączyć.
## Instalacja pakietu RustBox
Pakiet instaluje:
```text
/usr/local/bin/rustbox
/usr/lib/systemd/system/rustbox.service
/etc/rustbox/rustbox.toml
/etc/logrotate.d/rustbox
```
oraz tworzy:
```text
/var/lib/rustbox rustbox:rustbox 0700
/var/log/rustbox rustbox:rustbox 0750
/etc/rustbox rustbox:rustbox 0750
```
`/etc/rustbox/rustbox.toml` ma finalnie `rustbox:rustbox 0640` i jest oznaczony jako `config|noreplace`, więc aktualizacja pakietu nie nadpisuje lokalnej konfiguracji.
### systemd
Jednostka używa:
```ini
User=rustbox
Group=rustbox
Environment=RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml
WorkingDirectory=/var/lib/rustbox
ExecStart=/usr/local/bin/rustbox
StateDirectory=rustbox
LogsDirectory=rustbox
ProtectSystem=strict
ProtectHome=true
NoNewPrivileges=true
```
Logi tracing RustBox trafiają do `/var/log/rustbox/rustbox.log`. `StandardOutput=null`; `StandardError=journal` zostaje tylko dla błędów startowych, które wystąpią zanim aplikacja uruchomi własny logger.
### logrotate
`/etc/logrotate.d/rustbox`:
```text
/var/log/rustbox/rustbox.log
```
jest obracany codziennie i przechowywany przez 7 rotacji, z kompresją i właścicielem `rustbox:rustbox`.
## Domyślna konfiguracja pakietu
```toml
host = "0.0.0.0"
port = 3001
environment = "production"
data_dir = "/var/lib/rustbox"
log_dir = "/var/log/rustbox"
session_timeout_hours = 72
remember_me_enabled = true
remember_me_days = 30
imap_host = "localhost"
imap_port = 993
smtp_host = "localhost"
smtp_port = 587
tls_enabled = true
tls_verify = false
max_attachment_size_mb = 50
max_message_size_mb = 100
sieve_port = 4190
sieve_tls_mode = "none"
sieve_allow_insecure_auth = true
sieve_timeout_secs = 10
default_junk_folder = "Spam"
default_theme = "system"
default_interface_skin = "apple-mail"
trusted_proxies = "127.0.0.1,::1"
outgoing_headers = []
pgp_wkd_enabled = true
pgp_hkp_enabled = false
pgp_hkp_url = "https://keys.openpgp.org"
pgp_network_timeout_secs = 10
pgp_max_key_bytes = 1048576
pgp_allow_private_export = true
```
## Repo APT
Panel pokazuje gotowe polecenia. Ręcznie:
```bash
curl -fsSL https://repo.example.com/repo-key.gpg \
| sudo gpg --dearmor -o /usr/share/keyrings/rustbox.gpg
echo "deb [signed-by=/usr/share/keyrings/rustbox.gpg] https://repo.example.com/apt stable main" \
| sudo tee /etc/apt/sources.list.d/rustbox.list
sudo apt update
sudo apt install rustbox
```
## Repo DNF/YUM
```bash
sudo rpm --import https://repo.example.com/repo-key.gpg
sudo tee /etc/yum.repos.d/rustbox.repo >/dev/null <<'REPO'
[rustbox]
name=RustBox Repository
baseurl=https://repo.example.com/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.example.com/repo-key.gpg
REPO
sudo dnf install rustbox
```
## Repo SUSE/Zypper
```bash
sudo rpm --import https://repo.example.com/repo-key.gpg
sudo zypper addrepo --refresh --gpgcheck https://repo.example.com/suse/x86_64/ rustbox
sudo zypper refresh rustbox
sudo zypper install rustbox
```
## Reverse proxy
Nginx buildera nasłuchuje lokalnie na `HTTP_PORT` (domyślnie `18765`). Dla publicznego HTTPS zalecany jest zewnętrzny reverse proxy kierujący na ten port.
## Logi buildera
```bash
docker compose --env-file .env logs -f rustbox-package-builder
docker compose --env-file .env logs -f repository-http
docker compose --env-file .env logs -f repository-dashboard
```
## Licencja paczki
Dostarczone źródła RustBox nie zawierają autorytatywnego pliku `LICENSE`; `NOTICE.md` zaleca weryfikację licencji upstream przed publiczną redystrybucją. Dlatego builder ustawia obecnie metadane pakietu na `UNKNOWN`, zamiast zgadywać licencję. Przed publicznym repozytorium warto ustalić właściwe oznaczenie i wtedy zmienić je w `scripts/prepare-package.sh`.
## Zależności runtime
Pakiety deklarują jawnie systemowy OpenSSL i GnuPG używane przez RustBox. Dla APT są to `libssl3`/`libssl3t64` i `gnupg`, dla RHEL/Rocky `openssl-libs` i `gnupg2`, a dla SUSE `libopenssl3` i `gpg2`.
+2
View File
@@ -0,0 +1,2 @@
__pycache__
*.pyc
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.13-alpine
WORKDIR /app
COPY app.py /app/app.py
COPY web /app/web
USER 65532:65532
EXPOSE 8000
ENTRYPOINT ["python3", "/app/app.py"]
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import logging
import os
import re
import threading
import time
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import quote, urlsplit
LOG = logging.getLogger("rustbox.repository")
REPOSITORY = Path(os.environ.get("REPOSITORY_ROOT", "/srv/repository"))
WEB = Path(__file__).resolve().parent / "web"
PORT = int(os.environ.get("DASHBOARD_PORT", "8000"))
CACHE_SECONDS = max(1, int(os.environ.get("DASHBOARD_CACHE_SECONDS", "15")))
REPOSITORY_URL = os.environ.get("REPOSITORY_URL", "http://localhost:18765").rstrip("/")
SITES = ("linux", "suse")
META_PATHS = {"linux": Path("LINUX-BUILD-INFO.txt"), "suse": Path("SUSE-BUILD-INFO.txt")}
DEB_FILENAME = re.compile(r"^(?P<name>.+?)_(?P<version>[^_]+)_(?P<arch>[^_]+)\.deb$")
RPM_FILENAME = re.compile(r"^(?P<name>.+)-(?P<version>\d[^-]*)-(?P<release>[^/]+)\.(?P<arch>x86_64|amd64|aarch64|noarch|arm64)\.rpm$")
def iso_time(stamp: float) -> str:
return datetime.fromtimestamp(stamp, tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def parse_metadata(path: Path) -> dict[str, str]:
try:
text = path.read_text(encoding="utf-8")
except OSError:
return {}
result: dict[str, str] = {}
for line in text.splitlines():
key, sep, value = line.partition("=")
if sep and re.fullmatch(r"[a-z][a-z0-9_]*", key):
result[key] = value.strip()[:1024]
return result
def get_current(repository: Path) -> Path | None:
try:
root = repository.resolve(strict=True)
current = (root / "current").resolve(strict=True)
if current.is_dir() and current.is_relative_to(root):
return current
except (OSError, RuntimeError):
pass
return None
def publication_info(current: Path, channel: str) -> dict[str, object]:
location = current / META_PATHS[channel]
raw = parse_metadata(location)
built_at = raw.get("built_at") or None
return {
"channel": channel,
"published": bool(raw),
"app_version": raw.get("app_version", ""),
"package_release": raw.get("package_release", ""),
"package_version": f"{raw.get('app_version','')}-{raw.get('package_release','')}" if raw.get("app_version") and raw.get("package_release") else raw.get("app_version", ""),
"source_commit": raw.get("source_commit", ""),
"source_ref": raw.get("source_ref", ""),
"source_url": raw.get("source_url", ""),
"packaging_revision": raw.get("packaging_revision", ""),
"built_at": built_at,
}
def parse_package(path: Path, kind: str) -> tuple[str, str, str] | None:
pattern = DEB_FILENAME if kind == "deb" else RPM_FILENAME
m = pattern.fullmatch(path.name)
if not m:
return None
if kind == "deb":
return m["name"], m["version"], m["arch"]
return m["name"], f"{m['version']}-{m['release']}", m["arch"]
def generate_catalog(repository: Path = REPOSITORY) -> dict[str, object]:
current = get_current(repository)
empty = {"published": False, "app_version": "", "package_release": "", "package_version": "", "source_commit": "", "source_ref": "", "source_url": "", "packaging_revision": "", "built_at": None}
builds = {site: publication_info(current, site) if current else {"channel": site, **empty} for site in SITES}
packages: list[dict[str, object]] = []
if current:
scans = [
("linux", "DEB", "deb", current / "apt" / "pool", "*.deb"),
("linux", "RPM", "rpm", current / "rpm" / "x86_64", "*.rpm"),
("suse", "RPM", "rpm", current / "suse" / "x86_64", "*.rpm"),
]
for channel, label, kind, directory, pattern in scans:
if not directory.is_dir():
continue
for path in directory.rglob(pattern):
parsed = parse_package(path, kind)
if not parsed or not path.is_file():
continue
name, version, arch = parsed
stat = path.stat()
rel = path.relative_to(current)
encoded = "/".join(quote(part, safe="") for part in rel.parts)
packages.append({
"name": name,
"version": version,
"architecture": arch,
"format": label,
"channel": channel,
"size_bytes": stat.st_size,
"built_at": iso_time(stat.st_mtime),
"download_url": f"{REPOSITORY_URL}/{encoded}",
"relative_path": rel.as_posix(),
})
packages.sort(key=lambda item: (str(item["built_at"]), str(item["version"])), reverse=True)
build_times = [str(item["built_at"]) for item in builds.values() if item.get("built_at")]
return {
"generated_at": iso_time(time.time()),
"ready": bool(packages),
"repository_url": REPOSITORY_URL,
"apt_distribution": os.environ.get("REPO_DISTRIBUTION", "stable"),
"apt_component": os.environ.get("REPO_COMPONENT", "main"),
"builds": builds,
"packages": packages,
"summary": {
"packages_count": len(packages),
"channels_published": sum(bool(item.get("published")) for item in builds.values()),
"latest_build_at": max(build_times, default=None),
},
}
class Cache:
def __init__(self) -> None:
self.lock = threading.Lock()
self.expires = 0.0
self.snapshot: dict[str, object] | None = None
def get(self) -> dict[str, object]:
with self.lock:
if self.snapshot is None or time.monotonic() >= self.expires:
self.snapshot = generate_catalog()
self.expires = time.monotonic() + CACHE_SECONDS
return self.snapshot
CACHE = Cache()
class Handler(BaseHTTPRequestHandler):
server_version = "RustBoxRepository/1.0"
sys_version = ""
def do_GET(self) -> None:
path = urlsplit(self.path).path
if path == "/healthz":
self.send_bytes(b"ok\n", "text/plain; charset=utf-8", "no-store")
elif path == "/api/v1/packages":
self.send_bytes(json.dumps(CACHE.get(), ensure_ascii=False, separators=(",", ":")).encode(), "application/json; charset=utf-8", "no-store")
elif path in ("/", "/index.html"):
self.send_bytes((WEB / "index.html").read_bytes(), "text/html; charset=utf-8", "no-store", True)
elif path in ("/ui/style.css", "/ui/app.js", "/ui/favicon.svg"):
name = path.rsplit("/", 1)[-1]
mime = {"style.css": "text/css", "app.js": "application/javascript", "favicon.svg": "image/svg+xml"}[name]
self.send_bytes((WEB / name).read_bytes(), f"{mime}; charset=utf-8", "public, max-age=600")
else:
self.send_error(HTTPStatus.NOT_FOUND)
def do_HEAD(self) -> None:
self.do_GET()
def send_bytes(self, payload: bytes, mime: str, cache: str, html: bool = False) -> None:
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", mime)
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", cache)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
if html:
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(payload)
def log_message(self, fmt: str, *args: object) -> None:
LOG.info("%s - %s", self.address_string(), fmt % args)
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.daemon_threads = True
LOG.info("RustBox repository dashboard ready on %s", PORT)
server.serve_forever(poll_interval=0.5)
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
(() => {
const $ = (id) => document.getElementById(id);
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state = {data:null, view:'overview', install:'apt', search:'', channel:'all', format:'all'};
const titles = {overview:'Przegląd',packages:'Paczki',install:'Instalacja',repositories:'Repozytoria'};
const fmtDate = (value) => value ? new Intl.DateTimeFormat('pl-PL',{dateStyle:'medium',timeStyle:'short'}).format(new Date(value)) : '—';
const fmtSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n/1024).toFixed(1)} KiB` : `${(n/1048576).toFixed(1)} MiB`;
function toast(text){const t=$('toast');t.textContent=text;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1600)}
function navigate(view){state.view=view;document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('.nav').forEach(x=>x.classList.toggle('active',x.dataset.view===view));$('view-title').textContent=titles[view]||view;location.hash=view}
function packageRow(p){return `<div class="package-row"><div><div class="pkg-name">${esc(p.name)}</div><div class="muted">${esc(p.version)}</div></div><span class="pill">${esc(p.channel.toUpperCase())}</span><span class="pill">${esc(p.format)}</span><span class="muted">${esc(fmtSize(p.size_bytes))}</span><a class="download" href="${esc(p.download_url)}">Pobierz</a></div>`}
function renderOverview(){const d=state.data;if(!d)return;const linux=d.builds.linux||{};const suse=d.builds.suse||{};$('latest-version').textContent=linux.app_version||'—';$('latest-build').textContent=fmtDate(linux.built_at);$('packages-count').textContent=d.summary.packages_count;$('linux-status').textContent=linux.published?'GOTOWE':'BRAK';$('suse-status').textContent=suse.published?'GOTOWE':'BRAK';$('recent-packages').innerHTML=d.packages.slice(0,6).map(packageRow).join('')||'<div class="empty">Brak opublikowanych paczek.</div>'}
function renderPackages(){if(!state.data)return;const q=state.search.toLowerCase();const list=state.data.packages.filter(p=>(state.channel==='all'||p.channel===state.channel)&&(state.format==='all'||p.format===state.format)&&(!q||`${p.name} ${p.version} ${p.architecture}`.toLowerCase().includes(q)));$('packages-table').innerHTML=list.map(p=>`<div class="table-row"><div><div class="pkg-name">${esc(p.name)}</div><div class="muted">${esc(p.version)}</div></div><span class="pill">${esc(p.channel.toUpperCase())}</span><span class="pill">${esc(p.format)}</span><span class="muted">${esc(fmtDate(p.built_at))}</span><a class="download" href="${esc(p.download_url)}">Pobierz</a></div>`).join('')||'<div class="empty">Brak wyników.</div>'}
function instruction(kind){const d=state.data;const url=d.repository_url,dist=d.apt_distribution,comp=d.apt_component;if(kind==='dnf')return {title:'RHEL / Rocky / Alma / Fedora',desc:'Instalacja przez podpisane repozytorium DNF/YUM.',code:`sudo rpm --import ${url}/repo-key.gpg\n\nsudo tee /etc/yum.repos.d/rustbox.repo >/dev/null <<'REPO'\n[rustbox]\nname=RustBox Repository\nbaseurl=${url}/rpm/$basearch\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=${url}/repo-key.gpg\nREPO\n\nsudo dnf install rustbox`};if(kind==='suse')return {title:'SUSE / openSUSE',desc:'Instalacja przez podpisane repozytorium Zypper.',code:`sudo rpm --import ${url}/repo-key.gpg\nsudo zypper addrepo --refresh --gpgcheck ${url}/suse/x86_64/ rustbox\nsudo zypper refresh rustbox\nsudo zypper install rustbox`};return {title:'Debian / Ubuntu',desc:'Instalacja przez podpisane repozytorium APT.',code:`curl -fsSL ${url}/repo-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/rustbox.gpg\n\necho "deb [signed-by=/usr/share/keyrings/rustbox.gpg] ${url}/apt ${dist} ${comp}" | sudo tee /etc/apt/sources.list.d/rustbox.list\n\nsudo apt update\nsudo apt install rustbox`}}
function renderInstall(){if(!state.data)return;const x=instruction(state.install);$('install-title').textContent=x.title;$('install-desc').textContent=x.desc;$('install-command').textContent=x.code;document.querySelectorAll('[data-install]').forEach(b=>b.classList.toggle('active',b.dataset.install===state.install))}
function repoCard(title,url,links){return `<article class="repo-card"><h3>${esc(title)}</h3><a href="${esc(url)}">${esc(url)}</a><div class="repo-links">${links.map(([n,u])=>`<a href="${esc(u)}"><span>${esc(n)}</span></a>`).join('')}</div></article>`}
function renderRepositories(){const d=state.data;if(!d)return;const u=d.repository_url,dist=encodeURIComponent(d.apt_distribution);$('repo-cards').innerHTML=repoCard('APT + DNF',u,[['GPG public key',`${u}/repo-key.gpg`],['APT InRelease',`${u}/apt/dists/${dist}/InRelease`],['RPM repomd.xml',`${u}/rpm/x86_64/repodata/repomd.xml`],['Build info',`${u}/LINUX-BUILD-INFO.txt`]])+repoCard('SUSE / Zypper',`${u}/suse/x86_64`,[['RPM repomd.xml',`${u}/suse/x86_64/repodata/repomd.xml`],['Metadata signature',`${u}/suse/x86_64/repodata/repomd.xml.asc`],['Build info',`${u}/SUSE-BUILD-INFO.txt`]]);$('metadata').innerHTML=['linux','suse'].map(ch=>{const b=d.builds[ch]||{};return `<div class="meta-block"><h3>${ch==='linux'?'Linux':'SUSE'}</h3>${[['Wersja',b.app_version],['Wydanie paczki',b.package_release],['Build',fmtDate(b.built_at)],['Commit',b.source_commit?b.source_commit.slice(0,12):'—'],['Rewizja pakowania',b.packaging_revision]].map(([a,v])=>`<div class="meta-row"><span>${esc(a)}</span><strong>${esc(v||'—')}</strong></div>`).join('')}</div>`}).join('')}
function renderAll(){renderOverview();renderPackages();renderInstall();renderRepositories()}
async function refresh(){const btn=$('refresh');btn.disabled=true;try{const r=await fetch('/api/v1/packages',{cache:'no-store'});if(!r.ok)throw new Error(`HTTP ${r.status}`);state.data=await r.json();renderAll();$('last-sync').textContent=`Ostatnia synchronizacja: ${fmtDate(state.data.generated_at)}`;$('status-dot').classList.add('ok');$('status-text').textContent=state.data.ready?'Repozytorium aktywne':'Oczekiwanie na build'}catch(e){$('status-dot').classList.remove('ok');$('status-text').textContent='Błąd odczytu repozytorium';console.error(e)}finally{btn.disabled=false}}
function setTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('rustbox-repo-theme',theme)}
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.view)));
document.querySelectorAll('[data-go]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.go)));
document.querySelectorAll('[data-install]').forEach(b=>b.addEventListener('click',()=>{state.install=b.dataset.install;renderInstall()}));
$('search').addEventListener('input',e=>{state.search=e.target.value;renderPackages()});$('channel').addEventListener('change',e=>{state.channel=e.target.value;renderPackages()});$('format').addEventListener('change',e=>{state.format=e.target.value;renderPackages()});$('refresh').addEventListener('click',refresh);$('copy-install').addEventListener('click',async()=>{try{await navigator.clipboard.writeText($('install-command').textContent);toast('Skopiowano')}catch{toast('Nie udało się skopiować')}});$('theme-toggle').addEventListener('click',()=>setTheme(document.documentElement.dataset.theme==='dark'?'light':'dark'));
try{setTheme(localStorage.getItem('rustbox-repo-theme')||'dark')}catch{}navigate(location.hash.slice(1)||'overview');window.addEventListener('hashchange',()=>navigate(location.hash.slice(1)||'overview'));refresh();setInterval(()=>{if(!document.hidden)refresh()},60000);
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#8fb9a4"/><path d="M18 16h17c9 0 14 5 14 12 0 5-3 9-8 11l10 10H39L30 40h-2v9H18V16zm10 9v7h7c3 0 5-1 5-4s-2-3-5-3h-7z" fill="#102018"/></svg>

After

Width:  |  Height:  |  Size: 245 B

+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="pl" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>RustBox Repository</title>
<link rel="icon" href="/ui/favicon.svg">
<link rel="stylesheet" href="/ui/style.css">
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand"><div class="brand-icon">R</div><div><strong>RustBox</strong><span>Package Repository</span></div></div>
<nav>
<button class="nav active" data-view="overview">Przegląd</button>
<button class="nav" data-view="packages">Paczki</button>
<button class="nav" data-view="install">Instalacja</button>
<button class="nav" data-view="repositories">Repozytoria</button>
</nav>
<div class="sidebar-foot"><span id="status-dot" class="status-dot"></span><span id="status-text">Ładowanie…</span></div>
</aside>
<main>
<header class="topbar">
<div><h1 id="view-title">Przegląd</h1><p id="last-sync">—</p></div>
<div class="toolbar"><button id="theme-toggle" title="Motyw">◐</button><button id="refresh" title="Odśwież">↻</button></div>
</header>
<section id="overview" class="view active">
<div class="hero">
<div><span class="eyebrow">RUSTBOX / LINUX</span><h2>Repozytorium wydań</h2><p>Podpisane paczki APT, DNF/RPM i Zypper budowane bezpośrednio ze źródeł RustBox.</p></div>
<div class="version-box"><span>NAJNOWSZA WERSJA</span><strong id="latest-version">—</strong><small id="latest-build">—</small></div>
</div>
<div class="cards">
<article><span>Paczki</span><strong id="packages-count">0</strong><small>opublikowane artefakty</small></article>
<article><span>Linux</span><strong id="linux-status">—</strong><small>APT + DNF</small></article>
<article><span>SUSE</span><strong id="suse-status">—</strong><small>Zypper RPM</small></article>
</div>
<div class="panel"><div class="panel-head"><h3>Ostatnie paczki</h3><button data-go="packages">Pokaż wszystkie</button></div><div id="recent-packages" class="package-list"></div></div>
</section>
<section id="packages" class="view">
<div class="panel">
<div class="filters"><input id="search" type="search" placeholder="Szukaj paczki lub wersji"><select id="channel"><option value="all">Wszystkie kanały</option><option value="linux">Linux</option><option value="suse">SUSE</option></select><select id="format"><option value="all">Wszystkie formaty</option><option value="DEB">DEB</option><option value="RPM">RPM</option></select></div>
<div id="packages-table" class="table"></div>
</div>
</section>
<section id="install" class="view">
<div class="install-tabs"><button class="active" data-install="apt">Debian / Ubuntu</button><button data-install="dnf">RHEL / Rocky / Alma / Fedora</button><button data-install="suse">SUSE / openSUSE</button></div>
<div class="panel command-panel"><div class="panel-head"><div><h3 id="install-title">Debian / Ubuntu</h3><p id="install-desc">Instalacja przez podpisane repozytorium APT.</p></div><button id="copy-install">Kopiuj</button></div><pre><code id="install-command"></code></pre></div>
<div class="note">Po instalacji konfiguracja znajduje się w <code>/etc/rustbox/rustbox.toml</code>, dane w <code>/var/lib/rustbox</code>, a logi aplikacji w <code>/var/log/rustbox/rustbox.log</code>.</div>
</section>
<section id="repositories" class="view">
<div id="repo-cards" class="repo-grid"></div>
<div class="panel"><div class="panel-head"><h3>Metadane buildu</h3></div><div id="metadata" class="metadata"></div></div>
</section>
</main>
</div>
<div id="toast" class="toast"></div>
<script src="/ui/app.js" defer></script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
:root{color-scheme:dark;--bg:#121513;--panel:#181c19;--panel2:#1e231f;--panel3:#242a25;--sidebar:#101310;--line:#313832;--line2:#485149;--text:#edf0ec;--muted:#9da79f;--subtle:#7c877f;--accent:#8fb9a4;--accent2:#a4c9b6;--good:#a7c892;--field:#121713;--hover:#202621;--shadow:rgba(0,0,0,.32);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
html[data-theme="light"]{color-scheme:light;--bg:#f2f4f1;--panel:#fff;--panel2:#f7f8f6;--panel3:#edf1ec;--sidebar:#e9eee9;--line:#cbd2cb;--line2:#aeb8af;--text:#202622;--muted:#647068;--subtle:#7a847d;--accent:#507b67;--accent2:#416b58;--good:#587a49;--field:#fff;--hover:#e8ede8;--shadow:rgba(31,39,33,.16)}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-size:14px}.shell{min-height:100vh;display:grid;grid-template-columns:228px minmax(0,1fr)}.sidebar{position:sticky;top:0;height:100vh;background:var(--sidebar);border-right:1px solid var(--line);padding:18px 12px;display:flex;flex-direction:column}.brand{display:flex;gap:11px;align-items:center;padding:5px 8px 24px}.brand-icon{width:34px;height:34px;border-radius:9px;background:var(--accent);color:#102018;display:grid;place-items:center;font-weight:800;font-size:18px}.brand strong,.brand span{display:block}.brand span{color:var(--muted);font-size:11px;margin-top:2px}.sidebar nav{display:grid;gap:3px}.nav{border:0;background:transparent;color:var(--muted);text-align:left;padding:10px 12px;border-radius:5px;cursor:pointer;font-weight:600}.nav:hover{background:var(--hover);color:var(--text)}.nav.active{background:var(--panel3);color:var(--text);box-shadow:inset 2px 0 var(--accent)}.sidebar-foot{margin-top:auto;border-top:1px solid var(--line);padding:15px 8px 2px;color:var(--muted);display:flex;align-items:center;gap:8px;font-size:12px}.status-dot{width:8px;height:8px;border-radius:50%;background:var(--subtle)}.status-dot.ok{background:var(--good);box-shadow:0 0 0 3px color-mix(in srgb,var(--good) 15%,transparent)}main{min-width:0}.topbar{height:72px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:color-mix(in srgb,var(--bg) 84%,transparent);backdrop-filter:blur(10px);position:sticky;top:0;z-index:5}.topbar h1{font-size:18px;margin:0}.topbar p{margin:3px 0 0;color:var(--subtle);font-size:11px}.toolbar{display:flex;gap:7px}.toolbar button,.panel button,.install-tabs button{border:1px solid var(--line);background:var(--panel);color:var(--text);min-height:32px;padding:0 11px;border-radius:5px;cursor:pointer}.toolbar button:hover,.panel button:hover,.install-tabs button:hover{border-color:var(--line2);background:var(--hover)}.view{display:none;padding:24px;max-width:1320px;margin:0 auto}.view.active{display:block}.hero{display:grid;grid-template-columns:minmax(0,1fr) 240px;gap:18px;background:linear-gradient(135deg,var(--panel),var(--panel2));border:1px solid var(--line);border-radius:7px;padding:28px}.hero h2{font-size:30px;margin:7px 0 8px}.hero p{color:var(--muted);max-width:700px;margin:0;line-height:1.55}.eyebrow{color:var(--accent2);font-size:11px;font-weight:800;letter-spacing:.14em}.version-box{background:var(--field);border:1px solid var(--line);border-radius:6px;padding:18px;display:flex;flex-direction:column;justify-content:center}.version-box span{color:var(--subtle);font-size:10px;letter-spacing:.1em}.version-box strong{font-size:27px;margin:5px 0}.version-box small{color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:12px 0}.cards article{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:17px}.cards span,.cards small{display:block;color:var(--muted)}.cards strong{display:block;font-size:22px;margin:5px 0}.panel{background:var(--panel);border:1px solid var(--line);border-radius:6px;margin-top:12px;overflow:hidden}.panel-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.panel-head h3{margin:0;font-size:14px}.panel-head p{margin:4px 0 0;color:var(--muted)}.package-row,.table-row{display:grid;grid-template-columns:minmax(220px,1fr) 150px 90px 110px 150px;gap:12px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line)}.package-row:last-child,.table-row:last-child{border-bottom:0}.package-row:hover,.table-row:hover{background:var(--hover)}.pkg-name{font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.muted{color:var(--muted)}.pill{display:inline-flex;align-items:center;border:1px solid var(--line2);border-radius:99px;padding:3px 7px;font-size:10px;color:var(--muted);width:max-content}.download{color:var(--accent2);text-decoration:none;font-weight:650}.filters{display:grid;grid-template-columns:minmax(240px,1fr) 190px 170px;gap:8px;padding:12px;border-bottom:1px solid var(--line)}input,select{width:100%;height:34px;background:var(--field);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 10px;outline:none}input:focus,select:focus{border-color:var(--accent)}.install-tabs{display:flex;gap:7px;flex-wrap:wrap}.install-tabs button.active{background:var(--accent);border-color:var(--accent);color:#102018;font-weight:700}.command-panel pre{margin:0;background:#0c0f0d;color:#e8efe9;padding:18px;overflow:auto;font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace}.note{margin-top:12px;border:1px solid var(--line);background:var(--panel2);padding:14px;border-radius:6px;color:var(--muted)}code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.repo-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.repo-card{background:var(--panel);bordLine truncated
+58
View File
@@ -0,0 +1,58 @@
services:
rustbox-package-builder:
build:
context: .
image: rustbox/package-builder:local
environment:
SOURCE_REPO: "${SOURCE_REPO:?SOURCE_REPO is required}"
SOURCE_BRANCH: "${SOURCE_BRANCH:-master}"
REPOSITORY_URL: "${REPOSITORY_URL:?REPOSITORY_URL is required}"
POLL_INTERVAL: "${POLL_INTERVAL:-300}"
REPO_DISTRIBUTION: "${REPO_DISTRIBUTION:-stable}"
REPO_COMPONENT: "${REPO_COMPONENT:-main}"
PACKAGE_RELEASE: "${PACKAGE_RELEASE:-auto}"
RUN_LINUX_TESTS: "${RUN_LINUX_TESTS:-compile}"
MAX_GLIBC_VERSION: "${MAX_GLIBC_VERSION:-2.34}"
BUILD_SUSE: "${BUILD_SUSE:-1}"
RELEASE_RETENTION: "${RELEASE_RETENTION:-10}"
volumes:
- ./data:/var/lib/rustbox-package-builder
- ./gpg:/run/secrets/gpg:ro
restart: unless-stopped
repository-dashboard:
build:
context: ./dashboard
image: rustbox/repository-dashboard:local
environment:
REPOSITORY_ROOT: /srv/repository
REPOSITORY_URL: "${REPOSITORY_URL:?REPOSITORY_URL is required}"
REPO_DISTRIBUTION: "${REPO_DISTRIBUTION:-stable}"
REPO_COMPONENT: "${REPO_COMPONENT:-main}"
volumes:
- ./data/repository:/srv/repository:ro
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
restart: unless-stopped
repository-http:
depends_on:
- repository-dashboard
image: nginxinc/nginx-unprivileged:stable-alpine-slim
ports:
- "0.0.0.0:${HTTP_PORT:-18965}:8080"
volumes:
- ./data/repository:/srv/repository:ro
- ./http/nginx.conf:/etc/nginx/nginx.conf:ro
entrypoint: [ "nginx", "-g", "daemon off;" ]
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
restart: unless-stopped
+46
View File
@@ -0,0 +1,46 @@
worker_processes 1;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events { worker_connections 256; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
server_tokens off;
sendfile on;
tcp_nopush on;
keepalive_timeout 15s;
client_max_body_size 1k;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
server {
listen 8080 default_server;
server_name _;
root /srv/repository;
index index.html;
autoindex off;
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
location = /healthz {
default_type text/plain;
return 200 "ok\n";
}
location = / { proxy_pass http://repository-dashboard:8000/; }
location = /index.html { proxy_pass http://repository-dashboard:8000/index.html; }
location ^~ /ui/ { proxy_pass http://repository-dashboard:8000; }
location = /api/v1/packages { proxy_pass http://repository-dashboard:8000; }
location ~ (^|/)\. { deny all; }
location / {
expires 10m;
try_files $uri =404;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
/var/log/rustbox/rustbox.log {
daily
rotate 7
missingok
notifempty
compress
delaycompress
copytruncate
create 0600 rustbox rustbox
su rustbox rustbox
}
+37
View File
@@ -0,0 +1,37 @@
[Unit]
Description=RustBox webmail
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=rustbox
Group=rustbox
Environment=RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml
Environment=RUST_LOG=info
WorkingDirectory=/var/lib/rustbox
ExecStart=/usr/local/bin/rustbox
Restart=on-failure
RestartSec=3
StateDirectory=rustbox
StateDirectoryMode=0700
LogsDirectory=rustbox
LogsDirectoryMode=0750
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
StandardOutput=null
StandardError=journal
[Install]
WantedBy=multi-user.target
+31
View File
@@ -0,0 +1,31 @@
host = "0.0.0.0"
port = 3001
environment = "production"
data_dir = "/var/lib/rustbox"
log_dir = "/var/log/rustbox"
session_timeout_hours = 72
remember_me_enabled = true
remember_me_days = 30
imap_host = "localhost"
imap_port = 993
smtp_host = "localhost"
smtp_port = 587
tls_enabled = true
tls_verify = false
max_attachment_size_mb = 50
max_message_size_mb = 100
sieve_port = 4190
sieve_tls_mode = "none"
sieve_allow_insecure_auth = true
sieve_timeout_secs = 10
default_junk_folder = "Spam"
default_theme = "system"
default_interface_skin = "apple-mail"
trusted_proxies = "127.0.0.1,::1"
outgoing_headers = []
pgp_wkd_enabled = true
pgp_hkp_enabled = false
pgp_hkp_url = "https://keys.openpgp.org"
pgp_network_timeout_secs = 10
pgp_max_key_bytes = 1048576
pgp_allow_private_export = true
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source /opt/rustbox-package-builder/scripts/lib.sh
: "${SHA:?SHA required}"
: "${SRC:?SRC required}"
REPOSITORY="$DATA_DIR/repository"
APTLY_CONF="$DATA_DIR/state/aptly.conf"
OUT="$DATA_DIR/work/out"
PKG="$DATA_DIR/work/package"
rm -rf "$OUT" "$PKG"
mkdir -p "$OUT" "$DATA_DIR/packages/deb" "$DATA_DIR/packages/rpm/x86_64" "$DATA_DIR/packages/suse/x86_64"
VERSION=$(python3.11 - "$SRC/Cargo.toml" <<'PY'
import sys, tomllib
p=tomllib.load(open(sys.argv[1],'rb'))
print(p['package']['version'])
PY
)
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9._-]+)?$ ]] || fatal "unsupported RustBox version: $VERSION"
SHORT_SHA=${SHA:0:12}
requested=${PACKAGE_RELEASE:-auto}
if [[ "$requested" == auto ]]; then
last_version=""; last_release=0
[[ -r "$DATA_DIR/state/last_version" ]] && last_version=$(cat "$DATA_DIR/state/last_version")
[[ -r "$DATA_DIR/state/last_package_release" ]] && last_release=$(cat "$DATA_DIR/state/last_package_release")
if [[ "$last_version" == "$VERSION" && "$last_release" =~ ^[0-9]+$ ]]; then
RELEASE=$((last_release + 1))
else
RELEASE=1
fi
else
[[ "$requested" =~ ^[1-9][0-9]*$ ]] || fatal "PACKAGE_RELEASE must be auto or a positive integer"
RELEASE=$requested
fi
cd "$SRC"
TOOLCHAIN=$(/opt/rustbox-package-builder/scripts/ensure-rust-toolchain.sh "$SRC")
log "building RustBox $VERSION-$RELEASE from $SHA with Rust $TOOLCHAIN"
[[ -f Cargo.lock ]] || fatal "Cargo.lock is required for reproducible packages"
if ! grep -q 'log_dir' src/config.rs || ! grep -q 'log_dir' src/main.rs; then
fatal "RustBox source must support native file logging through log_dir"
fi
case "${RUN_LINUX_TESTS:-compile}" in
0|off|false|no) log "cargo tests skipped" ;;
1|compile|check|no-run) cargo "+$TOOLCHAIN" test --release --locked --no-run ;;
full|run) cargo "+$TOOLCHAIN" test --release --locked ;;
*) fatal "RUN_LINUX_TESTS must be 0, compile, or full" ;;
esac
cargo "+$TOOLCHAIN" build --release --locked
BINARY="$CARGO_TARGET_DIR/release/rustbox"
[[ -x "$BINARY" ]] || fatal "built binary not found: $BINARY"
"$BINARY" --version
RUSTBOX_CONFIG=/opt/rustbox-package-builder/packaging/rustbox.toml "$BINARY" --check-config
MAX_GLIBC_FOUND=$(objdump -T "$BINARY" 2>/dev/null | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sed 's/^GLIBC_//' | sort -V | tail -n1 || true)
if [[ -n "$MAX_GLIBC_FOUND" ]]; then
newest=$(printf '%s\n%s\n' "$MAX_GLIBC_VERSION" "$MAX_GLIBC_FOUND" | sort -V | tail -n1)
[[ "$newest" == "$MAX_GLIBC_VERSION" ]] || fatal "binary requires GLIBC_$MAX_GLIBC_FOUND; configured maximum is GLIBC_$MAX_GLIBC_VERSION"
log "glibc compatibility: GLIBC_$MAX_GLIBC_FOUND or older"
fi
/opt/rustbox-package-builder/scripts/prepare-package.sh "$SRC" "$PKG" "$BINARY" "$VERSION" "$RELEASE"
DEB="$OUT/rustbox_${VERSION}-${RELEASE}_amd64.deb"
RPM="$OUT/rustbox-${VERSION}-${RELEASE}.x86_64.rpm"
SUSE="$OUT/rustbox-${VERSION}-${RELEASE}.suse.x86_64.rpm"
nfpm package --config "$PKG/nfpm-deb.yaml" --packager deb --target "$DEB"
nfpm package --config "$PKG/nfpm-rpm.yaml" --packager rpm --target "$RPM"
if [[ "${BUILD_SUSE:-1}" == 1 ]]; then
nfpm package --config "$PKG/nfpm-suse.yaml" --packager rpm --target "$SUSE"
fi
ar t "$DEB" | grep '^_gpgorigin$' >/dev/null || fatal "DEB package is not signed"
rpm -qpi "$RPM" | awk -F: '/^Signature/ && $0 !~ /\(none\)/ {ok=1} END{exit !ok}' || fatal "RPM package is not signed"
if [[ -f "$SUSE" ]]; then rpm -qpi "$SUSE" | awk -F: '/^Signature/ && $0 !~ /\(none\)/ {ok=1} END{exit !ok}' || fatal "SUSE RPM package is not signed"; fi
cp -f "$DEB" "$DATA_DIR/packages/deb/"
cp -f "$RPM" "$DATA_DIR/packages/rpm/x86_64/"
[[ -f "$SUSE" ]] && cp -f "$SUSE" "$DATA_DIR/packages/suse/x86_64/"
aptly -config="$APTLY_CONF" repo add -force-replace rustbox "$DEB" >/dev/null
SNAPSHOT="rustbox-${VERSION}-${SHORT_SHA}-$(date +%s)"
aptly -config="$APTLY_CONF" snapshot create "$SNAPSHOT" from repo rustbox >/dev/null
APT_SIGN=(-batch -gpg-key="$GPG_KEY_ID")
if aptly -config="$APTLY_CONF" publish list -raw | awk -v d="$REPO_DISTRIBUTION" '$2 == d {found=1} END{exit !found}'; then
aptly -config="$APTLY_CONF" publish switch -force-overwrite "${APT_SIGN[@]}" "$REPO_DISTRIBUTION" . "$SNAPSHOT" >/dev/null
else
aptly -config="$APTLY_CONF" publish snapshot -force-overwrite "${APT_SIGN[@]}" -distribution="$REPO_DISTRIBUTION" -component="$REPO_COMPONENT" -architectures=amd64 "$SNAPSHOT" >/dev/null
fi
APT_PUBLIC="$DATA_DIR/aptly/public"
[[ -f "$APT_PUBLIC/dists/$REPO_DISTRIBUTION/InRelease" ]] || fatal "APT InRelease missing"
RELEASE_ID="$(date -u '+%Y%m%dT%H%M%SZ')-${SHORT_SHA}"
RELEASE_DIR="$REPOSITORY/releases/$RELEASE_ID"
mkdir -p "$RELEASE_DIR/apt" "$RELEASE_DIR/rpm/x86_64" "$RELEASE_DIR/suse/x86_64"
rsync -a --delete "$APT_PUBLIC/" "$RELEASE_DIR/apt/"
rsync -a --delete "$DATA_DIR/packages/rpm/x86_64/" "$RELEASE_DIR/rpm/x86_64/"
if [[ "${BUILD_SUSE:-1}" == 1 ]]; then rsync -a --delete "$DATA_DIR/packages/suse/x86_64/" "$RELEASE_DIR/suse/x86_64/"; fi
for rpm_root in "$RELEASE_DIR/rpm/x86_64"; do
rm -rf "$rpm_root/repodata"; createrepo_c --update "$rpm_root" >/dev/null
done
if [[ "${BUILD_SUSE:-1}" == 1 ]]; then
rm -rf "$RELEASE_DIR/suse/x86_64/repodata"; createrepo_c --update "$RELEASE_DIR/suse/x86_64" >/dev/null
fi
GPG_SIGN=(--batch --yes --pinentry-mode loopback)
for rpm_root in "$RELEASE_DIR/rpm/x86_64"; do
gpg "${GPG_SIGN[@]}" --armor --detach-sign --local-user "$GPG_KEY_ID" --output "$rpm_root/repodata/repomd.xml.asc" "$rpm_root/repodata/repomd.xml"
done
if [[ "${BUILD_SUSE:-1}" == 1 ]]; then
gpg "${GPG_SIGN[@]}" --armor --detach-sign --local-user "$GPG_KEY_ID" --output "$RELEASE_DIR/suse/x86_64/repodata/repomd.xml.asc" "$RELEASE_DIR/suse/x86_64/repodata/repomd.xml"
fi
gpg --batch --armor --export "$GPG_KEY_ID" > "$RELEASE_DIR/repo-key.gpg"
BUILT_AT=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
cat > "$RELEASE_DIR/LINUX-BUILD-INFO.txt" <<INFO
app_version=$VERSION
package_release=$RELEASE
built_at=$BUILT_AT
packaging_revision=$PACKAGING_REVISION
source_url=$SOURCE_REPO
source_ref=$SOURCE_BRANCH
source_commit=$SHA
build_reason=source-or-packaging-change
INFO
if [[ "${BUILD_SUSE:-1}" == 1 ]]; then
cp "$RELEASE_DIR/LINUX-BUILD-INFO.txt" "$RELEASE_DIR/SUSE-BUILD-INFO.txt"
printf '%s\n' 'rpm_flavor=suse' >> "$RELEASE_DIR/SUSE-BUILD-INFO.txt"
else
: > "$RELEASE_DIR/SUSE-BUILD-INFO.txt"
fi
rm -f "$REPOSITORY/current.new"
ln -s "releases/$RELEASE_ID" "$REPOSITORY/current.new"
mv -Tf "$REPOSITORY/current.new" "$REPOSITORY/current"
printf '%s\n' "$SHA" | atomic_write "$DATA_DIR/state/last_commit"
printf '%s\n' "$VERSION" | atomic_write "$DATA_DIR/state/last_version"
printf '%s\n' "$RELEASE" | atomic_write "$DATA_DIR/state/last_package_release"
retention=${RELEASE_RETENTION:-10}
if [[ "$retention" =~ ^[1-9][0-9]*$ ]]; then
mapfile -t old < <(find "$REPOSITORY/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -nr | awk -v keep="$retention" 'NR>keep {$1=""; sub(/^ /,""); print}')
for dir in "${old[@]}"; do [[ "$dir" == "$RELEASE_DIR" ]] || rm -rf -- "$dir"; done
fi
log "published RustBox $VERSION-$RELEASE: $REPOSITORY_URL"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source /opt/rustbox-package-builder/scripts/lib.sh
WORK="$DATA_DIR/work/source"
REPOSITORY="$DATA_DIR/repository"
if [[ ! -d "$WORK/.git" ]]; then
rm -rf "$WORK"
git clone --no-tags "$SOURCE_REPO" "$WORK"
fi
cd "$WORK"
git remote set-url origin "$SOURCE_REPO"
git fetch --no-tags --prune origin "$SOURCE_BRANCH"
SHA=$(git rev-parse FETCH_HEAD)
CURRENT_SHA=""
CURRENT_REV=""
if [[ -r "$REPOSITORY/current/LINUX-BUILD-INFO.txt" ]]; then
CURRENT_SHA=$(sed -n 's/^source_commit=//p' "$REPOSITORY/current/LINUX-BUILD-INFO.txt" | head -n1)
CURRENT_REV=$(sed -n 's/^packaging_revision=//p' "$REPOSITORY/current/LINUX-BUILD-INFO.txt" | head -n1)
fi
if [[ "$CURRENT_SHA" == "$SHA" && "$CURRENT_REV" == "$PACKAGING_REVISION" ]]; then
log "no new RustBox build: commit=$SHA packaging_revision=$PACKAGING_REVISION"
exit 0
fi
git reset --hard "$SHA"
git clean -fdx
export SHA SRC="$WORK"
/opt/rustbox-package-builder/scripts/build-release.sh
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SOURCE_DIR=${1:?usage: ensure-rust-toolchain.sh SOURCE_DIR}
[[ -r "$SOURCE_DIR/Cargo.toml" ]] || { echo "missing Cargo.toml: $SOURCE_DIR" >&2; exit 1; }
metadata=$(python3.11 - "$SOURCE_DIR" <<'PY'
import pathlib, re, sys, tomllib
root=pathlib.Path(sys.argv[1])
manifest=tomllib.loads((root/'Cargo.toml').read_text())
minimum=manifest.get('package',{}).get('rust-version','')
pinned=''
p=root/'rust-toolchain.toml'
if p.exists(): pinned=tomllib.loads(p.read_text()).get('toolchain',{}).get('channel','')
if minimum and not re.fullmatch(r'[0-9]+\.[0-9]+(?:\.[0-9]+)?', minimum): raise SystemExit('unsupported rust-version')
if pinned and not (pinned=='stable' or re.fullmatch(r'[0-9]+\.[0-9]+(?:\.[0-9]+)?', pinned)): raise SystemExit('unsupported rust-toolchain channel')
print(minimum)
print(pinned)
PY
)
mapfile -t requested <<< "$metadata"
minimum=${requested[0]:-}
channel=${requested[1]:-stable}
[[ -n "$channel" ]] || channel=stable
if ! rustup run "$channel" rustc --version >/dev/null 2>&1; then
echo "installing Rust toolchain $channel" >&2
rustup toolchain install "$channel" --profile minimal >&2
fi
installed=$(rustup run "$channel" rustc --version | awk '{print $2}')
if [[ -n "$minimum" ]]; then
lowest=$(printf '%s\n%s\n' "$installed" "$minimum" | sort -V | head -n1)
if [[ "$lowest" != "$minimum" ]]; then
echo "Rust $installed does not satisfy project MSRV $minimum" >&2
exit 1
fi
fi
printf '%s\n' "$channel"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source /opt/rustbox-package-builder/scripts/lib.sh
: "${SOURCE_REPO:?SOURCE_REPO required}"
: "${REPOSITORY_URL:?REPOSITORY_URL required}"
: "${SOURCE_BRANCH:=master}"
: "${POLL_INTERVAL:=300}"
: "${REPO_DISTRIBUTION:=stable}"
: "${REPO_COMPONENT:=main}"
: "${PACKAGE_RELEASE:=auto}"
: "${RUN_LINUX_TESTS:=compile}"
: "${MAX_GLIBC_VERSION:=2.34}"
: "${BUILD_SUSE:=1}"
: "${RELEASE_RETENTION:=10}"
export SOURCE_BRANCH POLL_INTERVAL REPO_DISTRIBUTION REPO_COMPONENT PACKAGE_RELEASE RUN_LINUX_TESTS MAX_GLIBC_VERSION BUILD_SUSE RELEASE_RETENTION
mkdir -p "$DATA_DIR"/{state,repository,work,cargo,packages} /tmp/rustbox-gnupg
chmod 0700 /tmp/rustbox-gnupg
export GNUPGHOME=/tmp/rustbox-gnupg
GPG_PRIVATE_KEY_FILE=/run/secrets/gpg/private-key.asc
[[ -s "$GPG_PRIVATE_KEY_FILE" ]] || fatal "missing GPG private key: $GPG_PRIVATE_KEY_FILE"
gpg --batch --import "$GPG_PRIVATE_KEY_FILE" >/dev/null 2>&1
GPG_KEY_ID=$(gpg --batch --with-colons --list-secret-keys | awk -F: '/^sec:/ {seen=1; next} seen && /^fpr:/ {print $10; exit}')
[[ -n "$GPG_KEY_ID" ]] || fatal "cannot resolve GPG signing key"
export GPG_PRIVATE_KEY_FILE GPG_KEY_ID
/opt/rustbox-package-builder/scripts/init-storage.sh
while true; do
if ! /opt/rustbox-package-builder/scripts/check-and-build.sh; then
log "build cycle failed; retrying after ${POLL_INTERVAL}s"
fi
sleep "$POLL_INTERVAL"
done
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source /opt/rustbox-package-builder/scripts/lib.sh
APTLY_CONF="$DATA_DIR/state/aptly.conf"
APTLY_ROOT="$DATA_DIR/aptly"
REPOSITORY="$DATA_DIR/repository"
mkdir -p "$APTLY_ROOT" "$REPOSITORY/releases" "$DATA_DIR/packages/deb" "$DATA_DIR/packages/rpm/x86_64" "$DATA_DIR/packages/suse/x86_64"
cat > "$APTLY_CONF" <<JSON
{
"rootDir": "$APTLY_ROOT",
"architectures": ["amd64"],
"dependencyFollowSuggests": false,
"dependencyFollowRecommends": false,
"gpgDisableSign": false,
"gpgDisableVerify": false,
"gpgProvider": "gpg",
"skipLegacyPool": true
}
JSON
export APTLY_CONF
if ! aptly -config="$APTLY_CONF" repo list -raw | grep -Fx rustbox >/dev/null; then
aptly -config="$APTLY_CONF" repo create -distribution="$REPO_DISTRIBUTION" -component="$REPO_COMPONENT" rustbox >/dev/null
fi
for item in apt rpm suse repo-key.gpg LINUX-BUILD-INFO.txt SUSE-BUILD-INFO.txt; do
[[ ! -e "$REPOSITORY/$item" || -L "$REPOSITORY/$item" ]] || fatal "$REPOSITORY/$item exists and is not a symlink"
done
ln -sfn current/apt "$REPOSITORY/apt"
ln -sfn current/rpm "$REPOSITORY/rpm"
ln -sfn current/suse "$REPOSITORY/suse"
ln -sfn current/repo-key.gpg "$REPOSITORY/repo-key.gpg"
ln -sfn current/LINUX-BUILD-INFO.txt "$REPOSITORY/LINUX-BUILD-INFO.txt"
ln -sfn current/SUSE-BUILD-INFO.txt "$REPOSITORY/SUSE-BUILD-INFO.txt"
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -Eeuo pipefail
PACKAGING_REVISION=1
DATA_DIR=${DATA_DIR:-/var/lib/rustbox-package-builder}
log() { printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
fatal() { log "ERROR: $*" >&2; exit 1; }
require_cmd() { command -v "$1" >/dev/null 2>&1 || fatal "required command not found: $1"; }
yaml_quote() { python3 - "$1" <<'PY'
import json, sys
print(json.dumps(sys.argv[1]))
PY
}
atomic_write() { local path=$1 tmp="${path}.tmp.$$"; cat > "$tmp"; mv -f "$tmp" "$path"; }
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source /opt/rustbox-package-builder/scripts/lib.sh
SRC=${1:?source dir required}
PKG=${2:?package work dir required}
BINARY=${3:?binary path required}
VERSION=${4:?version required}
PACKAGE_RELEASE=${5:?release required}
rm -rf "$PKG"
mkdir -p "$PKG/payload/usr/local/bin" "$PKG/payload/usr/lib/systemd/system" "$PKG/payload/etc/rustbox" "$PKG/payload/etc/logrotate.d" "$PKG/scripts"
install -m 0755 "$BINARY" "$PKG/payload/usr/local/bin/rustbox"
install -m 0644 /opt/rustbox-package-builder/packaging/rustbox.service "$PKG/payload/usr/lib/systemd/system/rustbox.service"
install -m 0640 /opt/rustbox-package-builder/packaging/rustbox.toml "$PKG/payload/etc/rustbox/rustbox.toml"
install -m 0644 /opt/rustbox-package-builder/packaging/rustbox.logrotate "$PKG/payload/etc/logrotate.d/rustbox"
cat > "$PKG/scripts/deb-postinstall.sh" <<'SCRIPT'
#!/bin/sh
set -e
getent group rustbox >/dev/null 2>&1 || addgroup --system rustbox >/dev/null
if ! id rustbox >/dev/null 2>&1; then
adduser --system --ingroup rustbox --home /var/lib/rustbox --no-create-home --shell /usr/sbin/nologin rustbox >/dev/null
fi
install -d -o rustbox -g rustbox -m 0750 /etc/rustbox /var/log/rustbox
install -d -o rustbox -g rustbox -m 0700 /var/lib/rustbox
chown rustbox:rustbox /etc/rustbox/rustbox.toml
chmod 0640 /etc/rustbox/rustbox.toml
RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml /usr/local/bin/rustbox --check-config
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
systemctl enable rustbox.service >/dev/null 2>&1 || true
if [ -d /run/systemd/system ]; then systemctl restart rustbox.service || systemctl start rustbox.service || true; fi
fi
SCRIPT
cat > "$PKG/scripts/deb-preremove.sh" <<'SCRIPT'
#!/bin/sh
set -e
if [ "${1:-}" = remove ] && command -v systemctl >/dev/null 2>&1; then
systemctl disable --now rustbox.service >/dev/null 2>&1 || true
fi
SCRIPT
cat > "$PKG/scripts/deb-postremove.sh" <<'SCRIPT'
#!/bin/sh
set -e
command -v systemctl >/dev/null 2>&1 && systemctl daemon-reload >/dev/null 2>&1 || true
SCRIPT
cat > "$PKG/scripts/rpm-postinstall.sh" <<'SCRIPT'
#!/bin/sh
set -e
getent group rustbox >/dev/null 2>&1 || groupadd --system rustbox
if ! id rustbox >/dev/null 2>&1; then
NOLOGIN=/usr/sbin/nologin
[ -x "$NOLOGIN" ] || NOLOGIN=/sbin/nologin
useradd --system --gid rustbox --home-dir /var/lib/rustbox --no-create-home --shell "$NOLOGIN" rustbox
fi
install -d -o rustbox -g rustbox -m 0750 /etc/rustbox /var/log/rustbox
install -d -o rustbox -g rustbox -m 0700 /var/lib/rustbox
chown rustbox:rustbox /etc/rustbox/rustbox.toml
chmod 0640 /etc/rustbox/rustbox.toml
RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml /usr/local/bin/rustbox --check-config
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
systemctl enable rustbox.service >/dev/null 2>&1 || true
if [ -d /run/systemd/system ]; then systemctl restart rustbox.service || systemctl start rustbox.service || true; fi
fi
SCRIPT
cat > "$PKG/scripts/rpm-preremove.sh" <<'SCRIPT'
#!/bin/sh
set -e
if [ "${1:-1}" -eq 0 ] 2>/dev/null && command -v systemctl >/dev/null 2>&1; then
systemctl disable --now rustbox.service >/dev/null 2>&1 || true
fi
SCRIPT
cat > "$PKG/scripts/rpm-postremove.sh" <<'SCRIPT'
#!/bin/sh
set -e
command -v systemctl >/dev/null 2>&1 && systemctl daemon-reload >/dev/null 2>&1 || true
SCRIPT
chmod 0755 "$PKG/scripts"/*.sh
CONTENTS="$PKG/contents.yaml"
cat > "$CONTENTS" <<YAML
- src: $(yaml_quote "$PKG/payload/usr/local/bin/rustbox")
dst: /usr/local/bin/rustbox
file_info:
mode: 0755
- src: $(yaml_quote "$PKG/payload/usr/lib/systemd/system/rustbox.service")
dst: /usr/lib/systemd/system/rustbox.service
file_info:
mode: 0644
- src: $(yaml_quote "$PKG/payload/etc/rustbox/rustbox.toml")
dst: /etc/rustbox/rustbox.toml
type: config|noreplace
file_info:
mode: 0640
- src: $(yaml_quote "$PKG/payload/etc/logrotate.d/rustbox")
dst: /etc/logrotate.d/rustbox
type: config|noreplace
file_info:
mode: 0644
YAML
cat > "$PKG/nfpm-deb.yaml" <<YAML
name: rustbox
arch: amd64
platform: linux
version: $(yaml_quote "$VERSION")
release: "$PACKAGE_RELEASE"
section: mail
priority: optional
maintainer: "RustBox"
description: "RustBox webmail server"
license: "UNKNOWN"
contents:
$(cat "$CONTENTS")
depends:
- adduser
- ca-certificates
- gnupg
- libssl3 | libssl3t64
- logrotate
- systemd
scripts:
postinstall: $(yaml_quote "$PKG/scripts/deb-postinstall.sh")
preremove: $(yaml_quote "$PKG/scripts/deb-preremove.sh")
postremove: $(yaml_quote "$PKG/scripts/deb-postremove.sh")
deb:
compression: xz
signature:
key_file: $(yaml_quote "$GPG_PRIVATE_KEY_FILE")
type: origin
YAML
cat > "$PKG/nfpm-rpm.yaml" <<YAML
name: rustbox
arch: amd64
platform: linux
version: $(yaml_quote "$VERSION")
release: "$PACKAGE_RELEASE"
section: mail
priority: optional
maintainer: "RustBox"
description: "RustBox webmail server"
license: "UNKNOWN"
contents:
$(cat "$CONTENTS")
depends:
- ca-certificates
- gnupg2
- openssl-libs
- logrotate
- shadow-utils
- systemd
scripts:
postinstall: $(yaml_quote "$PKG/scripts/rpm-postinstall.sh")
preremove: $(yaml_quote "$PKG/scripts/rpm-preremove.sh")
postremove: $(yaml_quote "$PKG/scripts/rpm-postremove.sh")
rpm:
compression: zstd
signature:
key_file: $(yaml_quote "$GPG_PRIVATE_KEY_FILE")
YAML
cat > "$PKG/nfpm-suse.yaml" <<YAML
name: rustbox
arch: amd64
platform: linux
version: $(yaml_quote "$VERSION")
release: "$PACKAGE_RELEASE"
section: mail
priority: optional
maintainer: "RustBox"
description: "RustBox webmail server"
license: "UNKNOWN"
contents:
$(cat "$CONTENTS")
depends:
- ca-certificates
- gpg2
- libopenssl3
- logrotate
- shadow
- systemd
scripts:
postinstall: $(yaml_quote "$PKG/scripts/rpm-postinstall.sh")
preremove: $(yaml_quote "$PKG/scripts/rpm-preremove.sh")
postremove: $(yaml_quote "$PKG/scripts/rpm-postremove.sh")
rpm:
compression: gzip
signature:
key_file: $(yaml_quote "$GPG_PRIVATE_KEY_FILE")
YAML
Executable
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$ROOT"
ENV_FILE="$ROOT/.env"
DATA_DIR="$ROOT/data"
GPG_DIR="$ROOT/gpg"
DEFAULT_HTTP_PORT=18765
log() { printf '[rustbox-package-builder] %s\n' "$*"; }
die() { printf '[rustbox-package-builder] ERROR: %s\n' "$*" >&2; exit 1; }
require_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; }
require_cmd docker
if ! docker info >/dev/null 2>&1; then
die "Docker daemon is unavailable for the current user"
fi
if docker compose version >/dev/null 2>&1; then
COMPOSE=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE=(docker-compose)
else
die "Docker Compose is required"
fi
if [[ ! -f "$ENV_FILE" ]]; then
cp "$ROOT/.env.example" "$ENV_FILE"
chmod 0600 "$ENV_FILE"
die "created .env; set SOURCE_REPO and REPOSITORY_URL, then run ./start.sh again"
fi
set -a
source "$ENV_FILE"
set +a
: "${SOURCE_REPO:?SOURCE_REPO is required in .env}"
: "${REPOSITORY_URL:?REPOSITORY_URL is required in .env}"
HTTP_PORT=${HTTP_PORT:-$DEFAULT_HTTP_PORT}
[[ "$HTTP_PORT" =~ ^[0-9]+$ ]] || die "HTTP_PORT must be numeric"
(( HTTP_PORT >= 1024 && HTTP_PORT <= 65535 )) || die "HTTP_PORT must be between 1024 and 65535"
REPOSITORY_URL=${REPOSITORY_URL%/}
case "$REPOSITORY_URL" in
http://*|https://*) ;;
*) die "REPOSITORY_URL must start with http:// or https://" ;;
esac
if [[ "$REPOSITORY_URL" == http://* && ! "$REPOSITORY_URL" =~ :[0-9]+$ ]]; then
REPOSITORY_URL="${REPOSITORY_URL}:${HTTP_PORT}"
if grep -q '^REPOSITORY_URL=' "$ENV_FILE"; then
sed -i "s#^REPOSITORY_URL=.*#REPOSITORY_URL=${REPOSITORY_URL}#" "$ENV_FILE"
fi
export REPOSITORY_URL
log "REPOSITORY_URL updated to $REPOSITORY_URL"
fi
mkdir -p "$DATA_DIR"/{repository,state,cargo,work,packages} "$GPG_DIR"
chmod 0700 "$GPG_DIR" 2>/dev/null || true
log "building builder and repository dashboard"
"${COMPOSE[@]}" --env-file "$ENV_FILE" build rustbox-package-builder repository-dashboard
if [[ ! -s "$GPG_DIR/private-key.asc" ]]; then
log "generating persistent GPG signing key"
IMAGE_REF=$("${COMPOSE[@]}" --env-file "$ENV_FILE" config | awk '
$1 == "rustbox-package-builder:" { in_builder=1; next }
in_builder && $1 == "image:" { print $2; exit }
')
[[ -n "$IMAGE_REF" ]] || die "cannot resolve builder image name"
uid=$(id -u)
gid=$(id -g)
docker run --rm --user "$uid:$gid" --entrypoint /bin/bash -e HOME=/tmp \
-v "$GPG_DIR:/out" "$IMAGE_REF" -lc '
set -Eeuo pipefail
export GNUPGHOME=/tmp/rustbox-keygen
mkdir -p "$GNUPGHOME" && chmod 0700 "$GNUPGHOME"
cat > /tmp/gpg-batch <<"KEY"
Key-Type: RSA
Key-Length: 3072
Key-Usage: sign
Name-Real: RustBox Package Repository
Name-Email: packages@localhost
Expire-Date: 0
%no-protection
%commit
KEY
gpg --batch --generate-key /tmp/gpg-batch
fingerprint=$(gpg --batch --with-colons --list-secret-keys | awk -F: '\''/^sec:/ {seen=1; next} seen && /^fpr:/ {print $10; exit}'\'')
test -n "$fingerprint"
gpg --batch --armor --export-secret-keys "$fingerprint" > /out/private-key.asc
gpg --batch --armor --export "$fingerprint" > /out/public-key.asc
printf "%s\n" "$fingerprint" > /out/fingerprint
chmod 0600 /out/private-key.asc
chmod 0644 /out/public-key.asc /out/fingerprint
'
fi
log "starting builder and repository HTTP service"
"${COMPOSE[@]}" --env-file "$ENV_FILE" up -d rustbox-package-builder repository-http
printf '\nRepository: %s/\n' "$REPOSITORY_URL"
printf 'Local HTTP: http://127.0.0.1:%s/\n' "$HTTP_PORT"
printf 'Health: http://127.0.0.1:%s/healthz\n' "$HTTP_PORT"
printf 'Builder log: '
printf '%q ' "${COMPOSE[@]}"
printf 'logs -f rustbox-package-builder\n'
+34
View File
@@ -0,0 +1,34 @@
import importlib.util
import os
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
os.environ.setdefault("REPOSITORY_URL", "https://repo.example.test")
spec = importlib.util.spec_from_file_location("dashboard_app", ROOT / "dashboard/app.py")
app = importlib.util.module_from_spec(spec)
spec.loader.exec_module(app)
def test_package_filename_parsing():
assert app.parse_package(Path("rustbox_0.0.5-1_amd64.deb"), "deb") == ("rustbox", "0.0.5-1", "amd64")
assert app.parse_package(Path("rustbox-0.0.5-1.x86_64.rpm"), "rpm") == ("rustbox", "0.0.5-1", "x86_64")
def test_catalog_reads_linux_snapshot():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
rel = root / "releases/one"
(rel / "apt/pool/main/r/rustbox").mkdir(parents=True)
(rel / "rpm/x86_64").mkdir(parents=True)
(rel / "suse/x86_64").mkdir(parents=True)
(rel / "LINUX-BUILD-INFO.txt").write_text("app_version=0.0.5\npackage_release=1\nbuilt_at=2026-10-30T12:00:00Z\nsource_commit=abc\npackaging_revision=1\n")
(rel / "SUSE-BUILD-INFO.txt").write_text("app_version=0.0.5\npackage_release=1\nbuilt_at=2026-10-30T12:00:00Z\nsource_commit=abc\npackaging_revision=1\n")
(rel / "apt/pool/main/r/rustbox/rustbox_0.0.5-1_amd64.deb").write_bytes(b"deb")
(rel / "rpm/x86_64/rustbox-0.0.5-1.x86_64.rpm").write_bytes(b"rpm")
(rel / "suse/x86_64/rustbox-0.0.5-1.x86_64.rpm").write_bytes(b"rpm")
(root / "current").symlink_to("releases/one")
catalog = app.generate_catalog(root)
assert catalog["ready"] is True
assert catalog["builds"]["linux"]["app_version"] == "0.0.5"
assert len(catalog["packages"]) == 3
+59
View File
@@ -0,0 +1,59 @@
from pathlib import Path
import tomllib
ROOT = Path(__file__).resolve().parents[1]
def test_packaged_config_matches_linux_layout():
config = tomllib.loads((ROOT / "packaging/rustbox.toml").read_text())
assert config["host"] == "0.0.0.0"
assert config["data_dir"] == "/var/lib/rustbox"
assert config["log_dir"] == "/var/log/rustbox"
assert config["default_interface_skin"] == "apple-mail"
assert config["max_attachment_size_mb"] == 50
assert config["max_message_size_mb"] == 100
def test_systemd_hardening_and_paths():
unit = (ROOT / "packaging/rustbox.service").read_text()
for needle in (
"User=rustbox",
"Group=rustbox",
"WorkingDirectory=/var/lib/rustbox",
"ExecStart=/usr/local/bin/rustbox",
"StateDirectory=rustbox",
"LogsDirectory=rustbox",
"ProtectSystem=strict",
"ProtectHome=true",
"NoNewPrivileges=true",
"StandardOutput=null",
):
assert needle in unit
def test_logrotate_keeps_seven_daily_rotations():
data = (ROOT / "packaging/rustbox.logrotate").read_text()
assert "/var/log/rustbox/rustbox.log" in data
assert "daily" in data
assert "rotate 7" in data
assert "create 0600 rustbox rustbox" in data
def test_package_runtime_dependencies_are_explicit():
script = (ROOT / "scripts/prepare-package.sh").read_text()
for needle in (
"- gnupg",
"- libssl3 | libssl3t64",
"- gnupg2",
"- openssl-libs",
"- gpg2",
"- libopenssl3",
):
assert needle in script
def test_docker_context_excludes_secrets_and_state():
data = (ROOT / ".dockerignore").read_text().splitlines()
assert ".env" in data
assert "gpg/" in data
assert "data/" in data