),
},
{
- thing: "iface",
+ thing: "interface",
action: "*",
fileGenerator: multiFile(
- staticFiles("dhcpd4.conf", "dhcpd6.conf"),
+ staticFiles("maclist.conf"),
+ ),
+ },
+ {
+ thing: "interface",
+ action: "deleted",
+ fileGenerator: multiFile(
+ staticFiles("dhcpd4.conf", "dhcpd6.conf", "maclist.conf"),
+ ),
+ },
+ {
+ thing: "host",
+ action: "deleted",
+ fileGenerator: multiFile(
+ staticFiles("dhcpd4.conf", "dhcpd6.conf", "maclist.conf"),
),
},
}
} else {
r.log.Noticef("failed to load saved state from %s, no records will be served "+
"until the Machines API is reachable: %v", machinesRegistryStorePath, err)
- }
- err = r.tryInit()
- if err != nil {
- r.log.Warningf("failed to initialize API state, continuing startup; " +
- "Machines records will be served once the API is available")
+ err = r.tryInit()
+ if err != nil {
+ r.log.Warningf("failed to initialize API state, continuing startup; " +
+ "Machines records will be served once the API is available")
+ }
}
go r.monitorEvents()
return nil, fmt.Errorf("plugin not yet initialized")
}
- fqdn := strings.TrimSuffix(qname, ".")
+ fqdn := strings.ToLower(strings.TrimSuffix(qname, "."))
r.log.V(2).Debugf("LookupHost(%s)", fqdn)
if strings.HasSuffix(fqdn, ".in-addr.arpa") {
return r.lookupReverseIPv6(fqdn)
}
- nameParts := strings.Split(fqdn, ".")
- basename := strings.ToLower(nameParts[0])
- domainName := strings.ToLower(strings.Join(nameParts[1:], "."))
+ var basename, domainName string
var domain *machines.Domain
for _, d := range r.store.Domains {
- if d.Name == domainName {
+ if strings.HasSuffix(fqdn, "."+d.Name) {
+ domainName = d.Name
+ basename = fqdn[:len(fqdn)-len(d.Name)-1]
domain = d
break
}
if domain == nil {
// domain name is not known to us, maybe the query is for a domain at another site
- r.log.V(1).Debugf("domain is not known to us: %s", domainName)
+ r.log.V(2).Debugf("domain is not known to us: %s", domainName)
return nil, fmt.Errorf("domain is not known to us: %s", domainName)
}
+ baseParts := strings.Split(basename, ".")
+ switch len(baseParts) {
+ case 1:
+ return r.lookupHostLastSeenIface(basename)
+ case 2:
+ return r.lookupHostWithIface(basename)
+ default:
+ return nil, nil
+ }
+}
+
+func (r *registry) lookupHostLastSeenIface(basename string) (*Result, error) {
hostID, ok := r.store.HostNames[basename]
if !ok {
// host not found
r.log.V(1).Debugf("host %q: no valid LastDomain.ID", basename)
return nil, nil
}
- domain, ok = r.store.Domains[iface.LastDomain.ID()]
+ domain, ok := r.store.Domains[iface.LastDomain.ID()]
+ if !ok {
+ // last seen domain is not known to us, maybe the host was last seen at a different site
+ r.log.V(1).Debugf("host %q: last seen domain is not known to us: %s", basename, iface.LastDomain.ID())
+ return nil, nil
+ }
+
+ return &Result{
+ domain: domain,
+ host: host,
+ iface: iface,
+ }, nil
+}
+
+func (r *registry) lookupHostWithIface(basename string) (*Result, error) {
+ ifaceID, ok := r.store.HostInterfaceNames[basename]
+ if !ok {
+ // host not found
+ r.log.V(1).Debugf("interface.hostname not found: %s", basename)
+ return nil, nil
+ }
+ iface, ok := r.store.Ifaces[ifaceID]
+ if ifaceID == "" || !ok {
+ // host has never been seen on any interface
+ r.log.V(1).Debugf("host %q: host has never been seen on any interface", basename)
+ return nil, nil
+ }
+ if iface.LastDomain.ID() == "" {
+ // no valid record of the last domain this iface was seen on
+ r.log.V(1).Debugf("host %q: no valid LastDomain.ID", basename)
+ return nil, nil
+ }
+ if iface.Host.ID() == "" {
+ r.log.V(1).Debugf("interface %q, basename %q: hostID unset", ifaceID, basename)
+ return nil, nil
+ }
+ hostID := iface.Host.ID()
+ host, ok := r.store.Hosts[hostID]
+ if !ok {
+ // hostname map out of date???
+ r.log.V(1).Debugf("host %q, hostID %s found in hostname table but not in host list", basename, hostID)
+ return nil, fmt.Errorf("hostID %s found in hostname table but not in host list", hostID)
+ }
+ domain, ok := r.store.Domains[iface.LastDomain.ID()]
if !ok {
// last seen domain is not known to us, maybe the host was last seen at a different site
r.log.V(1).Debugf("host %q: last seen domain is not known to us: %s", basename, iface.LastDomain.ID())
parts = parts[0:4]
slices.Reverse(parts)
-
ip := net.ParseIP(strings.Join(parts, "."))
if ip == nil {
r.log.Errorf("failed to parse IP in ptr query: %s", strings.Join(parts, "."))
r.store.patchIface(iface)
}
+ host := r.store.Hosts[hostID]
iface := r.store.Ifaces[ifaceID]
+ host.LastSeenIface.Set(iface)
now := time.Now().Unix()
iface.LastSeen = machines.Timestamp(now)
}
r.store.patchIface(iface)
+ r.store.patchHost(host)
}
func (r *registry) tryInit() error {
+ r.log.Noticef("trying to init event listener")
client, err := machines.NewDefaultMachinesClient()
if err != nil {
+ r.log.Warningf("init machines client failed: %v", err)
return err
}
client.SetupStats(r.mb)
eventsCh, err := client.NewEventListener(r.ctx)
if err != nil {
+ r.log.Warningf("init event listener failed: %v", err)
return err
}
- if !r.store.initialized() {
+ r.client = client
+ r.eventsCh = eventsCh
+
+ if !r.store.initialized() || !r.store.fresh() {
err = r.store.fetch(client)
if err != nil {
+ r.log.Warningf("fresh data fetch failed: %v", err)
return err
}
r.didLiveFetch = true
}
- r.client = client
- r.eventsCh = eventsCh
-
return nil
}
answers := make([]dns.RR, 0)
qname := strings.ToLower(strings.TrimSuffix(ques.Name, "."))
- nameParts := strings.Split(qname, ".")
- basename := strings.ToLower(nameParts[0])
- domainName := strings.ToLower(strings.Join(nameParts[1:], "."))
+ var basename, domainName string
+ if strings.HasSuffix(qname, "."+r.domain.Name) {
+ basename = strings.TrimSuffix(qname, "."+r.domain.Name)
+ domainName = qname[len(basename)+1:]
+ } else {
+ nameParts := strings.Split(qname, ".")
+ basename = nameParts[0]
+ domainName = strings.Join(nameParts[1:], ".")
+ }
if strings.HasSuffix(qname, ".in-addr.arpa") || strings.HasSuffix(qname, ".ip6.arpa") {
if (r.iface.LastIPv4.Defined() || r.iface.LastIPv6.Defined()) && (ques.Qtype == dns.TypePTR || ques.Qtype == dns.TypeANY) {
Target: fmt.Sprintf("%s.%s.", basename, r.domain.Name),
}
answers = append(answers, msg)
- } else if strings.HasPrefix(qname, r.host.Name+".") {
+ } else if strings.HasPrefix(qname, r.host.Name+".") || strings.HasPrefix(qname, fmt.Sprintf("%s.%s.", r.iface.NameScrubbed, r.host.Name)) {
if r.iface.LastIPv4.Defined() && (ques.Qtype == dns.TypeA || ques.Qtype == dns.TypeANY) {
msg := &dns.A{
Hdr: dns.RR_Header{
defer (func() { r.stopCh <- struct{}{} })()
if r.client == nil {
- initTicker := time.NewTicker(1 * time.Second)
+ initTicker := time.NewTicker(10 * time.Second)
initLoop:
for {
r.stats.eventsReceived.WithLabelValues(mbclient.KV{"thing": event.ItemType, "action": event.Event}).Add(1)
if event.ItemType == "host" && event.Event == "seen" {
if via, ok := event.Tags["via"]; !ok || via != "dhcp" {
+ r.log.V(1).Debugf("skip seen event: not via dhcp")
continue
}
ip, ok := event.Tags["ip"]
if !ok || ip == "" {
+ r.log.V(1).Debugf("skip seen event: ip tag not present")
continue
}
hostID, ok := event.Tags["host"]
if !ok || hostID == "" {
+ r.log.V(1).Debugf("skip seen event: host tag not present")
continue
}
ifaceID, ok := event.Tags["iface"]
if !ok || ifaceID == "" {
+ r.log.V(1).Debugf("skip seen event: iface tag not present")
continue
}
"encoding/json"
"flag"
"fmt"
+ "net"
"os"
"strings"
"sync"
+ "time"
"go.fuhry.dev/runtime/constants"
"go.fuhry.dev/runtime/machines"
mu sync.Mutex `json:"-"`
log *log.Logger
+ lastRefresh time.Time
Sites map[string]*machines.Site `json:"Sites"`
Domains map[string]*machines.Domain `json:"Domains"`
Hosts map[string]*machines.Host `json:"Hosts"`
return rs.Sites != nil && rs.Domains != nil && rs.Hosts != nil && rs.Ifaces != nil && rs.Records != nil
}
+func (rs *registryStore) fresh() bool {
+ return rs.lastRefresh.Add(refreshInterval).After(time.Now())
+}
+
func (rs *registryStore) saveState() error {
if !rs.initialized() {
return nil
}
func (rs *registryStore) fetch(client machines.MachinesClient) error {
- rs.mu.Lock()
- defer rs.mu.Unlock()
-
sites := []*machines.Site{}
err := client.APICall("sites", nil, &sites)
if err != nil {
return err
}
+ rs.mu.Lock()
+ defer rs.mu.Unlock()
+
// update state only after all information is collected
rs.Sites = make(map[string]*machines.Site)
for _, s := range sites {
for _, h := range hosts {
h.Name = strings.ToLower(h.Name)
rs.Hosts[h.ID()] = h
- rs.HostNames[h.Name] = h.ID()
+ rs.HostNames[strings.ToLower(h.Name)] = h.ID()
}
rs.Ifaces = make(map[string]*machines.Iface)
}
rs.processRecords(records)
+ rs.lastRefresh = time.Now()
return nil
}
rs.Ifaces[iface.ID()] = iface
}
+
+func (rs *registryStore) DomainForFqdn(qname string) *machines.Domain {
+ for _, domain := range rs.Domains {
+ if strings.HasSuffix(qname, "."+domain.Name) {
+ return domain
+ }
+ }
+
+ return nil
+}
+
+func (rs *registryStore) DomainForAddress(addr net.Addr) *machines.Domain {
+ udpAddr, ok := addr.(*net.UDPAddr)
+ if !ok {
+ return nil
+ }
+
+ for _, domain := range rs.Domains {
+ if domain.ContainsIP(udpAddr.IP) {
+ return domain
+ }
+ }
+
+ return nil
+}
Authoritative: true,
Response: true,
},
- Answer: make([]dns.RR, 0),
+ Answer: nil,
}
for _, ques := range r.Question {
if rc, rrs := m.r.LookupRecord(ques); rc != dns.RcodeNameError {
domain := m.r.(*registry).domainFromQuestion(ques)
- m.r.(*registry).stats.recordLookups.WithLabelValues(mbclient.KV{
- "domain": domain.Name,
- "rcode": rcodeToStr(rc),
- }).Add(1)
+ if domain != nil {
+ m.r.(*registry).stats.recordLookups.WithLabelValues(mbclient.KV{
+ "domain": domain.Name,
+ "rcode": rcodeToStr(rc),
+ }).Add(1)
+ }
handled = true
answer.Question = append(answer.Question, ques)
- m.log.V(2).Debugf(" -> rcode %d, rrs %+v", rcode, rrs)
+ m.log.V(3).Debugf(" -> rcode %d, rrs %+v", rcode, rrs)
if rc != dns.RcodeSuccess {
m.log.Errorf("error in LookupRecord(%s): rcode=%d", ques.Name, rc)
rcode = rc
handled = true
answer.Question = append(answer.Question, ques)
if result == nil {
- m.log.V(2).Debugf(" -> not found")
+ m.log.V(3).Debugf(" -> not found")
rcode = dns.RcodeNameError
m.r.(*registry).stats.hostLookups.WithLabelValues(mbclient.KV{
"rcode": rcodeToStr(rcode),
}).Add(1)
} else {
- m.log.V(2).Debugf(" -> domain %s, host %s, iface %s", result.domain.ID(), result.host.ID(), result.iface.ID())
+ m.log.V(3).Debugf(" -> domain %s, host %s, iface %s", result.domain.ID(), result.host.ID(), result.iface.ID())
r, rrs := result.Answer(ques)
m.r.(*registry).stats.hostLookups.WithLabelValues(mbclient.KV{
}
if handled {
- m.log.V(3).Debugf(" -> WRITE %+v", answer)
+ m.log.V(4).Debugf(" -> WRITE %+v", answer)
answer.MsgHdr.Rcode = rcode
err := w.WriteMsg(answer)
if err != nil {
}
return rcode, nil
}
- m.log.V(3).Debugf("NEXT!")
+ m.log.V(4).Debugf("NEXT!")
return plugin.NextOrFailure(m.Name(), m.Next, ctx, w, r)
}
import (
"context"
"encoding/json"
- "errors"
"fmt"
"net/url"
"time"
return nil, fmt.Errorf("events url %q is invalid: %v", mc.eventsUrl, err)
}
+ msgChan := make(chan MachinesMqttEvent)
+
credentialsProvider := func() (string, string) {
accessToken, err := oauthClient.getAccessToken()
if err != nil {
return accessToken, "x"
}
- mqttOpts.Servers = append(mqttOpts.Servers, eventsUrl)
- mqttOpts.SetCredentialsProvider(credentialsProvider)
- mqttOpts.SetPingTimeout(10 * time.Second)
- mqttOpts.SetConnectTimeout(10 * time.Second)
- mqttOpts.SetCleanSession(true)
- mqttOpts.SetAutoReconnect(true)
- mqttOpts.SetConnectRetry(false)
- mqttOpts.SetMaxReconnectInterval(30 * time.Second)
-
- client := mqtt.NewClient(mqttOpts)
- t := client.Connect()
-
- select {
- case <-t.Done():
- if err = t.Error(); err != nil {
- return nil, err
- }
- case <-ctx.Done():
- return nil, context.Canceled
- }
-
- if !client.IsConnected() {
- return nil, errors.New("somehow we are still not connected??")
- }
-
- msgChan := make(chan MachinesMqttEvent)
-
- handler := func(client mqtt.Client, msg mqtt.Message) {
+ onMessage := func(client mqtt.Client, msg mqtt.Message) {
msg.Ack()
mc.logger.V(2).Debugf("got raw mqtt msg: %s", msg.Payload())
}
}
- t = client.Subscribe(constants.MachinesMqttTopic, byte(0), handler)
+ onConnect := func(client mqtt.Client) {
+ mqttLogger.Noticef("(Re)connected to server")
+ t := client.Subscribe(constants.MachinesMqttTopic, byte(0), onMessage)
+ select {
+ case <-t.Done():
+ mqttLogger.Noticef("(Re)subscribed to topic")
+ break
+ case <-ctx.Done():
+ close(msgChan)
+ }
+ }
+
+ mqttOpts.Servers = append(mqttOpts.Servers, eventsUrl)
+ mqttOpts.SetCredentialsProvider(credentialsProvider)
+ mqttOpts.SetPingTimeout(10 * time.Second)
+ mqttOpts.SetConnectTimeout(10 * time.Second)
+ mqttOpts.SetCleanSession(true)
+ mqttOpts.SetAutoReconnect(true)
+ mqttOpts.SetConnectRetry(true)
+ mqttOpts.SetMaxReconnectInterval(30 * time.Second)
+ mqttOpts.SetOnConnectHandler(onConnect)
+
+ client := mqtt.NewClient(mqttOpts)
+ t := client.Connect()
+
select {
case <-t.Done():
- break
+ if err = t.Error(); err != nil {
+ return nil, err
+ }
case <-ctx.Done():
- close(msgChan)
return nil, ctx.Err()
}
}
logger.Noticef("starting main event loop")
- defer logger.Critical("stopping xx0r-machines-agent")
+ defer logger.Critical("stopping machines-agent")
mainLoop:
for {
"rad.conf": {"domains", "site", "routeraddresses"},
"radvd.conf": {"domains", "site", "routeraddresses"},
"pf-captive-portal.conf": {"domains", "apiServer"},
+ "maclist.conf": {"domains", "interfaces"},
}
var baseVars = []string{
filePath: "/etc/radvd.conf",
serviceName: "radvd",
},
+ "maclist.conf": {
+ filePath: "/etc/pf/maclist.conf",
+ serviceName: "captive",
+ },
}
type calculator struct {
return sitesMap, nil
case "interfaces":
ifaces := make([]*Iface, 0)
- err := c.client.APICall("interfaces", nil, &ifaces)
+ err := c.client.APICall("interfaces?expand[]=hosts", nil, &ifaces)
if err != nil {
return nil, fmt.Errorf("while fetching interfaces: %+v", err)
}
package machines
+import (
+ "os/exec"
+ "path"
+
+ "go.fuhry.dev/runtime/utils/log"
+)
+
type ServiceStatus struct {
Running bool
Pid int
return &ServiceStatus{Running: true, Pid: 0}, nil
}
+type oneshotService struct {
+ command []string
+ stopCommand []string
+}
+
+func (s *oneshotService) EnsureStarted() error {
+ return s.do(s.command)
+}
+
+func (s *oneshotService) EnsureStopped() error {
+ if s.stopCommand != nil {
+ return s.do(s.stopCommand)
+ }
+
+ return nil
+}
+
+func (s *oneshotService) ReloadOrRestart(startIfStopped bool) error {
+ return s.do(s.command)
+}
+
+func (s *oneshotService) Status() (*ServiceStatus, error) {
+ return &ServiceStatus{
+ Running: true,
+ Pid: 0,
+ }, nil
+}
+
+func (s *oneshotService) do(cmdline []string) error {
+ executable := cmdline[0]
+ if !path.IsAbs(executable) {
+ exe, err := exec.LookPath(executable)
+ if err != nil {
+ return err
+ }
+ executable = exe
+ }
+
+ logger.Noticef("running command: %s %+v", executable, cmdline[1:])
+ cmd := exec.Command(executable, cmdline[1:]...)
+ cmd.Stdout = logger.AppendPrefix("[stdout]").NewWriter(log.INFO)
+ cmd.Stderr = logger.AppendPrefix("[stderr]").NewWriter(log.ERROR)
+ err := cmd.Run()
+ return err
+}
+
var services = make(map[string]Service)
func registerService(name string, svc Service) {
pidFile: "/var/run/dhcpd6.pid",
daemon: true,
})
+ registerService("captive", &oneshotService{
+ command: []string{"bash", "-c", "for f in /etc/hostname.bridge*; do /sbin/ifconfig ${f#/etc/hostname.} flushrule vlan${f#/etc/hostname.bridge} rulefile /etc/pf/maclist.conf; done"},
+ })
}
# {{ range.Name }}
pool {
range {{ domain.Ranges[id].IPv4Start }} {{ domain.Ranges[id].IPv4End }};
- {% if domain.Ranges[id].ID() == domain.DefaultRange.ID() %}
- allow unknown-clients;
- {%- else -%}
- deny unknown-clients;
- {%- endif %}
+ {%- if domain.Ranges.length > 1 -%}
+ {% if domain.Ranges[id].ID() == domain.DefaultRange.ID() %}
+ allow unknown-clients;
+ {%- else -%}
+ deny unknown-clients;
+ {%- endif %}
+ {%- endif -%}
{% for res in domain.Ranges[id].Reservations %}
{%- if res.AddressFamily == 'inet' -%}
# {{ range.Name }}
pool6 {
range6 {{ range.IPv6Start }} {{ range.IPv6End }};
- {% if range.ID() == domain.DefaultRange.ID() %}
- allow unknown-clients;
- {%- else -%}
- deny unknown-clients;
- {%- endif %}
+ {%- if domain.Ranges.length > 1 -%}
+ {% if range.ID() == domain.DefaultRange.ID() %}
+ allow unknown-clients;
+ {%- else -%}
+ deny unknown-clients;
+ {%- endif %}
+ {%- endif -%}
{%- for res in range.Reservations -%}
{% if res.AddressFamily == 'inet6' %}
# This file is automatically generated by machines-agent and will be overwritten
# any time a change is made to registered hosts/interfaces.
-{% for iface in interfaces -%}
-{% if iface.host != None and 'disabled' not in iface.host.flags and 'quarantine' not in iface.host.owner.flags -%}
-pass in on vlan{{ domain.vlan_id }} src {{ iface.hardware_address }} tag PERMIT
+{% for domain in domains %}
+{%- if 'captive_portal' in domain.Features %}
+{%- for iface in interfaces -%}
+{% if iface.Host.Defined() -%}
+pass in on vlan{{ domain.VlanID }} src {{ iface.HardwareAddress }} tag PERMIT
{% endif -%}
-{% endfor -%}
+{%- endfor -%}
+{%- endif -%}
+{%- endfor -%}
\ No newline at end of file
} `json:"endorsement_key"`
}
+func (d *Domain) ContainsIP(ip net.IP) bool {
+ if ip4 := ip.To4(); ip4 != nil {
+
+ } else if ip6 := ip.To16(); ip6 != nil {
+
+ }
+
+ return false
+}
+
+func (d *Domain) IPv4Network() net.IPNet {
+ return net.IPNet{
+ IP: d.IPv4Address.AsIP(),
+ Mask: d.IPv4PrefixLength.IPMask(),
+ }
+}
+
+func (d *Domain) IPv6Network() net.IPNet {
+ return net.IPNet{
+ IP: d.IPv6Address.AsIP(),
+ Mask: d.IPv6PrefixLength.IPMask(),
+ }
+}
+
func (t Timestamp) AsTime() time.Time {
return time.Unix(int64(t), 0)
}