Skip to content

Commit e1743bb

Browse files
committed
Add Redis check type (PING/AUTH over raw protocol)
1 parent 6e9d228 commit e1743bb

7 files changed

Lines changed: 194 additions & 4 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.5.0] - 2026-07-05
7+
8+
### Added
9+
10+
- Redis check type (`type: redis`): TCP connect, optional `AUTH`, `PING`
11+
must answer `+PONG`. Fields: `host`, `port` (default 6379), `password`.
12+
613
## [0.4.0] - 2026-07-05
714

815
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ HTMX-refreshed), and alerts you when something goes down — and when it recover
1515
| `postgres` | SQL query result against a rule (pure-Go pgx driver) |
1616
| `oracle` | SQL query result against a rule (pure-Go go-ora driver, no Oracle client needed) |
1717
| `ping` | ICMP echo (privileged or unprivileged) |
18+
| `redis` | TCP connect + optional `AUTH` + `PING`/`+PONG` |
1819
| `prometheus` | Fetches a `/metrics` route and evaluates a rule against metric values, with optional label selectors |
1920

2021
Rules: `> N`, `>= N`, `< N`, `<= N`, `== x`, `!= x`, `~ regex`, `rows > 0` (row count).

gjallar.example.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ monitors:
8989
interval: 15s
9090
timeout: 5s
9191

92+
# --- Redis: connect + optional AUTH + PING ---
93+
- name: cache
94+
type: redis
95+
host: "10.0.0.5"
96+
port: 6379 # default 6379
97+
password: "${REDIS_PASSWORD}" # omit if no AUTH
98+
9299
# --- Prometheus metrics: rule must hold for every matching series ---
93100
- name: node1-disk
94101
type: prometheus

internal/check/check.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ func New(m config.Monitor) (Checker, error) {
3939
c, err = newSQLCheck("oracle", m)
4040
case "ping":
4141
c, err = newPingCheck(m)
42+
case "redis":
43+
c, err = newRedisCheck(m)
4244
case "prometheus":
4345
c, err = newPromCheck(m)
4446
default:

internal/check/redis.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package check
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"net"
8+
"strconv"
9+
"strings"
10+
11+
"gjallar/internal/config"
12+
)
13+
14+
// redisCheck connects to a Redis server, optionally authenticates, and
15+
// expects +PONG to a PING. No client library — the protocol is two lines.
16+
type redisCheck struct {
17+
addr string
18+
password string
19+
}
20+
21+
func newRedisCheck(m config.Monitor) (*redisCheck, error) {
22+
port := m.Port
23+
if port == 0 {
24+
port = 6379
25+
}
26+
return &redisCheck{
27+
addr: net.JoinHostPort(m.Host, strconv.Itoa(port)),
28+
password: m.Password,
29+
}, nil
30+
}
31+
32+
func (c *redisCheck) Check(ctx context.Context) (bool, string) {
33+
var d net.Dialer
34+
conn, err := d.DialContext(ctx, "tcp", c.addr)
35+
if err != nil {
36+
return false, err.Error()
37+
}
38+
defer conn.Close()
39+
if deadline, ok := ctx.Deadline(); ok {
40+
conn.SetDeadline(deadline)
41+
}
42+
r := bufio.NewReader(conn)
43+
44+
if c.password != "" {
45+
if err := redisCommand(conn, r, "AUTH "+c.password, "+OK"); err != nil {
46+
return false, fmt.Sprintf("auth: %v", err)
47+
}
48+
}
49+
if err := redisCommand(conn, r, "PING", "+PONG"); err != nil {
50+
return false, err.Error()
51+
}
52+
return true, ""
53+
}
54+
55+
func redisCommand(conn net.Conn, r *bufio.Reader, cmd, want string) error {
56+
if _, err := fmt.Fprintf(conn, "%s\r\n", cmd); err != nil {
57+
return err
58+
}
59+
line, err := r.ReadString('\n')
60+
if err != nil {
61+
return err
62+
}
63+
if line = strings.TrimSpace(line); line != want {
64+
return fmt.Errorf("unexpected reply %q", line)
65+
}
66+
return nil
67+
}

internal/check/redis_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package check
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"net"
7+
"strconv"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"gjallar/internal/config"
13+
)
14+
15+
// fakeRedis answers PING/AUTH like a real server; password "" = no auth needed.
16+
func fakeRedis(t *testing.T, password string) (host string, port int) {
17+
t.Helper()
18+
ln, err := net.Listen("tcp", "127.0.0.1:0")
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
t.Cleanup(func() { ln.Close() })
23+
go func() {
24+
for {
25+
conn, err := ln.Accept()
26+
if err != nil {
27+
return
28+
}
29+
go func(c net.Conn) {
30+
defer c.Close()
31+
r := bufio.NewReader(c)
32+
authed := password == ""
33+
for {
34+
line, err := r.ReadString('\n')
35+
if err != nil {
36+
return
37+
}
38+
switch cmd := strings.TrimSpace(line); {
39+
case strings.HasPrefix(cmd, "AUTH "):
40+
if strings.TrimPrefix(cmd, "AUTH ") == password {
41+
authed = true
42+
c.Write([]byte("+OK\r\n"))
43+
} else {
44+
c.Write([]byte("-ERR invalid password\r\n"))
45+
}
46+
case cmd == "PING" && authed:
47+
c.Write([]byte("+PONG\r\n"))
48+
default:
49+
c.Write([]byte("-NOAUTH Authentication required.\r\n"))
50+
}
51+
}
52+
}(conn)
53+
}
54+
}()
55+
addr := ln.Addr().(*net.TCPAddr)
56+
return addr.IP.String(), addr.Port
57+
}
58+
59+
func redisMonitor(host string, port int, password string) config.Monitor {
60+
return config.Monitor{
61+
Name: "t", Type: "redis", Host: host, Port: port, Password: password,
62+
Timeout: config.Duration(3 * time.Second),
63+
}
64+
}
65+
66+
func TestRedisCheck(t *testing.T) {
67+
host, port := fakeRedis(t, "")
68+
c, _ := newRedisCheck(redisMonitor(host, port, ""))
69+
if ok, msg := c.Check(context.Background()); !ok {
70+
t.Errorf("expected ok, got %q", msg)
71+
}
72+
}
73+
74+
func TestRedisCheckAuth(t *testing.T) {
75+
host, port := fakeRedis(t, "s3cret")
76+
c, _ := newRedisCheck(redisMonitor(host, port, "s3cret"))
77+
if ok, msg := c.Check(context.Background()); !ok {
78+
t.Errorf("expected ok, got %q", msg)
79+
}
80+
81+
bad, _ := newRedisCheck(redisMonitor(host, port, "wrong"))
82+
if ok, msg := bad.Check(context.Background()); ok || !strings.Contains(msg, "auth") {
83+
t.Errorf("got ok=%v msg=%q", ok, msg)
84+
}
85+
86+
noauth, _ := newRedisCheck(redisMonitor(host, port, ""))
87+
if ok, _ := noauth.Check(context.Background()); ok {
88+
t.Error("expected NOAUTH failure")
89+
}
90+
}
91+
92+
func TestRedisCheckDown(t *testing.T) {
93+
c, _ := newRedisCheck(redisMonitor("127.0.0.1", 1, ""))
94+
if ok, _ := c.Check(context.Background()); ok {
95+
t.Error("expected connection failure")
96+
}
97+
}
98+
99+
func TestRedisDefaultPort(t *testing.T) {
100+
c, _ := newRedisCheck(config.Monitor{Host: "h"})
101+
if c.addr != "h:"+strconv.Itoa(6379) {
102+
t.Errorf("addr = %q", c.addr)
103+
}
104+
}

internal/config/config.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,18 +81,20 @@ type Monitor struct {
8181
Query string `yaml:"query"`
8282
Rule string `yaml:"rule"` // also used by prometheus
8383

84-
// ping
84+
// ping / redis
8585
Host string `yaml:"host"`
8686
Count int `yaml:"count"`
8787
Privileged bool `yaml:"privileged"`
88+
Port int `yaml:"port"` // redis; default 6379
89+
Password string `yaml:"password"` // redis; empty = no AUTH
8890

8991
// prometheus
9092
Metric string `yaml:"metric"`
9193
Labels map[string]string `yaml:"labels"`
9294
}
9395

9496
var monitorTypes = map[string]bool{
95-
"http": true, "postgres": true, "oracle": true, "ping": true, "prometheus": true,
97+
"http": true, "postgres": true, "oracle": true, "ping": true, "prometheus": true, "redis": true,
9698
}
9799

98100
// envRe matches ${VAR} only; a bare $ is left alone so regex rules like
@@ -236,7 +238,7 @@ func (c *Config) Validate() error {
236238

237239
func (m *Monitor) validate() error {
238240
if !monitorTypes[m.Type] {
239-
return fmt.Errorf("unknown type %q (supported: http, postgres, oracle, ping, prometheus)", m.Type)
241+
return fmt.Errorf("unknown type %q (supported: http, postgres, oracle, ping, prometheus, redis)", m.Type)
240242
}
241243
switch m.Type {
242244
case "http":
@@ -258,7 +260,7 @@ func (m *Monitor) validate() error {
258260
if m.Rule == "" {
259261
return fmt.Errorf("rule is required")
260262
}
261-
case "ping":
263+
case "ping", "redis":
262264
if m.Host == "" {
263265
return fmt.Errorf("host is required")
264266
}

0 commit comments

Comments
 (0)