diff --git a/Makefile b/Makefile index 9061057..98257bd 100644 --- a/Makefile +++ b/Makefile @@ -151,6 +151,7 @@ AGENT_ID ?= 1 load-sample-specs: cat misc/spec-samples/app.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name app.emissary.cadoles.com cat misc/spec-samples/proxy.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name proxy.emissary.cadoles.com + cat misc/spec-samples/mdns.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name mdns.emissary.cadoles.com full-version: @echo $(FULL_VERSION) \ No newline at end of file diff --git a/go.mod b/go.mod index 1ad2dd3..a6d989a 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/barnybug/go-cast v0.0.0-20201201064555-a87ccbc26692 // indirect + github.com/brutella/dnssd v1.2.6 // indirect github.com/dop251/goja_nodejs v0.0.0-20230320130059-dcf93ba651dd // indirect github.com/gabriel-vasile/mimetype v1.4.1 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect diff --git a/go.sum b/go.sum index 06c8233..5e17670 100644 --- a/go.sum +++ b/go.sum @@ -203,6 +203,8 @@ github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/brutella/dnssd v1.2.6 h1:/0P13JkHLRzeLQkWRPEn4hJCr4T3NfknIFw3aNPIC34= +github.com/brutella/dnssd v1.2.6/go.mod h1:JoW2sJUrmVIef25G6lrLj7HS6Xdwh6q8WUIvMkkBYXs= github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= @@ -980,6 +982,7 @@ github.com/maxbrunsfeld/counterfeiter/v6 v6.2.2/go.mod h1:eD9eIE7cdwcMi9rYluz88J github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= github.com/miekg/dns v1.1.51 h1:0+Xg7vObnhrz/4ZCZcZh7zPXlmU0aveS2HDBd0m0qSo= github.com/miekg/dns v1.1.51/go.mod h1:2Z9d3CP1LQWihRZUf29mQ19yDThaI4DAYzte2CaQW5c= github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= @@ -1515,6 +1518,7 @@ golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -1805,6 +1809,7 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= diff --git a/internal/agent/controller/mdns/controller.go b/internal/agent/controller/mdns/controller.go new file mode 100644 index 0000000..c87305a --- /dev/null +++ b/internal/agent/controller/mdns/controller.go @@ -0,0 +1,181 @@ +package mdns + +import ( + "context" + "net" + "sync" + + "forge.cadoles.com/Cadoles/emissary/internal/agent" + mdns "forge.cadoles.com/Cadoles/emissary/internal/agent/controller/mdns/spec" + "github.com/brutella/dnssd" + "github.com/mitchellh/hashstructure/v2" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" +) + +const ( + DefaultDomain = "local" +) + +type Controller struct { + serviceDefHash uint64 + cancel context.CancelFunc + responder dnssd.Responder + mutex sync.RWMutex +} + +// Name implements node.Controller. +func (c *Controller) Name() string { + return "mdns-controller" +} + +// Reconcile implements node.Controller. +func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error { + mdnsSpec := mdns.NewSpec() + + if err := state.GetSpec(mdns.Name, mdnsSpec); err != nil { + if errors.Is(err, agent.ErrSpecNotFound) { + logger.Info(ctx, "could not find mdns spec") + + c.stopResponder(ctx) + + return nil + } + + return errors.WithStack(err) + } + + logger.Info(ctx, "retrieved spec", logger.F("spec", mdnsSpec.SpecName()), logger.F("revision", mdnsSpec.SpecRevision())) + + if err := c.updateResponder(ctx, mdnsSpec); err != nil { + return errors.Wrap(err, "could not update responder") + } + + return nil +} + +func (c *Controller) stopResponder(ctx context.Context) { + c.mutex.Lock() + defer c.mutex.Unlock() + + if c.responder == nil { + return + } + + c.cancel() + c.responder = nil + c.cancel = nil +} + +func (c *Controller) updateResponder(ctx context.Context, spec *mdns.Spec) error { + serviceDef := struct { + Services map[string]mdns.Service + }{ + Services: spec.Services, + } + + newServerDefHash, err := hashstructure.Hash(serviceDef, hashstructure.FormatV2, nil) + if err != nil { + return errors.WithStack(err) + } + + c.mutex.RLock() + if newServerDefHash == c.serviceDefHash && c.responder != nil { + c.mutex.RUnlock() + return nil + } + c.mutex.RUnlock() + + c.stopResponder(ctx) + + defaultIfaces, err := c.getDefaultIfaces() + if err != nil { + return errors.WithStack(err) + } + + services := make([]dnssd.Service, 0, len(spec.Services)) + + for name, service := range spec.Services { + domain := service.Domain + if domain == "" { + domain = DefaultDomain + } + + ifaces := service.Ifaces + if len(ifaces) == 0 { + ifaces = defaultIfaces + } + + config := dnssd.Config{ + Name: name, + Type: service.Type, + Domain: domain, + Host: service.Host, + Ifaces: ifaces, + Port: service.Port, + } + + service, err := dnssd.NewService(config) + if err != nil { + logger.Error(ctx, "could not create mdns service", logger.E(errors.WithStack(err))) + + continue + } + + services = append(services, service) + } + + responder, err := dnssd.NewResponder() + if err != nil { + return errors.WithStack(err) + } + + for _, service := range services { + if _, err := responder.Add(service); err != nil { + logger.Error(ctx, "could not add mdns service", logger.E(errors.WithStack(err))) + + continue + } + } + + ctx, cancel := context.WithCancel(context.Background()) + + c.responder = responder + c.cancel = cancel + c.serviceDefHash = newServerDefHash + + go func() { + defer c.stopResponder(ctx) + + if err := responder.Respond(ctx); err != nil && !errors.Is(err, context.Canceled) { + logger.Error(ctx, "could not respond to mdns queries", logger.E(errors.WithStack(err))) + } + }() + + return nil +} + +func (c *Controller) getDefaultIfaces() ([]string, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, errors.WithStack(err) + } + + ifaceNames := make([]string, len(ifaces)) + + for idx, ifa := range ifaces { + ifaceNames[idx] = ifa.Name + } + + return ifaceNames, nil +} + +func NewController() *Controller { + return &Controller{ + cancel: nil, + responder: nil, + serviceDefHash: 0, + } +} + +var _ agent.Controller = &Controller{} diff --git a/internal/agent/controller/mdns/spec/init.go b/internal/agent/controller/mdns/spec/init.go new file mode 100644 index 0000000..e6983ae --- /dev/null +++ b/internal/agent/controller/mdns/spec/init.go @@ -0,0 +1,17 @@ +package spec + +import ( + _ "embed" + + "forge.cadoles.com/Cadoles/emissary/internal/spec" + "github.com/pkg/errors" +) + +//go:embed schema.json +var schema []byte + +func init() { + if err := spec.Register(Name, schema); err != nil { + panic(errors.WithStack(err)) + } +} diff --git a/internal/agent/controller/mdns/spec/schema.json b/internal/agent/controller/mdns/spec/schema.json new file mode 100644 index 0000000..78bb910 --- /dev/null +++ b/internal/agent/controller/mdns/spec/schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdns.edge.emissary.cadoles.com/spec.json", + "title": "MDNSSpec", + "description": "Emissary 'MDNS' specification", + "type": "object", + "properties": { + "services": { + "type": "object", + "patternProperties": { + ".*": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "host": { + "type": "string" + }, + "ifaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "port": { + "type": "number" + } + }, + "required": [ + "type", + "host", + "port" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "services" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/agent/controller/mdns/spec/spec.go b/internal/agent/controller/mdns/spec/spec.go new file mode 100644 index 0000000..0ff32ff --- /dev/null +++ b/internal/agent/controller/mdns/spec/spec.go @@ -0,0 +1,42 @@ +package spec + +import ( + "forge.cadoles.com/Cadoles/emissary/internal/spec" +) + +const Name spec.Name = "mdns.emissary.cadoles.com" + +type Spec struct { + Revision int `json:"revision"` + Services map[string]Service `json:"services"` +} + +type Service struct { + Type string `json:"type"` + Domain string `json:"domain"` + Host string `json:"host"` + Ifaces []string `json:"ifaces"` + Port int `json:"port"` +} + +func (s *Spec) SpecName() spec.Name { + return Name +} + +func (s *Spec) SpecRevision() int { + return s.Revision +} + +func (s *Spec) SpecData() map[string]any { + return map[string]any{ + "services": s.Services, + } +} + +func NewSpec() *Spec { + return &Spec{ + Revision: -1, + } +} + +var _ spec.Spec = &Spec{} diff --git a/internal/agent/controller/mdns/spec/testdata/spec-ok.json b/internal/agent/controller/mdns/spec/testdata/spec-ok.json new file mode 100644 index 0000000..48f445b --- /dev/null +++ b/internal/agent/controller/mdns/spec/testdata/spec-ok.json @@ -0,0 +1,15 @@ +{ + "name": "mdns.emissary.cadoles.com", + "data": { + "services": { + "My Website": { + "type": "_http._tcp", + "domain": "local", + "host": "mywebsite", + "ifaces": ["lo", "eth0"], + "port": 80 + } + } + }, + "revision": 0 +} \ No newline at end of file diff --git a/internal/agent/controller/mdns/spec/validator_test.go b/internal/agent/controller/mdns/spec/validator_test.go new file mode 100644 index 0000000..b360f65 --- /dev/null +++ b/internal/agent/controller/mdns/spec/validator_test.go @@ -0,0 +1,65 @@ +package spec + +import ( + "context" + "encoding/json" + "io/ioutil" + "testing" + + "forge.cadoles.com/Cadoles/emissary/internal/spec" + "github.com/pkg/errors" +) + +type validatorTestCase struct { + Name string + Source string + ShouldFail bool +} + +var validatorTestCases = []validatorTestCase{ + { + Name: "SpecOK", + Source: "testdata/spec-ok.json", + ShouldFail: false, + }, +} + +func TestValidator(t *testing.T) { + t.Parallel() + + validator := spec.NewValidator() + if err := validator.Register(Name, schema); err != nil { + t.Fatalf("+%v", errors.WithStack(err)) + } + + for _, tc := range validatorTestCases { + func(tc validatorTestCase) { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + rawSpec, err := ioutil.ReadFile(tc.Source) + if err != nil { + t.Fatalf("+%v", errors.WithStack(err)) + } + + var spec spec.RawSpec + + if err := json.Unmarshal(rawSpec, &spec); err != nil { + t.Fatalf("+%v", errors.WithStack(err)) + } + + ctx := context.Background() + + err = validator.Validate(ctx, &spec) + + if !tc.ShouldFail && err != nil { + t.Errorf("+%v", errors.WithStack(err)) + } + + if tc.ShouldFail && err == nil { + t.Error("validation should have failed") + } + }) + }(tc) + } +} diff --git a/internal/command/agent/run.go b/internal/command/agent/run.go index 9e4237c..7862b09 100644 --- a/internal/command/agent/run.go +++ b/internal/command/agent/run.go @@ -5,6 +5,7 @@ import ( "forge.cadoles.com/Cadoles/emissary/internal/agent" "forge.cadoles.com/Cadoles/emissary/internal/agent/controller/app" + "forge.cadoles.com/Cadoles/emissary/internal/agent/controller/mdns" "forge.cadoles.com/Cadoles/emissary/internal/agent/controller/openwrt" "forge.cadoles.com/Cadoles/emissary/internal/agent/controller/persistence" "forge.cadoles.com/Cadoles/emissary/internal/agent/controller/proxy" @@ -66,6 +67,10 @@ func RunCommand() *cli.Command { controllers = append(controllers, proxy.NewController()) } + if ctrlConf.MDNS.Enabled { + controllers = append(controllers, mdns.NewController()) + } + if ctrlConf.SysUpgrade.Enabled { sysUpgradeArgs := make([]string, 0) if len(ctrlConf.SysUpgrade.SysUpgradeCommand) > 1 { diff --git a/internal/config/agent.go b/internal/config/agent.go index 4771d40..0e582df 100644 --- a/internal/config/agent.go +++ b/internal/config/agent.go @@ -23,6 +23,7 @@ type ControllersConfig struct { UCI UCIControllerConfig `yaml:"uci"` App AppControllerConfig `yaml:"app"` SysUpgrade SysUpgradeControllerConfig `yaml:"sysupgrade"` + MDNS MDNSControllerConfig `yaml:"mdns"` } type PersistenceControllerConfig struct { @@ -55,6 +56,10 @@ type SysUpgradeControllerConfig struct { FirmwareVersionCommand InterpolatedStringSlice `yaml:"firmwareVersionCommand"` } +type MDNSControllerConfig struct { + Enabled InterpolatedBool `yaml:"enabled"` +} + func NewDefaultAgentConfig() AgentConfig { return AgentConfig{ ServerURL: "http://127.0.0.1:3000", @@ -86,6 +91,9 @@ func NewDefaultAgentConfig() AgentConfig { SysUpgradeCommand: InterpolatedStringSlice{"sysupgrade", "--force", "-u", "-v", openwrt.FirmwareFileTemplate}, FirmwareVersionCommand: InterpolatedStringSlice{"sh", "-c", `source /etc/openwrt_release && echo "$DISTRIB_ID-$DISTRIB_RELEASE-$DISTRIB_REVISION"`}, }, + MDNS: MDNSControllerConfig{ + Enabled: true, + }, }, Collectors: []ShellCollectorConfig{ { diff --git a/internal/imports/spec/spec_import.go b/internal/imports/spec/spec_import.go index 1ae3365..48792e1 100644 --- a/internal/imports/spec/spec_import.go +++ b/internal/imports/spec/spec_import.go @@ -2,6 +2,7 @@ package spec import ( _ "forge.cadoles.com/Cadoles/emissary/internal/agent/controller/app/spec" + _ "forge.cadoles.com/Cadoles/emissary/internal/agent/controller/mdns/spec" _ "forge.cadoles.com/Cadoles/emissary/internal/agent/controller/openwrt/spec/sysupgrade" _ "forge.cadoles.com/Cadoles/emissary/internal/spec/proxy" _ "forge.cadoles.com/Cadoles/emissary/internal/spec/uci" diff --git a/misc/spec-samples/mdns.emissary.cadoles.com.json b/misc/spec-samples/mdns.emissary.cadoles.com.json new file mode 100644 index 0000000..97e3df0 --- /dev/null +++ b/misc/spec-samples/mdns.emissary.cadoles.com.json @@ -0,0 +1,38 @@ +{ + "services": { + "arcad": { + "type": "_http._tcp", + "port": 8080, + "host": "arcad", + "ifaces": ["wlp4s0"] + }, + "portal": { + "type": "_http._tcp", + "port": 8080, + "host": "portal", + "domain": "arcad.local", + "ifaces": ["wlp4s0"] + }, + "hextris": { + "type": "_http._tcp", + "port": 8080, + "host": "hextris", + "domain": "arcad.local", + "ifaces": ["wlp4s0"] + }, + "test": { + "type": "_http._tcp", + "port": 8080, + "host": "test", + "domain": "arcad.local", + "ifaces": ["wlp4s0"] + }, + "diffusion": { + "type": "_http._tcp", + "port": 8080, + "host": "diffusion", + "domain": "arcad.local", + "ifaces": ["wlp4s0"] + } + } +} \ No newline at end of file diff --git a/misc/spec-samples/proxy.emissary.cadoles.com.json b/misc/spec-samples/proxy.emissary.cadoles.com.json index 0dc27e4..95310a3 100644 --- a/misc/spec-samples/proxy.emissary.cadoles.com.json +++ b/misc/spec-samples/proxy.emissary.cadoles.com.json @@ -19,6 +19,22 @@ "hostPattern": "diffusion.arcad.local:*", "target": "http://localhost:8085" }, + { + "hostPattern": "arcad-portal.local:*", + "target": "http://localhost:8082" + }, + { + "hostPattern": "arcad-hextris.local:*", + "target": "http://localhost:8083" + }, + { + "hostPattern": "arcad-test.local:*", + "target": "http://localhost:8084" + }, + { + "hostPattern": "arcad-diffusion.local:*", + "target": "http://localhost:8085" + }, { "hostPattern": "*", "target": "http://localhost:8082"