Skip to content

Commit 6e9d228

Browse files
committed
Add Signal alerts via signal-cli-rest-api gateways
1 parent 422fb10 commit 6e9d228

8 files changed

Lines changed: 130 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
All notable changes to Gjallar are documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
55

6+
## [0.4.0] - 2026-07-05
7+
8+
### Added
9+
10+
- Signal alerts (`type: signal`) through any signal-cli-rest-api compatible
11+
gateway: POST to `/v2/send` with sender `number` and `recipients`.
12+
613
## [0.3.0] - 2026-07-04
714

815
### Added

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Rules: `> N`, `>= N`, `< N`, `<= N`, `== x`, `!= x`, `~ regex`, `rows > 0` (row
2424
- Any [shoutrrr](https://shoutrrr.nickfedor.com/) URL: Telegram, email/SMTP, ntfy,
2525
Discord, Slack, Gotify, Pushover, generic webhooks, ...
2626
- Free Mobile SMS API (`type: freemobile`).
27+
- Signal via a [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api)
28+
gateway (`type: signal`: `url` to `/v2/send`, sender `number`, `recipients`).
2729

2830
A monitor alerts after `failure_threshold` consecutive failures (no flapping noise),
2931
and again on recovery. Open incidents survive restarts: no duplicate alerts.

gjallar.example.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ alerts:
3333
type: freemobile
3434
user: "12345678" # your Free Mobile login
3535
pass: "AbCdEfGh1234" # the API key from your subscriber area
36+
signal-ops: # signal-cli-rest-api gateway (POST /v2/send)
37+
type: signal
38+
url: "https://signal-api.example.com/v2/send"
39+
number: "+33600000000" # sender registered on the gateway
40+
recipients: ["+33611111111", "+33622222222"]
3641

3742
monitors:
3843
# --- HTTP/HTTPS: status code, body regex, TLS certificate expiry ---

internal/alert/notifiers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ func BuildNotifiers(alerts map[string]config.Alert) (map[string]Notifier, error)
2020
out[name] = n
2121
case "freemobile":
2222
out[name] = NewFreeMobile(a.User, a.Pass)
23+
case "signal":
24+
out[name] = NewSignal(a.URL, a.Number, a.Recipients)
2325
default:
2426
return nil, fmt.Errorf("alert %q: unknown type %q", name, a.Type)
2527
}

internal/alert/signal.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package alert
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
)
11+
12+
// Signal sends messages through a signal-cli-rest-api compatible gateway
13+
// (POST {url} with {"message", "number", "recipients"}).
14+
type Signal struct {
15+
URL string // full /v2/send endpoint
16+
Number string // sender number registered on the gateway
17+
Recipients []string
18+
Client *http.Client
19+
}
20+
21+
func NewSignal(url, number string, recipients []string) *Signal {
22+
return &Signal{URL: url, Number: number, Recipients: recipients, Client: &http.Client{}}
23+
}
24+
25+
func (s *Signal) Send(ctx context.Context, title, message string) error {
26+
payload, err := json.Marshal(map[string]any{
27+
"message": title + "\n" + message,
28+
"number": s.Number,
29+
"recipients": s.Recipients,
30+
})
31+
if err != nil {
32+
return err
33+
}
34+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.URL, bytes.NewReader(payload))
35+
if err != nil {
36+
return err
37+
}
38+
req.Header.Set("Content-Type", "application/json")
39+
40+
resp, err := s.Client.Do(req)
41+
if err != nil {
42+
return err
43+
}
44+
defer resp.Body.Close()
45+
if resp.StatusCode < 200 || resp.StatusCode > 299 {
46+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
47+
return fmt.Errorf("signal: status %d: %s", resp.StatusCode, bytes.TrimSpace(body))
48+
}
49+
return nil
50+
}

internal/alert/signal_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package alert
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"gjallar/internal/config"
12+
)
13+
14+
func TestSignal(t *testing.T) {
15+
var got map[string]any
16+
status := 201
17+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" {
19+
t.Errorf("method=%s content-type=%s", r.Method, r.Header.Get("Content-Type"))
20+
}
21+
json.NewDecoder(r.Body).Decode(&got)
22+
w.WriteHeader(status)
23+
}))
24+
defer srv.Close()
25+
26+
s := NewSignal(srv.URL, "+33616400522", []string{"+33689816957", "+33616400522"})
27+
if err := s.Send(context.Background(), "[Gjallar] DOWN: web", "web — boom"); err != nil {
28+
t.Fatal(err)
29+
}
30+
if got["number"] != "+33616400522" {
31+
t.Errorf("number = %v", got["number"])
32+
}
33+
if recs, _ := got["recipients"].([]any); len(recs) != 2 || recs[0] != "+33689816957" {
34+
t.Errorf("recipients = %v", got["recipients"])
35+
}
36+
if msg, _ := got["message"].(string); !strings.Contains(msg, "DOWN: web") || !strings.Contains(msg, "boom") {
37+
t.Errorf("message = %q", got["message"])
38+
}
39+
40+
status = 400
41+
if err := s.Send(context.Background(), "t", "m"); err == nil || !strings.Contains(err.Error(), "status 400") {
42+
t.Errorf("err = %v", err)
43+
}
44+
}
45+
46+
func TestBuildNotifiersSignal(t *testing.T) {
47+
ns, err := BuildNotifiers(map[string]config.Alert{
48+
"sig": {Type: "signal", URL: "http://h/v2/send", Number: "+336", Recipients: []string{"+337"}},
49+
})
50+
if err != nil || ns["sig"] == nil {
51+
t.Fatalf("BuildNotifiers: %v", err)
52+
}
53+
}

internal/config/config.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,16 @@ type Defaults struct {
4646
}
4747

4848
// Alert is a named notifier. Type "" (default) means a shoutrrr URL;
49-
// type "freemobile" uses the Free Mobile SMS API with User/Pass.
49+
// type "freemobile" uses the Free Mobile SMS API with User/Pass;
50+
// type "signal" posts to a signal-cli-rest-api /v2/send endpoint.
5051
type Alert struct {
5152
Type string `yaml:"type"`
5253
URL string `yaml:"url"`
5354
User string `yaml:"user"`
5455
Pass string `yaml:"pass"`
56+
// signal
57+
Number string `yaml:"number"` // sender number registered on the gateway
58+
Recipients []string `yaml:"recipients"` // destination numbers
5559
}
5660

5761
type Monitor struct {
@@ -196,8 +200,12 @@ func (c *Config) Validate() error {
196200
if a.User == "" || a.Pass == "" {
197201
return fmt.Errorf("alert %q: user and pass are required for type freemobile", name)
198202
}
203+
case "signal":
204+
if a.URL == "" || a.Number == "" || len(a.Recipients) == 0 {
205+
return fmt.Errorf("alert %q: url, number and recipients are required for type signal", name)
206+
}
199207
default:
200-
return fmt.Errorf("alert %q: unknown type %q (supported: shoutrrr, freemobile)", name, a.Type)
208+
return fmt.Errorf("alert %q: unknown type %q (supported: shoutrrr, freemobile, signal)", name, a.Type)
201209
}
202210
}
203211
if len(c.Monitors) == 0 {

internal/config/config_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ func TestLoadErrors(t *testing.T) {
178178
{"unknown alert ref", "monitors:\n - name: x\n type: ping\n host: h\n alerts: [nope]", `unknown alert "nope"`},
179179
{"alert missing url", "alerts:\n a: {}\nmonitors:\n - name: x\n type: ping\n host: h", "url is required"},
180180
{"freemobile missing pass", "alerts:\n a:\n type: freemobile\n user: u\nmonitors:\n - name: x\n type: ping\n host: h", "user and pass"},
181+
{"signal missing recipients", "alerts:\n a:\n type: signal\n url: http://h/v2/send\n number: \"+336\"\nmonitors:\n - name: x\n type: ping\n host: h", "url, number and recipients"},
181182
{"bad duration", "monitors:\n - name: x\n type: ping\n host: h\n interval: fast", "invalid duration"},
182183
}
183184
for _, tc := range cases {

0 commit comments

Comments
 (0)