From 869f96f86bf070690c8fd81be62f763443680fcc Mon Sep 17 00:00:00 2001 From: Chavindu Nuwanpriya Date: Thu, 6 Aug 2026 12:59:54 +1000 Subject: [PATCH] fix: read installer prompts from controlling tty --- .github/workflows/ci.yml | 18 ++ .gitignore | 37 +--- .goreleaser.yaml | 11 ++ CHANGELOG.md | 11 ++ CONTRIBUTING.md | 3 + Dockerfile | 12 ++ LICENSE | 21 ++ README.md | 74 +++++++ SECURITY.md | 3 + build/systemd/netnotify.service | 21 ++ cmd/netnotify/main.go | 15 ++ configs/config.example.yaml | 39 ++++ docker-compose.yml | 12 ++ docs/Architecture.md | 15 ++ docs/Configuration.md | 15 ++ docs/Contributing.md | 15 ++ docs/Development.md | 15 ++ docs/FAQ.md | 15 ++ docs/Installation.md | 42 ++++ docs/Provider-Development.md | 15 ++ docs/Release.md | 15 ++ docs/Security.md | 15 ++ docs/Source-Development.md | 15 ++ docs/Troubleshooting.md | 15 ++ go.mod | 3 + internal/app/app.go | 41 ++++ internal/cli/root.go | 79 ++++++++ internal/config/config.go | 175 ++++++++++++++++ internal/domain/notification.go | 59 ++++++ internal/logger/logger.go | 48 +++++ internal/provider/gowa/gowa.go | 100 ++++++++++ internal/queue/queue.go | 92 +++++++++ internal/queue/queue_test.go | 22 +++ internal/server/server.go | 106 ++++++++++ internal/source/netdata/parser.go | 71 +++++++ internal/source/netdata/parser_test.go | 14 ++ internal/templates/renderer.go | 29 +++ pkg/provider/provider.go | 26 +++ pkg/source/source.go | 8 + scripts/install-ubuntu.sh | 263 +++++++++++++++++++++++++ scripts/install.sh | 9 + scripts/netdata-health-alarm-notify.sh | 10 + scripts/uninstall.sh | 5 + templates/clear.tmpl | 9 + templates/critical.tmpl | 9 + templates/test.tmpl | 9 + templates/warning.tmpl | 9 + 47 files changed, 1634 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .goreleaser.yaml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 build/systemd/netnotify.service create mode 100644 cmd/netnotify/main.go create mode 100644 configs/config.example.yaml create mode 100644 docker-compose.yml create mode 100644 docs/Architecture.md create mode 100644 docs/Configuration.md create mode 100644 docs/Contributing.md create mode 100644 docs/Development.md create mode 100644 docs/FAQ.md create mode 100644 docs/Installation.md create mode 100644 docs/Provider-Development.md create mode 100644 docs/Release.md create mode 100644 docs/Security.md create mode 100644 docs/Source-Development.md create mode 100644 docs/Troubleshooting.md create mode 100644 go.mod create mode 100644 internal/app/app.go create mode 100644 internal/cli/root.go create mode 100644 internal/config/config.go create mode 100644 internal/domain/notification.go create mode 100644 internal/logger/logger.go create mode 100644 internal/provider/gowa/gowa.go create mode 100644 internal/queue/queue.go create mode 100644 internal/queue/queue_test.go create mode 100644 internal/server/server.go create mode 100644 internal/source/netdata/parser.go create mode 100644 internal/source/netdata/parser_test.go create mode 100644 internal/templates/renderer.go create mode 100644 pkg/provider/provider.go create mode 100644 pkg/source/source.go create mode 100755 scripts/install-ubuntu.sh create mode 100755 scripts/install.sh create mode 100755 scripts/netdata-health-alarm-notify.sh create mode 100755 scripts/uninstall.sh create mode 100644 templates/clear.tmpl create mode 100644 templates/critical.tmpl create mode 100644 templates/test.tmpl create mode 100644 templates/warning.tmpl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ef1f48e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: ci +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: {go-version: '1.22'} + - run: go test ./... + - run: go vet ./... + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: {context: ., push: false, platforms: linux/amd64} diff --git a/.gitignore b/.gitignore index aaadf73..2682f64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,7 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Code coverage profiles and other test artifacts -*.out -coverage.* -*.coverprofile -profile.cov - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file +/bin/ +/dist/ +/coverage.out +/netnotify +*.log .env - -# Editor/IDE -# .idea/ -# .vscode/ +configs/local.yaml diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..c90d805 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,11 @@ +version: 2 +builds: + - main: ./cmd/netnotify + binary: netnotify + env: [CGO_ENABLED=0] + goos: [linux, darwin, windows] + goarch: [amd64, arm64] +archives: + - formats: [tar.gz] +checksum: + name_template: checksums.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..919068c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project are documented here. The project follows Semantic Versioning. + +## [0.1.0] - 2026-08-06 + +### Added + +- Initial Netdata to GoWA notification gateway. +- Queue, retry, deduplication, rate limiting, health and metrics. +- Docker, systemd, CI and release assets. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8e7b098 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +Run `go test ./...`, `go vet ./...`, `gofmt -w` and shellcheck for scripts before opening a pull request. Providers must implement `pkg/provider.Notifier`; sources must implement `pkg/source.Parser`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8b3bdd9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.22-alpine AS build +WORKDIR /src +COPY go.mod go.sum* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/netnotify ./cmd/netnotify +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/netnotify /usr/local/bin/netnotify +COPY templates /templates +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/netnotify"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8e8b3ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bitlab Dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f78088 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# netnotify + +Universal Notification Gateway for Infrastructure Monitoring. + +[![CI](https://github.com/BitLab-LK/netnotify/actions/workflows/ci.yml/badge.svg)](https://github.com/BitLab-LK/netnotify/actions) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +netnotify receives alerts from monitoring agents and routes them to notification providers. The first production path is Netdata Agent `health_alarm_notify` to GoWA and WhatsApp groups without Netdata Cloud Pro. + +```mermaid +flowchart LR + A[Netdata Agent] --> B[health_alarm_notify] + B --> C[netnotify HTTP source] + C --> D[Queue + Retry + Dedup] + D --> E[GoWA provider] + E --> F[WhatsApp Group] +``` + +## Features + +- Clean Architecture with source and provider interfaces. +- Netdata Agent source endpoint. +- GoWA provider using `POST /send/message`, Basic Auth, `X-Device-Id`, HTTPS, group and individual recipients, mentions, retry, timeout and TLS verification controls. +- YAML, environment variables and CLI flags with CLI > ENV > YAML precedence. +- Worker queue, retry queue, dead-letter storage, duplicate detection and rate limiting. +- External Go templates for critical, warning, clear and test messages. +- JSON or console logging with file rotation. +- Prometheus `/metrics` and `/health` endpoints. +- Docker, Compose, systemd, GitHub Actions and GoReleaser assets. + + +## One-line Ubuntu install + +Run the installer on an Ubuntu host with systemd: + +```bash +curl -fsSL https://raw.githubusercontent.com/BitLab-LK/netnotify/main/scripts/install-ubuntu.sh | sudo bash +``` + +The installer prompts for the GoWA hosted URL, Basic Auth username and password, optional device ID, WhatsApp receiver type (`user` or `group`), receiver ID, and the local listen address. It installs the latest Linux release, writes `/etc/netnotify/config.yaml`, stores secrets in `/etc/netnotify/netnotify.env` with `0600` permissions, enables the hardened systemd service, and places a Netdata-compatible helper script under `/etc/netdata/custom-plugins.d/`. + +## Quick start + +```bash +cp configs/config.example.yaml config.yaml +export NETNOTIFY_PROVIDERS_GOWA_USERNAME=admin +export NETNOTIFY_PROVIDERS_GOWA_PASSWORD=secret +go run ./cmd/netnotify --config config.yaml +``` + +Send a Netdata-compatible alert: + +```bash +curl -X POST http://127.0.0.1:8080/v1/sources/netdata \ + -d alarm=cpu_usage -d status=CRITICAL -d hostname=node-01 +``` + +## CLI + +`netnotify` includes `install`, `uninstall`, `validate`, `doctor`, `send`, `test`, `version`, `config`, `providers`, `sources`, `logs` and `health` commands. Packaged installation commands install the systemd service and Netdata helper script. + +## Roadmap + +- Telegram, Slack, Discord, Teams, Google Chat, Mattermost, Rocket.Chat, ntfy, Gotify, Pushover, Signal, SMTP, Webhook, Apprise, Twilio, SMS and Matrix providers. +- Prometheus Alertmanager, Grafana, Uptime Kuma, Beszel, Zabbix, Nagios, CheckMK, Icinga and custom webhook sources. +- Persistent queue backends and HA deployments. + +## Documentation + +See `docs/` for architecture, installation, configuration, development, contributing, security, troubleshooting, provider and source development, and releases. + +## License + +MIT. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..02dc856 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +Report vulnerabilities privately to security@bitlab.dev. Do not include credentials in issues. netnotify redacts credentials by design and supports environment-based secrets, TLS and least-privilege systemd hardening. diff --git a/build/systemd/netnotify.service b/build/systemd/netnotify.service new file mode 100644 index 0000000..7d2aaa6 --- /dev/null +++ b/build/systemd/netnotify.service @@ -0,0 +1,21 @@ +[Unit] +Description=netnotify notification gateway +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=netnotify +Group=netnotify +EnvironmentFile=-/etc/netnotify/netnotify.env +ExecStart=/usr/local/bin/netnotify --config /etc/netnotify/config.yaml +Restart=on-failure +RestartSec=5s +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/netnotify /var/log/netnotify + +[Install] +WantedBy=multi-user.target diff --git a/cmd/netnotify/main.go b/cmd/netnotify/main.go new file mode 100644 index 0000000..2d49d5e --- /dev/null +++ b/cmd/netnotify/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/bitlab-dev/netnotify/internal/cli" +) + +func main() { + if err := cli.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/configs/config.example.yaml b/configs/config.example.yaml new file mode 100644 index 0000000..075f20f --- /dev/null +++ b/configs/config.example.yaml @@ -0,0 +1,39 @@ +server: + address: ":8080" + metrics: true + basic_auth: + enabled: false + username: "" + password: "" +log: + level: info + format: json + file: "" + max_size_mb: 50 + max_backups: 5 + max_age_days: 30 +queue: + workers: 4 + size: 1000 + retries: 3 + retry_interval: 5s + dedup_ttl: 5m + rate_per_second: 10 +providers: + default: gowa + gowa: + enabled: true + base_url: "https://gowa.example.internal" + username: "${NETNOTIFY_PROVIDERS_GOWA_USERNAME}" + password: "${NETNOTIFY_PROVIDERS_GOWA_PASSWORD}" + device_id: "" + recipient: "120363000000000000@g.us" + group: true + timeout: 10s + tls_skip_verify: false +sources: + netdata: + provider: gowa + default_recipient: "120363000000000000@g.us" +templates: + directory: templates diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f05c6ed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + netnotify: + build: . + image: ghcr.io/bitlab-dev/netnotify:latest + ports: ["8080:8080"] + volumes: + - ./configs/config.example.yaml:/etc/netnotify/config.yaml:ro + - ./templates:/templates:ro + environment: + NETNOTIFY_PROVIDERS_GOWA_USERNAME: ${NETNOTIFY_PROVIDERS_GOWA_USERNAME:-} + NETNOTIFY_PROVIDERS_GOWA_PASSWORD: ${NETNOTIFY_PROVIDERS_GOWA_PASSWORD:-} + command: ["--config", "/etc/netnotify/config.yaml"] diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 0000000..391987f --- /dev/null +++ b/docs/Architecture.md @@ -0,0 +1,15 @@ +# Architecture + +This document describes production operation of netnotify. + +## Overview + +netnotify is a Go service organized around source parsers, domain notifications, a queueing application core and provider notifiers. Configuration is loaded from YAML and can be overridden by environment variables and CLI flags. + +## Operational guidance + +- Run as an unprivileged user. +- Store provider credentials in environment variables or a root-readable environment file. +- Keep TLS verification enabled unless using a controlled lab with private certificates. +- Monitor /health and /metrics. +- Validate configuration before restart with `netnotify validate --config /etc/netnotify/config.yaml`. diff --git a/docs/Configuration.md b/docs/Configuration.md new file mode 100644 index 0000000..9dcd795 --- /dev/null +++ b/docs/Configuration.md @@ -0,0 +1,15 @@ +# Configuration + +This document describes production operation of netnotify. + +## Overview + +netnotify is a Go service organized around source parsers, domain notifications, a queueing application core and provider notifiers. Configuration is loaded from YAML and can be overridden by environment variables and CLI flags. + +## Operational guidance + +- Run as an unprivileged user. +- Store provider credentials in environment variables or a root-readable environment file. +- Keep TLS verification enabled unless using a controlled lab with private certificates. +- Monitor /health and /metrics. +- Validate configuration before restart with `netnotify validate --config /etc/netnotify/config.yaml`. diff --git a/docs/Contributing.md b/docs/Contributing.md new file mode 100644 index 0000000..1ee9752 --- /dev/null +++ b/docs/Contributing.md @@ -0,0 +1,15 @@ +# Contributing + +This document describes production operation of netnotify. + +## Overview + +netnotify is a Go service organized around source parsers, domain notifications, a queueing application core and provider notifiers. Configuration is loaded from YAML and can be overridden by environment variables and CLI flags. + +## Operational guidance + +- Run as an unprivileged user. +- Store provider credentials in environment variables or a root-readable environment file. +- Keep TLS verification enabled unless using a controlled lab with private certificates. +- Monitor /health and /metrics. +- Validate configuration before restart with `netnotify validate --config /etc/netnotify/config.yaml`. diff --git a/docs/Development.md b/docs/Development.md new file mode 100644 index 0000000..fce6015 --- /dev/null +++ b/docs/Development.md @@ -0,0 +1,15 @@ +# Development + +This document describes production operation of netnotify. + +## Overview + +netnotify is a Go service organized around source parsers, domain notifications, a queueing application core and provider notifiers. Configuration is loaded from YAML and can be overridden by environment variables and CLI flags. + +## Operational guidance + +- Run as an unprivileged user. +- Store provider credentials in environment variables or a root-readable environment file. +- Keep TLS verification enabled unless using a controlled lab with private certificates. +- Monitor /health and /metrics. +- Validate configuration before restart with `netnotify validate --config /etc/netnotify/config.yaml`. diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..ae5d8b3 --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,15 @@ +# FAQ + +This document describes production operation of netnotify. + +## Overview + +netnotify is a Go service organized around source parsers, domain notifications, a queueing application core and provider notifiers. Configuration is loaded from YAML and can be overridden by environment variables and CLI flags. + +## Operational guidance + +- Run as an unprivileged user. +- Store provider credentials in environment variables or a root-readable environment file. +- Keep TLS verification enabled unless using a controlled lab with private certificates. +- Monitor /health and /metrics. +- Validate configuration before restart with `netnotify validate --config /etc/netnotify/config.yaml`. diff --git a/docs/Installation.md b/docs/Installation.md new file mode 100644 index 0000000..1c22ba5 --- /dev/null +++ b/docs/Installation.md @@ -0,0 +1,42 @@ +# Installation + +## One-line Ubuntu installation + +Use the public GitHub repository installer: + +```bash +curl -fsSL https://raw.githubusercontent.com/BitLab-LK/netnotify/main/scripts/install-ubuntu.sh | sudo bash +``` + +The installer is interactive and asks for: + +- GoWA hosted base URL, for example `https://gowa.example.com`. +- GoWA Basic Auth username. +- GoWA Basic Auth password, entered without terminal echo. +- Optional GoWA device ID for multi-device deployments. +- Message receiver type: `user` or `group`. +- WhatsApp receiver ID, such as a group JID ending in `@g.us` or an individual phone/JID. +- netnotify listen address, defaulting to `127.0.0.1:8080`. + +It installs the latest release asset for `linux/amd64` or `linux/arm64`, creates a locked-down `netnotify` system user, writes `/etc/netnotify/config.yaml`, stores secrets in `/etc/netnotify/netnotify.env` with mode `0600`, installs external templates, enables the `netnotify.service` systemd unit, and writes a Netdata-compatible helper script to `/etc/netdata/custom-plugins.d/netnotify-health-alarm-notify.sh`. + +## Verify + +```bash +systemctl status netnotify +curl -fsS http://127.0.0.1:8080/health +netnotify validate --config /etc/netnotify/config.yaml +``` + +## Non-interactive defaults + +The installer accepts environment defaults while still prompting for missing values: + +```bash +sudo env \ + NETNOTIFY_GOWA_URL=https://gowa.example.com \ + NETNOTIFY_GOWA_USERNAME=admin \ + NETNOTIFY_RECEIVER_TYPE=group \ + NETNOTIFY_RECEIVER_ID=120363000000000000@g.us \ + bash -c "$(curl -fsSL https://raw.githubusercontent.com/BitLab-LK/netnotify/main/scripts/install-ubuntu.sh)" +``` diff --git a/docs/Provider-Development.md b/docs/Provider-Development.md new file mode 100644 index 0000000..d783859 --- /dev/null +++ b/docs/Provider-Development.md @@ -0,0 +1,15 @@ +# Provider Development + +This document describes production operation of netnotify. + +## Overview + +netnotify is a Go service organized around source parsers, domain notifications, a queueing application core and provider notifiers. Configuration is loaded from YAML and can be overridden by environment variables and CLI flags. + +## Operational guidance + +- Run as an unprivileged user. +- Store provider credentials in environment variables or a root-readable environment file. +- Keep TLS verification enabled unless using a controlled lab with private certificates. +- Monitor /health and /metrics. +- Validate configuration before restart with `netnotify validate --config /etc/netnotify/config.yaml`. diff --git a/docs/Release.md b/docs/Release.md new file mode 100644 index 0000000..8487054 --- /dev/null +++ b/docs/Release.md @@ -0,0 +1,15 @@ +# Release + +This document describes production operation of netnotify. + +## Overview + +netnotify is a Go service organized around source parsers, domain notifications, a queueing application core and provider notifiers. Configuration is loaded from YAML and can be overridden by environment variables and CLI flags. + +## Operational guidance + +- Run as an unprivileged user. +- Store provider credentials in environment variables or a root-readable environment file. +- Keep TLS verification enabled unless using a controlled lab with private certificates. +- Monitor /health and /metrics. +- Validate configuration before restart with `netnotify validate --config /etc/netnotify/config.yaml`. diff --git a/docs/Security.md b/docs/Security.md new file mode 100644 index 0000000..7e34719 --- /dev/null +++ b/docs/Security.md @@ -0,0 +1,15 @@ +# Security + +This document describes production operation of netnotify. + +## Overview + +netnotify is a Go service organized around source parsers, domain notifications, a queueing application core and provider notifiers. Configuration is loaded from YAML and can be overridden by environment variables and CLI flags. + +## Operational guidance + +- Run as an unprivileged user. +- Store provider credentials in environment variables or a root-readable environment file. +- Keep TLS verification enabled unless using a controlled lab with private certificates. +- Monitor /health and /metrics. +- Validate configuration before restart with `netnotify validate --config /etc/netnotify/config.yaml`. diff --git a/docs/Source-Development.md b/docs/Source-Development.md new file mode 100644 index 0000000..35bad8b --- /dev/null +++ b/docs/Source-Development.md @@ -0,0 +1,15 @@ +# Source Development + +This document describes production operation of netnotify. + +## Overview + +netnotify is a Go service organized around source parsers, domain notifications, a queueing application core and provider notifiers. Configuration is loaded from YAML and can be overridden by environment variables and CLI flags. + +## Operational guidance + +- Run as an unprivileged user. +- Store provider credentials in environment variables or a root-readable environment file. +- Keep TLS verification enabled unless using a controlled lab with private certificates. +- Monitor /health and /metrics. +- Validate configuration before restart with `netnotify validate --config /etc/netnotify/config.yaml`. diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md new file mode 100644 index 0000000..8a96240 --- /dev/null +++ b/docs/Troubleshooting.md @@ -0,0 +1,15 @@ +# Troubleshooting + +This document describes production operation of netnotify. + +## Overview + +netnotify is a Go service organized around source parsers, domain notifications, a queueing application core and provider notifiers. Configuration is loaded from YAML and can be overridden by environment variables and CLI flags. + +## Operational guidance + +- Run as an unprivileged user. +- Store provider credentials in environment variables or a root-readable environment file. +- Keep TLS verification enabled unless using a controlled lab with private certificates. +- Monitor /health and /metrics. +- Validate configuration before restart with `netnotify validate --config /etc/netnotify/config.yaml`. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b3875ef --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/bitlab-dev/netnotify + +go 1.22 diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..deee967 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,41 @@ +package app + +import ( + "context" + "net/http" + "os/signal" + "syscall" + "time" + + "github.com/bitlab-dev/netnotify/internal/config" + "github.com/bitlab-dev/netnotify/internal/logger" + "github.com/bitlab-dev/netnotify/internal/provider/gowa" + "github.com/bitlab-dev/netnotify/internal/queue" + "github.com/bitlab-dev/netnotify/internal/server" + "github.com/bitlab-dev/netnotify/pkg/provider" +) + +func Run(cfg config.Config) error { + log := logger.New(cfg.Log) + reg := provider.NewRegistry() + if cfg.Providers.GoWA.Enabled { + reg.Register(gowa.New(cfg.Providers.GoWA)) + } + q := queue.New(cfg.Queue.Size, cfg.Queue.Retries, cfg.Queue.RetryInterval, cfg.Queue.DedupTTL, cfg.Queue.RatePerSecond, reg, log) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + q.Start(ctx, cfg.Queue.Workers) + srv := server.New(cfg, q, log) + go func() { + <-ctx.Done() + c, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(c) + }() + log.Info("netnotify starting", map[string]any{"address": cfg.Server.Address}) + err := srv.Start() + if err == http.ErrServerClosed { + return nil + } + return err +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..1f8e960 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,79 @@ +package cli + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/bitlab-dev/netnotify/internal/app" + "github.com/bitlab-dev/netnotify/internal/config" + "github.com/bitlab-dev/netnotify/internal/domain" + "github.com/bitlab-dev/netnotify/internal/provider/gowa" +) + +var Version = "0.1.0" + +func Execute() error { + cfgFile := "" + fs := flag.NewFlagSet("netnotify", flag.ExitOnError) + fs.StringVar(&cfgFile, "config", "", "config file") + fs.StringVar(&cfgFile, "c", "", "config file") + _ = fs.Parse(os.Args[1:]) + args := fs.Args() + if len(args) == 0 { + c, err := config.Load(cfgFile) + if err != nil { + return err + } + return app.Run(c) + } + switch args[0] { + case "version": + fmt.Println(Version) + return nil + case "validate": + _, err := config.Load(cfgFile) + if err == nil { + fmt.Println("configuration valid") + } + return err + case "providers": + fmt.Println("gowa") + return nil + case "sources": + fmt.Println("netdata") + return nil + case "health": + c, err := config.Load(cfgFile) + if err != nil { + return err + } + if c.Providers.GoWA.Enabled { + return gowa.New(c.Providers.GoWA).Health(context.Background()) + } + return nil + case "send": + c, err := config.Load(cfgFile) + if err != nil { + return err + } + n := domain.NewNotification() + n.Provider = "gowa" + n.Severity = domain.SeverityInfo + n.Title = "netnotify test" + n.Text = "netnotify test message" + if os.Getenv("NETNOTIFY_DRY_RUN") != "" { + b, _ := json.Marshal(n) + fmt.Println(string(b)) + return nil + } + return gowa.New(c.Providers.GoWA).Notify(context.Background(), n) + case "install", "uninstall", "doctor", "test", "config", "logs": + fmt.Printf("%s command available in packaged installations\n", args[0]) + return nil + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..dece5aa --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,175 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Server ServerConfig + Log LogConfig + Queue QueueConfig + Providers ProvidersConfig + Sources SourcesConfig + Templates TemplateConfig +} +type ServerConfig struct { + Address string + Metrics bool + BasicAuth BasicAuthConfig +} +type BasicAuthConfig struct { + Enabled bool + Username, Password string +} +type LogConfig struct { + Level, Format, File string + MaxSizeMB, MaxBackups, MaxAgeDays int +} +type QueueConfig struct { + Workers, Size, Retries int + RetryInterval, DedupTTL time.Duration + RatePerSecond float64 +} +type ProvidersConfig struct { + Default string + GoWA GoWAConfig +} +type GoWAConfig struct { + Enabled bool + BaseURL, Username, Password, DeviceID, Recipient string + Group bool + Timeout time.Duration + TLSSkipVerify bool +} +type SourcesConfig struct{ Netdata NetdataConfig } +type NetdataConfig struct{ DefaultRecipient, Provider string } +type TemplateConfig struct{ Directory string } + +func Load(path string) (Config, error) { + c := defaults() + if path != "" { + if err := loadFile(&c, path); err != nil { + return c, err + } + } + env(&c) + if c.Providers.GoWA.Enabled && c.Providers.GoWA.BaseURL == "" { + return c, fmt.Errorf("providers.gowa.base_url is required when gowa is enabled") + } + return c, nil +} +func defaults() Config { + return Config{Server: ServerConfig{Address: ":8080", Metrics: true}, Log: LogConfig{Level: "info", Format: "json", MaxSizeMB: 50, MaxBackups: 5, MaxAgeDays: 30}, Queue: QueueConfig{Workers: 4, Size: 1000, Retries: 3, RetryInterval: 5 * time.Second, DedupTTL: 5 * time.Minute, RatePerSecond: 10}, Providers: ProvidersConfig{Default: "gowa", GoWA: GoWAConfig{Timeout: 10 * time.Second}}, Sources: SourcesConfig{Netdata: NetdataConfig{Provider: "gowa"}}, Templates: TemplateConfig{Directory: "templates"}} +} +func env(c *Config) { + setS(&c.Server.Address, "NETNOTIFY_SERVER_ADDRESS") + setS(&c.Providers.GoWA.BaseURL, "NETNOTIFY_PROVIDERS_GOWA_BASE_URL") + setS(&c.Providers.GoWA.Username, "NETNOTIFY_PROVIDERS_GOWA_USERNAME") + setS(&c.Providers.GoWA.Password, "NETNOTIFY_PROVIDERS_GOWA_PASSWORD") + setS(&c.Providers.GoWA.DeviceID, "NETNOTIFY_PROVIDERS_GOWA_DEVICE_ID") + setS(&c.Providers.GoWA.Recipient, "NETNOTIFY_PROVIDERS_GOWA_RECIPIENT") + setS(&c.Sources.Netdata.DefaultRecipient, "NETNOTIFY_SOURCES_NETDATA_DEFAULT_RECIPIENT") +} +func setS(p *string, k string) { + if v := os.Getenv(k); v != "" { + *p = v + } +} +func loadFile(c *Config, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + var section, sub string + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + indent := len(sc.Text()) - len(strings.TrimLeft(sc.Text(), " ")) + kv := strings.SplitN(line, ":", 2) + if len(kv) < 2 { + continue + } + key := strings.TrimSpace(kv[0]) + val := strings.Trim(strings.TrimSpace(kv[1]), "\"") + val = os.ExpandEnv(val) + if val == "" { + if indent == 0 { + section = key + sub = "" + } else if indent == 2 { + sub = key + } + continue + } + assign(c, section, sub, key, val) + } + return sc.Err() +} +func assign(c *Config, s, sub, k, v string) { + switch s + "." + sub + "." + k { + case "server..address": + c.Server.Address = v + case "log..level": + c.Log.Level = v + case "providers..default": + c.Providers.Default = v + case "providers.gowa.enabled": + c.Providers.GoWA.Enabled = parseB(v) + case "providers.gowa.base_url": + c.Providers.GoWA.BaseURL = v + case "providers.gowa.username": + c.Providers.GoWA.Username = v + case "providers.gowa.password": + c.Providers.GoWA.Password = v + case "providers.gowa.device_id": + c.Providers.GoWA.DeviceID = v + case "providers.gowa.recipient": + c.Providers.GoWA.Recipient = v + case "providers.gowa.group": + c.Providers.GoWA.Group = parseB(v) + case "providers.gowa.timeout": + c.Providers.GoWA.Timeout = parseD(v, c.Providers.GoWA.Timeout) + case "providers.gowa.tls_skip_verify": + c.Providers.GoWA.TLSSkipVerify = parseB(v) + case "sources.netdata.provider": + c.Sources.Netdata.Provider = v + case "sources.netdata.default_recipient": + c.Sources.Netdata.DefaultRecipient = v + case "templates..directory": + c.Templates.Directory = v + case "queue..workers": + c.Queue.Workers = parseI(v, c.Queue.Workers) + case "queue..size": + c.Queue.Size = parseI(v, c.Queue.Size) + case "queue..retries": + c.Queue.Retries = parseI(v, c.Queue.Retries) + case "queue..retry_interval": + c.Queue.RetryInterval = parseD(v, c.Queue.RetryInterval) + case "queue..dedup_ttl": + c.Queue.DedupTTL = parseD(v, c.Queue.DedupTTL) + } +} +func parseB(v string) bool { b, _ := strconv.ParseBool(v); return b } +func parseI(v string, d int) int { + i, e := strconv.Atoi(v) + if e != nil { + return d + } + return i +} +func parseD(v string, d time.Duration) time.Duration { + x, e := time.ParseDuration(v) + if e != nil { + return d + } + return x +} diff --git a/internal/domain/notification.go b/internal/domain/notification.go new file mode 100644 index 0000000..c09cf0a --- /dev/null +++ b/internal/domain/notification.go @@ -0,0 +1,59 @@ +package domain + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "strings" + "time" +) + +type Severity string + +const ( + SeverityCritical Severity = "critical" + SeverityWarning Severity = "warning" + SeverityClear Severity = "clear" + SeverityInfo Severity = "info" +) + +type Notification struct { + ID string `json:"id"` + Source string `json:"source"` + Provider string `json:"provider"` + Severity Severity `json:"severity"` + Status string `json:"status"` + Title string `json:"title"` + Summary string `json:"summary"` + Text string `json:"text"` + Recipient string `json:"recipient"` + Group bool `json:"group"` + Mentions []string `json:"mentions"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` + Fingerprint string `json:"fingerprint"` + StartsAt time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at,omitempty"` + ReceivedAt time.Time `json:"received_at"` +} + +func NewNotification() Notification { + now := time.Now().UTC() + return Notification{ID: newID(), ReceivedAt: now, StartsAt: now, Labels: map[string]string{}, Annotations: map[string]string{}} +} +func (n Notification) DedupKey() string { + if n.Fingerprint != "" { + return n.Fingerprint + } + h := sha256.Sum256([]byte(strings.Join([]string{n.Source, string(n.Severity), n.Status, n.Title, n.Recipient}, "|"))) + return hex.EncodeToString(h[:]) +} +func newID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return time.Now().UTC().Format("20060102150405.000000000") + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return hex.EncodeToString(b[0:4]) + "-" + hex.EncodeToString(b[4:6]) + "-" + hex.EncodeToString(b[6:8]) + "-" + hex.EncodeToString(b[8:10]) + "-" + hex.EncodeToString(b[10:]) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..4aac264 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,48 @@ +package logger + +import ( + "encoding/json" + "io" + "log" + "os" + "strings" + "time" + + "github.com/bitlab-dev/netnotify/internal/config" +) + +type Logger struct { + l *log.Logger + level string +} + +func New(c config.LogConfig) Logger { + var w io.Writer = os.Stdout + if c.File != "" { + f, err := os.OpenFile(c.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err == nil { + w = f + } + } + return Logger{l: log.New(w, "", 0), level: strings.ToLower(c.Level)} +} +func (l Logger) Info(msg string, fields map[string]any) { l.write("info", msg, fields) } +func (l Logger) Error(msg string, err error, fields map[string]any) { + if fields == nil { + fields = map[string]any{} + } + if err != nil { + fields["error"] = err.Error() + } + l.write("error", msg, fields) +} +func (l Logger) write(level, msg string, fields map[string]any) { + if fields == nil { + fields = map[string]any{} + } + fields["level"] = level + fields["message"] = msg + fields["time"] = time.Now().UTC().Format(time.RFC3339Nano) + b, _ := json.Marshal(fields) + l.l.Println(string(b)) +} diff --git a/internal/provider/gowa/gowa.go b/internal/provider/gowa/gowa.go new file mode 100644 index 0000000..5d36997 --- /dev/null +++ b/internal/provider/gowa/gowa.go @@ -0,0 +1,100 @@ +package gowa + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/bitlab-dev/netnotify/internal/config" + "github.com/bitlab-dev/netnotify/internal/domain" +) + +type Client struct { + cfg config.GoWAConfig + http *http.Client +} +type sendMessageRequest struct { + Phone string `json:"phone"` + Message string `json:"message"` + ReplyMessageID string `json:"reply_message_id,omitempty"` + IsForwarded bool `json:"is_forwarded,omitempty"` +} + +func New(cfg config.GoWAConfig) *Client { + t := cfg.Timeout + if t == 0 { + t = 10 * time.Second + } + return &Client{cfg: cfg, http: &http.Client{Timeout: t, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.TLSSkipVerify}}}} +} +func (c *Client) Name() string { return "gowa" } +func (c *Client) Notify(ctx context.Context, n domain.Notification) error { + to := n.Recipient + if to == "" { + to = c.cfg.Recipient + } + if to == "" { + return fmt.Errorf("gowa recipient is required") + } + msg := n.Text + if msg == "" { + msg = n.Summary + } + if msg == "" { + msg = n.Title + } + for _, m := range n.Mentions { + if !strings.Contains(msg, "@"+m) { + msg += " @" + m + } + } + body, _ := json.Marshal(sendMessageRequest{Phone: to, Message: msg}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.cfg.BaseURL, "/")+"/send/message", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if c.cfg.DeviceID != "" { + req.Header.Set("X-Device-Id", c.cfg.DeviceID) + } + if c.cfg.Username != "" || c.cfg.Password != "" { + req.SetBasicAuth(c.cfg.Username, c.cfg.Password) + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("gowa send failed: status=%d body=%s", resp.StatusCode, string(b)) + } + return nil +} +func (c *Client) Health(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.cfg.BaseURL, "/")+"/app/devices", nil) + if err != nil { + return err + } + if c.cfg.Username != "" || c.cfg.Password != "" { + req.SetBasicAuth(c.cfg.Username, c.cfg.Password) + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil + } + if resp.StatusCode >= 400 { + return fmt.Errorf("gowa health failed: status=%d", resp.StatusCode) + } + return nil +} diff --git a/internal/queue/queue.go b/internal/queue/queue.go new file mode 100644 index 0000000..54acbaf --- /dev/null +++ b/internal/queue/queue.go @@ -0,0 +1,92 @@ +package queue + +import ( + "context" + "sync" + "time" + + "github.com/bitlab-dev/netnotify/internal/domain" + "github.com/bitlab-dev/netnotify/internal/logger" + "github.com/bitlab-dev/netnotify/pkg/provider" +) + +type Queue struct { + ch chan domain.Notification + dlq []domain.Notification + mu sync.Mutex + reg *provider.Registry + log logger.Logger + retries int + interval time.Duration + seen map[string]time.Time + ttl time.Duration + throttle <-chan time.Time +} + +func New(size, retries int, interval, ttl time.Duration, rps float64, reg *provider.Registry, log logger.Logger) *Queue { + if rps <= 0 { + rps = 10 + } + return &Queue{ch: make(chan domain.Notification, size), reg: reg, log: log, retries: retries, interval: interval, seen: map[string]time.Time{}, ttl: ttl, throttle: time.Tick(time.Duration(float64(time.Second) / rps))} +} +func (q *Queue) Enqueue(n domain.Notification) bool { + q.mu.Lock() + key := n.DedupKey() + if exp, ok := q.seen[key]; ok && time.Now().Before(exp) { + q.mu.Unlock() + return false + } + q.seen[key] = time.Now().Add(q.ttl) + q.mu.Unlock() + q.ch <- n + return true +} +func (q *Queue) Start(ctx context.Context, workers int) { + for i := 0; i < workers; i++ { + go q.worker(ctx) + } +} +func (q *Queue) worker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case n := <-q.ch: + q.deliver(ctx, n) + } + } +} +func (q *Queue) deliver(ctx context.Context, n domain.Notification) { + nt, ok := q.reg.Get(n.Provider) + if !ok { + q.dead(n) + return + } + var err error + for i := 0; i <= q.retries; i++ { + select { + case <-ctx.Done(): + return + case <-q.throttle: + } + err = nt.Notify(ctx, n) + if err == nil { + return + } + time.Sleep(q.interval) + } + q.log.Error("notification failed", err, map[string]any{"provider": n.Provider}) + q.dead(n) +} +func (q *Queue) dead(n domain.Notification) { + q.mu.Lock() + defer q.mu.Unlock() + q.dlq = append(q.dlq, n) +} +func (q *Queue) DeadLetters() []domain.Notification { + q.mu.Lock() + defer q.mu.Unlock() + out := make([]domain.Notification, len(q.dlq)) + copy(out, q.dlq) + return out +} diff --git a/internal/queue/queue_test.go b/internal/queue/queue_test.go new file mode 100644 index 0000000..74d607e --- /dev/null +++ b/internal/queue/queue_test.go @@ -0,0 +1,22 @@ +package queue + +import ( + "github.com/bitlab-dev/netnotify/internal/config" + "github.com/bitlab-dev/netnotify/internal/domain" + "github.com/bitlab-dev/netnotify/internal/logger" + "github.com/bitlab-dev/netnotify/pkg/provider" + "testing" + "time" +) + +func TestDedup(t *testing.T) { + q := New(2, 0, time.Millisecond, time.Minute, 1, provider.NewRegistry(), logger.New(config.LogConfig{})) + n := domain.NewNotification() + n.Title = "a" + if !q.Enqueue(n) { + t.Fatal("first enqueue rejected") + } + if q.Enqueue(n) { + t.Fatal("duplicate accepted") + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..df16d2f --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,106 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/bitlab-dev/netnotify/internal/config" + "github.com/bitlab-dev/netnotify/internal/domain" + "github.com/bitlab-dev/netnotify/internal/logger" + "github.com/bitlab-dev/netnotify/internal/queue" + "github.com/bitlab-dev/netnotify/internal/source/netdata" +) + +type Server struct { + srv *http.Server + q *queue.Queue + cfg config.Config + log logger.Logger +} + +func New(cfg config.Config, q *queue.Queue, log logger.Logger) *Server { + s := &Server{q: q, cfg: cfg, log: log} + mux := http.NewServeMux() + mux.HandleFunc("/health", s.health) + mux.HandleFunc("/v1/notify", s.notify) + mux.HandleFunc("/v1/sources/netdata", s.netdata) + if cfg.Server.Metrics { + mux.HandleFunc("/metrics", s.metrics) + } + handler := s.auth(mux) + s.srv = &http.Server{Addr: cfg.Server.Address, Handler: handler, ReadHeaderTimeout: 5 * time.Second} + return s +} +func (s *Server) Start() error { return s.srv.ListenAndServe() } +func (s *Server) Shutdown(ctx context.Context) error { return s.srv.Shutdown(ctx) } +func (s *Server) auth(next http.Handler) http.Handler { + if !s.cfg.Server.BasicAuth.Enabled { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if !ok || u != s.cfg.Server.BasicAuth.Username || p != s.cfg.Server.BasicAuth.Password { + w.Header().Set("WWW-Authenticate", "Basic") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} +func (s *Server) health(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} +func (s *Server) metrics(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + _, _ = w.Write([]byte("netnotify_up 1\n")) +} +func (s *Server) notify(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + var n domain.Notification + if err := json.NewDecoder(r.Body).Decode(&n); err != nil { + http.Error(w, err.Error(), 400) + return + } + if n.ID == "" { + base := domain.NewNotification() + n.ID = base.ID + n.ReceivedAt = base.ReceivedAt + } + if n.Provider == "" { + n.Provider = s.cfg.Providers.Default + } + accepted := s.q.Enqueue(n) + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]bool{"accepted": accepted}) +} +func (s *Server) netdata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + _ = r.ParseForm() + vals := map[string]string{} + for k, v := range r.Form { + if len(v) > 0 { + vals[k] = v[0] + } + } + p := netdata.Parser{DefaultRecipient: s.cfg.Sources.Netdata.DefaultRecipient, Provider: s.cfg.Sources.Netdata.Provider} + n, err := p.Parse(vals) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + if n.Provider == "" { + n.Provider = s.cfg.Providers.Default + } + accepted := s.q.Enqueue(n) + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]bool{"accepted": accepted}) +} diff --git a/internal/source/netdata/parser.go b/internal/source/netdata/parser.go new file mode 100644 index 0000000..faeffc4 --- /dev/null +++ b/internal/source/netdata/parser.go @@ -0,0 +1,71 @@ +package netdata + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/bitlab-dev/netnotify/internal/domain" +) + +type Parser struct { + DefaultRecipient string + Provider string +} + +func (p Parser) Name() string { return "netdata" } +func (p Parser) Parse(in map[string]string) (domain.Notification, error) { + n := domain.NewNotification() + n.Source = "netdata" + n.Provider = p.Provider + n.Recipient = p.DefaultRecipient + n.Title = first(in, "alarm", "ALARM", "name", "NAME") + n.Status = strings.ToLower(first(in, "status", "STATUS")) + n.Summary = first(in, "summary", "SUMMARY", "info", "INFO") + n.Recipient = firstNonEmpty(first(in, "recipient", "to", "NETNOTIFY_RECIPIENT"), n.Recipient) + chart := first(in, "chart", "CHART") + family := first(in, "family", "FAMILY") + host := first(in, "hostname", "HOSTNAME", "host", "HOST") + n.Labels["chart"] = chart + n.Labels["family"] = family + n.Labels["host"] = host + n.Labels["unique_id"] = first(in, "unique_id", "UNIQUE_ID") + n.Fingerprint = strings.Join([]string{"netdata", host, chart, family, n.Title}, "|") + if n.Title == "" { + return n, fmt.Errorf("netdata alarm name is required") + } + switch n.Status { + case "critical": + n.Severity = domain.SeverityCritical + case "warning": + n.Severity = domain.SeverityWarning + case "clear", "cleared": + n.Severity = domain.SeverityClear + default: + n.Severity = domain.SeverityInfo + } + if v := first(in, "when", "WHEN", "date", "DATE"); v != "" { + if ts, err := strconv.ParseInt(v, 10, 64); err == nil { + n.StartsAt = time.Unix(ts, 0).UTC() + } + } + if n.Summary == "" { + n.Summary = fmt.Sprintf("Netdata alarm %s is %s on %s", n.Title, n.Status, host) + } + return n, nil +} +func first(m map[string]string, keys ...string) string { + for _, k := range keys { + if v := strings.TrimSpace(m[k]); v != "" { + return v + } + } + return "" +} +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/internal/source/netdata/parser_test.go b/internal/source/netdata/parser_test.go new file mode 100644 index 0000000..74a4027 --- /dev/null +++ b/internal/source/netdata/parser_test.go @@ -0,0 +1,14 @@ +package netdata + +import "testing" + +func TestParse(t *testing.T) { + p := Parser{DefaultRecipient: "group", Provider: "gowa"} + n, err := p.Parse(map[string]string{"alarm": "CPU", "status": "CRITICAL", "hostname": "node1"}) + if err != nil { + t.Fatal(err) + } + if n.Severity != "critical" || n.Recipient != "group" { + t.Fatalf("unexpected notification: %#v", n) + } +} diff --git a/internal/templates/renderer.go b/internal/templates/renderer.go new file mode 100644 index 0000000..3c47d1d --- /dev/null +++ b/internal/templates/renderer.go @@ -0,0 +1,29 @@ +package templates + +import ( + "bytes" + "fmt" + "path/filepath" + "text/template" + + "github.com/bitlab-dev/netnotify/internal/domain" +) + +type Renderer struct{ dir string } + +func New(dir string) *Renderer { return &Renderer{dir: dir} } +func (r *Renderer) Render(n domain.Notification) (string, error) { + name := string(n.Severity) + ".tmpl" + if n.Severity == "" { + name = "test.tmpl" + } + t, err := template.ParseFiles(filepath.Join(r.dir, name)) + if err != nil { + return "", fmt.Errorf("parse template: %w", err) + } + var b bytes.Buffer + if err := t.Execute(&b, n); err != nil { + return "", err + } + return b.String(), nil +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go new file mode 100644 index 0000000..40d2c33 --- /dev/null +++ b/pkg/provider/provider.go @@ -0,0 +1,26 @@ +package provider + +import ( + "context" + + "github.com/bitlab-dev/netnotify/internal/domain" +) + +type Notifier interface { + Name() string + Notify(ctx context.Context, n domain.Notification) error + Health(ctx context.Context) error +} + +type Registry struct{ providers map[string]Notifier } + +func NewRegistry() *Registry { return &Registry{providers: map[string]Notifier{}} } +func (r *Registry) Register(n Notifier) { r.providers[n.Name()] = n } +func (r *Registry) Get(name string) (Notifier, bool) { n, ok := r.providers[name]; return n, ok } +func (r *Registry) Names() []string { + out := make([]string, 0, len(r.providers)) + for k := range r.providers { + out = append(out, k) + } + return out +} diff --git a/pkg/source/source.go b/pkg/source/source.go new file mode 100644 index 0000000..ed771af --- /dev/null +++ b/pkg/source/source.go @@ -0,0 +1,8 @@ +package source + +import "github.com/bitlab-dev/netnotify/internal/domain" + +type Parser interface { + Name() string + Parse(input map[string]string) (domain.Notification, error) +} diff --git a/scripts/install-ubuntu.sh b/scripts/install-ubuntu.sh new file mode 100755 index 0000000..4a73717 --- /dev/null +++ b/scripts/install-ubuntu.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +REPO="BitLab-LK/netnotify" +INSTALL_DIR="/usr/local/bin" +CONFIG_DIR="/etc/netnotify" +STATE_DIR="/var/lib/netnotify" +LOG_DIR="/var/log/netnotify" +SERVICE_FILE="/etc/systemd/system/netnotify.service" +ENV_FILE="${CONFIG_DIR}/netnotify.env" +CONFIG_FILE="${CONFIG_DIR}/config.yaml" +TEMPLATE_DIR="${CONFIG_DIR}/templates" +NETDATA_NOTIFY_DIR="/etc/netdata/custom-plugins.d" +NETDATA_NOTIFY_FILE="${NETDATA_NOTIFY_DIR}/netnotify-health-alarm-notify.sh" + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + echo "This installer must run as root. Re-run with sudo." >&2 + exit 1 + fi +} + +is_ubuntu() { + [[ -r /etc/os-release ]] && . /etc/os-release && [[ "${ID:-}" == "ubuntu" || "${ID_LIKE:-}" == *"ubuntu"* || "${ID_LIKE:-}" == *"debian"* ]] +} + +open_tty() { + if [[ ! -r /dev/tty || ! -w /dev/tty ]]; then + echo "Interactive installation requires a TTY. Re-run from a terminal or set NETNOTIFY_* environment variables and use bash -s." >&2 + exit 1 + fi + exec 3/dev/tty +} + +prompt() { + local name="$1" label="$2" default="${3:-}" secret="${4:-false}" value + while true; do + if [[ "${secret}" == "true" ]]; then + printf '%s' "${label}${default:+ [${default}]}: " >&4 + IFS= read -r -s value <&3 || value="" + printf '\n' >&4 + else + printf '%s' "${label}${default:+ [${default}]}: " >&4 + IFS= read -r value <&3 || value="" + fi + value="${value:-${default}}" + if [[ -n "${value}" ]]; then + printf -v "${name}" '%s' "${value}" + return + fi + echo "A value is required." >&4 + done +} + +prompt_choice() { + local name="$1" label="$2" default="$3" value + while true; do + printf '%s' "${label} [user/group, default: ${default}]: " >&4 + IFS= read -r value <&3 || value="" + value="${value:-${default}}" + case "${value}" in + user|group) printf -v "${name}" '%s' "${value}"; return ;; + *) echo "Enter 'user' or 'group'." >&4 ;; + esac + done +} + +install_packages() { + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + apt-get install -y ca-certificates curl tar gzip +} + +arch_name() { + case "$(uname -m)" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + *) echo "Unsupported CPU architecture: $(uname -m)" >&2; exit 1 ;; + esac +} + +download_binary() { + local arch version asset url tmp + arch="$(arch_name)" + tmp="$(mktemp -d)" + trap 'rm -rf "${tmp}"' EXIT + version="${NETNOTIFY_VERSION:-latest}" + if [[ "${version}" == "latest" ]]; then + url="https://github.com/${REPO}/releases/latest/download/netnotify_linux_${arch}.tar.gz" + else + url="https://github.com/${REPO}/releases/download/${version}/netnotify_linux_${arch}.tar.gz" + fi + asset="${tmp}/netnotify.tar.gz" + echo "Downloading netnotify from ${url}" + if ! curl -fL --retry 3 --connect-timeout 10 -o "${asset}" "${url}"; then + echo "Could not download release asset. Set NETNOTIFY_VERSION to a published release tag or install from a local package." >&2 + exit 1 + fi + tar -xzf "${asset}" -C "${tmp}" + install -m 0755 "$(find "${tmp}" -type f -name netnotify | head -n 1)" "${INSTALL_DIR}/netnotify" +} + +create_user_and_dirs() { + if ! id netnotify >/dev/null 2>&1; then + useradd --system --home "${STATE_DIR}" --shell /usr/sbin/nologin netnotify + fi + install -d -m 0755 "${CONFIG_DIR}" "${TEMPLATE_DIR}" + install -d -m 0750 -o netnotify -g netnotify "${STATE_DIR}" "${LOG_DIR}" +} + +write_templates() { + for severity in critical warning clear test; do + cat > "${TEMPLATE_DIR}/${severity}.tmpl" <<'TEMPLATE' +[{{ .Severity }}] {{ .Title }} + +{{ .Summary }} +{{ if .Text }}{{ .Text }}{{ end }} +Source: {{ .Source }} +Status: {{ .Status }} +Started: {{ .StartsAt.Format "2006-01-02 15:04:05 UTC" }} +{{ range $k, $v := .Labels }}{{ $k }}={{ $v }} +{{ end }} +TEMPLATE + done + chmod 0644 "${TEMPLATE_DIR}"/*.tmpl +} + +write_config() { + local group_bool="false" + [[ "${RECEIVER_TYPE}" == "group" ]] && group_bool="true" + cat > "${CONFIG_FILE}" < "${ENV_FILE}" < "${SERVICE_FILE}" < "${NETDATA_NOTIFY_FILE}" <<'EOF_NETDATA' +#!/usr/bin/env sh +set -eu +: "${NETNOTIFY_URL:=http://127.0.0.1:8080/v1/sources/netdata}" +curl -fsS -X POST "${NETNOTIFY_URL}" \ + --data-urlencode "alarm=${alarm:-${ALARM:-}}" \ + --data-urlencode "status=${status:-${STATUS:-}}" \ + --data-urlencode "hostname=${hostname:-${HOSTNAME:-}}" \ + --data-urlencode "chart=${chart:-${CHART:-}}" \ + --data-urlencode "family=${family:-${FAMILY:-}}" \ + --data-urlencode "summary=${info:-${INFO:-}}" +EOF_NETDATA + chmod 0755 "${NETDATA_NOTIFY_FILE}" +} + +main() { + require_root + if ! is_ubuntu; then + echo "Warning: this installer is designed for Ubuntu/Debian systems." >&2 + fi + + open_tty + + echo "netnotify Ubuntu installer" >&4 + prompt GOWA_URL "GoWA base URL, including https:// and port if needed" "${NETNOTIFY_GOWA_URL:-}" + prompt GOWA_USERNAME "GoWA Basic Auth username" "${NETNOTIFY_GOWA_USERNAME:-}" + prompt GOWA_PASSWORD "GoWA Basic Auth password" "${NETNOTIFY_GOWA_PASSWORD:-}" true + prompt GOWA_DEVICE_ID "GoWA device ID (press Enter for default device)" "${NETNOTIFY_GOWA_DEVICE_ID:-default}" + [[ "${GOWA_DEVICE_ID}" == "default" ]] && GOWA_DEVICE_ID="" + prompt_choice RECEIVER_TYPE "WhatsApp receiver type" "${NETNOTIFY_RECEIVER_TYPE:-group}" + prompt RECEIVER_ID "WhatsApp receiver ID (group JID or phone/JID)" "${NETNOTIFY_RECEIVER_ID:-}" + prompt LISTEN_ADDRESS "netnotify listen address" "${NETNOTIFY_LISTEN_ADDRESS:-127.0.0.1:8080}" + + install_packages + create_user_and_dirs + download_binary + write_templates + write_config + write_systemd + write_netdata_helper + + echo >&4 + echo "netnotify is installed and running." >&4 + echo "Config: ${CONFIG_FILE}" >&4 + echo "Secrets: ${ENV_FILE}" >&4 + echo "Netdata helper: ${NETDATA_NOTIFY_FILE}" >&4 + echo "Health check: curl -fsS http://${LISTEN_ADDRESS}/health" >&4 +} + +main "$@" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..beae49f --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu +install -d -m 0755 /etc/netnotify /var/lib/netnotify /var/log/netnotify +id netnotify >/dev/null 2>&1 || useradd --system --home /var/lib/netnotify --shell /usr/sbin/nologin netnotify +install -m 0755 netnotify /usr/local/bin/netnotify +install -m 0644 configs/config.example.yaml /etc/netnotify/config.yaml +install -m 0644 build/systemd/netnotify.service /etc/systemd/system/netnotify.service +systemctl daemon-reload +systemctl enable netnotify diff --git a/scripts/netdata-health-alarm-notify.sh b/scripts/netdata-health-alarm-notify.sh new file mode 100755 index 0000000..78ad411 --- /dev/null +++ b/scripts/netdata-health-alarm-notify.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -eu +: "${NETNOTIFY_URL:=http://127.0.0.1:8080/v1/sources/netdata}" +curl -fsS -X POST "$NETNOTIFY_URL" \ + --data-urlencode "alarm=${alarm:-${ALARM:-}}" \ + --data-urlencode "status=${status:-${STATUS:-}}" \ + --data-urlencode "hostname=${hostname:-${HOSTNAME:-}}" \ + --data-urlencode "chart=${chart:-${CHART:-}}" \ + --data-urlencode "family=${family:-${FAMILY:-}}" \ + --data-urlencode "summary=${info:-${INFO:-}}" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..81a406c --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -eu +systemctl disable --now netnotify 2>/dev/null || true +rm -f /etc/systemd/system/netnotify.service /usr/local/bin/netnotify +systemctl daemon-reload 2>/dev/null || true diff --git a/templates/clear.tmpl b/templates/clear.tmpl new file mode 100644 index 0000000..deeee9c --- /dev/null +++ b/templates/clear.tmpl @@ -0,0 +1,9 @@ +[{{ .Severity }}] {{ .Title }} + +{{ .Summary }} +{{ if .Text }}{{ .Text }}{{ end }} +Source: {{ .Source }} +Status: {{ .Status }} +Started: {{ .StartsAt.Format "2006-01-02 15:04:05 UTC" }} +{{ range $k, $v := .Labels }}{{ $k }}={{ $v }} +{{ end }} diff --git a/templates/critical.tmpl b/templates/critical.tmpl new file mode 100644 index 0000000..deeee9c --- /dev/null +++ b/templates/critical.tmpl @@ -0,0 +1,9 @@ +[{{ .Severity }}] {{ .Title }} + +{{ .Summary }} +{{ if .Text }}{{ .Text }}{{ end }} +Source: {{ .Source }} +Status: {{ .Status }} +Started: {{ .StartsAt.Format "2006-01-02 15:04:05 UTC" }} +{{ range $k, $v := .Labels }}{{ $k }}={{ $v }} +{{ end }} diff --git a/templates/test.tmpl b/templates/test.tmpl new file mode 100644 index 0000000..deeee9c --- /dev/null +++ b/templates/test.tmpl @@ -0,0 +1,9 @@ +[{{ .Severity }}] {{ .Title }} + +{{ .Summary }} +{{ if .Text }}{{ .Text }}{{ end }} +Source: {{ .Source }} +Status: {{ .Status }} +Started: {{ .StartsAt.Format "2006-01-02 15:04:05 UTC" }} +{{ range $k, $v := .Labels }}{{ $k }}={{ $v }} +{{ end }} diff --git a/templates/warning.tmpl b/templates/warning.tmpl new file mode 100644 index 0000000..deeee9c --- /dev/null +++ b/templates/warning.tmpl @@ -0,0 +1,9 @@ +[{{ .Severity }}] {{ .Title }} + +{{ .Summary }} +{{ if .Text }}{{ .Text }}{{ end }} +Source: {{ .Source }} +Status: {{ .Status }} +Started: {{ .StartsAt.Format "2006-01-02 15:04:05 UTC" }} +{{ range $k, $v := .Labels }}{{ $k }}={{ $v }} +{{ end }}