feat: add secure Pi-hole DNS management
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
1a6b4d0939
commit
1c32ca0671
18 files changed
+2159
-34
No files matched your search
@@ -2,6 +2,8 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,6 +12,32 @@ import (
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
)
|
||||
|
||||
func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
dataDir := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
databasePath := filepath.Join(dataDir, "app.db")
|
||||
database, err := Open(ctx, databasePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
dirInfo, err := os.Stat(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dbInfo, err := os.Stat(databasePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dirInfo.Mode().Perm() != 0o700 || dbInfo.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("insecure storage permissions: dir=%o database=%o", dirInfo.Mode().Perm(), dbInfo.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -45,10 +73,14 @@ func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
||||
if home.StaticIPv4 != "203.0.113.10" {
|
||||
t.Fatalf("static IPv4 was not persisted: %#v", home)
|
||||
}
|
||||
other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "other", FQDN: "other.example.de", UpdateIPv4: true, TTL: 60})
|
||||
other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "aaa", FQDN: "aaa.example.de", UpdateIPv4: true, TTL: 60})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orderedTargets, err := database.ListTargets(ctx, zone.ID)
|
||||
if err != nil || len(orderedTargets) != 2 || orderedTargets[0].ID != home.ID || orderedTargets[1].ID != other.ID {
|
||||
t.Fatalf("active domain target was not sorted before inactive target: %#v err=%v", orderedTargets, err)
|
||||
}
|
||||
|
||||
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{TargetIDs: []int64{home.ID}, ManageTargetIDs: []int64{home.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -74,7 +106,7 @@ func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targets, err = database.ListAccessibleTargets(ctx, member.ID, zone.ID)
|
||||
if err != nil || len(targets) != 2 {
|
||||
if err != nil || len(targets) != 2 || targets[0].ID != home.ID || targets[1].ID != other.ID {
|
||||
t.Fatalf("domain grant did not include all targets: %d, %v", len(targets), err)
|
||||
}
|
||||
canCreate, err := database.CanCreateTarget(ctx, member.ID, zone.ID)
|
||||
@@ -136,6 +168,82 @@ func TestInactiveUserSessionIsRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
created, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole Zuhause", BaseURL: "https://pi.hole/api", InsecureTLS: true}, model.PiHoleCredential{Ciphertext: []byte("encrypted"), Nonce: []byte("nonce")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Zweites Pi-hole", BaseURL: "https://second.pi.hole/api"}, model.PiHoleCredential{Ciphertext: []byte("encrypted"), Nonce: []byte("nonce")}); !errors.Is(err, ErrPiHoleAlreadyConfigured) {
|
||||
t.Fatalf("second Pi-hole integration was not rejected: %v", err)
|
||||
}
|
||||
loaded, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, created.ID)
|
||||
if err != nil || loaded.Name != "Pi-hole Zuhause" || loaded.BaseURL != "https://pi.hole/api" || !loaded.InsecureTLS || string(credential.Ciphertext) != "encrypted" {
|
||||
t.Fatalf("Pi-hole integration not persisted: %#v %#v err=%v", loaded, credential, err)
|
||||
}
|
||||
loaded.Name = "Pi-hole Büro"
|
||||
loaded.InsecureTLS = false
|
||||
if err := database.UpdatePiHoleIntegration(ctx, loaded, model.PiHoleCredential{Ciphertext: []byte("new-encrypted"), Nonce: []byte("new-nonce")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.MarkPiHoleSynced(ctx, loaded.ID, "connection failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
integrations, err := database.ListPiHoleIntegrations(ctx)
|
||||
if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole Büro" || integrations[0].LastError != "connection failed" || integrations[0].LastSyncAt.IsZero() {
|
||||
t.Fatalf("Pi-hole integration status not listed: %#v err=%v", integrations, err)
|
||||
}
|
||||
links, err := database.SyncPiHoleRecordLinks(ctx, loaded.ID, []string{"192.168.1.20 nas.lan", "192.168.1.30 printer.lan"})
|
||||
if err != nil || len(links) != 2 || !links[0].Active || !links[1].Active {
|
||||
t.Fatalf("Pi-hole record links not synchronized: %#v err=%v", links, err)
|
||||
}
|
||||
firstID, secondID := links[0].ID, links[1].ID
|
||||
if err := database.ArchivePiHoleRecordLink(ctx, loaded.ID, firstID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
links, err = database.ListPiHoleRecordLinks(ctx, loaded.ID)
|
||||
if err != nil || len(links) != 2 || links[0].ArchivedAt.IsZero() {
|
||||
t.Fatalf("Pi-hole record link not archived: %#v err=%v", links, err)
|
||||
}
|
||||
if err := database.RestorePiHoleRecordLink(ctx, loaded.ID, firstID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdatePiHoleRecordLink(ctx, loaded.ID, firstID, "192.168.1.21 nas.lan", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.DeletePiHoleRecordLink(ctx, loaded.ID, secondID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
links, err = database.ListPiHoleRecordLinks(ctx, loaded.ID)
|
||||
if err != nil || len(links) != 1 || links[0].Value != "192.168.1.21 nas.lan" || links[0].Active || !links[0].ArchivedAt.IsZero() {
|
||||
t.Fatalf("Pi-hole record link lifecycle failed: %#v err=%v", links, err)
|
||||
}
|
||||
links, err = database.SyncPiHoleRecordLinks(ctx, loaded.ID, []string{"192.168.1.21 nas.lan"})
|
||||
if err != nil || len(links) != 1 || !links[0].Active {
|
||||
t.Fatalf("remote Pi-hole record did not become active: %#v err=%v", links, err)
|
||||
}
|
||||
links, err = database.SyncPiHoleRecordLinks(ctx, loaded.ID, nil)
|
||||
if err != nil || len(links) != 1 || links[0].Active {
|
||||
t.Fatalf("missing remote Pi-hole record was deleted instead of retained as inactive: %#v err=%v", links, err)
|
||||
}
|
||||
if err := database.DeletePiHoleIntegration(ctx, loaded.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.GetPiHoleIntegration(ctx, loaded.ID); err == nil {
|
||||
t.Fatal("deleted Pi-hole integration still exists")
|
||||
}
|
||||
if links, err := database.ListPiHoleRecordLinks(ctx, loaded.ID); err != nil || len(links) != 0 {
|
||||
t.Fatalf("Pi-hole record links were not removed with integration: %#v err=%v", links, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -263,7 +371,7 @@ func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "www", FQDN: "www.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300})
|
||||
created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "aaa", FQDN: "aaa.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -277,18 +385,21 @@ func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
|
||||
t.Fatalf("CNAME update not persisted: %#v, %v", updated, err)
|
||||
}
|
||||
|
||||
if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "cdn", FQDN: "cdn.example.de", Destination: "assets.example.net.", TTL: 120}}); err != nil {
|
||||
if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "zzz", FQDN: "zzz.example.de", Destination: "assets.example.net.", TTL: 120}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := database.ListCNAMERecords(ctx, zone.ID)
|
||||
if err != nil || len(records) != 2 {
|
||||
t.Fatalf("provider CNAME import was not mirrored: %#v, %v", records, err)
|
||||
}
|
||||
if records[0].RecordName != "zzz" || records[1].RecordName != "aaa" {
|
||||
t.Fatalf("active CNAME was not sorted before inactive CNAME: %#v", records)
|
||||
}
|
||||
byName := make(map[string]model.CNAMERecord, len(records))
|
||||
for _, record := range records {
|
||||
byName[record.RecordName] = record
|
||||
}
|
||||
if byName["www"].Active || !byName["cdn"].Active || byName["cdn"].Destination != "assets.example.net." || byName["cdn"].TTL != 120 {
|
||||
if byName["aaa"].Active || !byName["zzz"].Active || byName["zzz"].Destination != "assets.example.net." || byName["zzz"].TTL != 120 {
|
||||
t.Fatalf("inactive/local CNAME lifecycle was not preserved: %#v", byName)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user