From 2b8f11ef8c3fb65569e518b94289406136472c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 7 Oct 2026 09:23:17 +0200 Subject: [PATCH] opn --- Dockerfile | 47 ++- README.md | 285 ++++++++---------- docker-compose.yml | 18 ++ http/nginx.conf | 51 +++- .../patches/0.20.0/0001-opnsense-build.patch | 75 +++++ opnsense/plugin/+POST_INSTALL.pre | 47 +++ opnsense/plugin/+PRE_DEINSTALL.pre | 13 + opnsense/plugin/Makefile | 9 + opnsense/plugin/pkg-descr | 4 + opnsense/plugin/src/etc/rc.d/rustproxy | 276 +++++++++++++++++ .../src/etc/rc.syshook.d/10-rustproxy.start | 4 + .../RustProxy/Api/ServiceController.php | 20 ++ .../RustProxy/Api/SettingsController.php | 11 + .../OPNsense/RustProxy/IndexController.php | 12 + .../OPNsense/RustProxy/forms/general.xml | 79 +++++ .../app/models/OPNsense/RustProxy/ACL/ACL.xml | 10 + .../models/OPNsense/RustProxy/Menu/Menu.xml | 7 + .../OPNsense/RustProxy/Migrations/M1_4_0.php | 68 +++++ .../models/OPNsense/RustProxy/RustProxy.php | 9 + .../models/OPNsense/RustProxy/RustProxy.xml | 67 ++++ .../app/views/OPNsense/RustProxy/index.volt | 141 +++++++++ .../src/opnsense/scripts/rustproxy/service.sh | 45 +++ .../conf/actions.d/actions_rustproxy.conf | 42 +++ .../templates/OPNsense/RustProxy/+TARGETS | 1 + .../OPNsense/RustProxy/rustproxy.conf | 18 ++ scripts/apply-freebsd-patches.sh | 39 +++ scripts/build-opnsense.sh | 51 ++++ scripts/build-release.sh | 99 +++++- scripts/entrypoint.sh | 28 +- scripts/generate-index.sh | 27 +- scripts/generate-opnsense-index.sh | 71 +++++ scripts/generate-opnsense-repo.py | 141 +++++++++ scripts/init-storage.sh | 3 +- scripts/package-opnsense.sh | 143 +++++++++ start.sh | 167 +++++----- tests/static.sh | 49 +-- 36 files changed, 1868 insertions(+), 309 deletions(-) create mode 100644 opnsense/patches/0.20.0/0001-opnsense-build.patch create mode 100755 opnsense/plugin/+POST_INSTALL.pre create mode 100755 opnsense/plugin/+PRE_DEINSTALL.pre create mode 100644 opnsense/plugin/Makefile create mode 100644 opnsense/plugin/pkg-descr create mode 100755 opnsense/plugin/src/etc/rc.d/rustproxy create mode 100755 opnsense/plugin/src/etc/rc.syshook.d/10-rustproxy.start create mode 100644 opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/Api/ServiceController.php create mode 100644 opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/Api/SettingsController.php create mode 100644 opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/IndexController.php create mode 100644 opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/forms/general.xml create mode 100644 opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/ACL/ACL.xml create mode 100644 opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/Menu/Menu.xml create mode 100644 opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/Migrations/M1_4_0.php create mode 100644 opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/RustProxy.php create mode 100644 opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/RustProxy.xml create mode 100644 opnsense/plugin/src/opnsense/mvc/app/views/OPNsense/RustProxy/index.volt create mode 100755 opnsense/plugin/src/opnsense/scripts/rustproxy/service.sh create mode 100644 opnsense/plugin/src/opnsense/service/conf/actions.d/actions_rustproxy.conf create mode 100644 opnsense/plugin/src/opnsense/service/templates/OPNsense/RustProxy/+TARGETS create mode 100644 opnsense/plugin/src/opnsense/service/templates/OPNsense/RustProxy/rustproxy.conf create mode 100755 scripts/apply-freebsd-patches.sh create mode 100755 scripts/build-opnsense.sh create mode 100755 scripts/generate-opnsense-index.sh create mode 100755 scripts/generate-opnsense-repo.py create mode 100755 scripts/package-opnsense.sh diff --git a/Dockerfile b/Dockerfile index 29f46d2..17fb89c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,36 @@ +# syntax=docker/dockerfile:1 + +# FreeBSD headers and libraries used only as a cross-compilation sysroot. +FROM rockylinux:9 AS freebsd-sysroot +ARG FREEBSD_RELEASE=15.1-RELEASE +ARG FREEBSD_MIRROR=https://download.freebsd.org/releases/amd64/amd64 +RUN dnf -y install --allowerasing ca-certificates coreutils curl tar xz \ + && dnf clean all \ + && rm -rf /var/cache/dnf +WORKDIR /tmp/freebsd +RUN set -eux; \ + url="${FREEBSD_MIRROR}/${FREEBSD_RELEASE}"; \ + curl -fsSL "${url}/base.txz" -o base.txz; \ + curl -fsSL "${url}/MANIFEST" -o MANIFEST; \ + expected="$(awk '$1 == "base.txz" { print $2; exit }' MANIFEST)"; \ + test -n "$expected"; \ + echo "$expected base.txz" | sha256sum -c -; \ + mkdir -p /freebsd-sysroot; \ + tar -xJf base.txz -C /freebsd-sysroot ./lib ./usr/include ./usr/lib + FROM rockylinux:9 ARG RUST_TOOLCHAIN=stable +ARG FREEBSD_RUST_TOOLCHAIN=1.88.0 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 cmake coreutils createrepo_c curl findutils \ - gcc gcc-c++ git gnupg2 gzip make openssl pkgconf-pkg-config python3 python3.11 \ - rpm rsync shadow-utils sqlite-devel tar unzip xz bzip2 \ + bash binutils ca-certificates clang cmake coreutils createrepo_c curl file findutils \ + gcc gcc-c++ git gnupg2 gzip libarchive lld llvm make openssl pkgconf-pkg-config \ + python3 python3.11 rpm rsync shadow-utils sqlite-devel tar unzip xz bzip2 zstd \ && dnf clean all \ && rm -rf /var/cache/dnf @@ -40,14 +61,30 @@ 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}" --component rustfmt --component clippy; \ rm -f /tmp/rustup-init.sh; \ + rustup toolchain install "${FREEBSD_RUST_TOOLCHAIN}" --profile minimal; \ + rustup target add --toolchain "${FREEBSD_RUST_TOOLCHAIN}" x86_64-unknown-freebsd; \ rustc --version; \ - cargo --version + cargo --version; \ + rustc "+${FREEBSD_RUST_TOOLCHAIN}" --version + +COPY --from=freebsd-sysroot /freebsd-sysroot /opt/freebsd-sysroot + +ENV FREEBSD_RUST_TOOLCHAIN=${FREEBSD_RUST_TOOLCHAIN} \ + CARGO_TARGET_X86_64_UNKNOWN_FREEBSD_LINKER=clang \ + CC_x86_64_unknown_freebsd=clang \ + CXX_x86_64_unknown_freebsd=clang++ \ + AR_x86_64_unknown_freebsd=llvm-ar \ + RANLIB_x86_64_unknown_freebsd=llvm-ranlib \ + CFLAGS_x86_64_unknown_freebsd="--target=x86_64-unknown-freebsd15.1 --sysroot=/opt/freebsd-sysroot" \ + CXXFLAGS_x86_64_unknown_freebsd="--target=x86_64-unknown-freebsd15.1 --sysroot=/opt/freebsd-sysroot" \ + CARGO_TARGET_X86_64_UNKNOWN_FREEBSD_RUSTFLAGS="-C link-arg=--target=x86_64-unknown-freebsd15.1 -C link-arg=--sysroot=/opt/freebsd-sysroot -C link-arg=-fuse-ld=lld" # Registry/git/target cache is persistent, while rustup/toolchains stay in the image. ENV CARGO_HOME=/var/lib/package-builder/cargo COPY scripts/ /opt/rust-package-builder/scripts/ -RUN chmod 0755 /opt/rust-package-builder/scripts/*.sh +COPY opnsense/ /opt/rust-package-builder/opnsense/ +RUN chmod 0755 /opt/rust-package-builder/scripts/*.sh /opt/rust-package-builder/scripts/*.py VOLUME ["/var/lib/package-builder"] ENTRYPOINT ["/opt/rust-package-builder/scripts/entrypoint.sh"] diff --git a/README.md b/README.md index 9da1841..ead1b12 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,159 @@ -# Rust Package Builder +# RustProxy unified package builder -Minimalny automat Docker, który cyklicznie sprawdza branch Git, buduje projekt Rust i publikuje podpisane repozytoria APT oraz RPM/DNF. Projekt zawiera również mały, utwardzony serwer HTTP udostępniający gotowe repozytorium i stronę z instrukcją instalacji. +Jeden kontener buildera buduje z jednego commita RustProxy: -## Obsługiwane cele +- Linux x86_64: DEB + podpisane repo APT, +- Linux x86_64: RPM + podpisane repo DNF/YUM, +- FreeBSD x86_64: cross-build bez VM FreeBSD, +- OPNsense: `os-rustproxy-_.pkg` + statyczne repo `pkg`. -- architektura: amd64 / x86_64 -- Debian 12, Debian 13 -- Ubuntu 24.04, Ubuntu 26.04 -- RHEL/Rocky/Alma 9 i 10 +Nie ma Docker-in-Docker, SCP ani osobnego watchera repo OPNsense. FreeBSD sysroot, clang/lld i Rust target są częścią głównego obrazu buildera. OPNsense dostaje osobny publiczny adres, ale wszystkie artefakty są publikowane jednym atomowym przełączeniem `repository/current`. -Obraz buildera bazuje na Rocky Linux 9. Dzięki temu binarka jest budowana na glibc 2.34; builder dodatkowo odrzuca wynik wymagający nowszego symbolu glibc. Ta sama binarka może być pakowana do DEB i RPM dla wymienionych, nowszych systemów x86_64. - -## RustProxy - -Dla dostarczonego RustProxy builder automatycznie używa plików znajdujących się w repozytorium źródłowym: - -- `debian/rustproxy.service` -- `debian/rustproxy.environment` -- `debian/rustproxy.logrotate` -- `debian/rustproxy.tmpfiles` -- logiki `debian/rustproxy.postinst` -- `grafana/rustproxy-overview.json` - -Nie utrzymuje drugiej kopii konfiguracji systemd RustProxy. Wersja i nazwa binarki pochodzą z metadanych Cargo, a nazwa pakietu i maintainer są przejmowane z `debian/control`, jeśli plik istnieje. - -## Uruchomienie - -Ustawiasz ręcznie tylko dwie wartości: +## Szybki start ```bash cp .env.example .env nano .env +./start.sh ``` -Przykład dla serwera `192.168.1.100`: +Minimalnie ustaw: ```env SOURCE_REPO=https://git.linuxiarz.pl/gru/RustProxy.git REPOSITORY_URL=http://192.168.1.100 +OPNSENSE_REPOSITORY_URL=https://opn-rustproxy.linuxiarz.pl ``` -Następnie: +Dla `http://` bez portu `start.sh` dopisze domyślny port. Dla `https://` zakładany jest zewnętrzny reverse proxy/TLS. -```bash -./start.sh -``` - -Jeżeli w `REPOSITORY_URL` nie podasz portu, `start.sh` automatycznie ustawi `18765` i zapisze efektywny adres, np.: +Domyślne lokalne porty: ```text -http://192.168.1.100:18765 +APT/RPM: 18765 -> nginx :8080 +OPNsense: 18766 -> nginx :8081 ``` -Jeżeli chcesz inny port, wystarczy wpisać go bezpośrednio w adresie: - -```env -REPOSITORY_URL=http://192.168.1.100:23456 -``` - -Nie trzeba ustawiać osobnego `HTTP_PORT` ręcznie — skrypt pobierze go z adresu. `HTTP_PORT` jest zapisywany pomocniczo do `.env`, aby Compose zachował tę samą konfigurację. - -`start.sh` automatycznie: - -- sprawdza Docker i Docker Compose, -- tworzy katalogi `data/` i `gpg/`, -- buduje obraz buildera, -- przy pierwszym uruchomieniu generuje trwały klucz GPG RSA 3072 bez hasła, -- zapisuje klucz prywatny jako `gpg/private-key.asc`, publiczny jako `gpg/public-key.asc` oraz fingerprint jako `gpg/fingerprint`, -- przy kolejnych uruchomieniach używa tego samego klucza, -- uruchamia builder i mikroserwer HTTP, -- builder natychmiast sprawdza repozytorium Git i po udanym buildzie publikuje APT oraz RPM/DNF, -- strona główna HTTP automatycznie pokazuje instrukcję instalacji i linki do klucza/metadanych. - -Można też uruchomić bez wcześniejszego tworzenia `.env`: - -```bash -SOURCE_REPO=https://git.linuxiarz.pl/gru/RustProxy.git \ -REPOSITORY_URL=http://192.168.1.100 \ -./start.sh -``` - -## Mikro HTTP - -Repozytorium jest wystawiane przez osobny kontener `repository-http` oparty o `nginxinc/nginx-unprivileged:stable-alpine-slim`. - -Domyślnie: +Przykładowo reverse proxy może kierować: ```text -http://ADRES_SERWERA:18765/ +packages.example.net -> http://builder:18765 +opn-rustproxy.linuxiarz.pl -> http://builder:18766 ``` -Serwer HTTP jest celowo bardzo prosty: - -- działa jako użytkownik nieuprzywilejowany, -- kontener ma `cap_drop: ALL`, -- ma `no-new-privileges`, -- root filesystem jest read-only, -- repozytorium jest montowane read-only, -- obsługuje wyłącznie `GET` i `HEAD`, -- nie pozwala na upload, -- nie pokazuje listingów katalogów, -- blokuje pliki ukryte, -- dodaje podstawowe nagłówki bezpieczeństwa, -- posiada `/healthz`, -- logi idą do `docker compose logs`. - -Przed pierwszym udanym buildem `/` wyświetla prostą informację, że repozytorium jest inicjalizowane. Po publikacji builder atomowo podmienia stronę na właściwą instrukcję instalacji. - -### HTTPS - -Wbudowany mikroserwer świadomie obsługuje tylko HTTP. Jeżeli `REPOSITORY_URL` zaczyna się od `https://`, zakładane jest, że przed nim stoi zewnętrzny reverse proxy/TLS (np. RustProxy/nginx/Caddy). Sam builder nadal wystawia lokalny HTTP na porcie `18765` lub `HTTP_PORT`. - -Dla kompletnego uruchomienia bez reverse proxy użyj `http://IP_LUB_HOST`. - -## Klucz GPG - -Klucz jest generowany tylko wtedy, gdy `gpg/private-key.asc` nie istnieje. Nie usuwaj katalogu `gpg/` po publikacji repozytorium: zmiana klucza spowoduje, że klienci z wcześniej zainstalowanym kluczem przestaną ufać nowym podpisom. - -Do backupu zachowaj co najmniej: +## Pipeline ```text -gpg/private-key.asc -gpg/public-key.asc -gpg/fingerprint -data/ +Git RustProxy + | + +-> cargo test + +-> Linux cargo build + | +-> DEB + | +-> RPM + | + +-> izolowana kopia źródła + +-> poprawki FreeBSD/OPNsense + +-> x86_64-unknown-freebsd + +-> os-rustproxy.pkg + | + +-> APT metadata + +-> RPM metadata + +-> OPNsense pkg metadata + +-> walidacja + +-> atomic switch repository/current +``` + +Jeżeli którykolwiek etap się nie powiedzie, publiczne repozytoria nie są przełączane. + +## OPNsense repo + +Publiczny root `OPNSENSE_REPOSITORY_URL` zawiera m.in.: + +```text +/ +├── meta.conf +├── packagesite.pkg +├── data.pkg +├── RustProxy.conf +├── BUILD-INFO.txt +├── SHA256 +├── os-rustproxy-latest.pkg +└── 26.7/ + └── amd64/ + ├── os-rustproxy-0.20.0_123.pkg + └── ... +``` + +`RustProxy.conf` jest generowany automatycznie i wskazuje na `OPNSENSE_REPOSITORY_URL`. Repo OPNsense jest obecnie niesygnowane, zgodnie z wcześniejszym builderem/repo (`signature_type: "none"`). Repo APT/RPM pozostaje podpisane GPG. + +Ręczna konfiguracja na OPNsense: + +```sh +mkdir -p /usr/local/etc/pkg/repos +fetch -o /usr/local/etc/pkg/repos/RustProxy.conf \ + https://opn-rustproxy.linuxiarz.pl/RustProxy.conf +pkg update -f +pkg install os-rustproxy ``` ## Konfiguracja -Wymagane: +Najważniejsze zmienne: -```text -SOURCE_REPO -REPOSITORY_URL -``` - -Domyślne / automatyczne: - -```text +```env +SOURCE_REPO=https://git.linuxiarz.pl/gru/RustProxy.git SOURCE_BRANCH=master -POLL_INTERVAL=300 + +REPOSITORY_URL=http://192.168.1.100 HTTP_PORT=18765 -REPO_DISTRIBUTION=stable -REPO_COMPONENT=main + +OPNSENSE_REPOSITORY_URL=https://opn-rustproxy.linuxiarz.pl +OPNSENSE_HTTP_PORT=18766 + +POLL_INTERVAL=300 PACKAGE_RELEASE=auto MAX_GLIBC_VERSION=2.34 -GPG_PRIVATE_KEY_FILE=/run/secrets/gpg/private-key.asc -GPG_PASSPHRASE_FILE=/run/secrets/gpg/passphrase +RUN_LINUX_TESTS=1 + +OPNSENSE_ABI=26.7 +OPNSENSE_RETENTION=10 +FREEBSD_RELEASE=15.1-RELEASE +FREEBSD_ABI=FreeBSD:15:amd64 +FREEBSD_ARCH=freebsd:15:x86:64 +FREEBSD_RUST_TOOLCHAIN=1.88.0 +APPLY_FREEBSD_PATCHES=1 ``` -`GPG_PASSPHRASE_FILE` nie musi istnieć dla automatycznie generowanego klucza bez hasła. `GPG_KEY_ID` może być podany przez ENV; jeśli go brak, builder wybiera pierwszy zaimportowany klucz prywatny. +`OPNSENSE_RETENTION=10` zachowuje ostatnie 10 wersji pakietu dla danej serii/architektury OPNsense. `0` wyłącza usuwanie starych wersji. -## Dane trwałe +## FreeBSD cross-build + +Obraz buildera pobiera i weryfikuje `base.txz` dla `FREEBSD_RELEASE`, a następnie używa: ```text -/var/lib/package-builder/ +/opt/freebsd-sysroot +clang / lld / llvm +Rust target x86_64-unknown-freebsd +``` + +Poprawki tylko dla builda OPNsense są w: + +```text +opnsense/patches//*.patch +``` + +Są nakładane na izolowaną kopię źródła. Build Linux nie jest modyfikowany. + +Kod pluginu OPNsense znajduje się w: + +```text +opnsense/plugin/ +``` + +## Dane + +```text +data/ ├── aptly/ ├── cargo/ ├── packages/ @@ -164,69 +163,33 @@ GPG_PASSPHRASE_FILE=/run/secrets/gpg/passphrase │ ├── current -> releases/ │ ├── apt -> current/apt │ ├── rpm -> current/rpm -│ ├── repo-key.gpg -> current/repo-key.gpg -│ ├── index.html -> current/index.html +│ ├── opnsense -> current/opnsense │ └── releases/ ├── state/ -│ ├── last_commit -│ ├── last_version -│ └── last_package_release └── work/ ``` -Poprzednie wersje pakietów nie są usuwane. Aktualne repozytoria APT i RPM zawierają historię pakietów. Przy `PACKAGE_RELEASE=auto` nowy commit z niezmienionym `Cargo.toml version` dostaje kolejną rewizję paczki (`-2`, `-3`, ...), więc nie nadpisuje wcześniej opublikowanego artefaktu. +Jeden release zawiera jednocześnie `apt/`, `rpm/` i `opnsense/`. Dzięki temu oba publiczne adresy przechodzą na nową wersję jednocześnie. -## Publikacja bez uszkodzenia działającego repo +## HTTP i logi -Nowy commit przechodzi kolejno: - -```text -clone -> cargo build --release -> nFPM DEB/RPM -> podpisy -> aptly -> createrepo_c -> walidacja -> atomic switch -``` - -Publiczny symlink `repository/current` jest podmieniany dopiero na końcu. Błąd kompilacji, pakowania, podpisu lub generowania metadata nie przełącza publicznego repozytorium. `last_commit` jest zapisywany dopiero po udanej publikacji, więc nieudany commit zostanie ponowiony w następnym cyklu. - -Kolejne snapshoty w `repository/releases/` używają hardlinków do niezmienionych plików poprzedniego snapshotu. - -## Podpisy - -- DEB: podpis GPG nFPM (`_gpgorigin`) oraz podpisane `Release.gpg`/`InRelease` przez aptly. -- RPM: podpis GPG pakietu nFPM. -- RPM metadata: `repodata/repomd.xml.asc`; instrukcja używa `repo_gpgcheck=1` i `gpgcheck=1`. -- publiczny klucz: `http(s)://.../repo-key.gpg` jako ASCII-armored OpenPGP, zgodny z `gpg --dearmor` i `rpm --import`. - -## Logi i kontrola +Jeden kontener nginx obsługuje oba repozytoria na dwóch lokalnych portach: ```bash docker compose logs -f rust-package-builder docker compose logs -f repository-http + curl -f http://127.0.0.1:18765/healthz +curl -f http://127.0.0.1:18766/healthz ``` -## Test plików dostarczonego RustProxy +## Testy statyczne ```bash ./tests/static.sh ./tests/check-rustproxy-source.sh /sciezka/do/RustProxy ``` -## Własny klucz GPG (opcjonalnie) +## Klucz GPG -Jeżeli zamiast automatycznie generowanego klucza chcesz użyć własnego, umieść go przed pierwszym `./start.sh` jako: - -```text -gpg/private-key.asc -``` - -Przykładowy eksport: - -```bash -gpg --armor --export-secret-keys YOUR_KEY_ID > gpg/private-key.asc -chmod 600 gpg/private-key.asc -``` - -`start.sh` wykryje istniejący klucz i nie wygeneruje nowego. Klucz prywatny nie jest kopiowany do obrazu. Kontener otrzymuje katalog `gpg/` wyłącznie jako read-only mount `/run/secrets/gpg`. - -## Rocky Linux 9 / pakiety bazowe - -Dockerfile używa `dnf --allowerasing`, ponieważ obrazy Rocky Linux 9 mogą zawierać `coreutils-single` i `curl-minimal`. Podczas budowy są one zastępowane pełnymi pakietami `coreutils` i `curl`, wymaganymi przez builder. +`./start.sh` generuje trwały klucz GPG przy pierwszym uruchomieniu. Katalogu `gpg/` nie należy usuwać po publikacji APT/RPM. diff --git a/docker-compose.yml b/docker-compose.yml index a1b54f3..9fdf0d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,12 +2,29 @@ services: rust-package-builder: build: context: . + args: + FREEBSD_RELEASE: "${FREEBSD_RELEASE:-15.1-RELEASE}" + FREEBSD_RUST_TOOLCHAIN: "${FREEBSD_RUST_TOOLCHAIN:-1.88.0}" image: linuxiarz/rust-package-builder:latest environment: SOURCE_REPO: "${SOURCE_REPO:?SOURCE_REPO is required}" SOURCE_BRANCH: "${SOURCE_BRANCH:-master}" REPOSITORY_URL: "${REPOSITORY_URL:?REPOSITORY_URL is required}" + OPNSENSE_REPOSITORY_URL: "${OPNSENSE_REPOSITORY_URL:?OPNSENSE_REPOSITORY_URL is required}" POLL_INTERVAL: "${POLL_INTERVAL:-300}" + REPO_DISTRIBUTION: "${REPO_DISTRIBUTION:-stable}" + REPO_COMPONENT: "${REPO_COMPONENT:-main}" + PACKAGE_RELEASE: "${PACKAGE_RELEASE:-auto}" + MAX_GLIBC_VERSION: "${MAX_GLIBC_VERSION:-2.34}" + KEEP_WORKSPACE: "${KEEP_WORKSPACE:-0}" + OPNSENSE_ABI: "${OPNSENSE_ABI:-26.7}" + OPNSENSE_RETENTION: "${OPNSENSE_RETENTION:-10}" + FREEBSD_RELEASE: "${FREEBSD_RELEASE:-15.1-RELEASE}" + FREEBSD_ABI: "${FREEBSD_ABI:-FreeBSD:15:amd64}" + FREEBSD_ARCH: "${FREEBSD_ARCH:-freebsd:15:x86:64}" + FREEBSD_RUST_TOOLCHAIN: "${FREEBSD_RUST_TOOLCHAIN:-1.88.0}" + APPLY_FREEBSD_PATCHES: "${APPLY_FREEBSD_PATCHES:-1}" + RUN_LINUX_TESTS: "${RUN_LINUX_TESTS:-1}" volumes: - ./data:/var/lib/package-builder - ./gpg:/run/secrets/gpg:ro @@ -17,6 +34,7 @@ services: image: nginxinc/nginx-unprivileged:stable-alpine-slim ports: - "0.0.0.0:${HTTP_PORT:-18765}:8080" + - "0.0.0.0:${OPNSENSE_HTTP_PORT:-18766}:8081" volumes: - ./data/repository:/srv/repository:ro - ./http/nginx.conf:/etc/nginx/nginx.conf:ro diff --git a/http/nginx.conf b/http/nginx.conf index c6aa278..c03f5b8 100644 --- a/http/nginx.conf +++ b/http/nginx.conf @@ -23,6 +23,7 @@ http { uwsgi_temp_path /tmp/uwsgi_temp; scgi_temp_path /tmp/scgi_temp; + # Linux APT/RPM repository. server { listen 8080 default_server; server_name _; @@ -34,8 +35,6 @@ http { add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "no-referrer" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; if ($request_method !~ ^(GET|HEAD)$) { return 405; @@ -47,12 +46,54 @@ http { } location = / { - try_files /index.html @initializing; + try_files /index.html @linux_initializing; } - location @initializing { + location @linux_initializing { default_type text/html; - return 200 'Rust Package Repository

Rust Package Repository

Repozytorium HTTP działa. Pierwszy build pakietu jeszcze trwa albo nie został zakończony.

Stan buildera sprawdzisz przez docker compose logs -f rust-package-builder.

'; + return 200 'RustProxy repository

RustProxy Linux repository

Repository is being initialized.

'; + } + + location ~ (^|/)\. { + deny all; + } + + location / { + try_files $uri =404; + } + } + + # OPNsense/FreeBSD pkg repository. It has a separate public URL and local port, + # but follows the same atomic repository/current switch as the Linux repo. + server { + listen 8081; + server_name _; + + root /srv/repository/opnsense; + index index.html; + autoindex off; + + #add_header X-Content-Type-Options "nosniff" always; + #add_header X-Frame-Options "DENY" always; + #add_header Referrer-Policy "no-referrer" always; + #add_header Cache-Control "no-cache, no-store, must-revalidate" always; + + if ($request_method !~ ^(GET|HEAD)$) { + return 405; + } + + location = /healthz { + default_type text/plain; + return 200 "ok\n"; + } + + location = / { + try_files /index.html @opnsense_initializing; + } + + location @opnsense_initializing { + default_type text/html; + return 200 'RustProxy OPNsense repository

RustProxy OPNsense repository

Repository is being initialized.

'; } location ~ (^|/)\. { diff --git a/opnsense/patches/0.20.0/0001-opnsense-build.patch b/opnsense/patches/0.20.0/0001-opnsense-build.patch new file mode 100644 index 0000000..f13a6b1 --- /dev/null +++ b/opnsense/patches/0.20.0/0001-opnsense-build.patch @@ -0,0 +1,75 @@ +diff --git a/rust-toolchain.toml b/rust-toolchain.toml +index 05e6ca1..fdda5b9 100644 +--- a/rust-toolchain.toml ++++ b/rust-toolchain.toml +@@ -1,4 +1,4 @@ + [toolchain] +-channel = "stable" ++channel = "1.88.0" + profile = "minimal" + components = ["rustfmt", "clippy"] +diff --git a/src/cache.rs b/src/cache.rs +index fcd6f36..82332e5 100644 +--- a/src/cache.rs ++++ b/src/cache.rs +@@ -3774,9 +3774,10 @@ mod tests { + .unwrap(), + ); + h.insert("age", HeaderValue::from_static("3")); +- let plan = plan(StatusCode::OK, &h, &settings, Duration::from_millis(250)).unwrap(); +- assert!(plan.initial_age >= Duration::from_secs(10)); +- assert!(plan.initial_age < Duration::from_secs(12)); ++ let cache_plan = ++ plan(StatusCode::OK, &h, &settings, Duration::from_millis(250)).unwrap(); ++ assert!(cache_plan.initial_age >= Duration::from_secs(10)); ++ assert!(cache_plan.initial_age < Duration::from_secs(12)); + let fresh = plan( + StatusCode::OK, + &headers("max-age=60"), +diff --git a/src/proxy/websocket.rs b/src/proxy/websocket.rs +index a25776e..c1d3519 100644 +--- a/src/proxy/websocket.rs ++++ b/src/proxy/websocket.rs +@@ -653,7 +653,11 @@ mod tests { + .unwrap(); + request + .extensions_mut() +- .insert("websocket".parse::().unwrap()); ++ .insert( ++ "websocket" ++ .parse::() ++ .unwrap_or_else(|_| panic!("websocket is a valid HTTP/3 protocol token")), ++ ); + request + } + #[test] +@@ -702,7 +706,11 @@ mod tests { + let mut request = h3_request(); + request + .extensions_mut() +- .insert("connect-udp".parse::().unwrap()); ++ .insert( ++ "connect-udp" ++ .parse::() ++ .unwrap_or_else(|_| panic!("connect-udp is a valid HTTP/3 protocol token")), ++ ); + assert!(validate_h3(&request).is_err()); + } + #[test] +diff --git a/tests/http3.rs b/tests/http3.rs +index c5990bc..6f775ea 100644 +--- a/tests/http3.rs ++++ b/tests/http3.rs +@@ -14,7 +14,11 @@ fn rfc9220_extended_connect_is_recognized() { + .unwrap(); + request + .extensions_mut() +- .insert("websocket".parse::().unwrap()); ++ .insert( ++ "websocket" ++ .parse::() ++ .unwrap_or_else(|_| panic!("websocket is a valid HTTP/3 protocol token")), ++ ); + + assert_eq!(h3_protocol(&request), Some("websocket")); + let handshake = validate_h3(&request).expect("valid RFC 9220 bootstrap"); diff --git a/opnsense/plugin/+POST_INSTALL.pre b/opnsense/plugin/+POST_INSTALL.pre new file mode 100755 index 0000000..d55fd8c --- /dev/null +++ b/opnsense/plugin/+POST_INSTALL.pre @@ -0,0 +1,47 @@ +#!/bin/sh + +# Keep the historical service account so package downgrades remain possible, +# but current OPNsense runtime executes RustProxy as root (see rc.d script). +if ! /usr/sbin/pw groupshow rustproxy >/dev/null 2>&1; then + /usr/sbin/pw groupadd rustproxy +fi + +if ! /usr/sbin/pw usershow rustproxy >/dev/null 2>&1; then + /usr/sbin/pw useradd rustproxy -g rustproxy -c "RustProxy daemon" -d /var/db/rustproxy -s /usr/sbin/nologin +fi + +/usr/bin/install -d -o root -g wheel -m 0700 /var/db/rustproxy +/usr/sbin/chown -R root:wheel /var/db/rustproxy +/bin/chmod 0700 /var/db/rustproxy +/usr/bin/install -d -o root -g wheel -m 0750 /var/log/rustproxy +/usr/bin/install -d -o root -g wheel -m 0750 /var/run/rustproxy +/usr/bin/install -d -o root -g wheel -m 0755 /usr/local/etc/rustproxy + +# configd discovers actions only on startup/reload. A direct `pkg add` must +# therefore refresh configd before the MVC service controller can call them. +/usr/sbin/service configd restart >/dev/null 2>&1 || true +/usr/local/sbin/pluginctl -cq cache_flush >/dev/null 2>&1 || true + +# Apply model migrations before rendering the service template. In particular +# 1.4.0 copies the old combined listener values into the split address/port +# fields so upgrades keep their existing ports instead of falling back. +if [ -x /usr/local/opnsense/mvc/script/run_migrations.php ]; then + /usr/local/opnsense/mvc/script/run_migrations.php >/dev/null 2>&1 || true +fi +/usr/local/sbin/configctl template reload OPNsense/RustProxy >/dev/null 2>&1 || true + +# A package upgrade invokes the previous package pre-deinstall hook, which stops +# RustProxy before replacing the binary. Start it again after migrations and +# template rendering. Use the installed service helper directly so this does +# not depend on configd having finished reloading its action registry. The +# helper itself checks enabled=1; on a fresh/default-disabled install this is a +# no-op. restart is deliberate: if an old process survived a forced pkg add, +# it is stopped before the new binary starts. +if [ -x /usr/local/opnsense/scripts/rustproxy/service.sh ]; then + if ! /usr/local/opnsense/scripts/rustproxy/service.sh restart; then + echo "WARNING: RustProxy package installed but the service could not be started." >&2 + echo "Check /var/log/rustproxy/rustproxy.log and run: configctl rustproxy start" >&2 + fi +fi + +exit 0 diff --git a/opnsense/plugin/+PRE_DEINSTALL.pre b/opnsense/plugin/+PRE_DEINSTALL.pre new file mode 100755 index 0000000..0b70e65 --- /dev/null +++ b/opnsense/plugin/+PRE_DEINSTALL.pre @@ -0,0 +1,13 @@ +#!/bin/sh + +# pkg upgrade runs the old package pre-deinstall hook before installing the new +# binary. Stop the supervised child first so the upgrade never leaves an old +# RustProxy process running from an unlinked executable. configctl is preferred +# on OPNsense; the rc.d fallback also works while configd is restarting. +if ! /usr/local/sbin/configctl rustproxy stop >/dev/null 2>&1; then + if [ -x /usr/local/etc/rc.d/rustproxy ]; then + /usr/local/etc/rc.d/rustproxy onestop >/dev/null 2>&1 || true + fi +fi + +exit 0 diff --git a/opnsense/plugin/Makefile b/opnsense/plugin/Makefile new file mode 100644 index 0000000..abd1039 --- /dev/null +++ b/opnsense/plugin/Makefile @@ -0,0 +1,9 @@ +PLUGIN_NAME= rustproxy +PLUGIN_VERSION?= 0.23.3 +PLUGIN_REVISION?= 1 +PLUGIN_COMMENT= RustProxy reverse proxy service +PLUGIN_MAINTAINER?= local@localhost +PLUGIN_WWW?= https://git.linuxiarz.pl/gru/RustProxy +PLUGIN_LICENSE= UNKNOWN + +.include "../../Mk/plugins.mk" diff --git a/opnsense/plugin/pkg-descr b/opnsense/plugin/pkg-descr new file mode 100644 index 0000000..f41d8c3 --- /dev/null +++ b/opnsense/plugin/pkg-descr @@ -0,0 +1,4 @@ +RustProxy packaged as an OPNsense service plugin. + +The OPNsense UI exposes service enable/disable, separate bind-address and +port fields for HTTP, HTTPS/TLS and administration, plus remote-admin opt-in. diff --git a/opnsense/plugin/src/etc/rc.d/rustproxy b/opnsense/plugin/src/etc/rc.d/rustproxy new file mode 100755 index 0000000..3338e84 --- /dev/null +++ b/opnsense/plugin/src/etc/rc.d/rustproxy @@ -0,0 +1,276 @@ +#!/bin/sh + +# PROVIDE: rustproxy +# REQUIRE: LOGIN NETWORKING +# KEYWORD: shutdown + +. /etc/rc.subr + +name="rustproxy" +rcvar="${name}_enable" + +load_rc_config "$name" + +: ${rustproxy_enable:="NO"} +# RustProxy on OPNsense/FreeBSD runs as root. In practice the application +# needs access to its complete data/certificate state and service startup was +# unreliable after daemon(8) dropped privileges to the dedicated account. +: ${rustproxy_user:="root"} +: ${rustproxy_group:="wheel"} +: ${rustproxy_data_dir:="/var/db/rustproxy"} +: ${rustproxy_log_dir:="/var/log/rustproxy"} +: ${rustproxy_log_file:="${rustproxy_log_dir}/rustproxy.log"} +: ${rustproxy_run_dir:="/var/run/rustproxy"} +: ${rustproxy_command:="/usr/local/sbin/rust-proxy"} +: ${rustproxy_restart_delay:="5"} +: ${rustproxy_allow_remote_admin:="1"} +# FreeBSD uses jemalloc rather than glibc. Keep the same low-arena intent as +# Linux MALLOC_ARENA_MAX=2 with the native jemalloc knob. rc.conf can override +# rustproxy_malloc_conf or set it to an empty value to disable the tuning. +if [ "${rustproxy_malloc_conf+x}" != "x" ]; then + rustproxy_malloc_conf="${MALLOC_CONF:-narenas:2}" +fi + +# Split listener settings written by the current OPNsense model. +: ${rustproxy_proxy_address:=""} +: ${rustproxy_proxy_port:=""} +: ${rustproxy_tls_enabled:="1"} +: ${rustproxy_tls_address:=""} +: ${rustproxy_tls_port:=""} +: ${rustproxy_admin_address:=""} +: ${rustproxy_admin_port:=""} + +# Combined listener settings used by plugin model 1.2.x and earlier. +: ${rustproxy_proxy_listen_legacy:=""} +: ${rustproxy_tls_listen_legacy:=""} +: ${rustproxy_admin_listen_legacy:=""} + +# Old rc.conf-style variable names are also accepted for hand-written configs. +: ${rustproxy_proxy_listen:=""} +: ${rustproxy_tls_listen:=""} +: ${rustproxy_admin_listen:=""} + +enabled="0" +config_file="/usr/local/etc/rustproxy/opnsense.conf" +if [ -r "$config_file" ]; then + . "$config_file" +fi + +if [ "$enabled" = "1" ]; then + rustproxy_enable="YES" +else + rustproxy_enable="NO" +fi + +rustproxy_valid_port() +{ + case "$1" in + ''|*[!0-9]*) return 1 ;; + esac + [ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null +} + +rustproxy_valid_listener() +{ + _rp_listener="$1" + case "$_rp_listener" in + \[*\]:*) _rp_port="${_rp_listener##*:}" ;; + *:*) _rp_port="${_rp_listener##*:}" ;; + *) return 1 ;; + esac + rustproxy_valid_port "$_rp_port" +} + +rustproxy_format_listener() +{ + _rp_address="$1" + _rp_port="$2" + case "$_rp_address" in + \[*\]) printf '%s:%s\n' "$_rp_address" "$_rp_port" ;; + *:*) printf '[%s]:%s\n' "$_rp_address" "$_rp_port" ;; + *) printf '%s:%s\n' "$_rp_address" "$_rp_port" ;; + esac +} + +rustproxy_resolve_listener() +{ + _rp_address="$1" + _rp_port="$2" + _rp_legacy="$3" + _rp_old_rc="$4" + _rp_default_address="$5" + _rp_default_port="$6" + _rp_name="$7" + + # New split settings are authoritative as soon as either half is set. + if [ -n "$_rp_address" ] || [ -n "$_rp_port" ]; then + [ -n "$_rp_address" ] || _rp_address="$_rp_default_address" + [ -n "$_rp_port" ] || _rp_port="$_rp_default_port" + if ! rustproxy_valid_port "$_rp_port"; then + echo "Invalid RustProxy ${_rp_name} port: ${_rp_port}" >&2 + return 1 + fi + rustproxy_format_listener "$_rp_address" "$_rp_port" + return 0 + fi + + # Preserve a valid listener from plugin 1.2.x during upgrade. + if [ -n "$_rp_legacy" ] && rustproxy_valid_listener "$_rp_legacy"; then + printf '%s\n' "$_rp_legacy" + return 0 + fi + + # Preserve hand-written rc.conf settings as another compatibility path. + if [ -n "$_rp_old_rc" ] && rustproxy_valid_listener "$_rp_old_rc"; then + printf '%s\n' "$_rp_old_rc" + return 0 + fi + + # Malformed legacy values such as "0.0.0.0:" must never reach RustProxy. + # Fall back to a known-good listener and let the user set the separate port + # field in Services -> RustProxy. + rustproxy_format_listener "$_rp_default_address" "$_rp_default_port" +} + +rustproxy_proxy_listen_resolved="$(rustproxy_resolve_listener \ + "$rustproxy_proxy_address" "$rustproxy_proxy_port" \ + "$rustproxy_proxy_listen_legacy" "$rustproxy_proxy_listen" \ + "0.0.0.0" "8080" "HTTP")" || exit 1 + +rustproxy_tls_listen_resolved="$(rustproxy_resolve_listener \ + "$rustproxy_tls_address" "$rustproxy_tls_port" \ + "$rustproxy_tls_listen_legacy" "$rustproxy_tls_listen" \ + "0.0.0.0" "8181" "HTTPS/TLS")" || exit 1 + +rustproxy_admin_listen_resolved="$(rustproxy_resolve_listener \ + "$rustproxy_admin_address" "$rustproxy_admin_port" \ + "$rustproxy_admin_listen_legacy" "$rustproxy_admin_listen" \ + "0.0.0.0" "9090" "admin")" || exit 1 + +rustproxy_runtime_args="--data-dir ${rustproxy_data_dir} --admin-listen ${rustproxy_admin_listen_resolved} --update-check-interval-secs 0 serve --proxy-listen ${rustproxy_proxy_listen_resolved}" +if [ "$rustproxy_tls_enabled" = "1" ]; then + rustproxy_runtime_args="${rustproxy_runtime_args} --tls-listen ${rustproxy_tls_listen_resolved}" +fi +if [ "$rustproxy_allow_remote_admin" = "1" ]; then + rustproxy_runtime_args="${rustproxy_runtime_args} --allow-remote-admin" +fi + +pidfile="${rustproxy_run_dir}/daemon.pid" +child_pidfile="${rustproxy_run_dir}/rustproxy.pid" +command="/usr/sbin/daemon" + +rustproxy_daemon_user_arg="" +if [ "$rustproxy_user" != "root" ]; then + rustproxy_daemon_user_arg="-u ${rustproxy_user}" +fi +command_args="-r -R ${rustproxy_restart_delay} -P ${pidfile} -p ${child_pidfile} ${rustproxy_daemon_user_arg} -o ${rustproxy_log_file} ${rustproxy_command} ${rustproxy_runtime_args}" +required_files="${rustproxy_command}" +start_precmd="rustproxy_prestart" +start_postcmd="rustproxy_poststart" +stop_postcmd="rustproxy_poststop" +status_cmd="rustproxy_status" + +rustproxy_prestart() +{ + # RustProxy on OPNsense/FreeBSD uses jemalloc. MALLOC_ARENA_MAX is a + # glibc/Linux setting and has no useful effect here, so export MALLOC_CONF + # instead. daemon(8) inherits this environment and passes it to RustProxy. + if [ -n "${rustproxy_malloc_conf}" ]; then + MALLOC_CONF="${rustproxy_malloc_conf}" + export MALLOC_CONF + else + unset MALLOC_CONF + fi + + # /var/run is ephemeral and recreated on boot. + /usr/bin/install -d -o "${rustproxy_user}" -g "${rustproxy_group}" -m 0750 "${rustproxy_run_dir}" + /usr/sbin/chown "${rustproxy_user}:${rustproxy_group}" "${rustproxy_run_dir}" + /bin/chmod 0750 "${rustproxy_run_dir}" + + /usr/bin/install -d -o "${rustproxy_user}" -g "${rustproxy_group}" -m 0700 "${rustproxy_data_dir}" + /usr/sbin/chown "${rustproxy_user}:${rustproxy_group}" "${rustproxy_data_dir}" + /bin/chmod 0700 "${rustproxy_data_dir}" + + /usr/bin/install -d -o "${rustproxy_user}" -g "${rustproxy_group}" -m 0750 "${rustproxy_log_dir}" + /usr/bin/touch "${rustproxy_log_file}" + /usr/sbin/chown "${rustproxy_user}:${rustproxy_group}" "${rustproxy_log_file}" + /bin/chmod 0640 "${rustproxy_log_file}" + + echo "RustProxy listeners: HTTP=${rustproxy_proxy_listen_resolved} TLS=${rustproxy_tls_listen_resolved} ADMIN=${rustproxy_admin_listen_resolved}" + if [ -n "${rustproxy_malloc_conf}" ]; then + echo "RustProxy allocator: MALLOC_CONF=${rustproxy_malloc_conf}" + fi + echo "RustProxy exec: ${rustproxy_command} ${rustproxy_runtime_args}" + printf '%s\n' "${rustproxy_command} ${rustproxy_runtime_args}" > "${rustproxy_run_dir}/command.txt" + /bin/chmod 0640 "${rustproxy_run_dir}/command.txt" +} + +rustproxy_child_pid() +{ + [ -r "${child_pidfile}" ] || return 1 + _rp_pid="$(/bin/cat "${child_pidfile}" 2>/dev/null)" + case "${_rp_pid}" in + ''|*[!0-9]*) return 1 ;; + esac + /bin/kill -0 "${_rp_pid}" 2>/dev/null || return 1 + printf '%s\n' "${_rp_pid}" +} + +rustproxy_poststart() +{ + # daemon(8) may stay alive with -r even when rust-proxy exits immediately. + # Verify the supervised child so service/configd cannot report a false OK. + /bin/sleep 1 + if _rp_pid="$(rustproxy_child_pid)"; then + echo "RustProxy child started as PID ${_rp_pid}" + return 0 + fi + + echo "RustProxy failed to stay running; stopping the restart supervisor." >&2 + if [ -s "${rustproxy_log_file}" ]; then + echo "Last RustProxy log lines:" >&2 + /usr/bin/tail -n 40 "${rustproxy_log_file}" >&2 + fi + if [ -r "${pidfile}" ]; then + _rp_supervisor_pid="$(/bin/cat "${pidfile}" 2>/dev/null)" + case "${_rp_supervisor_pid}" in + ''|*[!0-9]*) ;; + *) /bin/kill -TERM "${_rp_supervisor_pid}" 2>/dev/null || true ;; + esac + fi + /bin/sleep 1 + /bin/rm -f "${child_pidfile}" "${pidfile}" + return 1 +} + +rustproxy_status() +{ + if _rp_pid="$(rustproxy_child_pid)"; then + echo "rustproxy is running as pid ${_rp_pid}" + return 0 + fi + + _rp_supervisor_pid="" + if [ -r "${pidfile}" ]; then + _rp_supervisor_pid="$(/bin/cat "${pidfile}" 2>/dev/null)" + fi + case "${_rp_supervisor_pid}" in + ''|*[!0-9]*) ;; + *) + if /bin/kill -0 "${_rp_supervisor_pid}" 2>/dev/null; then + echo "rustproxy is not running (daemon supervisor ${_rp_supervisor_pid} is waiting/restarting)" + return 1 + fi + ;; + esac + + echo "rustproxy is not running" + return 1 +} + +rustproxy_poststop() +{ + /bin/rm -f "${child_pidfile}" "${pidfile}" +} + +run_rc_command "$1" diff --git a/opnsense/plugin/src/etc/rc.syshook.d/10-rustproxy.start b/opnsense/plugin/src/etc/rc.syshook.d/10-rustproxy.start new file mode 100755 index 0000000..a661a5f --- /dev/null +++ b/opnsense/plugin/src/etc/rc.syshook.d/10-rustproxy.start @@ -0,0 +1,4 @@ +#!/bin/sh + +/usr/local/sbin/configctl template reload OPNsense/RustProxy >/dev/null 2>&1 || true +/usr/local/sbin/configctl rustproxy boot >/dev/null 2>&1 || true diff --git a/opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/Api/ServiceController.php b/opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/Api/ServiceController.php new file mode 100644 index 0000000..9f25c66 --- /dev/null +++ b/opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/Api/ServiceController.php @@ -0,0 +1,20 @@ +view->generalForm = $this->getForm('general'); + $this->view->pick('OPNsense/RustProxy/index'); + } +} diff --git a/opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/forms/general.xml b/opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/forms/general.xml new file mode 100644 index 0000000..f7e8bdb --- /dev/null +++ b/opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/forms/general.xml @@ -0,0 +1,79 @@ +
+ + + header + true + + + rustproxy.general.enabled + + checkbox + Start RustProxy and keep it enabled after OPNsense restarts. + + + + + header + true + + + rustproxy.general.proxyAddress + + text + HTTP bind address. Default: 0.0.0.0. Existing installations are migrated from the legacy combined listener automatically. + + + rustproxy.general.proxyPort + + text + HTTP port passed as --proxy-listen. Default: 8080. + + + + + header + true + + + rustproxy.general.tlsEnabled + + checkbox + When enabled, pass --tls-listen to RustProxy. + + + rustproxy.general.tlsAddress + + text + HTTPS/TLS bind address. Default: 0.0.0.0. Existing installations are migrated automatically. + + + rustproxy.general.tlsPort + + text + HTTPS/TLS port passed as --tls-listen. Default: 8181. + + + + + header + true + + + rustproxy.general.adminAddress + + text + Administration bind address. Default: 0.0.0.0. Existing installations are migrated automatically. + + + rustproxy.general.adminPort + + text + Administration port passed as --admin-listen. Default: 9090. + + + rustproxy.general.allowRemoteAdmin + + checkbox + Pass --allow-remote-admin so the RustProxy panel accepts non-loopback clients. Keep firewall access restricted to trusted networks; do not expose the admin port to WAN unless this is intentional. + +
diff --git a/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/ACL/ACL.xml b/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/ACL/ACL.xml new file mode 100644 index 0000000..bada5e3 --- /dev/null +++ b/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/ACL/ACL.xml @@ -0,0 +1,10 @@ + + + Services: RustProxy + Allow access to RustProxy settings and service controls + + ui/rustproxy/* + api/rustproxy/* + + + diff --git a/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/Menu/Menu.xml b/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/Menu/Menu.xml new file mode 100644 index 0000000..14374cc --- /dev/null +++ b/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/Menu/Menu.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/Migrations/M1_4_0.php b/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/Migrations/M1_4_0.php new file mode 100644 index 0000000..202805c --- /dev/null +++ b/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/Migrations/M1_4_0.php @@ -0,0 +1,68 @@ + 65535) { + return null; + } + + return [$address, (string)$number]; + } + + private function migrateListener($general, $legacyField, $addressField, $portField) + { + $parts = $this->splitListener((string)$general->$legacyField); + if ($parts === null) { + return; + } + $general->$addressField = $parts[0]; + $general->$portField = $parts[1]; + } + + public function run($model) + { + parent::run($model); + + // Deliberately prefer valid 1.2.x values during this one migration. + // This also repairs hosts that already installed the broken 1.3.x model. + $this->migrateListener($model->general, 'proxyListen', 'proxyAddress', 'proxyPort'); + $this->migrateListener($model->general, 'tlsListen', 'tlsAddress', 'tlsPort'); + $this->migrateListener($model->general, 'adminListen', 'adminAddress', 'adminPort'); + } +} diff --git a/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/RustProxy.php b/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/RustProxy.php new file mode 100644 index 0000000..774b91c --- /dev/null +++ b/opnsense/plugin/src/opnsense/mvc/app/models/OPNsense/RustProxy/RustProxy.php @@ -0,0 +1,9 @@ + + //OPNsense/rustproxy + 1.4.0 + RustProxy service settings + + + + 0 + Y + + + + + 0.0.0.0 + Y + N + Enter a valid HTTP bind address, for example 0.0.0.0. + + + 8080 + Y + Enter a valid HTTP port between 1 and 65535. + + + 1 + Y + + + 0.0.0.0 + Y + N + Enter a valid HTTPS/TLS bind address, for example 0.0.0.0. + + + 8181 + Y + Enter a valid HTTPS/TLS port between 1 and 65535. + + + 0.0.0.0 + Y + N + Enter a valid administration bind address, for example 0.0.0.0. + + + 9090 + Y + Enter a valid administration port between 1 and 65535. + + + 1 + Y + + + + + + + + + diff --git a/opnsense/plugin/src/opnsense/mvc/app/views/OPNsense/RustProxy/index.volt b/opnsense/plugin/src/opnsense/mvc/app/views/OPNsense/RustProxy/index.volt new file mode 100644 index 0000000..bc7ce11 --- /dev/null +++ b/opnsense/plugin/src/opnsense/mvc/app/views/OPNsense/RustProxy/index.volt @@ -0,0 +1,141 @@ + + +
+
+ {{ partial("layout_partials/base_form", ['fields': generalForm, 'id': 'frm_GeneralSettings']) }} +
+
+
+
+
+ + +

+

+ {{ lang._('Remote administration is controlled by the RustProxy listener and OPNsense firewall rules. The plugin does not automatically create a WAN firewall rule for the admin port.') }} +

+
+
+
diff --git a/opnsense/plugin/src/opnsense/scripts/rustproxy/service.sh b/opnsense/plugin/src/opnsense/scripts/rustproxy/service.sh new file mode 100755 index 0000000..d4f1c44 --- /dev/null +++ b/opnsense/plugin/src/opnsense/scripts/rustproxy/service.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +set -u + +CONFIG_FILE="/usr/local/etc/rustproxy/opnsense.conf" +RC_SCRIPT="/usr/local/etc/rc.d/rustproxy" +enabled="0" + +if [ -r "$CONFIG_FILE" ]; then + . "$CONFIG_FILE" +fi + +start_service() +{ + if [ "$enabled" != "1" ]; then + echo "RustProxy is disabled in OPNsense configuration" + return 0 + fi + "$RC_SCRIPT" onestart +} + +stop_service() +{ + "$RC_SCRIPT" onestop >/dev/null 2>&1 || true +} + +case "${1:-}" in + start|boot) + start_service + ;; + stop) + stop_service + ;; + restart|reload) + stop_service + start_service + ;; + status) + "$RC_SCRIPT" status + ;; + *) + echo "usage: $0 {start|stop|restart|reload|status|boot}" >&2 + exit 64 + ;; +esac diff --git a/opnsense/plugin/src/opnsense/service/conf/actions.d/actions_rustproxy.conf b/opnsense/plugin/src/opnsense/service/conf/actions.d/actions_rustproxy.conf new file mode 100644 index 0000000..2d4f657 --- /dev/null +++ b/opnsense/plugin/src/opnsense/service/conf/actions.d/actions_rustproxy.conf @@ -0,0 +1,42 @@ +[start] +command:/usr/local/opnsense/scripts/rustproxy/service.sh start +parameters: +type:script +message:starting RustProxy +allowed_groups:wheel,wwwonly + +[stop] +command:/usr/local/opnsense/scripts/rustproxy/service.sh stop +parameters: +type:script +message:stopping RustProxy +allowed_groups:wheel,wwwonly + +[restart] +command:/usr/local/opnsense/scripts/rustproxy/service.sh restart +parameters: +type:script +message:restarting RustProxy +allowed_groups:wheel,wwwonly + +[reload] +command:/usr/local/opnsense/scripts/rustproxy/service.sh restart +parameters: +type:script +message:reloading RustProxy +allowed_groups:wheel,wwwonly + +[status] +command:/usr/local/opnsense/scripts/rustproxy/service.sh status +parameters: +type:script_output +errors:no +message:RustProxy status +allowed_groups:wheel,wwwonly + +[boot] +command:/usr/local/opnsense/scripts/rustproxy/service.sh boot +parameters: +type:script +message:starting RustProxy at boot +allowed_groups:wheel,wwwonly diff --git a/opnsense/plugin/src/opnsense/service/templates/OPNsense/RustProxy/+TARGETS b/opnsense/plugin/src/opnsense/service/templates/OPNsense/RustProxy/+TARGETS new file mode 100644 index 0000000..5bb520e --- /dev/null +++ b/opnsense/plugin/src/opnsense/service/templates/OPNsense/RustProxy/+TARGETS @@ -0,0 +1 @@ +rustproxy.conf:/usr/local/etc/rustproxy/opnsense.conf diff --git a/opnsense/plugin/src/opnsense/service/templates/OPNsense/RustProxy/rustproxy.conf b/opnsense/plugin/src/opnsense/service/templates/OPNsense/RustProxy/rustproxy.conf new file mode 100644 index 0000000..dc4cffb --- /dev/null +++ b/opnsense/plugin/src/opnsense/service/templates/OPNsense/RustProxy/rustproxy.conf @@ -0,0 +1,18 @@ +enabled="{% if not helpers.empty('OPNsense.rustproxy.general.enabled') %}1{% else %}0{% endif %}" + +# New split listener fields. Empty values are resolved by rc.d using the +# legacy combined field (upgrade path) and finally the built-in defaults. +rustproxy_proxy_address="{% if not helpers.empty('OPNsense.rustproxy.general.proxyAddress') %}{{ OPNsense.rustproxy.general.proxyAddress }}{% endif %}" +rustproxy_proxy_port="{% if not helpers.empty('OPNsense.rustproxy.general.proxyPort') %}{{ OPNsense.rustproxy.general.proxyPort }}{% endif %}" +rustproxy_tls_enabled="{% if not helpers.empty('OPNsense.rustproxy.general.tlsEnabled') %}1{% else %}0{% endif %}" +rustproxy_tls_address="{% if not helpers.empty('OPNsense.rustproxy.general.tlsAddress') %}{{ OPNsense.rustproxy.general.tlsAddress }}{% endif %}" +rustproxy_tls_port="{% if not helpers.empty('OPNsense.rustproxy.general.tlsPort') %}{{ OPNsense.rustproxy.general.tlsPort }}{% endif %}" +rustproxy_admin_address="{% if not helpers.empty('OPNsense.rustproxy.general.adminAddress') %}{{ OPNsense.rustproxy.general.adminAddress }}{% endif %}" +rustproxy_admin_port="{% if not helpers.empty('OPNsense.rustproxy.general.adminPort') %}{{ OPNsense.rustproxy.general.adminPort }}{% endif %}" +rustproxy_allow_remote_admin="{% if not helpers.empty('OPNsense.rustproxy.general.allowRemoteAdmin') %}1{% else %}0{% endif %}" + +# Legacy 1.2.x combined settings. They are only used when the corresponding +# new split address/port fields are both empty. +rustproxy_proxy_listen_legacy="{% if not helpers.empty('OPNsense.rustproxy.general.proxyListen') %}{{ OPNsense.rustproxy.general.proxyListen }}{% endif %}" +rustproxy_tls_listen_legacy="{% if not helpers.empty('OPNsense.rustproxy.general.tlsListen') %}{{ OPNsense.rustproxy.general.tlsListen }}{% endif %}" +rustproxy_admin_listen_legacy="{% if not helpers.empty('OPNsense.rustproxy.general.adminListen') %}{{ OPNsense.rustproxy.general.adminListen }}{% endif %}" diff --git a/scripts/apply-freebsd-patches.sh b/scripts/apply-freebsd-patches.sh new file mode 100755 index 0000000..4c025e4 --- /dev/null +++ b/scripts/apply-freebsd-patches.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +source /opt/rust-package-builder/scripts/lib.sh + +SOURCE_DIR=${1:?usage: apply-freebsd-patches.sh /path/to/source} +PATCH_ROOT=/opt/rust-package-builder/opnsense/patches + +[[ -f "$SOURCE_DIR/Cargo.toml" ]] || fatal "missing Cargo.toml in FreeBSD build source: $SOURCE_DIR" + +VERSION=$(python3 - "$SOURCE_DIR/Cargo.toml" <<'PY' +import sys, tomllib +from pathlib import Path +with Path(sys.argv[1]).open('rb') as f: + print(tomllib.load(f)['package']['version']) +PY +) + +PATCH_DIR="$PATCH_ROOT/$VERSION" +if [[ ! -d "$PATCH_DIR" ]]; then + log "no FreeBSD/OPNsense patch set for RustProxy $VERSION; using upstream source" + exit 0 +fi + +shopt -s nullglob +patches=("$PATCH_DIR"/*.patch) +(( ${#patches[@]} > 0 )) || fatal "FreeBSD patch directory is empty: $PATCH_DIR" + +log "applying FreeBSD/OPNsense patches for RustProxy $VERSION" +for patch_file in "${patches[@]}"; do + patch_name=$(basename "$patch_file") + if git -C "$SOURCE_DIR" apply --check "$patch_file" 2>/dev/null; then + git -C "$SOURCE_DIR" apply "$patch_file" + log "applied patch: $patch_name" + elif git -C "$SOURCE_DIR" apply --reverse --check "$patch_file" 2>/dev/null; then + log "patch already present: $patch_name" + else + fatal "patch does not apply cleanly: $patch_name (RustProxy $VERSION)" + fi +done diff --git a/scripts/build-opnsense.sh b/scripts/build-opnsense.sh new file mode 100755 index 0000000..4177e2d --- /dev/null +++ b/scripts/build-opnsense.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +source /opt/rust-package-builder/scripts/lib.sh + +SOURCE_DIR=${1:?source path required} +OUTPUT_BINARY=${2:?output binary required} +BINARY_NAME=${3:?binary name required} +WORK_DIR=${4:?work directory required} + +BUILD_SOURCE="$WORK_DIR/freebsd-source" +rm -rf "$BUILD_SOURCE" +mkdir -p "$BUILD_SOURCE" "$(dirname "$OUTPUT_BINARY")" + +rsync -a --delete \ + --exclude='.git/' \ + --exclude='target/' \ + "$SOURCE_DIR/" "$BUILD_SOURCE/" + +if [[ "${APPLY_FREEBSD_PATCHES:-1}" == "1" ]]; then + /opt/rust-package-builder/scripts/apply-freebsd-patches.sh "$BUILD_SOURCE" +else + log "FreeBSD/OPNsense source patches disabled" +fi + +FREEBSD_TARGET_VERSION=${FREEBSD_RELEASE%-RELEASE} +export CFLAGS_x86_64_unknown_freebsd="--target=x86_64-unknown-freebsd${FREEBSD_TARGET_VERSION} --sysroot=/opt/freebsd-sysroot" +export CXXFLAGS_x86_64_unknown_freebsd="--target=x86_64-unknown-freebsd${FREEBSD_TARGET_VERSION} --sysroot=/opt/freebsd-sysroot" +export CARGO_TARGET_X86_64_UNKNOWN_FREEBSD_RUSTFLAGS="-C link-arg=--target=x86_64-unknown-freebsd${FREEBSD_TARGET_VERSION} -C link-arg=--sysroot=/opt/freebsd-sysroot -C link-arg=-fuse-ld=lld" + +log "cross-compiling $BINARY_NAME for x86_64-unknown-freebsd${FREEBSD_TARGET_VERSION}" +cd "$BUILD_SOURCE" +if [[ -f Cargo.lock ]]; then + cargo "+$FREEBSD_RUST_TOOLCHAIN" build --release --locked --target x86_64-unknown-freebsd +else + cargo "+$FREEBSD_RUST_TOOLCHAIN" build --release --target x86_64-unknown-freebsd +fi + +BUILT="$CARGO_TARGET_DIR/x86_64-unknown-freebsd/release/$BINARY_NAME" +[[ -x "$BUILT" ]] || fatal "FreeBSD binary not found: $BUILT" +install -m 0555 "$BUILT" "$OUTPUT_BINARY" + +desc=$(file -b "$OUTPUT_BINARY") +log "FreeBSD binary: $desc" +[[ "$desc" == *FreeBSD* ]] || fatal "cross-built ELF is not identified as FreeBSD" + +header=$(llvm-readelf -h "$OUTPUT_BINARY") +printf '%s\n' "$header" | grep -qi FreeBSD || fatal "cross-built ELF header is not marked for FreeBSD" +dynamic=$(llvm-readelf -d "$OUTPUT_BINARY" || true) +if printf '%s\n' "$dynamic" | grep -q 'libc\.so\.6'; then + fatal "Linux libc detected in FreeBSD target binary" +fi diff --git a/scripts/build-release.sh b/scripts/build-release.sh index f8ee104..f11f5dd 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -11,8 +11,13 @@ PKG="$WORK/package" OUT="$WORK/out" REPOSITORY="$DATA_DIR/repository" APTLY_CONF="$DATA_DIR/state/aptly.conf" +RELEASE_DIR="" +PUBLISHED=0 cleanup() { + if [[ "$PUBLISHED" != "1" && -n "$RELEASE_DIR" && -d "$RELEASE_DIR" ]]; then + rm -rf "$RELEASE_DIR" + fi if [[ "${KEEP_WORKSPACE:-0}" != "1" ]]; then rm -rf "$WORK" else @@ -28,6 +33,7 @@ git clone --quiet --no-tags --single-branch --branch "$SOURCE_BRANCH" "$SOURCE_R git -C "$SRC" checkout --quiet --detach "$SHA" ACTUAL_SHA=$(git -C "$SRC" rev-parse HEAD) [[ "$ACTUAL_SHA" == "$SHA" ]] || fatal "checkout SHA mismatch" +SOURCE_REVISION=$(git -C "$SRC" rev-list --count "$SHA") cd "$SRC" META_JSON=$(cargo metadata --format-version 1 --no-deps) @@ -109,13 +115,24 @@ fi export PACKAGE_NAME VERSION BINARY_NAME DESCRIPTION LICENSE HOMEPAGE MAINTAINER PACKAGE_RELEASE log "building version $VERSION ($PACKAGE_NAME / $BINARY_NAME)" -log "cargo build started" + +if [[ "${RUN_LINUX_TESTS:-1}" == "1" ]]; then + log "cargo tests started" + if [[ -f Cargo.lock ]]; then + cargo test --release --locked + else + cargo test --release + fi + log "cargo tests completed" +fi + +log "Linux cargo build started" if [[ -f Cargo.lock ]]; then cargo build --release --locked else cargo build --release fi -log "cargo build completed" +log "Linux cargo build completed" BINARY_PATH="$CARGO_TARGET_DIR/release/$BINARY_NAME" [[ -x "$BINARY_PATH" ]] || fatal "built binary not found: $BINARY_PATH" @@ -140,8 +157,24 @@ nfpm package --config "$PKG/nfpm-rpm.yaml" --packager rpm --target "$RPM_FILE" ar t "$DEB_FILE" | grep '^_gpgorigin$' >/dev/null || fatal "DEB package does not contain nFPM GPG origin signature" rpm -qpi "$RPM_FILE" | awk -F: '/^Signature/ && $0 !~ /\(none\)/ {ok=1} END{exit !ok}' || fatal "RPM package is not signed" -log "package signatures verified" +log "Linux package signatures verified" +# OPNsense is built from an isolated source copy. FreeBSD-specific patches never +# touch the Linux source checkout used above. +FREEBSD_BINARY="$OUT/rust-proxy-freebsd" +/opt/rust-package-builder/scripts/build-opnsense.sh "$SRC" "$FREEBSD_BINARY" "$BINARY_NAME" "$WORK" + +if [[ "$SOURCE_REVISION" == "0" ]]; then + OPNSENSE_PACKAGE_VERSION="$VERSION" +else + OPNSENSE_PACKAGE_VERSION="${VERSION}_${SOURCE_REVISION}" +fi +OPNSENSE_PACKAGE_FILE="$OUT/os-rustproxy-${OPNSENSE_PACKAGE_VERSION}.pkg" +/opt/rust-package-builder/scripts/package-opnsense.sh \ + "$SRC" "$FREEBSD_BINARY" "$OPNSENSE_PACKAGE_FILE" "$OPNSENSE_PACKAGE_VERSION" + +# Only after all package formats have been built successfully do we update the +# persistent Linux package stores and the internal APT repository. cp -f "$DEB_FILE" "$DATA_DIR/packages/deb/" cp -f "$RPM_FILE" "$DATA_DIR/packages/rpm/x86_64/" @@ -178,7 +211,7 @@ gpg --batch --verify \ "$APT_PUBLIC/dists/$REPO_DISTRIBUTION/Release" >/dev/null 2>&1 || fatal "APT Release signature verification failed" RELEASE_DIR="$REPOSITORY/releases/$BUILD_ID" -mkdir -p "$RELEASE_DIR/apt" "$RELEASE_DIR/rpm/x86_64" +mkdir -p "$RELEASE_DIR/apt" "$RELEASE_DIR/rpm/x86_64" "$RELEASE_DIR/opnsense" CURRENT_TARGET="" if [[ -L "$REPOSITORY/current" ]]; then @@ -214,24 +247,76 @@ gpg --batch --verify \ "$RELEASE_DIR/rpm/x86_64/repodata/repomd.xml.asc" \ "$RELEASE_DIR/rpm/x86_64/repodata/repomd.xml" >/dev/null 2>&1 || fatal "RPM metadata signature verification failed" +log "staging OPNsense pkg repository" +if [[ -n "$CURRENT_TARGET" && -d "$CURRENT_TARGET/opnsense" ]]; then + rsync -a --delete --link-dest="$CURRENT_TARGET/opnsense"/ "$CURRENT_TARGET/opnsense"/ "$RELEASE_DIR/opnsense"/ +fi +OPNSENSE_PACKAGE_DIR="$RELEASE_DIR/opnsense/$OPNSENSE_ABI/amd64" +mkdir -p "$OPNSENSE_PACKAGE_DIR" +# The previous OPNsense snapshot is hard-linked into this one. Unlink every +# file we are about to replace so the currently published snapshot stays immutable. +rm -f \ + "$OPNSENSE_PACKAGE_DIR/$(basename "$OPNSENSE_PACKAGE_FILE")" \ + "$RELEASE_DIR/opnsense/RustProxy.conf" \ + "$RELEASE_DIR/opnsense/os-rustproxy-latest.pkg" \ + "$RELEASE_DIR/opnsense/SHA256" \ + "$RELEASE_DIR/opnsense/BUILD-INFO.txt" \ + "$RELEASE_DIR/opnsense/index.html" +cp -f "$OPNSENSE_PACKAGE_FILE" "$OPNSENSE_PACKAGE_DIR/" +/opt/rust-package-builder/scripts/generate-opnsense-repo.py \ + "$RELEASE_DIR/opnsense" --retention "$OPNSENSE_RETENTION" +cat > "$RELEASE_DIR/opnsense/RustProxy.conf" < "$RELEASE_DIR/opnsense/SHA256" +cat > "$RELEASE_DIR/opnsense/BUILD-INFO.txt" < "$RELEASE_DIR/repo-key.gpg" [[ -s "$RELEASE_DIR/repo-key.gpg" ]] || fatal "public GPG key export failed" /opt/rust-package-builder/scripts/generate-index.sh "$RELEASE_DIR/index.html" "$PACKAGE_NAME" -# Validate all externally visible endpoints before the atomic switch. +# Validate both public repositories before one atomic switch exposes either one. [[ -s "$RELEASE_DIR/index.html" ]] [[ -f "$RELEASE_DIR/apt/dists/$REPO_DISTRIBUTION/InRelease" ]] [[ -f "$RELEASE_DIR/rpm/x86_64/repodata/repomd.xml" ]] +[[ -s "$RELEASE_DIR/opnsense/index.html" ]] +[[ -f "$RELEASE_DIR/opnsense/meta.conf" ]] +[[ -f "$RELEASE_DIR/opnsense/packagesite.pkg" ]] +[[ -f "$RELEASE_DIR/opnsense/data.pkg" ]] +[[ -f "$RELEASE_DIR/opnsense/os-rustproxy-latest.pkg" ]] +bsdtar -tf "$RELEASE_DIR/opnsense/packagesite.pkg" | grep -Fx 'packagesite.yaml' >/dev/null || fatal "invalid OPNsense packagesite.pkg" +bsdtar -xOf "$OPNSENSE_PACKAGE_DIR/$(basename "$OPNSENSE_PACKAGE_FILE")" +COMPACT_MANIFEST >/dev/null || fatal "invalid OPNsense package manifest" -log "publishing repository atomically" +log "publishing Linux and OPNsense repositories atomically" rm -f "$REPOSITORY/current.new" ln -s "releases/$BUILD_ID" "$REPOSITORY/current.new" mv -Tf "$REPOSITORY/current.new" "$REPOSITORY/current" +PUBLISHED=1 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' "$PACKAGE_RELEASE" | atomic_write "$DATA_DIR/state/last_package_release" +printf '%s\n' "$OPNSENSE_PACKAGE_VERSION" | atomic_write "$DATA_DIR/state/last_opnsense_package_version" printf '%s\n' "$BUILD_ID" | atomic_write "$DATA_DIR/state/last_release" -log "build completed: version=$VERSION package_release=$PACKAGE_RELEASE commit=$SHA release=$BUILD_ID" +log "build completed: version=$VERSION linux_release=$PACKAGE_RELEASE opnsense=$OPNSENSE_PACKAGE_VERSION commit=$SHA release=$BUILD_ID" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 30fdf67..84e6fb5 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -5,18 +5,33 @@ source /opt/rust-package-builder/scripts/lib.sh : "${SOURCE_REPO:?SOURCE_REPO is required}" SOURCE_BRANCH=${SOURCE_BRANCH:-master} : "${REPOSITORY_URL:?REPOSITORY_URL is required}" +: "${OPNSENSE_REPOSITORY_URL:?OPNSENSE_REPOSITORY_URL is required}" POLL_INTERVAL=${POLL_INTERVAL:-300} PACKAGE_RELEASE=${PACKAGE_RELEASE:-auto} +OPNSENSE_ABI=${OPNSENSE_ABI:-26.7} +OPNSENSE_RETENTION=${OPNSENSE_RETENTION:-10} +FREEBSD_RELEASE=${FREEBSD_RELEASE:-15.1-RELEASE} +FREEBSD_ABI=${FREEBSD_ABI:-FreeBSD:15:amd64} +FREEBSD_ARCH=${FREEBSD_ARCH:-freebsd:15:x86:64} +FREEBSD_RUST_TOOLCHAIN=${FREEBSD_RUST_TOOLCHAIN:-1.88.0} +APPLY_FREEBSD_PATCHES=${APPLY_FREEBSD_PATCHES:-1} +RUN_LINUX_TESTS=${RUN_LINUX_TESTS:-1} [[ "$POLL_INTERVAL" =~ ^[0-9]+$ ]] || fatal "POLL_INTERVAL must be an integer" (( POLL_INTERVAL >= 1 )) || fatal "POLL_INTERVAL must be >= 1" [[ "$PACKAGE_RELEASE" == "auto" || "$PACKAGE_RELEASE" =~ ^[1-9][0-9]*$ ]] || fatal "PACKAGE_RELEASE must be auto or a positive integer" +[[ "$OPNSENSE_RETENTION" =~ ^[0-9]+$ ]] || fatal "OPNSENSE_RETENTION must be >= 0" +[[ "$APPLY_FREEBSD_PATCHES" =~ ^[01]$ ]] || fatal "APPLY_FREEBSD_PATCHES must be 0 or 1" +[[ "$RUN_LINUX_TESTS" =~ ^[01]$ ]] || fatal "RUN_LINUX_TESTS must be 0 or 1" export DATA_DIR=${DATA_DIR:-/var/lib/package-builder} -export SOURCE_REPO SOURCE_BRANCH REPOSITORY_URL POLL_INTERVAL +export SOURCE_REPO SOURCE_BRANCH REPOSITORY_URL OPNSENSE_REPOSITORY_URL POLL_INTERVAL export REPO_DISTRIBUTION=${REPO_DISTRIBUTION:-stable} export REPO_COMPONENT=${REPO_COMPONENT:-main} export PACKAGE_RELEASE +export OPNSENSE_ABI OPNSENSE_RETENTION +export FREEBSD_RELEASE FREEBSD_ABI FREEBSD_ARCH FREEBSD_RUST_TOOLCHAIN +export APPLY_FREEBSD_PATCHES RUN_LINUX_TESTS export MAX_GLIBC_VERSION=${MAX_GLIBC_VERSION:-2.34} export GPG_PRIVATE_KEY_FILE=${GPG_PRIVATE_KEY_FILE:-/run/secrets/gpg/private-key.asc} export GPG_PASSPHRASE_FILE=${GPG_PASSPHRASE_FILE:-/run/secrets/gpg/passphrase} @@ -24,10 +39,15 @@ export GNUPGHOME=${GNUPGHOME:-/run/package-builder/gnupg} export CARGO_HOME="$DATA_DIR/cargo" export CARGO_TARGET_DIR="$DATA_DIR/cargo/target" -for cmd in git cargo rustc nfpm aptly createrepo_c gpg rsync python3 python3.11 objdump ar rpm; do +for cmd in git cargo rustc rustup nfpm aptly createrepo_c gpg rsync python3 python3.11 \ + objdump ar rpm clang llvm-readelf llvm-ar llvm-ranlib file tar zstd bsdtar sha256sum; do require_cmd "$cmd" done +rustc "+$FREEBSD_RUST_TOOLCHAIN" --version >/dev/null 2>&1 || fatal "FreeBSD Rust toolchain is unavailable: $FREEBSD_RUST_TOOLCHAIN" +rustup target list --toolchain "$FREEBSD_RUST_TOOLCHAIN" --installed | grep -Fx x86_64-unknown-freebsd >/dev/null \ + || fatal "Rust target x86_64-unknown-freebsd is not installed for $FREEBSD_RUST_TOOLCHAIN" + mkdir -p \ "$DATA_DIR/repository/releases" \ "$DATA_DIR/state" \ @@ -57,10 +77,10 @@ fi /opt/rust-package-builder/scripts/init-storage.sh -log "builder started: branch=$SOURCE_BRANCH interval=${POLL_INTERVAL}s" +log "builder started: branch=$SOURCE_BRANCH interval=${POLL_INTERVAL}s opnsense=$OPNSENSE_ABI freebsd=$FREEBSD_RELEASE" while true; do if ! /opt/rust-package-builder/scripts/check-and-build.sh; then - log "ERROR: build cycle failed; published repository was not switched" + log "ERROR: build cycle failed; published repositories were not switched" fi sleep "$POLL_INTERVAL" done diff --git a/scripts/generate-index.sh b/scripts/generate-index.sh index 70e7262..ca50398 100755 --- a/scripts/generate-index.sh +++ b/scripts/generate-index.sh @@ -6,7 +6,7 @@ OUT=${1:?output file required} PACKAGE=${2:?package name required} URL=$(trim_trailing_slash "$REPOSITORY_URL") -export OUT PACKAGE URL REPO_DISTRIBUTION REPO_COMPONENT +export OUT PACKAGE URL OPNSENSE_REPOSITORY_URL REPO_DISTRIBUTION REPO_COMPONENT python3 <<'PY_END' import html, os out=os.environ['OUT'] @@ -14,14 +14,15 @@ package=os.environ['PACKAGE'] url=os.environ['URL'] dist=os.environ['REPO_DISTRIBUTION'] component=os.environ['REPO_COMPONENT'] +opn_url=os.environ['OPNSENSE_REPOSITORY_URL'].rstrip('/') h=html.escape content=f''' - + -{h(package)} — repozytorium pakietów +{h(package)} — package repository -

{h(package)} — repozytorium pakietów

-

Podpisane pakiety DEB i RPM dla x86_64/amd64.

-
Repozytorium aktywne{h(url)}APT + RPM/DNF
+

{h(package)} — package repository

+

Signed DEB and RPM packages for x86_64/amd64.

+
Repository active{h(url)}APT + RPM/DNF

Debian / Ubuntu

@@ -68,17 +69,23 @@ sudo dnf install {h(package)}
-

Pliki repozytorium

+

OPNsense / FreeBSD pkg

+

The OPNsense plugin is published separately because it uses the FreeBSD pkg format.

+

{h(opn_url)}/

+
+ +
+

Repository files

-

Obsługiwane cele: Debian 12/13, Ubuntu 24.04/26.04, RHEL 9/10; architektura amd64/x86_64.

+

Supported targets: Debian 12/13, Ubuntu 24.04/26.04, RHEL 9/10; architecture: amd64/x86_64.

''' with open(out, 'w', encoding='utf-8') as f: f.write(content) -PY_END +PY_END \ No newline at end of file diff --git a/scripts/generate-opnsense-index.sh b/scripts/generate-opnsense-index.sh new file mode 100755 index 0000000..4291ae1 --- /dev/null +++ b/scripts/generate-opnsense-index.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +source /opt/rust-package-builder/scripts/lib.sh + +OUT=${1:?output file required} +URL=$(trim_trailing_slash "$OPNSENSE_REPOSITORY_URL") +PACKAGE=${PACKAGE_NAME:-rustproxy} + +export OUT URL PACKAGE OPNSENSE_ABI FREEBSD_ABI +python3 <<'PY' +import html +import os + +out = os.environ["OUT"] +url = os.environ["URL"] +package = os.environ["PACKAGE"] +series = os.environ["OPNSENSE_ABI"] +abi = os.environ["FREEBSD_ABI"] +h = html.escape +content = f''' + + + + +RustProxy OPNsense repository + + + +

RustProxy OPNsense repository

+

FreeBSD pkg repository for the os-rustproxy OPNsense plugin.

+
+

Repository: {h(url)}

+

OPNsense series: {h(series)}   ABI: {h(abi)}

+
+
+

pkg repository configuration

+
mkdir -p /usr/local/etc/pkg/repos
+cat > /usr/local/etc/pkg/repos/RustProxy.conf <<'REPO'
+RustProxy: {{
+  url: "{h(url)}",
+  enabled: yes,
+  mirror_type: "none",
+  signature_type: "none"
+}}
+REPO
+
+pkg update -f
+pkg install os-rustproxy
+
+
+

Repository metadata

+ +
+ + +''' +with open(out, "w", encoding="utf-8") as f: + f.write(content) +PY diff --git a/scripts/generate-opnsense-repo.py b/scripts/generate-opnsense-repo.py new file mode 100755 index 0000000..0bcfb65 --- /dev/null +++ b/scripts/generate-opnsense-repo.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import io +import json +import os +import subprocess +import tarfile +import time +from collections import defaultdict +from pathlib import Path + +CATALOG_FILES = {"data.pkg", "packagesite.pkg"} +LATEST_NAMES = {"latest.pkg", "os-rustproxy-latest.pkg"} + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def read_manifest(pkg: Path) -> dict: + for member in ("+COMPACT_MANIFEST", "+MANIFEST"): + p = subprocess.run( + ["bsdtar", "-xOf", str(pkg), member], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) + if p.returncode == 0 and p.stdout: + return json.loads(p.stdout.decode("utf-8")) + raise RuntimeError(f"{pkg.name}: missing +COMPACT_MANIFEST / +MANIFEST") + + +def is_payload_pkg(path: Path) -> bool: + return ( + path.is_file() + and path.suffix == ".pkg" + and path.name not in CATALOG_FILES + and path.name not in LATEST_NAMES + and not path.name.endswith(".tmp.pkg") + ) + + +def collect_packages(repo: Path): + packages = [] + for path in sorted(repo.rglob("*.pkg")): + if not is_payload_pkg(path): + continue + manifest = read_manifest(path) + manifest["path"] = path.relative_to(repo).as_posix() + manifest["pkgsize"] = path.stat().st_size + manifest["sum"] = sha256(path) + packages.append((path, manifest)) + return packages + + +def enforce_retention(packages, retention: int) -> bool: + if retention <= 0: + return False + groups = defaultdict(list) + for path, manifest in packages: + # Keep the requested number per repository subdirectory/ABI and package name. + key = (path.parent, manifest.get("name", path.stem)) + groups[key].append((path, manifest)) + + removed = False + for (_parent, _name), entries in groups.items(): + entries.sort(key=lambda item: item[0].stat().st_mtime_ns, reverse=True) + for path, _manifest in entries[retention:]: + try: + path.unlink() + removed = True + print(f"[opnsense-repo] retention: removed {path}", flush=True) + except FileNotFoundError: + pass + return removed + + +def make_tar(dst: Path, member_name: str, payload: bytes): + tmp = dst.with_name(dst.name + ".tmp") + with tarfile.open(tmp, "w", format=tarfile.PAX_FORMAT) as tf: + info = tarfile.TarInfo(member_name) + info.size = len(payload) + info.mode = 0o644 + info.mtime = int(time.time()) + tf.addfile(info, io.BytesIO(payload)) + os.replace(tmp, dst) + + +def rebuild(repo: Path, retention: int): + packages = collect_packages(repo) + if enforce_retention(packages, retention): + packages = collect_packages(repo) + + manifests = [manifest for _, manifest in packages] + packagesite = b"".join( + json.dumps(m, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + b"\n" + for m in manifests + ) + data = json.dumps( + {"groups": [], "expired_packages": [], "packages": manifests}, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + + make_tar(repo / "packagesite.pkg", "packagesite.yaml", packagesite) + make_tar(repo / "data.pkg", "data", data) + + meta = ( + 'version: 1;\n' + 'packing_format: "tar";\n' + 'manifests: "packagesite.yaml";\n' + 'manifests_archive: "packagesite";\n' + 'data: "data";\n' + 'data_archive: "data";\n' + 'filesite: "files";\n' + 'filesite_archive: "files";\n' + ) + tmp = repo / "meta.conf.tmp" + tmp.write_text(meta, encoding="utf-8") + os.replace(tmp, repo / "meta.conf") + print(f"[opnsense-repo] rebuilt: {len(manifests)} package(s)", flush=True) + + +def main(): + parser = argparse.ArgumentParser(description="Build a static FreeBSD pkg repository catalogue") + parser.add_argument("repo", type=Path, help="repository root") + parser.add_argument("--retention", type=int, default=10, help="versions kept per package/ABI; 0 disables pruning") + args = parser.parse_args() + + repo = args.repo.resolve() + repo.mkdir(parents=True, exist_ok=True) + rebuild(repo, args.retention) + + +if __name__ == "__main__": + main() diff --git a/scripts/init-storage.sh b/scripts/init-storage.sh index 90a1cfb..0c7fcc6 100755 --- a/scripts/init-storage.sh +++ b/scripts/init-storage.sh @@ -34,7 +34,7 @@ if ! aptly -config="$APTLY_CONF" repo list -raw | grep -Fx package-builder >/dev package-builder >/dev/null fi -for item in apt rpm repo-key.gpg index.html; do +for item in apt rpm opnsense repo-key.gpg index.html; do if [[ -e "$REPOSITORY/$item" && ! -L "$REPOSITORY/$item" ]]; then fatal "$REPOSITORY/$item exists and is not a symlink; refusing to overwrite" fi @@ -42,5 +42,6 @@ done ln -sfn current/apt "$REPOSITORY/apt" ln -sfn current/rpm "$REPOSITORY/rpm" +ln -sfn current/opnsense "$REPOSITORY/opnsense" ln -sfn current/repo-key.gpg "$REPOSITORY/repo-key.gpg" ln -sfn current/index.html "$REPOSITORY/index.html" diff --git a/scripts/package-opnsense.sh b/scripts/package-opnsense.sh new file mode 100755 index 0000000..4e18c0e --- /dev/null +++ b/scripts/package-opnsense.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +source /opt/rust-package-builder/scripts/lib.sh + +SOURCE_DIR=${1:?source path required} +BINARY=${2:?FreeBSD binary required} +PACKAGE_FILE=${3:?output package file required} +PACKAGE_VERSION=${4:?OPNsense package version required} + +PLUGIN_ROOT=/opt/rust-package-builder/opnsense/plugin +FREEBSD_ABI=${FREEBSD_ABI:-FreeBSD:15:amd64} +FREEBSD_ARCH=${FREEBSD_ARCH:-freebsd:15:x86:64} + +for tool in python3 sha256sum tar zstd file; do + require_cmd "$tool" +done + +[[ -f "$SOURCE_DIR/Cargo.toml" ]] || fatal "missing Cargo.toml in $SOURCE_DIR" +[[ -f "$BINARY" ]] || fatal "missing cross-built binary: $BINARY" +[[ -d "$PLUGIN_ROOT/src" ]] || fatal "missing embedded OPNsense plugin source: $PLUGIN_ROOT/src" + +binary_desc=$(file -b "$BINARY") +[[ "$binary_desc" == *FreeBSD* ]] || fatal "refusing to package a non-FreeBSD binary: $binary_desc" + +WORK=$(mktemp -d "${PACKAGE_FILE}.work.XXXXXX") +trap 'rm -rf "$WORK"' EXIT +STAGE="$WORK/stage" +META="$WORK/meta" +ARCHIVE="$WORK/os-rustproxy.tar" +PAYLOAD_LIST="$WORK/payload.list" +mkdir -p "$STAGE/usr/local" "$META" "$(dirname "$PACKAGE_FILE")" + +cp -a "$PLUGIN_ROOT/src/." "$STAGE/usr/local/" +mkdir -p "$STAGE/usr/local/sbin" +install -m 0555 "$BINARY" "$STAGE/usr/local/sbin/rust-proxy" + +chmod 0555 "$STAGE/usr/local/etc/rc.d/rustproxy" +chmod 0555 "$STAGE/usr/local/etc/rc.syshook.d/10-rustproxy.start" +chmod 0555 "$STAGE/usr/local/opnsense/scripts/rustproxy/service.sh" + +POST_INSTALL_FILE="$PLUGIN_ROOT/+POST_INSTALL.pre" +PRE_DEINSTALL_FILE="$PLUGIN_ROOT/+PRE_DEINSTALL.pre" + +python3 - "$STAGE" "$META" "$PACKAGE_VERSION" "$FREEBSD_ABI" "$FREEBSD_ARCH" \ + "$POST_INSTALL_FILE" "$PRE_DEINSTALL_FILE" <<'PY' +import hashlib +import json +from pathlib import Path +import sys + +stage = Path(sys.argv[1]) +meta_dir = Path(sys.argv[2]) +version = sys.argv[3] +abi = sys.argv[4] +arch = sys.argv[5] +post_install = Path(sys.argv[6]).read_text(encoding="utf-8") +pre_deinstall = Path(sys.argv[7]).read_text(encoding="utf-8") + +files = {} +flatsize = 0 +for p in sorted(stage.rglob("*")): + if p.is_symlink(): + files["/" + p.relative_to(stage).as_posix()] = "-" + elif p.is_file(): + files["/" + p.relative_to(stage).as_posix()] = hashlib.sha256(p.read_bytes()).hexdigest() + flatsize += p.stat().st_size + +common = { + "name": "os-rustproxy", + "version": version, + "origin": "opnsense/os-rustproxy", + "comment": "RustProxy reverse proxy service", + "desc": "RustProxy packaged as an OPNsense plugin with configurable HTTP, HTTPS/TLS and administration listeners.", + "maintainer": "local@localhost", + "www": "https://git.linuxiarz.pl/gru/RustProxy", + "prefix": "/usr/local", + "abi": abi, + "arch": arch, + "flatsize": flatsize, + "licenselogic": "single", + "licenses": ["UNKNOWN"], + "categories": ["www"], + "deps": {}, + "options": {}, +} +manifest = dict(common) +manifest["files"] = files +manifest["scripts"] = { + "post-install": post_install, + "pre-deinstall": pre_deinstall, +} + +(meta_dir / "+COMPACT_MANIFEST").write_text( + json.dumps(common, separators=(",", ":"), ensure_ascii=False) + "\n", + encoding="utf-8", +) +(meta_dir / "+MANIFEST").write_text( + json.dumps(manifest, separators=(",", ":"), ensure_ascii=False) + "\n", + encoding="utf-8", +) +PY + +( + cd "$STAGE" + find . \( -type f -o -type l \) -print | LC_ALL=C sort | sed 's#^\./##' > "$PAYLOAD_LIST" +) +[[ -s "$PAYLOAD_LIST" ]] || fatal "OPNsense package payload is empty" + +tar --format=ustar --owner=0 --group=0 --numeric-owner \ + -cf "$ARCHIVE" -C "$META" +COMPACT_MANIFEST +MANIFEST +tar -P --format=ustar --owner=0 --group=0 --numeric-owner --no-recursion \ + --transform='s,^,/,S' \ + -rf "$ARCHIVE" -C "$STAGE" -T "$PAYLOAD_LIST" + +python3 - "$ARCHIVE" "$META/+MANIFEST" <<'PY' +import json +from pathlib import Path +import sys +import tarfile + +archive = Path(sys.argv[1]) +manifest = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) +expected = set(manifest.get("files", {})) +with tarfile.open(archive, "r:") as tf: + members = tf.getmembers() +if [m.name for m in members[:2]] != ["+COMPACT_MANIFEST", "+MANIFEST"]: + raise SystemExit("package metadata must be the first two tar members") +payload = members[2:] +if any(m.isdir() for m in payload): + raise SystemExit("directory entries are not allowed in OPNsense package payload") +actual = {m.name for m in payload if m.isfile() or m.issym() or m.islnk()} +relative = sorted(name for name in actual if not name.startswith("/")) +if relative: + raise SystemExit(f"payload contains non-absolute paths: {relative[:5]}") +missing = sorted(expected - actual) +extra = sorted(actual - expected) +if missing or extra: + raise SystemExit(f"package payload does not match +MANIFEST; missing={missing[:5]} extra={extra[:5]}") +PY + +zstd -q -f -19 -T0 "$ARCHIVE" -o "$PACKAGE_FILE" +zstd -dc "$PACKAGE_FILE" | tar -tf - >/dev/null +log "created OPNsense package: $PACKAGE_FILE" diff --git a/start.sh b/start.sh index 6abb376..184098e 100755 --- a/start.sh +++ b/start.sh @@ -10,40 +10,24 @@ DATA_DIR="$ROOT/data" SERVICE="rust-package-builder" HTTP_SERVICE="repository-http" DEFAULT_HTTP_PORT=18765 +DEFAULT_OPNSENSE_HTTP_PORT=18766 -log() { - printf '[start] %s\n' "$*" -} - -warn() { - printf '[start] WARN: %s\n' "$*" >&2 -} - -die() { - printf '[start] ERROR: %s\n' "$*" >&2 - exit 1 -} - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || die "brak wymaganego polecenia: $1" -} +log() { printf '[start] %s\n' "$*"; } +warn() { printf '[start] WARN: %s\n' "$*" >&2; } +die() { printf '[start] ERROR: %s\n' "$*" >&2; exit 1; } +require_cmd() { command -v "$1" >/dev/null 2>&1 || die "brak wymaganego polecenia: $1"; } write_env_file() { - local source_repo=${SOURCE_REPO:-} - local repository_url=${REPOSITORY_URL:-} - cat > "$ENV_FILE" </dev/null || true } update_env_var() { - local key=$1 - local value=$2 - local tmp="$ENV_FILE.tmp.$$" - + local key=$1 value=$2 tmp="$ENV_FILE.tmp.$$" awk -v key="$key" -v value="$value" ' BEGIN { found=0 } index($0, key "=") == 1 { print key "=" value; found=1; next } @@ -55,58 +39,53 @@ update_env_var() { } load_env_file() { - # .env is intentionally limited to simple KEY=value entries used by Compose. - # shellcheck disable=SC1090 set -a + # shellcheck disable=SC1090 source "$ENV_FILE" set +a } -normalize_http_settings() { - local url=${REPOSITORY_URL%/} - local rest hostpart explicit_port="" +normalize_repo_url() { + local url_var=$1 port_var=$2 default_port=$3 label=$4 + local url=${!url_var:-} port=${!port_var:-} rest hostpart explicit_port="" - HTTP_PORT=${HTTP_PORT:-} + url=${url%/} + [[ -n "$url" ]] || die "$url_var jest wymagane" case "$url" in - http://*) - rest=${url#http://} - [[ "$rest" != */* ]] || die "REPOSITORY_URL dla wbudowanego HTTP nie moze zawierac sciezki: $REPOSITORY_URL" - hostpart=$rest - - if [[ "$hostpart" =~ ^\[[^]]+\]:([0-9]+)$ ]]; then - explicit_port=${BASH_REMATCH[1]} - elif [[ "$hostpart" =~ ^[^:]+:([0-9]+)$ ]]; then - explicit_port=${BASH_REMATCH[1]} - fi - - if [[ -n "$explicit_port" ]]; then - HTTP_PORT=$explicit_port - else - HTTP_PORT=${HTTP_PORT:-$DEFAULT_HTTP_PORT} - url="${url}:${HTTP_PORT}" - REPOSITORY_URL=$url - export REPOSITORY_URL - update_env_var REPOSITORY_URL "$REPOSITORY_URL" - log "REPOSITORY_URL bez portu - ustawiono $REPOSITORY_URL" - fi - ;; - https://*) - # HTTPS is supported as a public URL only when an external TLS proxy points - # to the local micro HTTP service. The embedded service itself is HTTP only. - HTTP_PORT=${HTTP_PORT:-$DEFAULT_HTTP_PORT} - warn "REPOSITORY_URL uzywa HTTPS. Wbudowany serwer nasluchuje po zwyklym HTTP na porcie $HTTP_PORT; HTTPS wymaga zewnetrznego reverse proxy/TLS." - ;; - *) - die "REPOSITORY_URL musi zaczynac sie od http:// albo https://" - ;; + http://*) rest=${url#http://} ;; + https://*) rest=${url#https://} ;; + *) die "$url_var musi zaczynac sie od http:// albo https://" ;; esac - [[ "$HTTP_PORT" =~ ^[0-9]+$ ]] || die "HTTP_PORT musi byc liczba" - (( HTTP_PORT >= 1024 && HTTP_PORT <= 65535 )) || die "HTTP_PORT musi byc z zakresu 1024-65535" + [[ "$rest" != */* ]] || die "$url_var nie moze zawierac sciezki: $url" + hostpart=$rest + if [[ "$hostpart" =~ ^\[[^]]+\]:([0-9]+)$ ]]; then + explicit_port=${BASH_REMATCH[1]} + elif [[ "$hostpart" =~ ^[^:]+:([0-9]+)$ ]]; then + explicit_port=${BASH_REMATCH[1]} + fi - export HTTP_PORT - update_env_var HTTP_PORT "$HTTP_PORT" + if [[ "$url" == http://* && -n "$explicit_port" ]]; then + port=$explicit_port + else + port=${port:-$default_port} + if [[ "$url" == http://* && -z "$explicit_port" ]]; then + url="${url}:${port}" + log "$label URL bez portu - ustawiono $url" + elif [[ "$url" == https://* ]]; then + warn "$label uzywa HTTPS. Wbudowany nginx nasluchuje lokalnie po HTTP na porcie $port; TLS wymaga reverse proxy." + fi + fi + + [[ "$port" =~ ^[0-9]+$ ]] || die "$port_var musi byc liczba" + (( port >= 1024 && port <= 65535 )) || die "$port_var musi byc z zakresu 1024-65535" + + printf -v "$url_var" '%s' "$url" + printf -v "$port_var" '%s' "$port" + export "$url_var" "$port_var" + update_env_var "$url_var" "$url" + update_env_var "$port_var" "$port" } require_cmd docker @@ -123,40 +102,40 @@ else die "brak Docker Compose (docker compose lub docker-compose)" fi -if [[ ! -f "$ENV_FILE" ]]; then - write_env_file -fi - +[[ -f "$ENV_FILE" ]] || write_env_file load_env_file + : "${SOURCE_REPO:?Uzupelnij SOURCE_REPO w .env albo ustaw zmienna przed uruchomieniem start.sh}" -: "${REPOSITORY_URL:?Uzupelnij REPOSITORY_URL w .env albo ustaw zmienna przed uruchomieniem start.sh}" +: "${REPOSITORY_URL:?Uzupelnij REPOSITORY_URL dla APT/RPM}" +: "${OPNSENSE_REPOSITORY_URL:?Uzupelnij OPNSENSE_REPOSITORY_URL dla repo FreeBSD pkg}" case "$SOURCE_REPO" in http://*|https://*|ssh://*|git@*|file://*) ;; *) die "SOURCE_REPO ma nieobslugiwany format: $SOURCE_REPO" ;; esac -normalize_http_settings +normalize_repo_url REPOSITORY_URL HTTP_PORT "$DEFAULT_HTTP_PORT" "APT/RPM" +normalize_repo_url OPNSENSE_REPOSITORY_URL OPNSENSE_HTTP_PORT "$DEFAULT_OPNSENSE_HTTP_PORT" "OPNsense" +[[ "$HTTP_PORT" != "$OPNSENSE_HTTP_PORT" ]] || die "HTTP_PORT i OPNSENSE_HTTP_PORT musza byc rozne" mkdir -p "$DATA_DIR/repository" "$DATA_DIR/state" "$DATA_DIR/cargo" "$DATA_DIR/work" "$GPG_DIR" chmod 0700 "$GPG_DIR" 2>/dev/null || true -log "budowanie obrazu buildera" +log "budowanie obrazu buildera (Linux + FreeBSD cross-toolchain)" "${COMPOSE[@]}" --env-file "$ENV_FILE" build "$SERVICE" if [[ ! -s "$GPG_DIR/private-key.asc" ]]; then - log "brak klucza GPG - generowanie stalego klucza podpisujacego" + log "brak klucza GPG - generowanie stalego klucza podpisujacego Linux APT/RPM" - IMAGE_REF=$("${COMPOSE[@]}" --env-file "$ENV_FILE" config | awk ''' + IMAGE_REF=$("${COMPOSE[@]}" --env-file "$ENV_FILE" config | awk ' $1 == "rust-package-builder:" { in_builder=1; next } in_builder && $1 == "image:" { print $2; exit } - ''') + ') [[ -n "$IMAGE_REF" ]] || die "nie mozna ustalic nazwy zbudowanego obrazu buildera" docker image inspect "$IMAGE_REF" >/dev/null 2>&1 || die "zbudowany obraz nie istnieje: $IMAGE_REF" uid=$(id -u) gid=$(id -g) - docker run --rm \ --user "$uid:$gid" \ --entrypoint /bin/bash \ @@ -168,7 +147,6 @@ if [[ ! -s "$GPG_DIR/private-key.asc" ]]; then export GNUPGHOME=/tmp/rpb-keygen mkdir -p "$GNUPGHOME" chmod 0700 "$GNUPGHOME" - cat > /tmp/gpg-batch <<"KEY" Key-Type: RSA Key-Length: 3072 @@ -179,11 +157,9 @@ 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 @@ -197,30 +173,25 @@ else log "uzywanie istniejacego klucza GPG" fi -log "uruchamianie buildera i mikro HTTP przez Docker Compose" +log "uruchamianie buildera i wspolnego nginx przez Docker Compose" "${COMPOSE[@]}" --env-file "$ENV_FILE" up -d "$SERVICE" "$HTTP_SERVICE" fingerprint="" -if [[ -r "$GPG_DIR/fingerprint" ]]; then - fingerprint=$(tr -d '[:space:]' < "$GPG_DIR/fingerprint") -fi +[[ -r "$GPG_DIR/fingerprint" ]] && fingerprint=$(tr -d '[:space:]' < "$GPG_DIR/fingerprint") log "gotowe" -printf '\n' -printf 'SOURCE_REPO: %s\n' "$SOURCE_REPO" -printf 'REPOSITORY_URL: %s\n' "$REPOSITORY_URL" -printf 'HTTP port: %s\n' "$HTTP_PORT" -[[ -n "$fingerprint" ]] && printf 'GPG fingerprint: %s\n' "$fingerprint" -printf 'Publiczny katalog: %s\n' "$DATA_DIR/repository" -printf '\n' -printf 'Repo/strona: %s/\n' "${REPOSITORY_URL%/}" -printf 'Healthcheck: http://127.0.0.1:%s/healthz\n' "$HTTP_PORT" -printf '\n' -printf 'Podglad logow buildera:\n ' +printf '\nSOURCE_REPO: %s\n' "$SOURCE_REPO" +printf 'APT/RPM URL: %s/\n' "${REPOSITORY_URL%/}" +printf 'APT/RPM local HTTP port: %s\n' "$HTTP_PORT" +printf 'OPNsense pkg URL: %s/\n' "${OPNSENSE_REPOSITORY_URL%/}" +printf 'OPNsense local HTTP port: %s\n' "$OPNSENSE_HTTP_PORT" +[[ -n "$fingerprint" ]] && printf 'GPG fingerprint: %s\n' "$fingerprint" +printf 'Publiczny katalog: %s\n\n' "$DATA_DIR/repository" +printf 'Health Linux: http://127.0.0.1:%s/healthz\n' "$HTTP_PORT" +printf 'Health OPNsense: http://127.0.0.1:%s/healthz\n\n' "$OPNSENSE_HTTP_PORT" +printf 'Logi buildera:\n ' printf '%q ' "${COMPOSE[@]}" printf 'logs -f %q\n' "$SERVICE" -printf 'Podglad logow HTTP:\n ' +printf 'Logi HTTP:\n ' printf '%q ' "${COMPOSE[@]}" printf 'logs -f %q\n' "$HTTP_SERVICE" -printf '\n' -printf 'Builder natychmiast sprawdzi branch master (lub SOURCE_BRANCH, jezeli ustawiono go opcjonalnie) i po udanym buildzie opublikuje repozytorium.\n' diff --git a/tests/static.sh b/tests/static.sh index 7859599..66b240f 100755 --- a/tests/static.sh +++ b/tests/static.sh @@ -5,36 +5,49 @@ ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) for f in "$ROOT"/start.sh "$ROOT"/scripts/*.sh "$ROOT"/tests/*.sh; do bash -n "$f" done +python3 -m py_compile "$ROOT/scripts/generate-opnsense-repo.py" +# Main Linux builder. grep -q 'FROM rockylinux:9' "$ROOT/Dockerfile" -grep -q 'dnf -y install --allowerasing' "$ROOT/Dockerfile" -grep -q 'python3.11' "$ROOT/Dockerfile" -grep -q 'RUST_PROXY_PYTHON=/usr/bin/python3.11' "$ROOT/Dockerfile" grep -q 'nfpm package' "$ROOT/scripts/build-release.sh" grep -q 'aptly .*publish' "$ROOT/scripts/build-release.sh" grep -q 'createrepo_c' "$ROOT/scripts/build-release.sh" -grep -q 'current.new' "$ROOT/scripts/build-release.sh" -grep -q 'last_package_release' "$ROOT/scripts/build-release.sh" grep -q 'repo add -force-replace' "$ROOT/scripts/build-release.sh" grep -q '/run/package-builder/gnupg' "$ROOT/scripts/entrypoint.sh" grep -q 'rustproxy-overview.json' "$ROOT/scripts/prepare-package.sh" -grep -q 'debian/${PACKAGE_NAME}.service' "$ROOT/scripts/prepare-package.sh" -grep -q 'private-key.asc' "$ROOT/start.sh" -grep -q 'docker compose' "$ROOT/start.sh" -grep -q 'Key-Type: RSA' "$ROOT/start.sh" -grep -q 'DEFAULT_HTTP_PORT=18765' "$ROOT/start.sh" -grep -q 'repository-http' "$ROOT/start.sh" -grep -Fq '${SOURCE_REPO:?SOURCE_REPO is required}' "$ROOT/docker-compose.yml" + +# Integrated FreeBSD/OPNsense build. +grep -q 'FREEBSD_RELEASE=15.1-RELEASE' "$ROOT/Dockerfile" +grep -q 'x86_64-unknown-freebsd' "$ROOT/Dockerfile" +grep -q '/opt/freebsd-sysroot' "$ROOT/Dockerfile" +grep -q 'build-opnsense.sh' "$ROOT/scripts/build-release.sh" +grep -q 'package-opnsense.sh' "$ROOT/scripts/build-release.sh" +grep -q 'generate-opnsense-repo.py' "$ROOT/scripts/build-release.sh" +grep -q 'OPNSENSE_REPOSITORY_URL' "$ROOT/scripts/entrypoint.sh" +grep -q 'OPNSENSE_RETENTION' "$ROOT/scripts/entrypoint.sh" +test -f "$ROOT/opnsense/plugin/src/etc/rc.d/rustproxy" +test -f "$ROOT/opnsense/patches/0.20.0/0001-opnsense-build.patch" + +# One atomic switch exposes Linux and OPNsense together. +grep -q 'current.new' "$ROOT/scripts/build-release.sh" +grep -q 'current/opnsense' "$ROOT/scripts/init-storage.sh" +grep -q 'last_opnsense_package_version' "$ROOT/scripts/build-release.sh" + +# Two public endpoints from one nginx container. grep -Fq '${REPOSITORY_URL:?REPOSITORY_URL is required}' "$ROOT/docker-compose.yml" -grep -q 'nginxinc/nginx-unprivileged:stable-alpine-slim' "$ROOT/docker-compose.yml" -grep -q 'read_only: true' "$ROOT/docker-compose.yml" -grep -q 'no-new-privileges:true' "$ROOT/docker-compose.yml" -grep -q 'cap_drop:' "$ROOT/docker-compose.yml" +grep -Fq '${OPNSENSE_REPOSITORY_URL:?OPNSENSE_REPOSITORY_URL is required}' "$ROOT/docker-compose.yml" grep -Fq '0.0.0.0:${HTTP_PORT:-18765}:8080' "$ROOT/docker-compose.yml" -grep -q 'server_tokens off' "$ROOT/http/nginx.conf" +grep -Fq '0.0.0.0:${OPNSENSE_HTTP_PORT:-18766}:8081' "$ROOT/docker-compose.yml" +grep -q 'listen 8080' "$ROOT/http/nginx.conf" +grep -q 'listen 8081' "$ROOT/http/nginx.conf" +grep -q 'root /srv/repository/opnsense' "$ROOT/http/nginx.conf" grep -q 'GET|HEAD' "$ROOT/http/nginx.conf" grep -q 'autoindex off' "$ROOT/http/nginx.conf" -grep -q 'X-Content-Type-Options' "$ROOT/http/nginx.conf" grep -q 'healthz' "$ROOT/http/nginx.conf" +# start.sh and example configuration expose both URLs/ports. +grep -q 'DEFAULT_HTTP_PORT=18765' "$ROOT/start.sh" +grep -q 'DEFAULT_OPNSENSE_HTTP_PORT=18766' "$ROOT/start.sh" +grep -q 'OPNSENSE_REPOSITORY_URL=https://opn-rustproxy.linuxiarz.pl' "$ROOT/.env.example" + echo 'static checks: OK'