first commit
This commit is contained in:
commit
0efaae1715
27 files changed
+1455
No files matched your search
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.env
|
||||
data/
|
||||
gpg/
|
||||
*.zip
|
||||
*.sha256
|
||||
**/__pycache__/
|
||||
**/.pytest_cache/
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
data/
|
||||
gpg/
|
||||
*.zip
|
||||
__pycache__/
|
||||
+54
@@ -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"]
|
||||
@@ -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`.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,25 @@
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||||
})();
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/var/log/rustbox/rustbox.log {
|
||||
daily
|
||||
rotate 7
|
||||
missingok
|
||||
notifempty
|
||||
compress
|
||||
delaycompress
|
||||
copytruncate
|
||||
create 0600 rustbox rustbox
|
||||
su rustbox rustbox
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+147
@@ -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"
|
||||
Executable
+27
@@ -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
|
||||
Executable
+35
@@ -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"
|
||||
Executable
+31
@@ -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
|
||||
Executable
+32
@@ -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
@@ -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"; }
|
||||
Executable
+189
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user