1
0
Fork 0

feat: initial release

This commit is contained in:
Arthur K. 2026-01-17 18:14:50 +03:00
parent a3cf21f5bd
commit 761174d035
Signed by: wzray
GPG key ID: B97F30FDC4636357
41 changed files with 2008 additions and 217 deletions

122
internal/roles/dns/dns.go Normal file
View file

@ -0,0 +1,122 @@
package dns
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"git.wzray.com/homelab/mastermind/internal/config"
"git.wzray.com/homelab/mastermind/internal/state"
"git.wzray.com/homelab/mastermind/internal/types"
"git.wzray.com/homelab/mastermind/internal/web/client"
"github.com/rs/zerolog/log"
)
const hostsDir = "/etc/hosts.d/"
type Role struct {
state *state.RuntimeState
config config.DnsConfig
group sync.WaitGroup
}
func New(state *state.RuntimeState, config config.DnsConfig) *Role {
r := &Role{
state: state,
config: config,
}
return r
}
func (r *Role) updateDnsmasq(filename string, data []byte) error {
if err := os.WriteFile(filename, data, 0644); err != nil {
return fmt.Errorf("write endpoint file %q: %w", filename, err)
}
if err := r.reload(); err != nil {
return fmt.Errorf("reload dnsmasq: %w", err)
}
return nil
}
func parseState(state types.HostState) (string, []byte) {
var builder strings.Builder
for _, d := range state.Domains {
builder.WriteString(fmt.Sprintf("%s %s\n", state.Name, d))
}
return hostsDir + state.Endpoint, []byte(builder.String())
}
func (r *Role) OnStartup(ctx context.Context) error {
r.group.Go(func() {
r.syncFromRegistry()
})
c := r.state.Registry.OnChanged()
r.group.Go(func() {
for {
select {
case <-ctx.Done():
return
case <-c:
r.syncFromRegistry()
}
}
})
return nil
}
func (r *Role) syncFromRegistry() {
for _, n := range r.state.Registry.ByRole(types.HostRole) {
state, err := client.Get[types.HostState](n.Address, types.PathHostDns)
if err != nil {
log.Warn().Str("name", n.Name).Err(err).Msg("unable to get host config")
continue
}
filename, data := parseState(*state)
if err := r.updateDnsmasq(filename, data); err != nil {
log.Warn().Str("name", n.Name).Err(err).Msg("unable to update dnsmasq")
continue
}
}
}
func (r *Role) OnShutdown() error {
r.group.Wait()
return nil
}
func (r *Role) reload() error {
var err error
if r.config.UseSystemd {
err = exec.Command("systemctl", "reload", "dnsmasq").Run()
} else {
err = exec.Command("/etc/init.d/dnsmasq", "reload").Run()
}
return err
}
func (r *Role) onCallback(state types.HostState) (bool, error) {
filename, data := parseState(state)
if err := r.updateDnsmasq(filename, data); err != nil {
return false, err
}
return true, nil
}
func (r *Role) RegisterHandlers(rg types.Registrator) {
rg.Register(types.PostEndpoint(types.PathDnsCallback, r.onCallback))
}

121
internal/roles/host/host.go Normal file
View file

@ -0,0 +1,121 @@
package host
import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"
"sync"
"git.wzray.com/homelab/mastermind/internal/config"
"git.wzray.com/homelab/mastermind/internal/state"
"git.wzray.com/homelab/mastermind/internal/types"
"git.wzray.com/homelab/mastermind/internal/web/client"
"github.com/rs/zerolog/log"
)
type Role struct {
state *state.RuntimeState
config config.HostConfig
client *traefikClient
tasksGroup sync.WaitGroup
externalDomains []string // TODO: i don't like hardcoding external/internal logic here
internalDomains []string
}
func New(state *state.RuntimeState, config config.HostConfig) *Role {
return &Role{
client: newClient(config.Domain, config.IpAddress),
state: state,
config: config,
}
}
func (r *Role) sendUpdate(domains []string, role types.Role) {
state := types.HostState{
Domains: domains,
Name: r.state.Self.Name,
Endpoint: r.state.Self.Address,
}
for _, node := range r.state.Registry.ByRole(role) {
r.tasksGroup.Go(func() {
logger := log.With().Str("name", node.Name).Logger()
logger.Debug().Msg("sending update")
if _, err := client.Post[any](node.Address, types.PathDnsCallback, state); err != nil {
logger.Warn().Err(err).Msg("unable to send dns info")
} else {
logger.Debug().Msg("update sent")
}
})
}
}
func (r *Role) mutateState(resp traefikResponse) {
newInternal := resp.Domains(r.config.InternalEntrypoint)
newExternal := resp.Domains(r.config.ExternalEntrypoint)
if !slices.Equal(newInternal, r.internalDomains) {
log.Info().Msg("internal domains updated, propogating")
r.internalDomains = newInternal
r.sendUpdate(newInternal, types.DnsRole)
}
if !slices.Equal(newExternal, r.externalDomains) {
log.Info().Msg("internal domains updated, propogating")
r.externalDomains = newExternal
r.sendUpdate(newExternal, types.NameserverRole)
}
}
func (r *Role) onCallback(w http.ResponseWriter, req *http.Request) {
var resp traefikResponse
if err := json.NewDecoder(req.Body).Decode(&resp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Err(err).Msg("unable to decode traefik callback data")
return
}
r.mutateState(resp)
w.Write([]byte("OK"))
}
func (r *Role) getInternal() (types.HostState, error) {
return types.HostState{
Domains: r.internalDomains,
Endpoint: r.state.Self.Address,
Name: r.state.Self.Name,
}, nil
}
func (r *Role) getExternal() (types.HostState, error) {
return types.HostState{}, nil
}
func (r *Role) RegisterHandlers(rg types.Registrator) {
rg.RegisterRaw(http.MethodPost, types.PathHostCallback.String(), r.onCallback)
rg.Register(types.GetEndpoint(types.PathHostDns, r.getInternal))
rg.Register(types.GetEndpoint(types.PathHostNs, r.getExternal))
}
func (r *Role) OnStartup(ctx context.Context) error {
resp, err := r.client.GetRawData()
if err != nil {
return fmt.Errorf("get traefik state: %w", err)
}
log.Info().Msg("got raw data from traefik")
log.Debug().Interface("response", resp).Send()
r.mutateState(*resp)
return nil
}
func (r *Role) OnShutdown() error {
r.tasksGroup.Wait()
return nil
}

View file

@ -0,0 +1,58 @@
package host
import (
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
type traefikClient struct {
client *http.Client
domain string
address url.URL
}
func newClient(domain string, addr string) *traefikClient {
return &traefikClient{
domain: domain,
address: url.URL{
Scheme: "https",
Host: addr,
},
client: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
ServerName: domain,
},
},
},
}
}
func (c *traefikClient) GetRawData() (*traefikResponse, error) {
var out traefikResponse
url := c.address
url.Path = "/api/rawdata"
req := http.Request{
Method: "GET",
URL: &url,
}
req.Host = c.domain
r, err := c.client.Do(&req)
if err != nil {
return nil, fmt.Errorf("make request: %w", err)
}
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("unmarshal body: %w", err)
}
return &out, nil
}

View file

@ -0,0 +1,74 @@
package host
import (
"encoding/json"
"regexp"
"slices"
)
var hostRegex = regexp.MustCompile("Host\\(`([^()`]+\\.[^()`]+)`\\)")
type rule struct {
Raw string
Domains []string
Valid bool
}
func (r *rule) UnmarshalJSON(data []byte) error {
r.Valid = false
raw := ""
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
matches := hostRegex.FindAllStringSubmatch(raw, -1)
for _, match := range matches {
if len(match) <= 1 {
continue
}
r.Domains = append(r.Domains, match[1:]...)
}
r.Valid = len(r.Domains) > 0
return nil
}
type router struct {
Rule rule `json:"rule"`
Entrypoints []string `json:"entryPoints"`
}
type traefikResponse struct {
Routers []router
}
func (r *traefikResponse) UnmarshalJSON(data []byte) error {
var raw struct {
Routers map[string]router `json:"routers"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
for _, v := range raw.Routers {
r.Routers = append(r.Routers, v)
}
return nil
}
func (r traefikResponse) Domains(entrypoint string) []string {
out := make([]string, 0, len(r.Routers))
for _, router := range r.Routers {
if router.Rule.Valid && slices.Contains(router.Entrypoints, entrypoint) {
out = append(out, router.Rule.Domains...)
}
}
return out
}

View file

@ -0,0 +1,93 @@
package master
import (
"context"
"sync"
"git.wzray.com/homelab/mastermind/internal/config"
"git.wzray.com/homelab/mastermind/internal/roles"
"git.wzray.com/homelab/mastermind/internal/state"
"git.wzray.com/homelab/mastermind/internal/types"
"git.wzray.com/homelab/mastermind/internal/web/client"
)
type Role struct {
state *state.RuntimeState
config config.MasterConfig
tasksGroup sync.WaitGroup
observer *observer
roles.BaseRole
}
func New(state *state.RuntimeState, config config.MasterConfig) *Role {
return &Role{
state: state,
config: config,
observer: newObserver(
state,
config.ObserverInterval,
config.BackoffSeconds,
config.BackoffCount,
),
}
}
func (r *Role) OnStartup(ctx context.Context) error {
r.tasksGroup.Go(func() {
r.observer.Start(ctx, func(n types.Node) error {
_, err := r.onLeave(n)
return err
})
})
return nil
}
func (r *Role) OnShutdown() error {
r.tasksGroup.Wait()
return nil
}
func (r *Role) notify(path types.Path, v any) {
for _, n := range r.state.Registry.Nodes() {
addr := n.Address
r.tasksGroup.Go(func() {
client.Post[any](addr, path, v)
})
}
}
func (r *Role) onJoin(node types.Node) ([]types.Node, error) {
if err := r.state.Registry.AddNode(node); err != nil {
return nil, err
}
r.notify(types.PathNodeJoin, node)
return r.state.Registry.AllNodes(), nil
}
func (r *Role) onLeave(node types.Node) (bool, error) {
if err := r.state.Registry.RemoveNode(node.Name); err != nil {
return false, err
}
r.notify(types.PathNodeLeave, node.Name)
return true, nil
}
func (r *Role) onKeepAlive(node types.Node) (bool, error) {
if ok := r.state.Registry.Exists(node.Name); !ok {
_, err := r.onJoin(node)
return true, err
}
return false, nil
}
func (c *Role) RegisterHandlers(r types.Registrator) {
r.Register(types.PostEndpoint(types.PathMasterJoin, c.onJoin))
r.Register(types.PostEndpoint(types.PathMasterLeave, c.onLeave))
r.Register(types.PostEndpoint(types.PathMasterKeepalive, c.onKeepAlive))
}

View file

@ -0,0 +1,83 @@
package master
import (
"context"
"time"
"git.wzray.com/homelab/mastermind/internal/state"
"git.wzray.com/homelab/mastermind/internal/types"
"git.wzray.com/homelab/mastermind/internal/web/client"
"github.com/rs/zerolog/log"
)
type observer struct {
state *state.RuntimeState
interval int
backoff int
backoffCount int
}
func newObserver(
state *state.RuntimeState,
interval int,
backoff int,
backoffCount int,
) *observer {
return &observer{
state: state,
interval: interval,
backoff: backoff,
backoffCount: backoffCount,
}
}
func (o *observer) pollNodes(ctx context.Context, onLeave func(types.Node) error) {
for _, n := range o.state.Registry.Nodes() {
name := n.Name
logger := log.With().Str("name", name).Logger()
logger.Debug().Msg("checking node")
delay := time.Duration(o.backoff)
alive := false
for i := o.backoffCount; i > 0; i-- {
_, err := client.Get[any](n.Address, types.PathNodeHealthcheck)
if err == nil {
logger.Debug().Msg("node is alive")
alive = true
break
}
if i == 0 {
break
}
logger.Info().Any("delay", delay).Msg("node didn't respond, sleeping")
select {
case <-ctx.Done():
goto dead
case <-time.After(delay * time.Second):
delay *= 2
}
}
dead:
if !alive {
logger.Info().Msg("node is dead, removing")
if err := onLeave(n); err != nil {
logger.Warn().Err(err).Msg("onLeave call failed")
}
}
}
}
func (o *observer) Start(ctx context.Context, onLeave func(types.Node) error) {
for {
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(o.interval) * time.Second):
o.pollNodes(ctx, onLeave)
}
}
}

168
internal/roles/node/node.go Normal file
View file

@ -0,0 +1,168 @@
package node
import (
"context"
"errors"
"sync"
"time"
"git.wzray.com/homelab/mastermind/internal/config"
"git.wzray.com/homelab/mastermind/internal/state"
"git.wzray.com/homelab/mastermind/internal/types"
"git.wzray.com/homelab/mastermind/internal/web/client"
"github.com/rs/zerolog/log"
)
type Role struct {
state *state.RuntimeState
keepaliveGroup sync.WaitGroup
config config.NodeConfig
}
func New(state *state.RuntimeState, config config.NodeConfig) *Role {
return &Role{
state: state,
config: config,
}
}
func (r *Role) Join(bootstrap string) error {
masters := make(map[string]struct{})
for _, node := range r.state.Registry.ByRole(types.MasterRole) {
if node.Name == r.state.Self.Name {
continue
}
masters[node.Address] = struct{}{}
}
if bootstrap != "" {
masters[bootstrap] = struct{}{}
} else if len(masters) == 0 {
return errors.New("no masters configured")
}
for m := range masters {
logger := log.With().Str("host", m).Logger()
logger.Debug().Msg("trying to join via master")
nodes, err := client.Post[[]types.Node](m, "/master/join", r.state.Self)
if err != nil {
logger.Debug().Err(err).Msg("unable to join")
continue
}
if err := r.state.Registry.Set(*nodes); err != nil {
logger.Debug().Err(err).Msg("unable to set master's nodes")
continue
}
return nil
}
return errors.New("unable to join")
}
func (r *Role) Leave() error {
masters := r.state.Registry.ByRole(types.MasterRole)
if len(masters) == 0 {
return nil
}
sent := false
for _, m := range masters {
logger := log.With().Str("name", m.Name).Logger()
logger.Debug().Msg("sending leave message")
_, err := client.Post[any](m.Address, types.PathMasterLeave, r.state.Self)
if err != nil {
logger.Debug().Err(err).Msg("unable to send leave message")
continue
} else {
sent = true
logger.Debug().Msg("leave message sent")
break
}
}
if !sent {
return errors.New("unable to send leave message")
}
return nil
}
func (r *Role) OnStartup(ctx context.Context) error {
if r.config.KeepaliveInterval != -1 {
r.keepaliveGroup.Go(r.keepaliveFunc(ctx))
} else {
log.Debug().Msg("keepalive disabled")
}
return nil
}
func (r *Role) OnShutdown() error {
r.keepaliveGroup.Wait()
return nil
}
func (r *Role) keepaliveFunc(ctx context.Context) func() {
sendKeepalive := func() {
masters := r.state.Registry.ByRole(types.MasterRole)
if len(masters) == 0 {
return
}
sent := false
for _, m := range masters {
logger := log.With().Str("name", m.Name).Logger()
logger.Debug().Msg("sending keepalive packet")
if _, err := client.Post[any](m.Address, types.PathMasterKeepalive, r.state.Self); err != nil {
continue
} else {
logger.Debug().Msg("keepalive packet sent")
sent = true
break
}
}
if !sent {
log.Info().Msg("unable to send keepalive packet")
}
}
return func() {
for {
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(r.config.KeepaliveInterval) * time.Second):
sendKeepalive()
}
}
}
}
func (r *Role) onJoin(node types.Node) (bool, error) {
if err := r.state.Registry.AddNode(node); err != nil {
return false, err
}
return true, nil
}
func (r *Role) onLeave(node types.Node) (bool, error) {
if err := r.state.Registry.RemoveNode(node.Name); err != nil {
return false, err
}
return true, nil
}
func healthcheck() (string, error) {
return "OK", nil
}
func (n *Role) RegisterHandlers(r types.Registrator) {
r.Register(types.GetEndpoint(types.PathNodeHealthcheck, healthcheck))
r.Register(types.PostEndpoint(types.PathNodeJoin, n.onJoin))
r.Register(types.PostEndpoint(types.PathNodeLeave, n.onLeave))
}

21
internal/roles/role.go Normal file
View file

@ -0,0 +1,21 @@
package roles
import (
"context"
"git.wzray.com/homelab/mastermind/internal/types"
)
type Role interface {
RegisterHandlers(types.Registrator)
OnStartup(context.Context) error
OnShutdown() error
}
type BaseRole struct{}
func (r *BaseRole) RegisterHandlers(types.Registrator) {}
func (r *BaseRole) OnStartup(context.Context) error { return nil }
func (r *BaseRole) OnShutdown() error { return nil }