A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).
hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
3.9 KiB
Go
105 lines
3.9 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"housecarl/internal/api"
|
|
"housecarl/internal/jarl"
|
|
"housecarl/internal/jarlsets"
|
|
"housecarl/internal/model"
|
|
"housecarl/internal/store"
|
|
)
|
|
|
|
// TestJarlSets: a set from Jarl, through Housecarl's Jarl client against a
|
|
// fake Jarl, filled after a change, with its status and warnings.
|
|
func TestJarlSets(t *testing.T) {
|
|
records := []map[string]string{
|
|
{"name": "nas.example.com.", "type": "A", "data": "10.0.10.20"},
|
|
{"name": "nas.example.com.", "type": "AAAA", "data": "2001:db8:10::20"},
|
|
{"name": "files.example.com.", "type": "CNAME", "data": "nas.example.com."},
|
|
{"name": "far.example.com.", "type": "A", "data": "192.0.2.9"},
|
|
}
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "Bearer jarl-token" {
|
|
w.WriteHeader(401)
|
|
return
|
|
}
|
|
switch r.URL.Path {
|
|
case "/api/v1/zones":
|
|
json.NewEncoder(w).Encode([]map[string]any{{"id": 1, "name": "example.com.", "view": "internal", "type": "primary"}})
|
|
case "/api/v1/zones/1/records":
|
|
if r.URL.Query().Get("disabled") != "0" {
|
|
t.Errorf("records asked without disabled=0: %s", r.URL)
|
|
}
|
|
json.NewEncoder(w).Encode(records)
|
|
default:
|
|
w.WriteHeader(404)
|
|
}
|
|
}))
|
|
t.Cleanup(fake.Close)
|
|
jc, err := jarl.New(fake.URL, "jarl-token", "", false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
js := &jarlsets.Resolver{Source: jc}
|
|
e := newEnvWith(t, Config{JarlSets: js})
|
|
js.State, js.Connected = e.eng.State, e.srv.Connected
|
|
js.Apply = func(ctx context.Context) error { return e.eng.ApplyCurrent(ctx) }
|
|
js.Audit = func(ctx context.Context, set, detail string) {
|
|
e.eng.Audit(ctx, nil, "set.jarl", set, "success", detail)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil) // 10.0.10.0/24
|
|
set := model.AddressSet{Name: "nas", Jarl: &model.JarlSource{Names: []model.JarlName{{Name: "files.example.com"}, {Name: "far.example.com", View: "internal"}}, Within: []string{"lan"}}}
|
|
if code := e.call(e.token, "POST", "/sets", set, nil); code != 201 {
|
|
t.Fatalf("create: %d", code)
|
|
}
|
|
if code := e.call(e.token, "POST", "/sets", model.AddressSet{Name: "bad", Entries: []string{"10.0.0.1"}, Jarl: set.Jarl}, nil); code != 422 {
|
|
t.Errorf("entries in a Jarl set: %d", code)
|
|
}
|
|
var st api.JarlSet
|
|
if code := e.call(e.token, "POST", "/sets/nas/jarl/refresh", nil, &st); code != 200 {
|
|
t.Fatalf("refresh: %d", code)
|
|
}
|
|
if strings.Join(st.Entries, ",") != "10.0.10.20" || len(st.Names) != 2 || !st.Configured || st.Read == nil {
|
|
t.Fatalf("status %+v", st)
|
|
}
|
|
files := st.Names[0]
|
|
if files.Found != "internal" || strings.Join(files.Chain, ",") != "nas.example.com" || len(files.Addresses) != 2 ||
|
|
!files.Addresses[0].Used || files.Addresses[1].Used || !strings.Contains(files.Addresses[1].Why, "not within lan") {
|
|
t.Errorf("files: %+v", files)
|
|
}
|
|
var status api.Status
|
|
e.call(e.token, "GET", "/status", nil, &status)
|
|
n := 0
|
|
for _, p := range status.Problems {
|
|
if p.Code == "jarl_outside" {
|
|
n++
|
|
}
|
|
}
|
|
if n != 2 {
|
|
t.Errorf("jarl_outside warnings: %d (%+v)", n, status.Problems)
|
|
}
|
|
// A Jarl set can be renamed (its addresses go by its names in Jarl).
|
|
if code := e.call(e.token, "PATCH", "/sets/nas", map[string]any{"name": "storage"}, nil); code != 200 {
|
|
t.Errorf("rename: %d", code)
|
|
}
|
|
if code := e.call(e.token, "GET", "/sets/trusted/jarl", nil, nil); code != 404 {
|
|
t.Errorf("no such set: %d", code)
|
|
}
|
|
e.call(e.token, "POST", "/sets", model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, nil)
|
|
if code := e.call(e.token, "GET", "/sets/trusted/jarl", nil, nil); code != 400 {
|
|
t.Errorf("a set not from Jarl: %d", code)
|
|
}
|
|
evs, _ := e.srv.store.ListAudit(ctx, store.AuditFilter{Action: "set.jarl"})
|
|
if len(evs) != 1 || evs[0].Detail != "added 10.0.10.20" {
|
|
t.Errorf("audit %+v", evs)
|
|
}
|
|
}
|