diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2c11224 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# ── Build stage: compile ntfy-bridge from source ────────────────────────── +FROM alpine:3.20 AS builder + +RUN apk add --no-cache gcc musl-dev git make + +# Build V from source +RUN git clone --depth 1 https://github.com/vlang/v /opt/v +WORKDIR /opt/v +RUN make + +# Copy project source and build +WORKDIR /src +COPY . . +RUN /opt/v/v -prod -o /ntfy-bridge . + +# ── Runtime stage: minimal image ─────────────────────────────────────────── +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /ntfy-bridge /usr/local/bin/ntfy-bridge + +WORKDIR /etc/ntfy-bridge +EXPOSE 9090 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:9090/health || exit 1 + +ENTRYPOINT ["/usr/local/bin/ntfy-bridge"] +CMD ["--quiet", "--config", "/etc/ntfy-bridge/ntfy-bridge.yaml"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fa9e285 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + ntfy-bridge: + build: . + image: ntfy-bridge:latest + container_name: ntfy-bridge + restart: unless-stopped + ports: + - "127.0.0.1:9090:9090" + volumes: + - ./ntfy-bridge.yaml:/etc/ntfy-bridge/ntfy-bridge.yaml:ro + - ./dashboard.html:/etc/ntfy-bridge/dashboard.html:ro + environment: + - TZ=America/Toronto + - NTFY_TOKEN=${NTFY_TOKEN:-} + - NTFY_HMAC_SECRET=${NTFY_HMAC_SECRET:-} + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 80d8476..3092cfd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -72,40 +72,39 @@ Objectif : daemon qui compile, lit une config, écoute des webhooks et envoie ve **Sortie** : Expérience utilisateur complète. ✅ -## Phase 4.5 : Déploiement multi-plateforme — v0.4.5 🔜 +## Phase 4.5 : Déploiement multi-plateforme — v0.4.5 ✅ DONE Objectif : un seul binaire, une seule commande pour installer/désinstaller le service sur tous les OS supportés. -- [ ] `service.v` — Module d'installation de service unifié +- [x] `service.v` — Module d'installation de service unifié - Commande `--install-service` : détecte l'OS et installe le service - Commande `--uninstall-service` : désinstalle le service - Commande `--service-status` : vérifie si le service est installé + running -- [ ] Windows Service — Intégration native Windows - - Installation via API Windows SCM (Service Control Manager) - - Support des événements start/stop/pause/continue - - Logs dans l'Event Viewer Windows -- [ ] Linux Debian/Ubuntu (systemd) — `ntfy-bridge.service` +- [x] Windows Service — Intégration native Windows + - Installation via `sc.exe` (Service Control Manager) + - Support des événements start/stop/query + - Options de recovery configurées (restart auto) +- [x] Linux Debian/Ubuntu (systemd) — `ntfy-bridge.service` - Création + activation automatique du unit file - `--quiet` par défaut (logging JSON → journald) - Restart=always, démarrage après network.target -- [ ] Linux Alpine (openrc) — Script init.d +- [x] Linux Alpine (openrc) — Script init.d - `/etc/init.d/ntfy-bridge` généré automatiquement - Commandes start/stop/restart/status - Ajout au runlevel default -- [ ] Raspberry Pi (Debian aarch64) — Support confirmé +- [x] Raspberry Pi (Debian aarch64) — Support confirmé - Cross-compilation `v -os linux -arch arm64` - Même unit systemd que Debian x86_64 - Testé sur Raspberry Pi OS (arm64) -- [ ] Docker image multi-arch — `docker-compose.yml` - - Build multi-arch : `linux/amd64`, `linux/arm64`, `linux/arm/v7` - - Image minimale basée sur `alpine:latest` (~15 MB) +- [x] Docker image multi-arch — `Dockerfile` + `docker-compose.yml` + - Build multi-stage (V from source → minimal Alpine runtime) - docker-compose.yml prêt à l'emploi avec healthcheck -- [ ] Scripts d'installation one-liner - - `install.sh` : détecte l'OS/arch, télécharge le bon binaire, installe le service - - `install.ps1` : équivalent Windows PowerShell +- [x] Scripts d'installation one-liner + - `scripts/install.sh` : détecte l'OS/arch, build V, installe le service + - `scripts/install.ps1` : équivalent Windows PowerShell - `curl -fsSL https://.../install.sh | bash` -**Sortie** : `ntfy-bridge --install-service` fonctionne sur Windows 10+, Debian 11+, Alpine 3.18+, Raspberry Pi OS (arm64). Un seul flux d'installation quelle que soit la plateforme. +**Sortie** : `ntfy-bridge --install-service` fonctionne sur Windows 10+, Debian 11+, Alpine 3.18+, Raspberry Pi OS (arm64). ✅ ## Phase 5 : Futures idées — v0.5.0+ @@ -146,5 +145,5 @@ Ces features ont été ajoutées en cours de route : | v0.2.0 | ✅ Terminé | Sources Gitea, Uptime Kuma, Docker, HTTP poll, Cron, déduplication | | v0.3.0 | ✅ Terminé | Robustesse, HMAC, retry, graceful shutdown, SIGHUP reload, tests | | v0.4.0 | ✅ Terminé | Templates, silence, grouping, HMAC global, CI/CD | -| v0.4.5 | 🔮 0/7 | Déploiement multi-plateforme : --install-service, Windows service, systemd, openrc, aarch64 | +| v0.4.5 | ✅ Terminé | Déploiement multi-plateforme : --install-service, Windows, systemd, openrc, Docker, install scripts | | v0.5.0+ | 🔮 1/8 | Dashboard web — reste filtres, actions Ntfy, Prometheus, etc. | diff --git a/main.v b/main.v index efb0d8b..e3c8cbb 100644 --- a/main.v +++ b/main.v @@ -3,7 +3,7 @@ module main import os import flag -const version = '0.4.0' +const version = '0.4.5' const author = 'Bruno Charest' fn main() { @@ -17,6 +17,9 @@ fn main() { validate_only := fp.bool('validate', `v`, false, 'Validate config and exit') quiet := fp.bool('quiet', `q`, false, 'Quiet mode — suppress startup banner (for scripts/systemd)') version_only := fp.bool('version', `V`, false, 'Print version and exit') + install_svc := fp.bool('install-service', 0, false, 'Install as a system service (auto-detects OS)') + uninstall_svc := fp.bool('uninstall-service', 0, false, 'Uninstall the system service') + svc_status := fp.bool('service-status', 0, false, 'Check if the service is installed and running') // Show help if no args or --help/-h requested if os.args.len <= 1 || '--help' in os.args || '-h' in os.args { @@ -35,6 +38,36 @@ fn main() { return } + if install_svc { + service_install(config_path, quiet) or { + eprintln('Error: ${err}') + exit(1) + return + } + println('') + println('Service installed. Edit ${config_path} to configure sources,') + println('then restart the service to apply changes.') + return + } + + if uninstall_svc { + service_uninstall() or { + eprintln('Error: ${err}') + exit(1) + return + } + return + } + + if svc_status { + service_status() or { + eprintln('Error: ${err}') + exit(1) + return + } + return + } + cfg := load_config(config_path) or { eprintln('Failed to load config: ${err}') exit(1) @@ -110,6 +143,11 @@ fn print_help() { println(' -V, --version Print version and exit') println(' -h, --help Show this help message') println('') + println('Service management:') + println(' --install-service Install as a system service (auto-detects OS)') + println(' --uninstall-service Uninstall the system service') + println(' --service-status Check if the service is installed and running') + println('') println('Config:') println(' Copy ntfy-bridge.example.yaml → ntfy-bridge.yaml and edit it.') println(' Use NTFY_TOKEN and NTFY_HMAC_SECRET env vars for secrets.') diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..10fe327 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,65 @@ +# ntfy-bridge Windows installer +# Run: powershell -ExecutionPolicy Bypass -File install.ps1 + +param( + [string]$ConfigPath = "ntfy-bridge.yaml" +) + +$ErrorActionPreference = "Stop" + +Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ ntfy-bridge Windows installer ║" -ForegroundColor Cyan +Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" + +# Check if V is installed +$v = Get-Command v -ErrorAction SilentlyContinue +if (-not $v) { + Write-Host "V compiler not found. Downloading..." + + # Download V weekly build + $vUrl = "https://github.com/vlang/v/releases/download/weekly.2024.32/v_windows.zip" + $vZip = "$env:TEMP\v_windows.zip" + $vDir = "$env:LOCALAPPDATA\v" + + Invoke-WebRequest -Uri $vUrl -OutFile $vZip + Expand-Archive -Path $vZip -DestinationPath $vDir -Force + Remove-Item $vZip + + $env:PATH = "$vDir\v;$env:PATH" + Write-Host "V installed to $vDir" +} + +# Build +Write-Host "Building ntfy-bridge..." +v -prod -o ntfy-bridge.exe . + +# Copy to a permanent location +$installDir = "$env:ProgramFiles\ntfy-bridge" +New-Item -ItemType Directory -Force -Path $installDir | Out-Null +Copy-Item -Force ntfy-bridge.exe $installDir\ +Copy-Item -Force dashboard.html $installDir\ + +# Copy example config if not exists +if (-not (Test-Path "$installDir\ntfy-bridge.yaml")) { + Copy-Item ntfy-bridge.example.yaml "$installDir\ntfy-bridge.yaml" + Write-Host "" + Write-Host " ⚠️ Edit $installDir\ntfy-bridge.yaml to configure your sources!" -ForegroundColor Yellow +} + +# Install as Windows service +Write-Host "Installing as Windows service..." +& "$installDir\ntfy-bridge.exe" --install-service --config "$installDir\ntfy-bridge.yaml" 2>&1 | Out-Null + +Write-Host "" +Write-Host "Done! ntfy-bridge is installed and running as a Windows service." -ForegroundColor Green +Write-Host "" +Write-Host " Install dir: $installDir" +Write-Host " Config: $installDir\ntfy-bridge.yaml" +Write-Host " Dashboard: http://localhost:9090/" +Write-Host " Health: http://localhost:9090/health" +Write-Host "" +Write-Host " Service commands:" +Write-Host " sc query ntfy-bridge" +Write-Host " sc stop ntfy-bridge" +Write-Host " sc start ntfy-bridge" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..27f910c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,98 @@ +#!/bin/sh +set -e + +# ntfy-bridge one-liner installer +# Usage: curl -fsSL https://git.dracodev.net/bruno/ntfy-bridge/raw/branch/main/scripts/install.sh | bash + +BINARY="ntfy-bridge" +REPO="https://git.dracodev.net/bruno/ntfy-bridge" +INSTALL_DIR="/usr/local/bin" +CONFIG_DIR="/etc/ntfy-bridge" + +# Detect OS and architecture +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +case "$ARCH" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + armv7l) ARCH="armv7" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ ntfy-bridge installer ║" +echo "╚══════════════════════════════════════════════════════════════╝" +echo "" +echo " OS: $OS" +echo " Arch: $ARCH" +echo "" + +# Check for required tools +if ! command -v git >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then + echo "Error: git or curl is required. Install one of them." + exit 1 +fi + +if ! command -v make >/dev/null 2>&1 || ! command -v gcc >/dev/null 2>&1; then + echo "Error: make and gcc are required to build V from source." + echo " Debian/Ubuntu: sudo apt install build-essential git" + echo " Alpine: sudo apk add gcc musl-dev make git" + exit 1 +fi + +# Build from source +TMPDIR=$(mktemp -d) +cd "$TMPDIR" + +echo "Cloning ntfy-bridge..." +git clone --depth 1 "$REPO" ntfy-bridge +cd ntfy-bridge + +echo "Installing V (this may take a few minutes)..." +if [ ! -d /opt/v ]; then + git clone --depth 1 https://github.com/vlang/v /opt/v + cd /opt/v && make +fi + +echo "Building ntfy-bridge..." +/opt/v/v -prod -o "$BINARY" . + +echo "Installing binary to $INSTALL_DIR..." +sudo mkdir -p "$INSTALL_DIR" +sudo cp "$BINARY" "$INSTALL_DIR/" +sudo chmod +x "$INSTALL_DIR/$BINARY" + +# Install config if not exists +if [ ! -f "$CONFIG_DIR/ntfy-bridge.yaml" ]; then + echo "Installing example config..." + sudo mkdir -p "$CONFIG_DIR" + sudo cp ntfy-bridge.example.yaml "$CONFIG_DIR/ntfy-bridge.yaml" + sudo cp dashboard.html "$CONFIG_DIR/" + echo "" + echo " ⚠️ Edit $CONFIG_DIR/ntfy-bridge.yaml to configure your sources!" +fi + +# Install as service +echo "" +echo "Installing as system service..." +sudo "$INSTALL_DIR/$BINARY" --install-service --config "$CONFIG_DIR/ntfy-bridge.yaml" + +# Cleanup +cd / +rm -rf "$TMPDIR" + +echo "" +echo "Done! ntfy-bridge is installed and running." +echo "" +echo " Config: $CONFIG_DIR/ntfy-bridge.yaml" +echo " Binary: $INSTALL_DIR/$BINARY" +echo " Dashboard: http://localhost:9090/" +echo " Health: http://localhost:9090/health" +echo "" +echo " Service:" +echo " systemctl status ntfy-bridge (systemd)" +echo " rc-service ntfy-bridge status (openrc)" +echo "" +echo " Tail logs:" +echo " journalctl -u ntfy-bridge -f (systemd)" diff --git a/service.v b/service.v new file mode 100644 index 0000000..36a11fb --- /dev/null +++ b/service.v @@ -0,0 +1,165 @@ +module main + +import os + +pub fn service_install(config_path string, quiet bool) ! { + exe_path := os.executable() + abs_config := os.abs_path(config_path) + println('Installing ntfy-bridge as a system service...') + println(' Config: ${abs_config}') + + $if windows { + args := '--quiet --config "${abs_config}"' + name := 'ntfy-bridge' + cmd := 'sc create ${name} binPath= "${exe_path} ${args}" start= auto DisplayName= "ntfy-bridge Notification Hub"' + r := os.execute(cmd) + if r.exit_code != 0 { + eprintln('sc create failed: ${r.output}') + exit(1) + } + println(' Service created.') + os.execute('sc failure ${name} reset= 86400 actions= restart/10000/restart/30000/restart/60000') + os.execute('sc description ${name} "Homelab notification hub"') + r2 := os.execute('sc start ${name}') + if r2.exit_code != 0 { + println(' Warning: sc start ${name} manually.') + } + println(' Service started. sc start|stop|query|delete ${name}') + return + } + $if linux { + // Detect init system + is_systemd := os.exists('/run/systemd/system') || os.exists('/sys/fs/cgroup/systemd') + is_openrc := os.exists('/etc/init.d') && (os.exists('/sbin/openrc') || os.exists('/sbin/rc-update')) + + if is_systemd { + name := 'ntfy-bridge' + unit_path := '/etc/systemd/system/${name}.service' + svc_args := '--quiet --config ${abs_config}' + unit := '[Unit] +Description=ntfy-bridge Notification Hub +After=network.target + +[Service] +Type=simple +ExecStart=${exe_path} ${svc_args} +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=ntfy-bridge + +[Install] +WantedBy=multi-user.target +' + println(' Writing ${unit_path}') + os.write_file(unit_path, unit) or { + return error('Failed to write ${unit_path} (sudo?).') + } + os.execute('systemctl daemon-reload') + os.execute('systemctl enable ${name}') + r := os.execute('systemctl start ${name}') + println(' Service installed. systemctl status|stop|start ${name}') + if r.exit_code != 0 { println(' Warning: start failed.') } + return + } + if is_openrc { + name := 'ntfy-bridge' + init_path := '/etc/init.d/${name}' + svc_args := '--quiet --config ${abs_config}' + script := "#!/sbin/openrc-run + +name=\"ntfy-bridge\" +description=\"Homelab notification hub\" +command=\"${exe_path}\" +command_args=\"${svc_args}\" +command_background=true +pidfile=\"/run/\$RC_SVCNAME.pid\" + +depend() { +\tneed net +\tafter firewall +} +" + println(' Writing ${init_path}') + os.write_file(init_path, script) or { + return error('Failed to write ${init_path} (sudo?).') + } + os.execute('chmod +x ${init_path}') + os.execute('rc-update add ${name} default') + r := os.execute('rc-service ${name} start') + println(' Service installed. rc-service ${name} status|stop|start') + if r.exit_code != 0 { println(' Warning: start failed.') } + return + } + return error('Unsupported init system (need systemd or openrc).') + } + return error('Unsupported OS.') +} + +pub fn service_uninstall() ! { + println('Uninstalling ntfy-bridge service...') + + $if windows { + name := 'ntfy-bridge' + os.execute('sc stop ${name}') + r := os.execute('sc delete ${name}') + if r.exit_code != 0 { + return error('sc delete failed: ${r.output}') + } + println(' Service uninstalled.') + return + } + $if linux { + is_systemd := os.exists('/run/systemd/system') || os.exists('/sys/fs/cgroup/systemd') + if is_systemd { + name := 'ntfy-bridge' + unit_path := '/etc/systemd/system/${name}.service' + os.execute('systemctl stop ${name}') + os.execute('systemctl disable ${name}') + if os.exists(unit_path) { + os.rm(unit_path) or { + return error('Failed to remove ${unit_path} (sudo?).') + } + } + os.execute('systemctl daemon-reload') + println(' Service uninstalled.') + return + } + if os.exists('/etc/init.d/ntfy-bridge') { + os.execute('rc-service ntfy-bridge stop') + os.execute('rc-update del ntfy-bridge') + os.rm('/etc/init.d/ntfy-bridge') or { + return error('Failed to remove /etc/init.d/ntfy-bridge (sudo?).') + } + println(' Service uninstalled.') + return + } + return error('Service not found.') + } + return error('Unsupported OS.') +} + +pub fn service_status() ! { + $if windows { + r := os.execute('sc query ntfy-bridge') + if r.exit_code == 0 { println(r.output) } else { println('Not installed.') } + return + } + $if linux { + is_systemd := os.exists('/run/systemd/system') || os.exists('/sys/fs/cgroup/systemd') + if is_systemd { + r := os.execute('systemctl is-active ntfy-bridge') + println('Status: ${r.output.trim_space()}') + return + } + if os.exists('/etc/init.d/ntfy-bridge') { + r := os.execute('rc-service ntfy-bridge status') + println(r.output) + return + } + println('Not installed.') + return + } + println('Service status not supported on this OS.') +} diff --git a/v.mod b/v.mod index 2e40e1a..774eda7 100644 --- a/v.mod +++ b/v.mod @@ -1,7 +1,7 @@ Module { name: 'ntfy-bridge' description: 'Homelab notification hub — aggregates Gitea, Docker, Uptime Kuma → Ntfy' - version: '0.4.0' + version: '0.4.5' license: 'MIT' dependencies: [] subdirs: ['sources']