mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-02-03 22:18:39 +00:00
Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7088df58dd | ||
|
|
9cbd9b3e44 | ||
|
|
e6586fd360 | ||
|
|
33a6db2599 | ||
|
|
70b0c4f7b9 | ||
|
|
5af3ec4f7b | ||
|
|
79476add12 | ||
|
|
1634a06330 | ||
|
|
a007394f60 | ||
|
|
62a0ba8731 | ||
|
|
e8d3ed1acd | ||
|
|
8b98faa441 | ||
|
|
30320ec9c7 | ||
|
|
5f4a399850 | ||
|
|
82e0d4b0c4 | ||
|
|
95a9df826d | ||
|
|
3b71d26cf3 | ||
|
|
c233ad9b1b | ||
|
|
12d6484b1c | ||
|
|
bc7b1cc6d8 | ||
|
|
ec684348ed | ||
|
|
18a19a3aa2 | ||
|
|
905f2d08c5 | ||
|
|
04947b4d87 | ||
|
|
72bf80533e | ||
|
|
9ddedf926e | ||
|
|
139dd62ff3 | ||
|
|
50ef00526e | ||
|
|
80cf79b9cb | ||
|
|
e6ad39b070 | ||
|
|
56f9c72569 | ||
|
|
dc48c908b8 | ||
|
|
9b0f0e792a | ||
|
|
b3eebb19b6 | ||
|
|
c24589a5be | ||
|
|
1e1c5a4dc8 | ||
|
|
339023421a | ||
|
|
a00d2a431a | ||
|
|
5aca118dbb | ||
|
|
411f7434f4 | ||
|
|
34801382f5 | ||
|
|
b9f2259ae4 | ||
|
|
19020a96bf | ||
|
|
96085147ff | ||
|
|
f3dd344026 | ||
|
|
486096416f | ||
|
|
5710f2e984 | ||
|
|
09936f1f07 | ||
|
|
0d6ca57536 | ||
|
|
3ddcb84db8 | ||
|
|
1012bf063f | ||
|
|
b8155e6182 | ||
|
|
9a34df61bb | ||
|
|
fbb879edf9 | ||
|
|
ac97c88876 | ||
|
|
a1fda2c0de | ||
|
|
f499770d45 | ||
|
|
4769da4ef4 | ||
|
|
c2556a8e39 | ||
|
|
29bf329f6a | ||
|
|
1dee4305bc | ||
|
|
429a98b690 | ||
|
|
da01a146d2 | ||
|
|
dd9f2465be | ||
|
|
b5cf0e2b31 | ||
|
|
1db159ad34 | ||
|
|
6604f973ac | ||
|
|
69ee6582e2 | ||
|
|
6f12667e8c | ||
|
|
b002dff624 | ||
|
|
affef963c1 | ||
|
|
56b2056190 | ||
|
|
c1e6f5126a | ||
|
|
1a8c1ec73d | ||
|
|
52954b8ceb | ||
|
|
a5025e35ea | ||
|
|
07f80c9ebf | ||
|
|
13db23553d | ||
|
|
3963fce43b | ||
|
|
ea4e5147bd | ||
|
|
7a491a4cc5 | ||
|
|
5ba90748f6 | ||
|
|
20f8f22bae | ||
|
|
b50cccac85 | ||
|
|
34ebe9b054 | ||
|
|
43d82cf1a7 | ||
|
|
ab88174091 | ||
|
|
ebcbf85373 | ||
|
|
87513cba6d | ||
|
|
64bcd2f00d | ||
|
|
cc6ae290f8 | ||
|
|
3e62bd3dbd | ||
|
|
8491f9c455 | ||
|
|
3ca754b438 | ||
|
|
8c7c3901e8 | ||
|
|
a9672dfff5 | ||
|
|
203a2ec8b8 | ||
|
|
810cbd1f4f | ||
|
|
49eebcdcbc | ||
|
|
e89021ec3a | ||
|
|
73a697b2fa | ||
|
|
9319d08046 | ||
|
|
7dc5138e91 | ||
|
|
8f189c919a | ||
|
|
906479a15c | ||
|
|
dabbf2037b | ||
|
|
b496147ce7 | ||
|
|
583718f234 | ||
|
|
fdb82f6ec3 | ||
|
|
5145729ab1 | ||
|
|
4d810261a4 | ||
|
|
18e8616834 | ||
|
|
d55563cac5 | ||
|
|
bb481d9bcc | ||
|
|
a163be3584 | ||
|
|
891b7cb2c6 | ||
|
|
176c22f229 | ||
|
|
faa0ed06b6 | ||
|
|
9515db7faf | ||
|
|
d822bf4257 | ||
|
|
0826671809 | ||
|
|
67d74774a9 | ||
|
|
5d65416227 | ||
|
|
49441f62f3 | ||
|
|
99651f6e5b | ||
|
|
edca1f4f89 | ||
|
|
3d834f00f6 | ||
|
|
6bb9e7a766 | ||
|
|
61fb71b1fa | ||
|
|
f8967c376f | ||
|
|
6d3c86c0be | ||
|
|
e42554f892 | ||
|
|
28984090e5 | ||
|
|
251255c746 | ||
|
|
32709dc64c | ||
|
|
71f26a6d81 | ||
|
|
44352f8006 | ||
|
|
af38623590 | ||
|
|
9c1665a759 | ||
|
|
eaad24e5e5 | ||
|
|
cfaf32f71a | ||
|
|
51b235b61a | ||
|
|
0a6d9d4454 | ||
|
|
dc700bbd52 | ||
|
|
cb445825f4 | ||
|
|
4d996e317b | ||
|
|
30c9012004 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
|
||||
go: ["1.20.x"]
|
||||
go: ["1.21.x"]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -3,3 +3,12 @@ gon.hcl
|
||||
|
||||
/Build
|
||||
.DS_Store
|
||||
|
||||
# Release folder
|
||||
dist/
|
||||
|
||||
# Binaries
|
||||
ctrld-*
|
||||
|
||||
# generated file
|
||||
cmd/cli/rsrc_*.syso
|
||||
|
||||
10
README.md
10
README.md
@@ -4,13 +4,16 @@
|
||||
[](https://pkg.go.dev/github.com/Control-D-Inc/ctrld)
|
||||
[](https://goreportcard.com/report/github.com/Control-D-Inc/ctrld)
|
||||
|
||||

|
||||
|
||||
A highly configurable DNS forwarding proxy with support for:
|
||||
- Multiple listeners for incoming queries
|
||||
- Multiple upstreams with fallbacks
|
||||
- Multiple network policy driven DNS query steering
|
||||
- Policy driven domain based "split horizon" DNS with wildcard support
|
||||
- Integrations with common router vendors and firmware
|
||||
- LAN client discovery via DHCP, mDNS, and ARP
|
||||
- LAN client discovery via DHCP, mDNS, ARP, NDP, hosts file parsing
|
||||
- Prometheus metrics exporter
|
||||
|
||||
## TLDR
|
||||
Proxy legacy DNS traffic to secure DNS upstreams in highly configurable ways.
|
||||
@@ -61,7 +64,7 @@ $ docker pull controldns/ctrld
|
||||
Alternatively, if you know what you're doing you can download pre-compiled binaries from the [Releases](https://github.com/Control-D-Inc/ctrld/releases) section for the appropriate platform.
|
||||
|
||||
## Build
|
||||
Lastly, you can build `ctrld` from source which requires `go1.19+`:
|
||||
Lastly, you can build `ctrld` from source which requires `go1.21+`:
|
||||
|
||||
```shell
|
||||
$ go build ./cmd/ctrld
|
||||
@@ -102,9 +105,11 @@ Available Commands:
|
||||
start Quick start service and configure DNS on interface
|
||||
stop Quick stop service and remove DNS from interface
|
||||
restart Restart the ctrld service
|
||||
reload Reload the ctrld service
|
||||
status Show status of the ctrld service
|
||||
uninstall Stop and uninstall the ctrld service
|
||||
clients Manage clients
|
||||
upgrade Upgrading ctrld to latest version
|
||||
|
||||
Flags:
|
||||
-h, --help help for ctrld
|
||||
@@ -232,7 +237,6 @@ See [Contribution Guideline](./docs/contributing.md)
|
||||
|
||||
## Roadmap
|
||||
The following functionality is on the roadmap and will be available in future releases.
|
||||
- Prometheus metrics exporter
|
||||
- DNS intercept mode
|
||||
- Direct listener mode
|
||||
- Support for more routers (let us know which ones)
|
||||
|
||||
@@ -18,4 +18,5 @@ type LeaseFileFormat string
|
||||
const (
|
||||
Dnsmasq LeaseFileFormat = "dnsmasq"
|
||||
IscDhcpd LeaseFileFormat = "isc-dhcpd"
|
||||
KeaDHCP4 LeaseFileFormat = "kea-dhcp4"
|
||||
)
|
||||
|
||||
1207
cmd/cli/cli.go
1207
cmd/cli/cli.go
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,31 @@ func Test_writeConfigFile(t *testing.T) {
|
||||
_, err := os.Stat(configPath)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
assert.NoError(t, writeConfigFile())
|
||||
assert.NoError(t, writeConfigFile(&cfg))
|
||||
|
||||
_, err = os.Stat(configPath)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_isStableVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ver string
|
||||
isStable bool
|
||||
}{
|
||||
{"stable", "v1.3.5", true},
|
||||
{"pre", "v1.3.5-next", false},
|
||||
{"pre with commit hash", "v1.3.5-next-asdf", false},
|
||||
{"dev", "dev", false},
|
||||
{"empty", "dev", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := isStableVersion(tc.ver); got != tc.isStable {
|
||||
t.Errorf("unexpected result for %s, want: %v, got: %v", tc.ver, tc.isStable, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,3 +27,8 @@ func newControlClient(addr string) *controlClient {
|
||||
func (c *controlClient) post(path string, data io.Reader) (*http.Response, error) {
|
||||
return c.c.Post("http://unix"+path, contentTypeJson, data)
|
||||
}
|
||||
|
||||
// deactivationRequest represents request for validating deactivation pin.
|
||||
type deactivationRequest struct {
|
||||
Pin int64 `json:"pin"`
|
||||
}
|
||||
|
||||
@@ -10,14 +10,21 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
const (
|
||||
contentTypeJson = "application/json"
|
||||
listClientsPath = "/clients"
|
||||
startedPath = "/started"
|
||||
reloadPath = "/reload"
|
||||
contentTypeJson = "application/json"
|
||||
listClientsPath = "/clients"
|
||||
startedPath = "/started"
|
||||
reloadPath = "/reload"
|
||||
deactivationPath = "/deactivation"
|
||||
cdPath = "/cd"
|
||||
ifacePath = "/iface"
|
||||
)
|
||||
|
||||
type controlServer struct {
|
||||
@@ -66,6 +73,25 @@ func (p *prog) registerControlServerHandler() {
|
||||
sort.Slice(clients, func(i, j int) bool {
|
||||
return clients[i].IP.Less(clients[j].IP)
|
||||
})
|
||||
if p.metricsQueryStats.Load() {
|
||||
for _, client := range clients {
|
||||
client.IncludeQueryCount = true
|
||||
dm := &dto.Metric{}
|
||||
m, err := statsClientQueriesCount.MetricVec.GetMetricWithLabelValues(
|
||||
client.IP.String(),
|
||||
client.Mac,
|
||||
client.Hostname,
|
||||
)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msgf("could not get metrics for client: %v", client)
|
||||
continue
|
||||
}
|
||||
if err := m.Write(dm); err == nil {
|
||||
client.QueryCount = int64(dm.Counter.GetValue())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(&clients); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -125,6 +151,49 @@ func (p *prog) registerControlServerHandler() {
|
||||
// Otherwise, reload is done.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
p.cs.register(deactivationPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
// Non-cd mode or pin code not set, always allowing deactivation.
|
||||
if cdUID == "" || deactivationPinNotSet() {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
var req deactivationRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
|
||||
w.WriteHeader(http.StatusPreconditionFailed)
|
||||
mainLog.Load().Err(err).Msg("invalid deactivation request")
|
||||
return
|
||||
}
|
||||
|
||||
code := http.StatusForbidden
|
||||
switch req.Pin {
|
||||
case cdDeactivationPin:
|
||||
code = http.StatusOK
|
||||
case defaultDeactivationPin:
|
||||
// If the pin code was set, but users do not provide --pin, return proper code to client.
|
||||
code = http.StatusBadRequest
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
}))
|
||||
p.cs.register(cdPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if cdUID != "" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(cdUID))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
p.cs.register(ifacePath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
// p.setDNS is only called when running as a service
|
||||
if !service.Interactive() {
|
||||
<-p.csSetDnsDone
|
||||
if p.csSetDnsOk {
|
||||
w.Write([]byte(iface))
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
}
|
||||
|
||||
func jsonResponse(next http.Handler) http.Handler {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"tailscale.com/net/tsaddr"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/controld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/dnscache"
|
||||
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
||||
)
|
||||
@@ -32,6 +33,9 @@ const (
|
||||
// https://thekelleys.org.uk/gitweb/?p=dnsmasq.git;a=blob;f=src/dns-protocol.h;h=76ac66a8c28317e9c121a74ab5fd0e20f6237dc8;hb=HEAD#l81
|
||||
// This is also dns.EDNS0LOCALSTART, but define our own constant here for clarification.
|
||||
EDNS0_OPTION_MAC = 0xFDE9
|
||||
|
||||
// selfUninstallMaxQueries is number of REFUSED queries seen before checking for self-uninstallation.
|
||||
selfUninstallMaxQueries = 32
|
||||
)
|
||||
|
||||
var osUpstreamConfig = &ctrld.UpstreamConfig{
|
||||
@@ -54,6 +58,14 @@ type proxyRequest struct {
|
||||
ufr *upstreamForResult
|
||||
}
|
||||
|
||||
// proxyResponse contains data for proxying a DNS response from upstream.
|
||||
type proxyResponse struct {
|
||||
answer *dns.Msg
|
||||
cached bool
|
||||
clientInfo bool
|
||||
upstream string
|
||||
}
|
||||
|
||||
// upstreamForResult represents the result of processing rules for a request.
|
||||
type upstreamForResult struct {
|
||||
upstreams []string
|
||||
@@ -81,6 +93,7 @@ func (p *prog) serveDNS(listenerNum string) error {
|
||||
_ = w.WriteMsg(answer)
|
||||
return
|
||||
}
|
||||
listenerConfig := p.cfg.Listener[listenerNum]
|
||||
reqId := requestID()
|
||||
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
|
||||
if !listenerConfig.AllowWanClients && isWanClient(w.RemoteAddr()) {
|
||||
@@ -93,6 +106,15 @@ func (p *prog) serveDNS(listenerNum string) error {
|
||||
go p.detectLoop(m)
|
||||
q := m.Question[0]
|
||||
domain := canonicalName(q.Name)
|
||||
if domain == selfCheckInternalTestDomain {
|
||||
answer := resolveInternalDomainTestQuery(ctx, domain, m)
|
||||
_ = w.WriteMsg(answer)
|
||||
return
|
||||
}
|
||||
if _, ok := p.cacheFlushDomainsMap[domain]; ok && p.cache != nil {
|
||||
p.cache.Purge()
|
||||
ctrld.Log(ctx, mainLog.Load().Debug(), "received query %q, local cache is purged", domain)
|
||||
}
|
||||
remoteIP, _, _ := net.SplitHostPort(w.RemoteAddr().String())
|
||||
ci := p.getClientInfo(remoteIP, m)
|
||||
ci.ClientIDPref = p.cfg.Service.ClientIDPref
|
||||
@@ -101,26 +123,50 @@ func (p *prog) serveDNS(listenerNum string) error {
|
||||
fmtSrcToDest := fmtRemoteToLocal(listenerNum, ci.Hostname, remoteAddr.String())
|
||||
t := time.Now()
|
||||
ctrld.Log(ctx, mainLog.Load().Info(), "QUERY: %s: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain)
|
||||
res := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, ci.Mac, domain)
|
||||
ur := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, ci.Mac, domain)
|
||||
|
||||
labelValues := make([]string, 0, len(statsQueriesCountLabels))
|
||||
labelValues = append(labelValues, net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port)))
|
||||
labelValues = append(labelValues, ci.IP)
|
||||
labelValues = append(labelValues, ci.Mac)
|
||||
labelValues = append(labelValues, ci.Hostname)
|
||||
|
||||
var answer *dns.Msg
|
||||
if !res.matched && listenerConfig.Restricted {
|
||||
if !ur.matched && listenerConfig.Restricted {
|
||||
ctrld.Log(ctx, mainLog.Load().Info(), "query refused, %s does not match any network policy", remoteAddr.String())
|
||||
answer = new(dns.Msg)
|
||||
answer.SetRcode(m, dns.RcodeRefused)
|
||||
labelValues = append(labelValues, "") // no upstream
|
||||
} else {
|
||||
var failoverRcode []int
|
||||
if listenerConfig.Policy != nil {
|
||||
failoverRcode = listenerConfig.Policy.FailoverRcodeNumbers
|
||||
}
|
||||
answer = p.proxy(ctx, &proxyRequest{
|
||||
pr := p.proxy(ctx, &proxyRequest{
|
||||
msg: m,
|
||||
ci: ci,
|
||||
failoverRcodes: failoverRcode,
|
||||
ufr: res,
|
||||
ufr: ur,
|
||||
})
|
||||
go p.doSelfUninstall(pr.answer)
|
||||
answer = pr.answer
|
||||
rtt := time.Since(t)
|
||||
ctrld.Log(ctx, mainLog.Load().Debug(), "received response of %d bytes in %s", answer.Len(), rtt)
|
||||
upstream := pr.upstream
|
||||
switch {
|
||||
case pr.cached:
|
||||
upstream = "cache"
|
||||
case pr.clientInfo:
|
||||
upstream = "client_info_table"
|
||||
}
|
||||
labelValues = append(labelValues, upstream)
|
||||
}
|
||||
labelValues = append(labelValues, dns.TypeToString[q.Qtype])
|
||||
labelValues = append(labelValues, dns.RcodeToString[answer.Rcode])
|
||||
go func() {
|
||||
p.WithLabelValuesInc(statsQueriesCount, labelValues...)
|
||||
p.WithLabelValuesInc(statsClientQueriesCount, []string{ci.IP, ci.Mac, ci.Hostname}...)
|
||||
}()
|
||||
if err := w.WriteMsg(answer); err != nil {
|
||||
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "serveDNS: failed to send DNS response to client")
|
||||
}
|
||||
@@ -170,12 +216,9 @@ func (p *prog) serveDNS(listenerNum string) error {
|
||||
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
|
||||
s, errCh := runDNSServer(addr, proto, handler)
|
||||
defer s.Shutdown()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-time.After(5 * time.Second):
|
||||
p.started <- struct{}{}
|
||||
}
|
||||
|
||||
p.started <- struct{}{}
|
||||
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
case <-ctx.Done():
|
||||
@@ -251,7 +294,7 @@ networkRules:
|
||||
macRules:
|
||||
for _, rule := range lc.Policy.Macs {
|
||||
for source, targets := range rule {
|
||||
if source != "" && strings.EqualFold(source, srcMac) {
|
||||
if source != "" && (strings.EqualFold(source, srcMac) || wildcardMatches(strings.ToLower(source), strings.ToLower(srcMac))) {
|
||||
matchedPolicy = lc.Policy.Name
|
||||
matchedNetwork = source
|
||||
networkTargets = targets
|
||||
@@ -360,7 +403,7 @@ func (p *prog) proxyLanHostnameQuery(ctx context.Context, msg *dns.Msg) *dns.Msg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
|
||||
func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
|
||||
var staleAnswer *dns.Msg
|
||||
upstreams := req.ufr.upstreams
|
||||
serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale
|
||||
@@ -370,6 +413,8 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
|
||||
upstreams = []string{upstreamOS}
|
||||
}
|
||||
|
||||
res := &proxyResponse{}
|
||||
|
||||
// LAN/PTR lookup flow:
|
||||
//
|
||||
// 1. If there's matching rule, follow it.
|
||||
@@ -384,14 +429,18 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
|
||||
case isPrivatePtrLookup(req.msg):
|
||||
isLanOrPtrQuery = true
|
||||
if answer := p.proxyPrivatePtrLookup(ctx, req.msg); answer != nil {
|
||||
return answer
|
||||
res.answer = answer
|
||||
res.clientInfo = true
|
||||
return res
|
||||
}
|
||||
upstreams, upstreamConfigs = p.upstreamsAndUpstreamConfigForLanAndPtr(upstreams, upstreamConfigs)
|
||||
ctrld.Log(ctx, mainLog.Load().Debug(), "private PTR lookup, using upstreams: %v", upstreams)
|
||||
case isLanHostnameQuery(req.msg):
|
||||
isLanOrPtrQuery = true
|
||||
if answer := p.proxyLanHostnameQuery(ctx, req.msg); answer != nil {
|
||||
return answer
|
||||
res.answer = answer
|
||||
res.clientInfo = true
|
||||
return res
|
||||
}
|
||||
upstreams, upstreamConfigs = p.upstreamsAndUpstreamConfigForLanAndPtr(upstreams, upstreamConfigs)
|
||||
ctrld.Log(ctx, mainLog.Load().Debug(), "lan hostname lookup, using upstreams: %v", upstreams)
|
||||
@@ -413,7 +462,9 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
|
||||
if cachedValue.Expire.After(now) {
|
||||
ctrld.Log(ctx, mainLog.Load().Debug(), "hit cached response")
|
||||
setCachedAnswerTTL(answer, now, cachedValue.Expire)
|
||||
return answer
|
||||
res.answer = answer
|
||||
res.cached = true
|
||||
return res
|
||||
}
|
||||
staleAnswer = answer
|
||||
}
|
||||
@@ -475,7 +526,9 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
|
||||
ctrld.Log(ctx, mainLog.Load().Debug(), "serving stale cached response")
|
||||
now := time.Now()
|
||||
setCachedAnswerTTL(staleAnswer, now, now.Add(staleTTL))
|
||||
return staleAnswer
|
||||
res.answer = staleAnswer
|
||||
res.cached = true
|
||||
return res
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -509,12 +562,15 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
|
||||
hostname = req.ci.Hostname
|
||||
}
|
||||
ctrld.Log(ctx, mainLog.Load().Info(), "REPLY: %s -> %s (%s): %s", upstreams[n], req.ufr.srcAddr, hostname, dns.RcodeToString[answer.Rcode])
|
||||
return answer
|
||||
res.answer = answer
|
||||
res.upstream = upstreamConfig.Endpoint
|
||||
return res
|
||||
}
|
||||
ctrld.Log(ctx, mainLog.Load().Error(), "all %v endpoints failed", upstreams)
|
||||
answer := new(dns.Msg)
|
||||
answer.SetRcode(req.msg, dns.RcodeServerFailure)
|
||||
return answer
|
||||
res.answer = answer
|
||||
return res
|
||||
}
|
||||
|
||||
func (p *prog) upstreamsAndUpstreamConfigForLanAndPtr(upstreams []string, upstreamConfigs []*ctrld.UpstreamConfig) ([]string, []*ctrld.UpstreamConfig) {
|
||||
@@ -546,7 +602,8 @@ func canonicalName(fqdn string) string {
|
||||
return q
|
||||
}
|
||||
|
||||
func wildcardMatches(wildcard, domain string) bool {
|
||||
// wildcardMatches reports whether string str matches the wildcard pattern.
|
||||
func wildcardMatches(wildcard, str string) bool {
|
||||
// Wildcard match.
|
||||
wildCardParts := strings.Split(wildcard, "*")
|
||||
if len(wildCardParts) != 2 {
|
||||
@@ -556,15 +613,15 @@ func wildcardMatches(wildcard, domain string) bool {
|
||||
switch {
|
||||
case len(wildCardParts[0]) > 0 && len(wildCardParts[1]) > 0:
|
||||
// Domain must match both prefix and suffix.
|
||||
return strings.HasPrefix(domain, wildCardParts[0]) && strings.HasSuffix(domain, wildCardParts[1])
|
||||
return strings.HasPrefix(str, wildCardParts[0]) && strings.HasSuffix(str, wildCardParts[1])
|
||||
|
||||
case len(wildCardParts[1]) > 0:
|
||||
// Only suffix must match.
|
||||
return strings.HasSuffix(domain, wildCardParts[1])
|
||||
return strings.HasSuffix(str, wildCardParts[1])
|
||||
|
||||
case len(wildCardParts[0]) > 0:
|
||||
// Only prefix must match.
|
||||
return strings.HasPrefix(domain, wildCardParts[0])
|
||||
return strings.HasPrefix(str, wildCardParts[0])
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -698,20 +755,19 @@ func runDNSServer(addr, network string, handler dns.Handler) (*dns.Server, <-cha
|
||||
Handler: handler,
|
||||
}
|
||||
|
||||
waitLock := sync.Mutex{}
|
||||
waitLock.Lock()
|
||||
s.NotifyStartedFunc = waitLock.Unlock
|
||||
startedCh := make(chan struct{})
|
||||
s.NotifyStartedFunc = func() { sync.OnceFunc(func() { close(startedCh) })() }
|
||||
|
||||
errCh := make(chan error)
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
if err := s.ListenAndServe(); err != nil {
|
||||
waitLock.Unlock()
|
||||
s.NotifyStartedFunc()
|
||||
mainLog.Load().Error().Err(err).Msgf("could not listen and serve on: %s", s.Addr)
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
waitLock.Lock()
|
||||
<-startedCh
|
||||
return s, errCh
|
||||
}
|
||||
|
||||
@@ -762,6 +818,13 @@ func (p *prog) getClientInfo(remoteIP string, msg *dns.Msg) *ctrld.ClientInfo {
|
||||
ci.Hostname = p.ciTable.LookupHostname(ci.IP, ci.Mac)
|
||||
}
|
||||
ci.Self = queryFromSelf(ci.IP)
|
||||
// If this is a query from self, but ci.IP is not loopback IP,
|
||||
// try using hostname mapping for lookback IP if presents.
|
||||
if ci.Self {
|
||||
if name := p.ciTable.LocalHostname(); name != "" {
|
||||
ci.Hostname = name
|
||||
}
|
||||
}
|
||||
p.spoofLoopbackIpInClientInfo(ci)
|
||||
return ci
|
||||
}
|
||||
@@ -779,6 +842,51 @@ func (p *prog) spoofLoopbackIpInClientInfo(ci *ctrld.ClientInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// doSelfUninstall performs self-uninstall if these condition met:
|
||||
//
|
||||
// - There is only 1 ControlD upstream in-use.
|
||||
// - Number of refused queries seen so far equals to selfUninstallMaxQueries.
|
||||
// - The cdUID is deleted.
|
||||
func (p *prog) doSelfUninstall(answer *dns.Msg) {
|
||||
if !p.canSelfUninstall.Load() || answer == nil || answer.Rcode != dns.RcodeRefused {
|
||||
return
|
||||
}
|
||||
|
||||
p.selfUninstallMu.Lock()
|
||||
defer p.selfUninstallMu.Unlock()
|
||||
if p.checkingSelfUninstall {
|
||||
return
|
||||
}
|
||||
|
||||
logger := mainLog.Load().With().Str("mode", "self-uninstall").Logger()
|
||||
if p.refusedQueryCount > selfUninstallMaxQueries {
|
||||
p.checkingSelfUninstall = true
|
||||
_, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev)
|
||||
logger.Debug().Msg("maximum number of refused queries reached, checking device status")
|
||||
selfUninstallCheck(err, p, logger)
|
||||
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("could not fetch resolver config")
|
||||
}
|
||||
// Cool-of period to prevent abusing the API.
|
||||
go p.selfUninstallCoolOfPeriod()
|
||||
return
|
||||
}
|
||||
p.refusedQueryCount++
|
||||
}
|
||||
|
||||
// selfUninstallCoolOfPeriod waits for 30 minutes before
|
||||
// calling API again for checking ControlD device status.
|
||||
func (p *prog) selfUninstallCoolOfPeriod() {
|
||||
t := time.NewTimer(time.Minute * 30)
|
||||
defer t.Stop()
|
||||
<-t.C
|
||||
p.selfUninstallMu.Lock()
|
||||
p.checkingSelfUninstall = false
|
||||
p.refusedQueryCount = 0
|
||||
p.selfUninstallMu.Unlock()
|
||||
}
|
||||
|
||||
// queryFromSelf reports whether the input IP is from device running ctrld.
|
||||
func queryFromSelf(ip string) bool {
|
||||
netIP := netip.MustParseAddr(ip)
|
||||
@@ -892,3 +1000,21 @@ func isWanClient(na net.Addr) bool {
|
||||
!ip.IsLinkLocalMulticast() &&
|
||||
!tsaddr.CGNATRange().Contains(ip)
|
||||
}
|
||||
|
||||
// resolveInternalDomainTestQuery resolves internal test domain query, returning the answer to the caller.
|
||||
func resolveInternalDomainTestQuery(ctx context.Context, domain string, m *dns.Msg) *dns.Msg {
|
||||
ctrld.Log(ctx, mainLog.Load().Debug(), "internal domain test query")
|
||||
|
||||
q := m.Question[0]
|
||||
answer := new(dns.Msg)
|
||||
rrStr := fmt.Sprintf("%s A %s", domain, net.IPv4zero)
|
||||
if q.Qtype == dns.TypeAAAA {
|
||||
rrStr = fmt.Sprintf("%s AAAA %s", domain, net.IPv6zero)
|
||||
}
|
||||
rr, err := dns.NewRR(rrStr)
|
||||
if err == nil {
|
||||
answer.Answer = append(answer.Answer, rr)
|
||||
}
|
||||
answer.SetReply(m)
|
||||
return answer
|
||||
}
|
||||
|
||||
@@ -22,14 +22,21 @@ func Test_wildcardMatches(t *testing.T) {
|
||||
domain string
|
||||
match bool
|
||||
}{
|
||||
{"prefix parent should not match", "*.windscribe.com", "windscribe.com", false},
|
||||
{"prefix", "*.windscribe.com", "anything.windscribe.com", true},
|
||||
{"prefix not match other domain", "*.windscribe.com", "example.com", false},
|
||||
{"prefix not match domain in name", "*.windscribe.com", "wwindscribe.com", false},
|
||||
{"suffix", "suffix.*", "suffix.windscribe.com", true},
|
||||
{"suffix not match other", "suffix.*", "suffix1.windscribe.com", false},
|
||||
{"both", "suffix.*.windscribe.com", "suffix.anything.windscribe.com", true},
|
||||
{"both not match", "suffix.*.windscribe.com", "suffix1.suffix.windscribe.com", false},
|
||||
{"domain - prefix parent should not match", "*.windscribe.com", "windscribe.com", false},
|
||||
{"domain - prefix", "*.windscribe.com", "anything.windscribe.com", true},
|
||||
{"domain - prefix not match other s", "*.windscribe.com", "example.com", false},
|
||||
{"domain - prefix not match s in name", "*.windscribe.com", "wwindscribe.com", false},
|
||||
{"domain - suffix", "suffix.*", "suffix.windscribe.com", true},
|
||||
{"domain - suffix not match other", "suffix.*", "suffix1.windscribe.com", false},
|
||||
{"domain - both", "suffix.*.windscribe.com", "suffix.anything.windscribe.com", true},
|
||||
{"domain - both not match", "suffix.*.windscribe.com", "suffix1.suffix.windscribe.com", false},
|
||||
{"mac - prefix", "*:98:05:b4:2b", "d4:67:98:05:b4:2b", true},
|
||||
{"mac - prefix not match other s", "*:98:05:b4:2b", "0d:ba:54:09:94:2c", false},
|
||||
{"mac - prefix not match s in name", "*:98:05:b4:2b", "e4:67:97:05:b4:2b", false},
|
||||
{"mac - suffix", "d4:67:98:*", "d4:67:98:05:b4:2b", true},
|
||||
{"mac - suffix not match other", "d4:67:98:*", "d4:67:97:15:b4:2b", false},
|
||||
{"mac - both", "d4:67:98:*:b4:2b", "d4:67:98:05:b4:2b", true},
|
||||
{"mac - both not match", "d4:67:98:*:b4:2b", "d4:67:97:05:c4:2b", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -187,8 +194,8 @@ func TestCache(t *testing.T) {
|
||||
got1 := prog.proxy(context.Background(), req1)
|
||||
got2 := prog.proxy(context.Background(), req2)
|
||||
assert.NotSame(t, got1, got2)
|
||||
assert.Equal(t, answer1.Rcode, got1.Rcode)
|
||||
assert.Equal(t, answer2.Rcode, got2.Rcode)
|
||||
assert.Equal(t, answer1.Rcode, got1.answer.Rcode)
|
||||
assert.Equal(t, answer2.Rcode, got2.answer.Rcode)
|
||||
}
|
||||
|
||||
func Test_ipAndMacFromMsg(t *testing.T) {
|
||||
|
||||
@@ -11,8 +11,9 @@ type AppCallback struct {
|
||||
|
||||
// AppConfig allows overwriting ctrld cli flags from mobile platforms.
|
||||
type AppConfig struct {
|
||||
CdUID string
|
||||
HomeDir string
|
||||
Verbose int
|
||||
LogPath string
|
||||
CdUID string
|
||||
HomeDir string
|
||||
UpstreamProto string
|
||||
Verbose int
|
||||
LogPath string
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ func (p *prog) checkDnsLoop() {
|
||||
for uid := range p.loop {
|
||||
msg := loopTestMsg(uid)
|
||||
uc := upstream[uid]
|
||||
// Skipping upstream which is being marked as down.
|
||||
if uc == nil {
|
||||
continue
|
||||
}
|
||||
resolver, err := ctrld.NewResolver(uc)
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("could not perform loop check for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
|
||||
|
||||
@@ -34,6 +34,10 @@ var (
|
||||
ifaceStartStop string
|
||||
nextdns string
|
||||
cdUpstreamProto string
|
||||
deactivationPin int64
|
||||
skipSelfChecks bool
|
||||
cleanup bool
|
||||
startOnly bool
|
||||
|
||||
mainLog atomic.Pointer[zerolog.Logger]
|
||||
consoleWriter zerolog.ConsoleWriter
|
||||
@@ -61,8 +65,11 @@ func Main() {
|
||||
}
|
||||
|
||||
func normalizeLogFilePath(logFilePath string) string {
|
||||
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
|
||||
return logFilePath
|
||||
// In cleanup mode, we always want the full log file path.
|
||||
if !cleanup {
|
||||
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
|
||||
return logFilePath
|
||||
}
|
||||
}
|
||||
if homedir != "" {
|
||||
return filepath.Join(homedir, logFilePath)
|
||||
@@ -119,14 +126,14 @@ func initLoggingWithBackup(doBackup bool) {
|
||||
flags := os.O_CREATE | os.O_RDWR | os.O_APPEND
|
||||
if doBackup {
|
||||
// Backup old log file with .1 suffix.
|
||||
if err := os.Rename(logFilePath, logFilePath+".1"); err != nil && !os.IsNotExist(err) {
|
||||
if err := os.Rename(logFilePath, logFilePath+oldLogSuffix); err != nil && !os.IsNotExist(err) {
|
||||
mainLog.Load().Error().Msgf("could not backup old log file: %v", err)
|
||||
} else {
|
||||
// Backup was created, set flags for truncating old log file.
|
||||
flags = os.O_CREATE | os.O_RDWR
|
||||
}
|
||||
}
|
||||
logFile, err := os.OpenFile(logFilePath, flags, os.FileMode(0o600))
|
||||
logFile, err := openLogFile(logFilePath, flags)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Msgf("failed to create log file: %v", err)
|
||||
os.Exit(1)
|
||||
|
||||
150
cmd/cli/metrics.go
Normal file
150
cmd/cli/metrics.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/prometheus/prom2json"
|
||||
)
|
||||
|
||||
// metricsServer represents a server to expose Prometheus metrics via HTTP.
|
||||
type metricsServer struct {
|
||||
server *http.Server
|
||||
mux *http.ServeMux
|
||||
reg *prometheus.Registry
|
||||
addr string
|
||||
started bool
|
||||
}
|
||||
|
||||
// newMetricsServer returns new metrics server.
|
||||
func newMetricsServer(addr string, reg *prometheus.Registry) (*metricsServer, error) {
|
||||
mux := http.NewServeMux()
|
||||
ms := &metricsServer{
|
||||
server: &http.Server{Handler: mux},
|
||||
mux: mux,
|
||||
reg: reg,
|
||||
}
|
||||
ms.addr = addr
|
||||
ms.registerMetricsServerHandler()
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// register adds handlers for given pattern.
|
||||
func (ms *metricsServer) register(pattern string, handler http.Handler) {
|
||||
ms.mux.Handle(pattern, handler)
|
||||
}
|
||||
|
||||
// registerMetricsServerHandler adds handlers for metrics server.
|
||||
func (ms *metricsServer) registerMetricsServerHandler() {
|
||||
ms.register("/metrics", promhttp.HandlerFor(
|
||||
ms.reg,
|
||||
promhttp.HandlerOpts{
|
||||
EnableOpenMetrics: true,
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
))
|
||||
ms.register("/metrics/json", jsonResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
g := prometheus.ToTransactionalGatherer(ms.reg)
|
||||
mfs, done, err := g.Gather()
|
||||
defer done()
|
||||
if err != nil {
|
||||
msg := "could not gather metrics"
|
||||
mainLog.Load().Warn().Err(err).Msg(msg)
|
||||
http.Error(w, msg, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
result := make([]*prom2json.Family, 0, len(mfs))
|
||||
for _, mf := range mfs {
|
||||
result = append(result, prom2json.NewFamily(mf))
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
msg := "could not marshal metrics result"
|
||||
mainLog.Load().Warn().Err(err).Msg(msg)
|
||||
http.Error(w, msg, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
// start runs the metricsServer.
|
||||
func (ms *metricsServer) start() error {
|
||||
listener, err := net.Listen("tcp", ms.addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go ms.server.Serve(listener)
|
||||
ms.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// stop shutdowns the metricsServer within 2 seconds timeout.
|
||||
func (ms *metricsServer) stop() error {
|
||||
if !ms.started {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*1)
|
||||
defer cancel()
|
||||
return ms.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// runMetricsServer initializes metrics stats and runs the metrics server if enabled.
|
||||
func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
||||
if !p.metricsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
// Reset all stats.
|
||||
statsVersion.Reset()
|
||||
statsQueriesCount.Reset()
|
||||
statsClientQueriesCount.Reset()
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
// Register queries count stats if enabled.
|
||||
if p.metricsQueryStats.Load() {
|
||||
reg.MustRegister(statsQueriesCount)
|
||||
reg.MustRegister(statsClientQueriesCount)
|
||||
}
|
||||
|
||||
addr := p.cfg.Service.MetricsListener
|
||||
ms, err := newMetricsServer(addr, reg)
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not create new metrics server")
|
||||
return
|
||||
}
|
||||
// Only start listener address if defined.
|
||||
if addr != "" {
|
||||
// Go runtime stats.
|
||||
reg.MustRegister(collectors.NewBuildInfoCollector())
|
||||
reg.MustRegister(collectors.NewGoCollector(
|
||||
collectors.WithGoCollectorRuntimeMetrics(collectors.MetricsAll),
|
||||
))
|
||||
// ctrld stats.
|
||||
reg.MustRegister(statsVersion)
|
||||
statsVersion.WithLabelValues(commit, runtime.Version(), curVersion()).Inc()
|
||||
reg.MustRegister(statsTimeStart)
|
||||
statsTimeStart.Set(float64(time.Now().Unix()))
|
||||
mainLog.Load().Debug().Msgf("starting metrics server on: %s", addr)
|
||||
if err := ms.start(); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not start metrics server")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
case <-ctx.Done():
|
||||
case <-reloadCh:
|
||||
}
|
||||
|
||||
if err := ms.stop(); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not stop metrics server")
|
||||
return
|
||||
}
|
||||
}
|
||||
34
cmd/cli/net.go
Normal file
34
cmd/cli/net.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package cli
|
||||
|
||||
import "strings"
|
||||
|
||||
// Copied from https://gist.github.com/Ultraporing/fe52981f678be6831f747c206a4861cb
|
||||
|
||||
// Mac Address parts to look for, and identify non-physical devices. There may be more, update me!
|
||||
var macAddrPartsToFilter = []string{
|
||||
"00:03:FF", // Microsoft Hyper-V, Virtual Server, Virtual PC
|
||||
"0A:00:27", // VirtualBox
|
||||
"00:00:00:00:00", // Teredo Tunneling Pseudo-Interface
|
||||
"00:50:56", // VMware ESX 3, Server, Workstation, Player
|
||||
"00:1C:14", // VMware ESX 3, Server, Workstation, Player
|
||||
"00:0C:29", // VMware ESX 3, Server, Workstation, Player
|
||||
"00:05:69", // VMware ESX 3, Server, Workstation, Player
|
||||
"00:1C:42", // Microsoft Hyper-V, Virtual Server, Virtual PC
|
||||
"00:0F:4B", // Virtual Iron 4
|
||||
"00:16:3E", // Red Hat Xen, Oracle VM, XenSource, Novell Xen
|
||||
"08:00:27", // Sun xVM VirtualBox
|
||||
"7A:79", // Hamachi
|
||||
}
|
||||
|
||||
// Filters the possible physical interface address by comparing it to known popular VM Software addresses
|
||||
// and Teredo Tunneling Pseudo-Interface.
|
||||
//
|
||||
//lint:ignore U1000 use in net_windows.go
|
||||
func isPhysicalInterface(addr string) bool {
|
||||
for _, macPart := range macAddrPartsToFilter {
|
||||
if strings.HasPrefix(strings.ToLower(addr), strings.ToLower(macPart)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -42,3 +42,33 @@ func networkServiceName(ifaceName string, r io.Reader) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validInterface reports whether the *net.Interface is a valid one.
|
||||
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
|
||||
_, ok := validIfacesMap[iface.Name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func validInterfacesMap() map[string]struct{} {
|
||||
b, err := exec.Command("networksetup", "-listallhardwareports").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseListAllHardwarePorts(bytes.NewReader(b))
|
||||
}
|
||||
|
||||
// parseListAllHardwarePorts parses output of "networksetup -listallhardwareports"
|
||||
// and returns map presents all hardware ports.
|
||||
func parseListAllHardwarePorts(r io.Reader) map[string]struct{} {
|
||||
m := make(map[string]struct{})
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
after, ok := strings.CutPrefix(line, "Device: ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m[after] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -57,3 +58,47 @@ func Test_networkServiceName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const listallhardwareportsOutput = `
|
||||
Hardware Port: Ethernet Adapter (en6)
|
||||
Device: en6
|
||||
Ethernet Address: 3a:3e:fc:1e:ab:41
|
||||
|
||||
Hardware Port: Ethernet Adapter (en7)
|
||||
Device: en7
|
||||
Ethernet Address: 3a:3e:fc:1e:ab:42
|
||||
|
||||
Hardware Port: Thunderbolt Bridge
|
||||
Device: bridge0
|
||||
Ethernet Address: 36:21:bb:3a:7a:40
|
||||
|
||||
Hardware Port: Wi-Fi
|
||||
Device: en0
|
||||
Ethernet Address: a0:78:17:68:56:3f
|
||||
|
||||
Hardware Port: Thunderbolt 1
|
||||
Device: en1
|
||||
Ethernet Address: 36:21:bb:3a:7a:40
|
||||
|
||||
Hardware Port: Thunderbolt 2
|
||||
Device: en2
|
||||
Ethernet Address: 36:21:bb:3a:7a:44
|
||||
|
||||
VLAN Configurations
|
||||
===================
|
||||
`
|
||||
|
||||
func Test_parseListAllHardwarePorts(t *testing.T) {
|
||||
expected := map[string]struct{}{
|
||||
"en0": {},
|
||||
"en1": {},
|
||||
"en2": {},
|
||||
"en6": {},
|
||||
"en7": {},
|
||||
"bridge0": {},
|
||||
}
|
||||
m := parseListAllHardwarePorts(strings.NewReader(listallhardwareportsOutput))
|
||||
if !maps.Equal(m, expected) {
|
||||
t.Errorf("unexpected output, want: %v, got: %v", expected, m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package cli
|
||||
|
||||
import "net"
|
||||
|
||||
func patchNetIfaceName(iface *net.Interface) error { return nil }
|
||||
|
||||
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool { return true }
|
||||
|
||||
func validInterfacesMap() map[string]struct{} { return nil }
|
||||
|
||||
23
cmd/cli/net_windows.go
Normal file
23
cmd/cli/net_windows.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
func patchNetIfaceName(iface *net.Interface) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validInterface reports whether the *net.Interface is a valid one.
|
||||
// On Windows, only physical interfaces are considered valid.
|
||||
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
|
||||
if iface == nil {
|
||||
return false
|
||||
}
|
||||
if isPhysicalInterface(iface.HardwareAddr.String()) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validInterfacesMap() map[string]struct{} { return nil }
|
||||
@@ -1,8 +1,12 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||
)
|
||||
@@ -27,6 +31,18 @@ func deAllocateIP(ip string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
||||
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
||||
if err := setDNS(iface, nameservers); err != nil {
|
||||
// TODO: investiate whether we can detect this without relying on error message.
|
||||
if strings.Contains(err.Error(), " is not a recognized network service") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// set the dns server for the provided network interface
|
||||
// networksetup -setdnsservers Wi-Fi 8.8.8.8 1.1.1.1
|
||||
// TODO(cuonglm): use system API
|
||||
@@ -34,9 +50,19 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
cmd := "networksetup"
|
||||
args := []string{"-setdnsservers", iface.Name}
|
||||
args = append(args, nameservers...)
|
||||
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%v: %w", string(out), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := exec.Command(cmd, args...).Run(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msgf("setDNS failed, ips = %q", nameservers)
|
||||
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
|
||||
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||
if err := resetDNS(iface); err != nil {
|
||||
// TODO: investiate whether we can detect this without relying on error message.
|
||||
if strings.Contains(err.Error(), " is not a recognized network service") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -44,12 +70,15 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
|
||||
// TODO(cuonglm): use system API
|
||||
func resetDNS(iface *net.Interface) error {
|
||||
if ns := savedStaticNameservers(iface); len(ns) > 0 {
|
||||
if err := setDNS(iface, ns); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmd := "networksetup"
|
||||
args := []string{"-setdnsservers", iface.Name, "empty"}
|
||||
|
||||
if err := exec.Command(cmd, args...).Run(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msgf("resetDNS failed")
|
||||
return err
|
||||
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%v: %w", string(out), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -57,3 +86,22 @@ func resetDNS(iface *net.Interface) error {
|
||||
func currentDNS(_ *net.Interface) []string {
|
||||
return resolvconffile.NameServers("")
|
||||
}
|
||||
|
||||
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||
func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||
cmd := "networksetup"
|
||||
args := []string{"-getdnsservers", iface.Name}
|
||||
out, err := exec.Command(cmd, args...).Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
var ns []string
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if ip := net.ParseIP(line); ip != nil {
|
||||
ns = append(ns, ip.String())
|
||||
}
|
||||
}
|
||||
return ns, nil
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ func deAllocateIP(ip string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
||||
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
||||
return setDNS(iface, nameservers)
|
||||
}
|
||||
|
||||
// set the dns server for the provided network interface
|
||||
func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
r, err := dns.NewOSConfigurator(logf, iface.Name)
|
||||
@@ -49,6 +54,11 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
|
||||
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||
return resetDNS(iface)
|
||||
}
|
||||
|
||||
func resetDNS(iface *net.Interface) error {
|
||||
r, err := dns.NewOSConfigurator(logf, iface.Name)
|
||||
if err != nil {
|
||||
@@ -66,3 +76,8 @@ func resetDNS(iface *net.Interface) error {
|
||||
func currentDNS(_ *net.Interface) []string {
|
||||
return resolvconffile.NameServers("")
|
||||
}
|
||||
|
||||
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||
func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||
return currentDNS(iface), nil
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv6"
|
||||
"github.com/insomniacslk/dhcp/dhcpv6/client6"
|
||||
@@ -25,10 +24,7 @@ import (
|
||||
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||
)
|
||||
|
||||
const (
|
||||
resolvConfPath = "/etc/resolv.conf"
|
||||
resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
|
||||
)
|
||||
const resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
|
||||
|
||||
// allocate loopback ip
|
||||
// sudo ip a add 127.0.0.2/24 dev lo
|
||||
@@ -52,7 +48,11 @@ func deAllocateIP(ip string) error {
|
||||
|
||||
const maxSetDNSAttempts = 5
|
||||
|
||||
// set the dns server for the provided network interface
|
||||
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
||||
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
||||
return setDNS(iface, nameservers)
|
||||
}
|
||||
|
||||
func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
r, err := dns.NewOSConfigurator(logf, iface.Name)
|
||||
if err != nil {
|
||||
@@ -69,12 +69,6 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
Nameservers: ns,
|
||||
SearchDomains: []dnsname.FQDN{},
|
||||
}
|
||||
defer func() {
|
||||
if r.Mode() == "direct" {
|
||||
go watchResolveConf(osConfig)
|
||||
}
|
||||
}()
|
||||
|
||||
trySystemdResolve := false
|
||||
for i := 0; i < maxSetDNSAttempts; i++ {
|
||||
if err := r.SetDNS(osConfig); err != nil {
|
||||
@@ -128,6 +122,11 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
|
||||
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||
return resetDNS(iface)
|
||||
}
|
||||
|
||||
func resetDNS(iface *net.Interface) (err error) {
|
||||
defer func() {
|
||||
if err == nil {
|
||||
@@ -203,6 +202,11 @@ func currentDNS(iface *net.Interface) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||
func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||
return currentDNS(iface), nil
|
||||
}
|
||||
|
||||
func getDNSByResolvectl(iface string) []string {
|
||||
b, err := exec.Command("resolvectl", "dns", "-i", iface).Output()
|
||||
if err != nil {
|
||||
@@ -284,8 +288,7 @@ func ignoringEINTR(fn func() error) error {
|
||||
func isSubSet(s1, s2 []string) bool {
|
||||
ok := true
|
||||
for _, ns := range s1 {
|
||||
// TODO(cuonglm): use slices.Contains once upgrading to go1.21
|
||||
if sliceContains(s2, ns) {
|
||||
if slices.Contains(s2, ns) {
|
||||
continue
|
||||
}
|
||||
ok = false
|
||||
@@ -293,75 +296,3 @@ func isSubSet(s1, s2 []string) bool {
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// sliceContains reports whether v is present in s.
|
||||
func sliceContains[S ~[]E, E comparable](s S, v E) bool {
|
||||
return sliceIndex(s, v) >= 0
|
||||
}
|
||||
|
||||
// sliceIndex returns the index of the first occurrence of v in s,
|
||||
// or -1 if not present.
|
||||
func sliceIndex[S ~[]E, E comparable](s S, v E) int {
|
||||
for i := range s {
|
||||
if v == s[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// watchResolveConf watches any changes to /etc/resolv.conf file,
|
||||
// and reverting to the original config set by ctrld.
|
||||
func watchResolveConf(oc dns.OSConfig) {
|
||||
mainLog.Load().Debug().Msg("start watching /etc/resolv.conf file")
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not create watcher for /etc/resolv.conf")
|
||||
return
|
||||
}
|
||||
|
||||
// We watch /etc instead of /etc/resolv.conf directly,
|
||||
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
|
||||
watchDir := filepath.Dir(resolvConfPath)
|
||||
if err := watcher.Add(watchDir); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not add /etc/resolv.conf to watcher list")
|
||||
return
|
||||
}
|
||||
|
||||
r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo") // interface name does not matter.
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Name != resolvConfPath { // skip if not /etc/resolv.conf changes.
|
||||
continue
|
||||
}
|
||||
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
|
||||
mainLog.Load().Debug().Msg("/etc/resolv.conf changes detected, reverting to ctrld setting")
|
||||
if err := watcher.Remove(watchDir); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
|
||||
continue
|
||||
}
|
||||
if err := r.SetDNS(oc); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
|
||||
}
|
||||
if err := watcher.Add(watchDir); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
|
||||
return
|
||||
}
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,80 +2,131 @@ package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||
|
||||
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
||||
)
|
||||
|
||||
const (
|
||||
v4InterfaceKeyPathFormat = `HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\`
|
||||
v6InterfaceKeyPathFormat = `HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces\`
|
||||
)
|
||||
|
||||
var (
|
||||
setDNSOnce sync.Once
|
||||
resetDNSOnce sync.Once
|
||||
)
|
||||
|
||||
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
||||
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
||||
return setDNS(iface, nameservers)
|
||||
}
|
||||
|
||||
func setDnsPowershellCmd(iface *net.Interface, nameservers []string) string {
|
||||
nss := make([]string, 0, len(nameservers))
|
||||
for _, ns := range nameservers {
|
||||
nss = append(nss, strconv.Quote(ns))
|
||||
}
|
||||
return fmt.Sprintf("Set-DnsClientServerAddress -InterfaceIndex %d -ServerAddresses (%s)", iface.Index, strings.Join(nss, ","))
|
||||
}
|
||||
|
||||
// setDNS sets the dns server for the provided network interface
|
||||
func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
if len(nameservers) == 0 {
|
||||
return errors.New("empty DNS nameservers")
|
||||
}
|
||||
primaryDNS := nameservers[0]
|
||||
if err := setPrimaryDNS(iface, primaryDNS); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(nameservers) > 1 {
|
||||
secondaryDNS := nameservers[1]
|
||||
_ = addSecondaryDNS(iface, secondaryDNS)
|
||||
setDNSOnce.Do(func() {
|
||||
// If there's a Dns server running, that means we are on AD with Dns feature enabled.
|
||||
// Configuring the Dns server to forward queries to ctrld instead.
|
||||
if windowsHasLocalDnsServerRunning() {
|
||||
file := absHomeDir(windowsForwardersFilename)
|
||||
oldForwardersContent, _ := os.ReadFile(file)
|
||||
hasLocalIPv6Listener := needLocalIPv6Listener()
|
||||
forwarders := slices.DeleteFunc(slices.Clone(nameservers), func(s string) bool {
|
||||
if !hasLocalIPv6Listener {
|
||||
return false
|
||||
}
|
||||
return s == "::1"
|
||||
})
|
||||
if err := os.WriteFile(file, []byte(strings.Join(forwarders, ",")), 0600); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not save forwarders settings")
|
||||
}
|
||||
oldForwarders := strings.Split(string(oldForwardersContent), ",")
|
||||
if err := addDnsServerForwarders(forwarders, oldForwarders); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not set forwarders settings")
|
||||
}
|
||||
}
|
||||
})
|
||||
out, err := powershell(setDnsPowershellCmd(iface, nameservers))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
|
||||
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||
return resetDNS(iface)
|
||||
}
|
||||
|
||||
// TODO(cuonglm): should we use system API?
|
||||
func resetDNS(iface *net.Interface) error {
|
||||
if ctrldnet.SupportsIPv6ListenLocal() {
|
||||
if output, err := netsh("interface", "ipv6", "set", "dnsserver", strconv.Itoa(iface.Index), "dhcp"); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("failed to reset ipv6 DNS: %s", string(output))
|
||||
resetDNSOnce.Do(func() {
|
||||
// See corresponding comment in setDNS.
|
||||
if windowsHasLocalDnsServerRunning() {
|
||||
file := absHomeDir(windowsForwardersFilename)
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("could not read forwarders settings")
|
||||
return
|
||||
}
|
||||
nameservers := strings.Split(string(content), ",")
|
||||
if err := removeDnsServerForwarders(nameservers); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("could not remove forwarders settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Restoring DHCP settings.
|
||||
cmd := fmt.Sprintf("Set-DnsClientServerAddress -InterfaceIndex %d -ResetServerAddresses", iface.Index)
|
||||
out, err := powershell(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, string(out))
|
||||
}
|
||||
|
||||
// If there's static DNS saved, restoring it.
|
||||
if nss := savedStaticNameservers(iface); len(nss) > 0 {
|
||||
v4ns := make([]string, 0, 2)
|
||||
v6ns := make([]string, 0, 2)
|
||||
for _, ns := range nss {
|
||||
if ctrldnet.IsIPv6(ns) {
|
||||
v6ns = append(v6ns, ns)
|
||||
} else {
|
||||
v4ns = append(v4ns, ns)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ns := range [][]string{v4ns, v6ns} {
|
||||
if len(ns) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := setDNS(iface, ns); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
output, err := netsh("interface", "ipv4", "set", "dnsserver", strconv.Itoa(iface.Index), "dhcp")
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msgf("failed to reset ipv4 DNS: %s", string(output))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setPrimaryDNS(iface *net.Interface, dns string) error {
|
||||
ipVer := "ipv4"
|
||||
if ctrldnet.IsIPv6(dns) {
|
||||
ipVer = "ipv6"
|
||||
}
|
||||
idx := strconv.Itoa(iface.Index)
|
||||
output, err := netsh("interface", ipVer, "set", "dnsserver", idx, "static", dns)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msgf("failed to set primary DNS: %s", string(output))
|
||||
return err
|
||||
}
|
||||
if ipVer == "ipv4" && ctrldnet.SupportsIPv6ListenLocal() {
|
||||
// Disable IPv6 DNS, so the query will be fallback to IPv4.
|
||||
_, _ = netsh("interface", "ipv6", "set", "dnsserver", idx, "static", "::1", "primary")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addSecondaryDNS(iface *net.Interface, dns string) error {
|
||||
ipVer := "ipv4"
|
||||
if ctrldnet.IsIPv6(dns) {
|
||||
ipVer = "ipv6"
|
||||
}
|
||||
output, err := netsh("interface", ipVer, "add", "dns", strconv.Itoa(iface.Index), dns, "index=2")
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("failed to add secondary DNS: %s", string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func netsh(args ...string) ([]byte, error) {
|
||||
return exec.Command("netsh", args...).Output()
|
||||
}
|
||||
|
||||
func currentDNS(iface *net.Interface) []string {
|
||||
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||
if err != nil {
|
||||
@@ -93,3 +144,74 @@ func currentDNS(iface *net.Interface) []string {
|
||||
}
|
||||
return ns
|
||||
}
|
||||
|
||||
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||
func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guid, err := luid.GUID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ns []string
|
||||
for _, path := range []string{v4InterfaceKeyPathFormat, v6InterfaceKeyPathFormat} {
|
||||
interfaceKeyPath := path + guid.String()
|
||||
found := false
|
||||
for _, key := range []string{"NameServer", "ProfileNameServer"} {
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
cmd := fmt.Sprintf(`Get-ItemPropertyValue -Path "%s" -Name "%s"`, interfaceKeyPath, key)
|
||||
out, err := powershell(cmd)
|
||||
if err == nil && len(out) > 0 {
|
||||
found = true
|
||||
for _, e := range strings.Split(string(out), ",") {
|
||||
ns = append(ns, strings.TrimRight(e, "\x00"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ns, nil
|
||||
}
|
||||
|
||||
// addDnsServerForwarders adds given nameservers to DNS server forwarders list,
|
||||
// and also removing old forwarders if provided.
|
||||
func addDnsServerForwarders(nameservers, old []string) error {
|
||||
newForwardersMap := make(map[string]struct{})
|
||||
newForwarders := make([]string, len(nameservers))
|
||||
for i := range nameservers {
|
||||
newForwardersMap[nameservers[i]] = struct{}{}
|
||||
newForwarders[i] = fmt.Sprintf("%q", nameservers[i])
|
||||
}
|
||||
oldForwarders := old[:0]
|
||||
for _, fwd := range old {
|
||||
if _, ok := newForwardersMap[fwd]; !ok {
|
||||
oldForwarders = append(oldForwarders, fwd)
|
||||
}
|
||||
}
|
||||
// NOTE: It is important to add new forwarder before removing old one.
|
||||
// Testing on Windows Server 2022 shows that removing forwarder1
|
||||
// then adding forwarder2 sometimes ends up adding both of them
|
||||
// to the forwarders list.
|
||||
cmd := fmt.Sprintf("Add-DnsServerForwarder -IPAddress %s", strings.Join(newForwarders, ","))
|
||||
if len(oldForwarders) > 0 {
|
||||
cmd = fmt.Sprintf("%s ; Remove-DnsServerForwarder -IPAddress %s -Force", cmd, strings.Join(oldForwarders, ","))
|
||||
}
|
||||
if out, err := powershell(cmd); err != nil {
|
||||
return fmt.Errorf("%w: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeDnsServerForwarders removes given nameservers from DNS server forwarders list.
|
||||
func removeDnsServerForwarders(nameservers []string) error {
|
||||
for _, ns := range nameservers {
|
||||
cmd := fmt.Sprintf("Remove-DnsServerForwarder -IPAddress %s -Force", ns)
|
||||
if out, err := powershell(cmd); err != nil {
|
||||
return fmt.Errorf("%w: %s", err, string(out))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
477
cmd/cli/prog.go
477
cmd/cli/prog.go
@@ -5,24 +5,31 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/spf13/viper"
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/net/tsaddr"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/clientinfo"
|
||||
"github.com/Control-D-Inc/ctrld/internal/controld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/dnscache"
|
||||
"github.com/Control-D-Inc/ctrld/internal/router"
|
||||
)
|
||||
@@ -31,11 +38,23 @@ const (
|
||||
defaultSemaphoreCap = 256
|
||||
ctrldLogUnixSock = "ctrld_start.sock"
|
||||
ctrldControlUnixSock = "ctrld_control.sock"
|
||||
upstreamPrefix = "upstream."
|
||||
upstreamOS = upstreamPrefix + "os"
|
||||
upstreamPrivate = upstreamPrefix + "private"
|
||||
// iOS unix socket name max length is 11.
|
||||
ctrldControlUnixSockMobile = "cd.sock"
|
||||
upstreamPrefix = "upstream."
|
||||
upstreamOS = upstreamPrefix + "os"
|
||||
upstreamPrivate = upstreamPrefix + "private"
|
||||
dnsWatchdogDefaultInterval = 20 * time.Second
|
||||
)
|
||||
|
||||
// ControlSocketName returns name for control unix socket.
|
||||
func ControlSocketName() string {
|
||||
if isMobile() {
|
||||
return ctrldControlUnixSockMobile
|
||||
} else {
|
||||
return ctrldControlUnixSock
|
||||
}
|
||||
}
|
||||
|
||||
var logf = func(format string, args ...any) {
|
||||
mainLog.Load().Debug().Msgf(format, args...)
|
||||
}
|
||||
@@ -49,25 +68,38 @@ var svcConfig = &service.Config{
|
||||
var useSystemdResolved = false
|
||||
|
||||
type prog struct {
|
||||
mu sync.Mutex
|
||||
waitCh chan struct{}
|
||||
stopCh chan struct{}
|
||||
reloadCh chan struct{} // For Windows.
|
||||
reloadDoneCh chan struct{}
|
||||
logConn net.Conn
|
||||
cs *controlServer
|
||||
mu sync.Mutex
|
||||
waitCh chan struct{}
|
||||
stopCh chan struct{}
|
||||
reloadCh chan struct{} // For Windows.
|
||||
reloadDoneCh chan struct{}
|
||||
apiReloadCh chan *ctrld.Config
|
||||
logConn net.Conn
|
||||
cs *controlServer
|
||||
csSetDnsDone chan struct{}
|
||||
csSetDnsOk bool
|
||||
dnsWatchDogOnce sync.Once
|
||||
dnsWg sync.WaitGroup
|
||||
dnsWatcherStopCh chan struct{}
|
||||
|
||||
cfg *ctrld.Config
|
||||
localUpstreams []string
|
||||
ptrNameservers []string
|
||||
appCallback *AppCallback
|
||||
cache dnscache.Cacher
|
||||
sema semaphore
|
||||
ciTable *clientinfo.Table
|
||||
um *upstreamMonitor
|
||||
router router.Router
|
||||
ptrLoopGuard *loopGuard
|
||||
lanLoopGuard *loopGuard
|
||||
cfg *ctrld.Config
|
||||
localUpstreams []string
|
||||
ptrNameservers []string
|
||||
appCallback *AppCallback
|
||||
cache dnscache.Cacher
|
||||
cacheFlushDomainsMap map[string]struct{}
|
||||
sema semaphore
|
||||
ciTable *clientinfo.Table
|
||||
um *upstreamMonitor
|
||||
router router.Router
|
||||
ptrLoopGuard *loopGuard
|
||||
lanLoopGuard *loopGuard
|
||||
metricsQueryStats atomic.Bool
|
||||
|
||||
selfUninstallMu sync.Mutex
|
||||
refusedQueryCount int
|
||||
canSelfUninstall atomic.Bool
|
||||
checkingSelfUninstall bool
|
||||
|
||||
loopMu sync.Mutex
|
||||
loop map[string]bool
|
||||
@@ -101,11 +133,15 @@ func (p *prog) runWait() {
|
||||
p.run(reload, reloadCh)
|
||||
reload = true
|
||||
}()
|
||||
|
||||
var newCfg *ctrld.Config
|
||||
select {
|
||||
case sig := <-reloadSigCh:
|
||||
logger.Notice().Msgf("got signal: %s, reloading...", sig.String())
|
||||
case <-p.reloadCh:
|
||||
logger.Notice().Msg("reloading...")
|
||||
case apiCfg := <-p.apiReloadCh:
|
||||
newCfg = apiCfg
|
||||
case <-p.stopCh:
|
||||
close(reloadCh)
|
||||
return
|
||||
@@ -115,28 +151,31 @@ func (p *prog) runWait() {
|
||||
close(reloadCh)
|
||||
<-done
|
||||
}
|
||||
newCfg := &ctrld.Config{}
|
||||
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
|
||||
ctrld.InitConfig(v, "ctrld")
|
||||
if configPath != "" {
|
||||
v.SetConfigFile(configPath)
|
||||
}
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
logger.Err(err).Msg("could not read new config")
|
||||
waitOldRunDone()
|
||||
continue
|
||||
}
|
||||
if err := v.Unmarshal(&newCfg); err != nil {
|
||||
logger.Err(err).Msg("could not unmarshal new config")
|
||||
waitOldRunDone()
|
||||
continue
|
||||
}
|
||||
if cdUID != "" {
|
||||
if err := processCDFlags(newCfg); err != nil {
|
||||
logger.Err(err).Msg("could not fetch ControlD config")
|
||||
|
||||
if newCfg == nil {
|
||||
newCfg = &ctrld.Config{}
|
||||
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
|
||||
ctrld.InitConfig(v, "ctrld")
|
||||
if configPath != "" {
|
||||
v.SetConfigFile(configPath)
|
||||
}
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
logger.Err(err).Msg("could not read new config")
|
||||
waitOldRunDone()
|
||||
continue
|
||||
}
|
||||
if err := v.Unmarshal(&newCfg); err != nil {
|
||||
logger.Err(err).Msg("could not unmarshal new config")
|
||||
waitOldRunDone()
|
||||
continue
|
||||
}
|
||||
if cdUID != "" {
|
||||
if err := processCDFlags(newCfg); err != nil {
|
||||
logger.Err(err).Msg("could not fetch ControlD config")
|
||||
waitOldRunDone()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waitOldRunDone()
|
||||
@@ -162,6 +201,10 @@ func (p *prog) runWait() {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := writeConfigFile(newCfg); err != nil {
|
||||
logger.Err(err).Msg("could not write new config")
|
||||
}
|
||||
|
||||
// This needs to be done here, otherwise, the DNS handler may observe an invalid
|
||||
// upstream config because its initialization function have not been called yet.
|
||||
mainLog.Load().Debug().Msg("setup upstream with new config")
|
||||
@@ -172,6 +215,7 @@ func (p *prog) runWait() {
|
||||
p.mu.Unlock()
|
||||
|
||||
logger.Notice().Msg("reloading config successfully")
|
||||
|
||||
select {
|
||||
case p.reloadDoneCh <- struct{}{}:
|
||||
default:
|
||||
@@ -180,9 +224,6 @@ func (p *prog) runWait() {
|
||||
}
|
||||
|
||||
func (p *prog) preRun() {
|
||||
if !service.Interactive() {
|
||||
p.setDNS()
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
p.onStopped = append(p.onStopped, func() {
|
||||
if !service.Interactive() {
|
||||
@@ -192,12 +233,76 @@ func (p *prog) preRun() {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *prog) postRun() {
|
||||
if !service.Interactive() {
|
||||
p.resetDNS()
|
||||
ns := ctrld.InitializeOsResolver()
|
||||
mainLog.Load().Debug().Msgf("initialized OS resolver with nameservers: %v", ns)
|
||||
p.setDNS()
|
||||
}
|
||||
}
|
||||
|
||||
// apiConfigReload calls API to check for latest config update then reload ctrld if necessary.
|
||||
func (p *prog) apiConfigReload() {
|
||||
if cdUID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
secs := 3600
|
||||
if p.cfg.Service.RefetchTime != nil && *p.cfg.Service.RefetchTime > 0 {
|
||||
secs = *p.cfg.Service.RefetchTime
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(time.Duration(secs) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
logger := mainLog.Load().With().Str("mode", "api-reload").Logger()
|
||||
logger.Debug().Msg("starting custom config reload timer")
|
||||
lastUpdated := time.Now().Unix()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev)
|
||||
selfUninstallCheck(err, p, logger)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("could not fetch resolver config")
|
||||
continue
|
||||
}
|
||||
|
||||
if resolverConfig.Ctrld.CustomConfig == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if resolverConfig.Ctrld.CustomLastUpdate > lastUpdated {
|
||||
lastUpdated = time.Now().Unix()
|
||||
cfg := &ctrld.Config{}
|
||||
if err := validateCdRemoteConfig(resolverConfig, cfg); err != nil {
|
||||
logger.Warn().Err(err).Msg("skipping invalid custom config")
|
||||
if _, err := controld.UpdateCustomLastFailed(cdUID, rootCmd.Version, cdDev, true); err != nil {
|
||||
logger.Error().Err(err).Msg("could not mark custom last update failed")
|
||||
}
|
||||
break
|
||||
}
|
||||
setListenerDefaultValue(cfg)
|
||||
logger.Debug().Msg("custom config changes detected, reloading...")
|
||||
p.apiReloadCh <- cfg
|
||||
} else {
|
||||
logger.Debug().Msg("custom config does not change")
|
||||
}
|
||||
case <-p.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *prog) setupUpstream(cfg *ctrld.Config) {
|
||||
localUpstreams := make([]string, 0, len(cfg.Upstream))
|
||||
ptrNameservers := make([]string, 0, len(cfg.Upstream))
|
||||
isControlDUpstream := false
|
||||
for n := range cfg.Upstream {
|
||||
uc := cfg.Upstream[n]
|
||||
uc.Init()
|
||||
isControlDUpstream = isControlDUpstream || uc.IsControlD()
|
||||
if uc.BootstrapIP == "" {
|
||||
uc.SetupBootstrapIP()
|
||||
mainLog.Load().Info().Msgf("bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs())
|
||||
@@ -214,6 +319,10 @@ func (p *prog) setupUpstream(cfg *ctrld.Config) {
|
||||
ptrNameservers = append(ptrNameservers, uc.Endpoint)
|
||||
}
|
||||
}
|
||||
// Self-uninstallation is ok If there is only 1 ControlD upstream, and no remote config.
|
||||
if len(cfg.Upstream) == 1 && isControlDUpstream {
|
||||
p.canSelfUninstall.Store(true)
|
||||
}
|
||||
p.localUpstreams = localUpstreams
|
||||
p.ptrNameservers = ptrNameservers
|
||||
}
|
||||
@@ -235,17 +344,31 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
|
||||
numListeners := len(p.cfg.Listener)
|
||||
if !reload {
|
||||
p.started = make(chan struct{}, numListeners)
|
||||
if p.cs != nil {
|
||||
p.csSetDnsDone = make(chan struct{}, 1)
|
||||
p.registerControlServerHandler()
|
||||
if err := p.cs.start(); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not start control server")
|
||||
}
|
||||
mainLog.Load().Debug().Msgf("control server started: %s", p.cs.addr)
|
||||
}
|
||||
}
|
||||
p.onStartedDone = make(chan struct{})
|
||||
p.loop = make(map[string]bool)
|
||||
p.lanLoopGuard = newLoopGuard()
|
||||
p.ptrLoopGuard = newLoopGuard()
|
||||
p.cacheFlushDomainsMap = nil
|
||||
p.metricsQueryStats.Store(p.cfg.Service.MetricsQueryStats)
|
||||
if p.cfg.Service.CacheEnable {
|
||||
cacher, err := dnscache.NewLRUCache(p.cfg.Service.CacheSize)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to create cacher, caching is disabled")
|
||||
} else {
|
||||
p.cache = cacher
|
||||
p.cacheFlushDomainsMap = make(map[string]struct{}, 256)
|
||||
for _, domain := range p.cfg.Service.CacheFlushDomains {
|
||||
p.cacheFlushDomainsMap[canonicalName(domain)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +471,13 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
|
||||
p.checkDnsLoopTicker(ctx)
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
// Prometheus exporter goroutine.
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p.runMetricsServer(ctx, reloadCh)
|
||||
}()
|
||||
|
||||
if !reload {
|
||||
// Stop writing log to unix socket.
|
||||
consoleWriter.Out = os.Stdout
|
||||
@@ -355,16 +485,17 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
|
||||
if p.logConn != nil {
|
||||
_ = p.logConn.Close()
|
||||
}
|
||||
if p.cs != nil {
|
||||
p.registerControlServerHandler()
|
||||
if err := p.cs.start(); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not start control server")
|
||||
}
|
||||
}
|
||||
go p.apiConfigReload()
|
||||
p.postRun()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// metricsEnabled reports whether prometheus exporter is enabled/disabled.
|
||||
func (p *prog) metricsEnabled() bool {
|
||||
return p.cfg.Service.MetricsQueryStats || p.cfg.Service.MetricsListener != ""
|
||||
}
|
||||
|
||||
func (p *prog) Stop(s service.Service) error {
|
||||
mainLog.Load().Info().Msg("Service stopped")
|
||||
close(p.stopCh)
|
||||
@@ -399,21 +530,35 @@ func (p *prog) deAllocateIP() error {
|
||||
}
|
||||
|
||||
func (p *prog) setDNS() {
|
||||
setDnsOK := false
|
||||
defer func() {
|
||||
p.csSetDnsOk = setDnsOK
|
||||
p.csSetDnsDone <- struct{}{}
|
||||
close(p.csSetDnsDone)
|
||||
}()
|
||||
|
||||
if cfg.Listener == nil {
|
||||
return
|
||||
}
|
||||
if iface == "" {
|
||||
return
|
||||
}
|
||||
if iface == "auto" {
|
||||
iface = defaultIfaceName()
|
||||
runningIface := iface
|
||||
// allIfaces tracks whether we should set DNS for all physical interfaces.
|
||||
allIfaces := false
|
||||
if runningIface == "auto" {
|
||||
runningIface = defaultIfaceName()
|
||||
// If runningIface is "auto", it means user does not specify "--iface" flag.
|
||||
// In this case, ctrld has to set DNS for all physical interfaces, so
|
||||
// thing will still work when user switch from one to the other.
|
||||
allIfaces = requiredMultiNICsConfig()
|
||||
}
|
||||
lc := cfg.FirstListener()
|
||||
if lc == nil {
|
||||
return
|
||||
}
|
||||
logger := mainLog.Load().With().Str("iface", iface).Logger()
|
||||
netIface, err := netInterface(iface)
|
||||
logger := mainLog.Load().With().Str("iface", runningIface).Logger()
|
||||
netIface, err := netInterface(runningIface)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get interface")
|
||||
return
|
||||
@@ -443,22 +588,116 @@ func (p *prog) setDNS() {
|
||||
if needRFC1918Listeners(lc) {
|
||||
nameservers = append(nameservers, ctrld.Rfc1918Addresses()...)
|
||||
}
|
||||
if needLocalIPv6Listener() {
|
||||
nameservers = append(nameservers, "::1")
|
||||
}
|
||||
slices.Sort(nameservers)
|
||||
if err := setDNS(netIface, nameservers); err != nil {
|
||||
logger.Error().Err(err).Msgf("could not set DNS for interface")
|
||||
return
|
||||
}
|
||||
setDnsOK = true
|
||||
logger.Debug().Msg("setting DNS successfully")
|
||||
if shouldWatchResolvconf() {
|
||||
servers := make([]netip.Addr, len(nameservers))
|
||||
for i := range nameservers {
|
||||
servers[i] = netip.MustParseAddr(nameservers[i])
|
||||
}
|
||||
p.dnsWg.Add(1)
|
||||
go func() {
|
||||
defer p.dnsWg.Done()
|
||||
p.watchResolvConf(netIface, servers, setResolvConf)
|
||||
}()
|
||||
}
|
||||
if allIfaces {
|
||||
withEachPhysicalInterfaces(netIface.Name, "set DNS", func(i *net.Interface) error {
|
||||
return setDnsIgnoreUnusableInterface(i, nameservers)
|
||||
})
|
||||
}
|
||||
if p.dnsWatchdogEnabled() {
|
||||
p.dnsWg.Add(1)
|
||||
go func() {
|
||||
defer p.dnsWg.Done()
|
||||
p.dnsWatchdog(netIface, nameservers, allIfaces)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// dnsWatchdogEnabled reports whether DNS watchdog is enabled.
|
||||
func (p *prog) dnsWatchdogEnabled() bool {
|
||||
if ptr := p.cfg.Service.DnsWatchdogEnabled; ptr != nil {
|
||||
return *ptr
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// dnsWatchdogDuration returns the time duration between each DNS watchdog loop.
|
||||
func (p *prog) dnsWatchdogDuration() time.Duration {
|
||||
if ptr := p.cfg.Service.DnsWatchdogInvterval; ptr != nil {
|
||||
if (*ptr).Seconds() > 0 {
|
||||
return *ptr
|
||||
}
|
||||
}
|
||||
return dnsWatchdogDefaultInterval
|
||||
}
|
||||
|
||||
// dnsWatchdog watches for DNS changes on Darwin and Windows then re-applying ctrld's settings.
|
||||
// This is only works when deactivation pin set.
|
||||
func (p *prog) dnsWatchdog(iface *net.Interface, nameservers []string, allIfaces bool) {
|
||||
if !requiredMultiNICsConfig() {
|
||||
return
|
||||
}
|
||||
|
||||
p.dnsWatchDogOnce.Do(func() {
|
||||
mainLog.Load().Debug().Msg("start DNS settings watchdog")
|
||||
ns := nameservers
|
||||
slices.Sort(ns)
|
||||
ticker := time.NewTicker(p.dnsWatchdogDuration())
|
||||
logger := mainLog.Load().With().Str("iface", iface.Name).Logger()
|
||||
for {
|
||||
select {
|
||||
case <-p.dnsWatcherStopCh:
|
||||
return
|
||||
case <-p.stopCh:
|
||||
mainLog.Load().Debug().Msg("stop dns watchdog")
|
||||
return
|
||||
case <-ticker.C:
|
||||
if dnsChanged(iface, ns) {
|
||||
logger.Debug().Msg("DNS settings were changed, re-applying settings")
|
||||
if err := setDNS(iface, ns); err != nil {
|
||||
mainLog.Load().Error().Err(err).Str("iface", iface.Name).Msgf("could not re-apply DNS settings")
|
||||
}
|
||||
}
|
||||
if allIfaces {
|
||||
withEachPhysicalInterfaces(iface.Name, "", func(i *net.Interface) error {
|
||||
if dnsChanged(i, ns) {
|
||||
if err := setDnsIgnoreUnusableInterface(i, nameservers); err != nil {
|
||||
mainLog.Load().Error().Err(err).Str("iface", i.Name).Msgf("could not re-apply DNS settings")
|
||||
} else {
|
||||
mainLog.Load().Debug().Msgf("re-applying DNS for interface %q successfully", i.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (p *prog) resetDNS() {
|
||||
if iface == "" {
|
||||
return
|
||||
}
|
||||
if iface == "auto" {
|
||||
iface = defaultIfaceName()
|
||||
runningIface := iface
|
||||
allIfaces := false
|
||||
if runningIface == "auto" {
|
||||
runningIface = defaultIfaceName()
|
||||
// See corresponding comments in (*prog).setDNS function.
|
||||
allIfaces = requiredMultiNICsConfig()
|
||||
}
|
||||
logger := mainLog.Load().With().Str("iface", iface).Logger()
|
||||
netIface, err := netInterface(iface)
|
||||
logger := mainLog.Load().With().Str("iface", runningIface).Logger()
|
||||
netIface, err := netInterface(runningIface)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get interface")
|
||||
return
|
||||
@@ -473,6 +712,9 @@ func (p *prog) resetDNS() {
|
||||
return
|
||||
}
|
||||
logger.Debug().Msg("Restoring DNS successfully")
|
||||
if allIfaces {
|
||||
withEachPhysicalInterfaces(netIface.Name, "reset DNS", resetDnsIgnoreUnusableInterface)
|
||||
}
|
||||
}
|
||||
|
||||
func randomLocalIP() string {
|
||||
@@ -556,6 +798,15 @@ func errNetworkError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// errConnectionRefused reports whether err is connection refused.
|
||||
func errConnectionRefused(err error) bool {
|
||||
var opErr *net.OpError
|
||||
if !errors.As(err, &opErr) {
|
||||
return false
|
||||
}
|
||||
return errors.Is(opErr.Err, syscall.ECONNREFUSED) || errors.Is(opErr.Err, windowsECONNREFUSED)
|
||||
}
|
||||
|
||||
func ifaceFirstPrivateIP(iface *net.Interface) string {
|
||||
if iface == nil {
|
||||
return ""
|
||||
@@ -637,3 +888,115 @@ func canBeLocalUpstream(addr string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withEachPhysicalInterfaces runs the function f with each physical interfaces, excluding
|
||||
// the interface that matches excludeIfaceName. The context is used to clarify the
|
||||
// log message when error happens.
|
||||
func withEachPhysicalInterfaces(excludeIfaceName, context string, f func(i *net.Interface) error) {
|
||||
validIfacesMap := validInterfacesMap()
|
||||
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
|
||||
// Skip loopback/virtual interface.
|
||||
if i.IsLoopback() || len(i.HardwareAddr) == 0 {
|
||||
return
|
||||
}
|
||||
// Skip invalid interface.
|
||||
if !validInterface(i.Interface, validIfacesMap) {
|
||||
return
|
||||
}
|
||||
netIface := i.Interface
|
||||
if err := patchNetIfaceName(netIface); err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("failed to patch net interface name")
|
||||
return
|
||||
}
|
||||
// Skip excluded interface.
|
||||
if netIface.Name == excludeIfaceName {
|
||||
return
|
||||
}
|
||||
// TODO: investigate whether we should report this error?
|
||||
if err := f(netIface); err == nil {
|
||||
if context != "" {
|
||||
mainLog.Load().Debug().Msgf("%s for interface %q successfully", context, i.Name)
|
||||
}
|
||||
} else if !errors.Is(err, errSaveCurrentStaticDNSNotSupported) {
|
||||
mainLog.Load().Err(err).Msgf("%s for interface %q failed", context, i.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// requiredMultiNicConfig reports whether ctrld needs to set/reset DNS for multiple NICs.
|
||||
func requiredMultiNICsConfig() bool {
|
||||
switch runtime.GOOS {
|
||||
case "windows", "darwin":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var errSaveCurrentStaticDNSNotSupported = errors.New("saving current DNS is not supported on this platform")
|
||||
|
||||
// saveCurrentStaticDNS saves the current static DNS settings for restoring later.
|
||||
// Only works on Windows and Mac.
|
||||
func saveCurrentStaticDNS(iface *net.Interface) error {
|
||||
switch runtime.GOOS {
|
||||
case "windows", "darwin":
|
||||
default:
|
||||
return errSaveCurrentStaticDNSNotSupported
|
||||
}
|
||||
file := savedStaticDnsSettingsFilePath(iface)
|
||||
ns, _ := currentStaticDNS(iface)
|
||||
if len(ns) == 0 {
|
||||
_ = os.Remove(file) // removing old static DNS settings
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(file); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not remove old static DNS settings file")
|
||||
}
|
||||
mainLog.Load().Debug().Msgf("DNS settings for %s is static, saving ...", iface.Name)
|
||||
if err := os.WriteFile(file, []byte(strings.Join(ns, ",")), 0600); err != nil {
|
||||
mainLog.Load().Err(err).Msgf("could not save DNS settings for iface: %s", iface.Name)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// savedStaticDnsSettingsFilePath returns the path to saved DNS settings of the given interface.
|
||||
func savedStaticDnsSettingsFilePath(iface *net.Interface) string {
|
||||
return absHomeDir(".dns_" + iface.Name)
|
||||
}
|
||||
|
||||
// savedStaticNameservers returns the static DNS nameservers of the given interface.
|
||||
//
|
||||
//lint:ignore U1000 use in os_windows.go and os_darwin.go
|
||||
func savedStaticNameservers(iface *net.Interface) []string {
|
||||
file := savedStaticDnsSettingsFilePath(iface)
|
||||
if data, _ := os.ReadFile(file); len(data) > 0 {
|
||||
return strings.Split(string(data), ",")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dnsChanged reports whether DNS settings for given interface was changed.
|
||||
// The caller must sort the nameservers before calling this function.
|
||||
func dnsChanged(iface *net.Interface, nameservers []string) bool {
|
||||
curNameservers, _ := currentStaticDNS(iface)
|
||||
slices.Sort(curNameservers)
|
||||
if !slices.Equal(curNameservers, nameservers) {
|
||||
mainLog.Load().Debug().Msgf("interface %q current DNS settings: %v, expected: %v", iface.Name, curNameservers, nameservers)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// selfUninstallCheck checks if the error dues to controld.InvalidConfigCode, perform self-uninstall then.
|
||||
func selfUninstallCheck(uninstallErr error, p *prog, logger zerolog.Logger) {
|
||||
var uer *controld.UtilityErrorResponse
|
||||
if errors.As(uninstallErr, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
|
||||
// Ensure all DNS watchers goroutine are terminated, so it won't mess up with self-uninstall.
|
||||
close(p.dnsWatcherStopCh)
|
||||
p.dnsWg.Wait()
|
||||
|
||||
// Perform self-uninstall now.
|
||||
selfUninstall(p, logger)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/dns"
|
||||
@@ -10,6 +17,10 @@ func init() {
|
||||
if r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo"); err == nil {
|
||||
useSystemdResolved = r.Mode() == "systemd-resolved"
|
||||
}
|
||||
// Disable quic-go's ECN support by default, see https://github.com/quic-go/quic-go/issues/3911
|
||||
if os.Getenv("QUIC_GO_DISABLE_ECN") == "" {
|
||||
os.Setenv("QUIC_GO_DISABLE_ECN", "true")
|
||||
}
|
||||
}
|
||||
|
||||
func setDependencies(svc *service.Config) {
|
||||
@@ -18,12 +29,34 @@ func setDependencies(svc *service.Config) {
|
||||
"After=network-online.target",
|
||||
"Wants=NetworkManager-wait-online.service",
|
||||
"After=NetworkManager-wait-online.service",
|
||||
"Wants=systemd-networkd-wait-online.service",
|
||||
"Wants=nss-lookup.target",
|
||||
"After=nss-lookup.target",
|
||||
}
|
||||
if out, _ := exec.Command("networkctl", "--no-pager").CombinedOutput(); len(out) > 0 {
|
||||
if wantsSystemDNetworkdWaitOnline(bytes.NewReader(out)) {
|
||||
svc.Dependencies = append(svc.Dependencies, "Wants=systemd-networkd-wait-online.service")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||
svc.WorkingDirectory = dir
|
||||
}
|
||||
|
||||
// wantsSystemDNetworkdWaitOnline reports whether "systemd-networkd-wait-online" service
|
||||
// is required to be added to ctrld dependencies services.
|
||||
// The input reader r is the output of "networkctl --no-pager" command.
|
||||
func wantsSystemDNetworkdWaitOnline(r io.Reader) bool {
|
||||
scanner := bufio.NewScanner(r)
|
||||
// Skip header
|
||||
scanner.Scan()
|
||||
configured := false
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) > 0 && fields[len(fields)-1] == "configured" {
|
||||
configured = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
48
cmd/cli/prog_linux_test.go
Normal file
48
cmd/cli/prog_linux_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
networkctlUnmanagedOutput = `IDX LINK TYPE OPERATIONAL SETUP
|
||||
1 lo loopback carrier unmanaged
|
||||
2 wlp0s20f3 wlan routable unmanaged
|
||||
3 tailscale0 none routable unmanaged
|
||||
4 br-9ac33145e060 bridge no-carrier unmanaged
|
||||
5 docker0 bridge no-carrier unmanaged
|
||||
|
||||
5 links listed.
|
||||
`
|
||||
networkctlManagedOutput = `IDX LINK TYPE OPERATIONAL SETUP
|
||||
1 lo loopback carrier unmanaged
|
||||
2 wlp0s20f3 wlan routable configured
|
||||
3 tailscale0 none routable unmanaged
|
||||
4 br-9ac33145e060 bridge no-carrier unmanaged
|
||||
5 docker0 bridge no-carrier unmanaged
|
||||
|
||||
5 links listed.
|
||||
`
|
||||
)
|
||||
|
||||
func Test_wantsSystemDNetworkdWaitOnline(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
r io.Reader
|
||||
required bool
|
||||
}{
|
||||
{"unmanaged", strings.NewReader(networkctlUnmanagedOutput), false},
|
||||
{"managed", strings.NewReader(networkctlManagedOutput), true},
|
||||
{"empty", strings.NewReader(""), false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if required := wantsSystemDNetworkdWaitOnline(tc.r); required != tc.required {
|
||||
t.Errorf("wants %v got %v", tc.required, required)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
57
cmd/cli/prog_test.go
Normal file
57
cmd/cli/prog_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_prog_dnsWatchdogEnabled(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{}}
|
||||
|
||||
// Default value is true.
|
||||
assert.True(t, p.dnsWatchdogEnabled())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
}{
|
||||
{"enabled", true},
|
||||
{"disabled", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p.cfg.Service.DnsWatchdogEnabled = &tc.enabled
|
||||
assert.Equal(t, tc.enabled, p.dnsWatchdogEnabled())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_prog_dnsWatchdogInterval(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{}}
|
||||
|
||||
// Default value is 20s.
|
||||
assert.Equal(t, dnsWatchdogDefaultInterval, p.dnsWatchdogDuration())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
duration time.Duration
|
||||
expected time.Duration
|
||||
}{
|
||||
{"valid", time.Minute, time.Minute},
|
||||
{"zero", 0, dnsWatchdogDefaultInterval},
|
||||
{"nagative", time.Duration(-1 * time.Minute), dnsWatchdogDefaultInterval},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p.cfg.Service.DnsWatchdogInvterval = &tc.duration
|
||||
assert.Equal(t, tc.expected, p.dnsWatchdogDuration())
|
||||
})
|
||||
}
|
||||
}
|
||||
57
cmd/cli/prometheus.go
Normal file
57
cmd/cli/prometheus.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package cli
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
const (
|
||||
metricsLabelListener = "listener"
|
||||
metricsLabelClientSourceIP = "client_source_ip"
|
||||
metricsLabelClientMac = "client_mac"
|
||||
metricsLabelClientHostname = "client_hostname"
|
||||
metricsLabelUpstream = "upstream"
|
||||
metricsLabelRRType = "rr_type"
|
||||
metricsLabelRCode = "rcode"
|
||||
)
|
||||
|
||||
// statsVersion represent ctrld version.
|
||||
var statsVersion = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ctrld_build_info",
|
||||
Help: "Version of ctrld process.",
|
||||
}, []string{"gitref", "goversion", "version"})
|
||||
|
||||
// statsTimeStart represents start time of ctrld service.
|
||||
var statsTimeStart = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ctrld_time_seconds",
|
||||
Help: "Start time of the ctrld process since unix epoch in seconds.",
|
||||
})
|
||||
|
||||
var statsQueriesCountLabels = []string{
|
||||
metricsLabelListener,
|
||||
metricsLabelClientSourceIP,
|
||||
metricsLabelClientMac,
|
||||
metricsLabelClientHostname,
|
||||
metricsLabelUpstream,
|
||||
metricsLabelRRType,
|
||||
metricsLabelRCode,
|
||||
}
|
||||
|
||||
// statsQueriesCount counts total number of queries.
|
||||
var statsQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ctrld_queries_count",
|
||||
Help: "Total number of queries.",
|
||||
}, statsQueriesCountLabels)
|
||||
|
||||
// statsClientQueriesCount counts total number of queries of a client.
|
||||
//
|
||||
// The labels "client_source_ip", "client_mac", "client_hostname" are unbounded,
|
||||
// thus this stat is highly inefficient if there are many devices.
|
||||
var statsClientQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ctrld_client_queries_count",
|
||||
Help: "Total number queries of a client.",
|
||||
}, []string{metricsLabelClientSourceIP, metricsLabelClientMac, metricsLabelClientHostname})
|
||||
|
||||
// WithLabelValuesInc increases prometheus counter by 1 if query stats is enabled.
|
||||
func (p *prog) WithLabelValuesInc(c *prometheus.CounterVec, lvs ...string) {
|
||||
if p.metricsQueryStats.Load() {
|
||||
c.WithLabelValues(lvs...).Inc()
|
||||
}
|
||||
}
|
||||
70
cmd/cli/resolvconf.go
Normal file
70
cmd/cli/resolvconf.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// watchResolvConf watches any changes to /etc/resolv.conf file,
|
||||
// and reverting to the original config set by ctrld.
|
||||
func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface *net.Interface, ns []netip.Addr) error) {
|
||||
resolvConfPath := "/etc/resolv.conf"
|
||||
// Evaluating symbolics link to watch the target file that /etc/resolv.conf point to.
|
||||
if rp, _ := filepath.EvalSymlinks(resolvConfPath); rp != "" {
|
||||
resolvConfPath = rp
|
||||
}
|
||||
mainLog.Load().Debug().Msgf("start watching %s file", resolvConfPath)
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not create watcher for /etc/resolv.conf")
|
||||
return
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
// We watch /etc instead of /etc/resolv.conf directly,
|
||||
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
|
||||
watchDir := filepath.Dir(resolvConfPath)
|
||||
if err := watcher.Add(watchDir); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("could not add %s to watcher list", watchDir)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.dnsWatcherStopCh:
|
||||
return
|
||||
case <-p.stopCh:
|
||||
mainLog.Load().Debug().Msgf("stopping watcher for %s", resolvConfPath)
|
||||
return
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Name != resolvConfPath { // skip if not /etc/resolv.conf changes.
|
||||
continue
|
||||
}
|
||||
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
|
||||
mainLog.Load().Debug().Msg("/etc/resolv.conf changes detected, reverting to ctrld setting")
|
||||
if err := watcher.Remove(watchDir); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
|
||||
continue
|
||||
}
|
||||
if err := setDnsFn(iface, ns); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
|
||||
}
|
||||
if err := watcher.Add(watchDir); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
|
||||
return
|
||||
}
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
|
||||
}
|
||||
}
|
||||
}
|
||||
49
cmd/cli/resolvconf_darwin.go
Normal file
49
cmd/cli/resolvconf_darwin.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile"
|
||||
)
|
||||
|
||||
const resolvConfPath = "/etc/resolv.conf"
|
||||
|
||||
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
||||
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||
servers := make([]string, len(ns))
|
||||
for i := range ns {
|
||||
servers[i] = ns[i].String()
|
||||
}
|
||||
if err := setDNS(iface, servers); err != nil {
|
||||
return err
|
||||
}
|
||||
slices.Sort(servers)
|
||||
curNs := currentDNS(iface)
|
||||
slices.Sort(curNs)
|
||||
if !slices.Equal(curNs, servers) {
|
||||
c, err := resolvconffile.ParseFile(resolvConfPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Nameservers = ns
|
||||
f, err := os.Create(resolvConfPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := c.Write(f); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
|
||||
func shouldWatchResolvconf() bool {
|
||||
return true
|
||||
}
|
||||
40
cmd/cli/resolvconf_not_darwin_unix.go
Normal file
40
cmd/cli/resolvconf_not_darwin_unix.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build unix && !darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/util/dnsname"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/dns"
|
||||
)
|
||||
|
||||
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
||||
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||
r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo") // interface name does not matter.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oc := dns.OSConfig{
|
||||
Nameservers: ns,
|
||||
SearchDomains: []dnsname.FQDN{},
|
||||
}
|
||||
return r.SetDNS(oc)
|
||||
}
|
||||
|
||||
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
|
||||
func shouldWatchResolvconf() bool {
|
||||
r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo") // interface name does not matter.
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch r.Mode() {
|
||||
case "direct", "resolvconf":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
16
cmd/cli/resolvconf_windows.go
Normal file
16
cmd/cli/resolvconf_windows.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
||||
func setResolvConf(_ *net.Interface, _ []netip.Addr) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
|
||||
func shouldWatchResolvconf() bool {
|
||||
return false
|
||||
}
|
||||
7
cmd/cli/self_delete_others.go
Normal file
7
cmd/cli/self_delete_others.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
var supportedSelfDelete = true
|
||||
|
||||
func selfDeleteExe() error { return nil }
|
||||
134
cmd/cli/self_delete_windows.go
Normal file
134
cmd/cli/self_delete_windows.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copied from https://github.com/secur30nly/go-self-delete
|
||||
// with modification to suitable for ctrld usage.
|
||||
|
||||
/*
|
||||
License: MIT Licence
|
||||
|
||||
References:
|
||||
- https://github.com/LloydLabs/delete-self-poc
|
||||
- https://twitter.com/jonasLyk/status/1350401461985955840
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var supportedSelfDelete = false
|
||||
|
||||
type FILE_RENAME_INFO struct {
|
||||
Union struct {
|
||||
ReplaceIfExists bool
|
||||
Flags uint32
|
||||
}
|
||||
RootDirectory windows.Handle
|
||||
FileNameLength uint32
|
||||
FileName [1]uint16
|
||||
}
|
||||
|
||||
type FILE_DISPOSITION_INFO struct {
|
||||
DeleteFile bool
|
||||
}
|
||||
|
||||
func dsOpenHandle(pwPath *uint16) (windows.Handle, error) {
|
||||
handle, err := windows.CreateFile(
|
||||
pwPath,
|
||||
windows.DELETE,
|
||||
0,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
0,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func dsRenameHandle(hHandle windows.Handle) error {
|
||||
var fRename FILE_RENAME_INFO
|
||||
DS_STREAM_RENAME, err := windows.UTF16FromString(":deadbeef")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lpwStream := &DS_STREAM_RENAME[0]
|
||||
fRename.FileNameLength = uint32(unsafe.Sizeof(lpwStream))
|
||||
|
||||
windows.NewLazyDLL("kernel32.dll").NewProc("RtlCopyMemory").Call(
|
||||
uintptr(unsafe.Pointer(&fRename.FileName[0])),
|
||||
uintptr(unsafe.Pointer(lpwStream)),
|
||||
unsafe.Sizeof(lpwStream),
|
||||
)
|
||||
|
||||
err = windows.SetFileInformationByHandle(
|
||||
hHandle,
|
||||
windows.FileRenameInfo,
|
||||
(*byte)(unsafe.Pointer(&fRename)),
|
||||
uint32(unsafe.Sizeof(fRename)+unsafe.Sizeof(lpwStream)),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func dsDepositeHandle(hHandle windows.Handle) error {
|
||||
var fDelete FILE_DISPOSITION_INFO
|
||||
fDelete.DeleteFile = true
|
||||
|
||||
err := windows.SetFileInformationByHandle(
|
||||
hHandle,
|
||||
windows.FileDispositionInfo,
|
||||
(*byte)(unsafe.Pointer(&fDelete)),
|
||||
uint32(unsafe.Sizeof(fDelete)),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func selfDeleteExe() error {
|
||||
var wcPath [windows.MAX_PATH + 1]uint16
|
||||
var hCurrent windows.Handle
|
||||
|
||||
_, err := windows.GetModuleFileName(0, &wcPath[0], windows.MAX_PATH)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hCurrent, err = dsOpenHandle(&wcPath[0])
|
||||
if err != nil || hCurrent == windows.InvalidHandle {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := dsRenameHandle(hCurrent); err != nil {
|
||||
_ = windows.CloseHandle(hCurrent)
|
||||
return err
|
||||
}
|
||||
_ = windows.CloseHandle(hCurrent)
|
||||
|
||||
hCurrent, err = dsOpenHandle(&wcPath[0])
|
||||
if err != nil || hCurrent == windows.InvalidHandle {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := dsDepositeHandle(hCurrent); err != nil {
|
||||
_ = windows.CloseHandle(hCurrent)
|
||||
return err
|
||||
}
|
||||
|
||||
return windows.CloseHandle(hCurrent)
|
||||
}
|
||||
16
cmd/cli/self_kill_others.go
Normal file
16
cmd/cli/self_kill_others.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !unix
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func selfUninstall(p *prog, logger zerolog.Logger) {
|
||||
if uninstallInvalidCdUID(p, logger, false) {
|
||||
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
45
cmd/cli/self_kill_unix.go
Normal file
45
cmd/cli/self_kill_unix.go
Normal file
@@ -0,0 +1,45 @@
|
||||
//go:build unix
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"syscall"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func selfUninstall(p *prog, logger zerolog.Logger) {
|
||||
if runtime.GOOS == "linux" {
|
||||
selfUninstallLinux(p, logger)
|
||||
}
|
||||
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("could not determine executable")
|
||||
}
|
||||
args := []string{"uninstall"}
|
||||
if !deactivationPinNotSet() {
|
||||
args = append(args, fmt.Sprintf("--pin=%d", cdDeactivationPin))
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
logger.Fatal().Err(err).Msg("could not start self uninstall command")
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
||||
_ = cmd.Wait()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func selfUninstallLinux(p *prog, logger zerolog.Logger) {
|
||||
if uninstallInvalidCdUID(p, logger, true) {
|
||||
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
@@ -20,14 +20,17 @@ func newService(i service.Interface, c *service.Config) (service.Service, error)
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case router.IsOldOpenwrt():
|
||||
return &procd{&sysV{s}}, nil
|
||||
case router.IsOldOpenwrt(), router.IsNetGearOrbi():
|
||||
return &procd{sysV: &sysV{s}, svcConfig: c}, nil
|
||||
case router.IsGLiNet():
|
||||
return &sysV{s}, nil
|
||||
case s.Platform() == "unix-systemv":
|
||||
return &sysV{s}, nil
|
||||
case s.Platform() == "linux-systemd":
|
||||
return &systemd{s}, nil
|
||||
case s.Platform() == "darwin-launchd":
|
||||
return newLaunchd(s), nil
|
||||
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -89,25 +92,31 @@ func (s *sysV) Status() (service.Status, error) {
|
||||
// like old GL.iNET Opal router.
|
||||
type procd struct {
|
||||
*sysV
|
||||
svcConfig *service.Config
|
||||
}
|
||||
|
||||
func (s *procd) Status() (service.Status, error) {
|
||||
if !s.installed() {
|
||||
return service.StatusUnknown, service.ErrNotInstalled
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return service.StatusUnknown, nil
|
||||
bin := s.svcConfig.Executable
|
||||
if bin == "" {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return service.StatusUnknown, nil
|
||||
}
|
||||
bin = exe
|
||||
}
|
||||
|
||||
// Looking for something like "/sbin/ctrld run ".
|
||||
shellCmd := fmt.Sprintf("ps | grep -q %q", exe+" [r]un ")
|
||||
shellCmd := fmt.Sprintf("ps | grep -q %q", bin+" [r]un ")
|
||||
if err := exec.Command("sh", "-c", shellCmd).Run(); err != nil {
|
||||
return service.StatusStopped, nil
|
||||
}
|
||||
return service.StatusRunning, nil
|
||||
}
|
||||
|
||||
// procd wraps a service.Service, and provide status command to
|
||||
// systemd wraps a service.Service, and provide status command to
|
||||
// report the status correctly.
|
||||
type systemd struct {
|
||||
service.Service
|
||||
@@ -121,6 +130,29 @@ func (s *systemd) Status() (service.Status, error) {
|
||||
return s.Service.Status()
|
||||
}
|
||||
|
||||
func newLaunchd(s service.Service) *launchd {
|
||||
return &launchd{
|
||||
Service: s,
|
||||
statusErrMsg: "Permission denied",
|
||||
}
|
||||
}
|
||||
|
||||
// launchd wraps a service.Service, and provide status command to
|
||||
// report the status correctly when not running as root on Darwin.
|
||||
//
|
||||
// TODO: remove this wrapper once https://github.com/kardianos/service/issues/400 fixed.
|
||||
type launchd struct {
|
||||
service.Service
|
||||
statusErrMsg string
|
||||
}
|
||||
|
||||
func (l *launchd) Status() (service.Status, error) {
|
||||
if os.Geteuid() != 0 {
|
||||
return service.StatusUnknown, errors.New(l.statusErrMsg)
|
||||
}
|
||||
return l.Service.Status()
|
||||
}
|
||||
|
||||
type task struct {
|
||||
f func() error
|
||||
abortOnError bool
|
||||
|
||||
@@ -9,3 +9,7 @@ import (
|
||||
func hasElevatedPrivilege() (bool, error) {
|
||||
return os.Geteuid() == 0, nil
|
||||
}
|
||||
|
||||
func openLogFile(path string, flags int) (*os.File, error) {
|
||||
return os.OpenFile(path, flags, os.FileMode(0o600))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package cli
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func hasElevatedPrivilege() (bool, error) {
|
||||
var sid *windows.SID
|
||||
@@ -22,3 +27,55 @@ func hasElevatedPrivilege() (bool, error) {
|
||||
token := windows.Token(0)
|
||||
return token.IsMember(sid)
|
||||
}
|
||||
|
||||
func openLogFile(path string, mode int) (*os.File, error) {
|
||||
if len(path) == 0 {
|
||||
return nil, &os.PathError{Path: path, Op: "open", Err: syscall.ERROR_FILE_NOT_FOUND}
|
||||
}
|
||||
|
||||
pathP, err := syscall.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var access uint32
|
||||
switch mode & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR) {
|
||||
case os.O_RDONLY:
|
||||
access = windows.GENERIC_READ
|
||||
case os.O_WRONLY:
|
||||
access = windows.GENERIC_WRITE
|
||||
case os.O_RDWR:
|
||||
access = windows.GENERIC_READ | windows.GENERIC_WRITE
|
||||
}
|
||||
if mode&os.O_CREATE != 0 {
|
||||
access |= windows.GENERIC_WRITE
|
||||
}
|
||||
if mode&os.O_APPEND != 0 {
|
||||
access &^= windows.GENERIC_WRITE
|
||||
access |= windows.FILE_APPEND_DATA
|
||||
}
|
||||
|
||||
shareMode := uint32(syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE | syscall.FILE_SHARE_DELETE)
|
||||
|
||||
var sa *syscall.SecurityAttributes
|
||||
|
||||
var createMode uint32
|
||||
switch {
|
||||
case mode&(os.O_CREATE|os.O_EXCL) == (os.O_CREATE | os.O_EXCL):
|
||||
createMode = windows.CREATE_NEW
|
||||
case mode&(os.O_CREATE|os.O_TRUNC) == (os.O_CREATE | os.O_TRUNC):
|
||||
createMode = windows.CREATE_ALWAYS
|
||||
case mode&os.O_CREATE == os.O_CREATE:
|
||||
createMode = windows.OPEN_ALWAYS
|
||||
case mode&os.O_TRUNC == os.O_TRUNC:
|
||||
createMode = windows.TRUNCATE_EXISTING
|
||||
default:
|
||||
createMode = windows.OPEN_EXISTING
|
||||
}
|
||||
|
||||
handle, err := syscall.CreateFile(pathP, access, shareMode, sa, createMode, syscall.FILE_ATTRIBUTE_NORMAL, 0)
|
||||
if err != nil {
|
||||
return nil, &os.PathError{Path: path, Op: "open", Err: err}
|
||||
}
|
||||
|
||||
return os.NewFile(uintptr(handle), path), nil
|
||||
}
|
||||
|
||||
20
cmd/cli/winres/winres.json
Normal file
20
cmd/cli/winres/winres.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"RT_VERSION": {
|
||||
"#1": {
|
||||
"0000": {
|
||||
"fixed": {
|
||||
"file_version": "0.0.0.1"
|
||||
},
|
||||
"info": {
|
||||
"0409": {
|
||||
"CompanyName": "ControlD Inc",
|
||||
"FileDescription": "Control D DNS daemon",
|
||||
"ProductName": "ctrld",
|
||||
"InternalName": "ctrld",
|
||||
"LegalCopyright": "ControlD Inc 2024"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
cmd/cli/winres_windows.go
Normal file
4
cmd/cli/winres_windows.go
Normal file
@@ -0,0 +1,4 @@
|
||||
//go:generate go-winres make --product-version=git-tag --file-version=git-tag
|
||||
package cli
|
||||
|
||||
// Placeholder file for windows builds.
|
||||
@@ -28,14 +28,15 @@ type AppCallback interface {
|
||||
// Start configures utility with config.toml from provided directory.
|
||||
// This function will block until Stop is called
|
||||
// Check port availability prior to calling it.
|
||||
func (c *Controller) Start(CdUID string, HomeDir string, logLevel int, logPath string) {
|
||||
func (c *Controller) Start(CdUID string, HomeDir string, UpstreamProto string, logLevel int, logPath string) {
|
||||
if c.stopCh == nil {
|
||||
c.stopCh = make(chan struct{})
|
||||
c.Config = cli.AppConfig{
|
||||
CdUID: CdUID,
|
||||
HomeDir: HomeDir,
|
||||
Verbose: logLevel,
|
||||
LogPath: logPath,
|
||||
CdUID: CdUID,
|
||||
HomeDir: HomeDir,
|
||||
UpstreamProto: UpstreamProto,
|
||||
Verbose: logLevel,
|
||||
LogPath: logPath,
|
||||
}
|
||||
appCallback := mapCallback(c.AppCallback)
|
||||
cli.RunMobile(&c.Config, &appCallback, c.stopCh)
|
||||
@@ -60,13 +61,18 @@ func mapCallback(callback AppCallback) cli.AppCallback {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) Stop() bool {
|
||||
if c.stopCh != nil {
|
||||
func (c *Controller) Stop(restart bool, pin int64) int {
|
||||
var errorCode = 0
|
||||
// Force disconnect without checking pin.
|
||||
// In iOS restart is required if vpn detects no connectivity after network change.
|
||||
if !restart {
|
||||
errorCode = cli.CheckDeactivationPin(pin, c.stopCh)
|
||||
}
|
||||
if errorCode == 0 && c.stopCh != nil {
|
||||
close(c.stopCh)
|
||||
c.stopCh = nil
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return errorCode
|
||||
}
|
||||
|
||||
func (c *Controller) IsRunning() bool {
|
||||
|
||||
127
config.go
127
config.go
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/miekg/dns"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/sync/singleflight"
|
||||
"tailscale.com/logtail/backoff"
|
||||
"tailscale.com/net/tsaddr"
|
||||
@@ -46,6 +47,15 @@ const (
|
||||
// depending on the record type of the DNS query.
|
||||
IpStackSplit = "split"
|
||||
|
||||
// FreeDnsDomain is the domain name of free ControlD service.
|
||||
FreeDnsDomain = "freedns.controld.com"
|
||||
// FreeDNSBoostrapIP is the IP address of freedns.controld.com.
|
||||
FreeDNSBoostrapIP = "76.76.2.11"
|
||||
// PremiumDnsDomain is the domain name of premium ControlD service.
|
||||
PremiumDnsDomain = "dns.controld.com"
|
||||
// PremiumDNSBoostrapIP is the IP address of dns.controld.com.
|
||||
PremiumDNSBoostrapIP = "76.76.2.22"
|
||||
|
||||
controlDComDomain = "controld.com"
|
||||
controlDNetDomain = "controld.net"
|
||||
controlDDevDomain = "controld.dev"
|
||||
@@ -104,14 +114,14 @@ func InitConfig(v *viper.Viper, name string) {
|
||||
})
|
||||
v.SetDefault("upstream", map[string]*UpstreamConfig{
|
||||
"0": {
|
||||
BootstrapIP: "76.76.2.11",
|
||||
BootstrapIP: FreeDNSBoostrapIP,
|
||||
Name: "Control D - Anti-Malware",
|
||||
Type: ResolverTypeDOH,
|
||||
Endpoint: "https://freedns.controld.com/p1",
|
||||
Timeout: 5000,
|
||||
},
|
||||
"1": {
|
||||
BootstrapIP: "76.76.2.11",
|
||||
BootstrapIP: FreeDNSBoostrapIP,
|
||||
Name: "Control D - No Ads",
|
||||
Type: ResolverTypeDOQ,
|
||||
Endpoint: "p2.freedns.controld.com",
|
||||
@@ -179,23 +189,30 @@ func (c *Config) FirstUpstream() *UpstreamConfig {
|
||||
|
||||
// ServiceConfig specifies the general ctrld config.
|
||||
type ServiceConfig struct {
|
||||
LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"`
|
||||
LogPath string `mapstructure:"log_path" toml:"log_path,omitempty"`
|
||||
CacheEnable bool `mapstructure:"cache_enable" toml:"cache_enable,omitempty"`
|
||||
CacheSize int `mapstructure:"cache_size" toml:"cache_size,omitempty"`
|
||||
CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"`
|
||||
CacheServeStale bool `mapstructure:"cache_serve_stale" toml:"cache_serve_stale,omitempty"`
|
||||
MaxConcurrentRequests *int `mapstructure:"max_concurrent_requests" toml:"max_concurrent_requests,omitempty" validate:"omitempty,gte=0"`
|
||||
DHCPLeaseFile string `mapstructure:"dhcp_lease_file_path" toml:"dhcp_lease_file_path" validate:"omitempty,file"`
|
||||
DHCPLeaseFileFormat string `mapstructure:"dhcp_lease_file_format" toml:"dhcp_lease_file_format" validate:"required_unless=DHCPLeaseFile '',omitempty,oneof=dnsmasq isc-dhcp"`
|
||||
DiscoverMDNS *bool `mapstructure:"discover_mdns" toml:"discover_mdns,omitempty"`
|
||||
DiscoverARP *bool `mapstructure:"discover_arp" toml:"discover_dhcp,omitempty"`
|
||||
DiscoverDHCP *bool `mapstructure:"discover_dhcp" toml:"discover_dhcp,omitempty"`
|
||||
DiscoverPtr *bool `mapstructure:"discover_ptr" toml:"discover_ptr,omitempty"`
|
||||
DiscoverHosts *bool `mapstructure:"discover_hosts" toml:"discover_hosts,omitempty"`
|
||||
ClientIDPref string `mapstructure:"client_id_preference" toml:"client_id_preference,omitempty" validate:"omitempty,oneof=host mac"`
|
||||
Daemon bool `mapstructure:"-" toml:"-"`
|
||||
AllocateIP bool `mapstructure:"-" toml:"-"`
|
||||
LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"`
|
||||
LogPath string `mapstructure:"log_path" toml:"log_path,omitempty"`
|
||||
CacheEnable bool `mapstructure:"cache_enable" toml:"cache_enable,omitempty"`
|
||||
CacheSize int `mapstructure:"cache_size" toml:"cache_size,omitempty"`
|
||||
CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"`
|
||||
CacheServeStale bool `mapstructure:"cache_serve_stale" toml:"cache_serve_stale,omitempty"`
|
||||
CacheFlushDomains []string `mapstructure:"cache_flush_domains" toml:"cache_flush_domains" validate:"max=256"`
|
||||
MaxConcurrentRequests *int `mapstructure:"max_concurrent_requests" toml:"max_concurrent_requests,omitempty" validate:"omitempty,gte=0"`
|
||||
DHCPLeaseFile string `mapstructure:"dhcp_lease_file_path" toml:"dhcp_lease_file_path" validate:"omitempty,file"`
|
||||
DHCPLeaseFileFormat string `mapstructure:"dhcp_lease_file_format" toml:"dhcp_lease_file_format" validate:"required_unless=DHCPLeaseFile '',omitempty,oneof=dnsmasq isc-dhcp"`
|
||||
DiscoverMDNS *bool `mapstructure:"discover_mdns" toml:"discover_mdns,omitempty"`
|
||||
DiscoverARP *bool `mapstructure:"discover_arp" toml:"discover_arp,omitempty"`
|
||||
DiscoverDHCP *bool `mapstructure:"discover_dhcp" toml:"discover_dhcp,omitempty"`
|
||||
DiscoverPtr *bool `mapstructure:"discover_ptr" toml:"discover_ptr,omitempty"`
|
||||
DiscoverHosts *bool `mapstructure:"discover_hosts" toml:"discover_hosts,omitempty"`
|
||||
DiscoverRefreshInterval int `mapstructure:"discover_refresh_interval" toml:"discover_refresh_interval,omitempty"`
|
||||
ClientIDPref string `mapstructure:"client_id_preference" toml:"client_id_preference,omitempty" validate:"omitempty,oneof=host mac"`
|
||||
MetricsQueryStats bool `mapstructure:"metrics_query_stats" toml:"metrics_query_stats,omitempty"`
|
||||
MetricsListener string `mapstructure:"metrics_listener" toml:"metrics_listener,omitempty"`
|
||||
DnsWatchdogEnabled *bool `mapstructure:"dns_watchdog_enabled" toml:"dns_watchdog_enabled,omitempty"`
|
||||
DnsWatchdogInvterval *time.Duration `mapstructure:"dns_watchdog_interval" toml:"dns_watchdog_interval,omitempty"`
|
||||
RefetchTime *int `mapstructure:"refetch_time" toml:"refetch_time,omitempty"`
|
||||
Daemon bool `mapstructure:"-" toml:"-"`
|
||||
AllocateIP bool `mapstructure:"-" toml:"-"`
|
||||
}
|
||||
|
||||
// NetworkConfig specifies configuration for networks where ctrld will handle requests.
|
||||
@@ -282,6 +299,7 @@ type Rule map[string][]string
|
||||
|
||||
// Init initialized necessary values for an UpstreamConfig.
|
||||
func (uc *UpstreamConfig) Init() {
|
||||
uc.initDoHScheme()
|
||||
uc.uid = upstreamUID()
|
||||
if u, err := url.Parse(uc.Endpoint); err == nil {
|
||||
uc.Domain = u.Host
|
||||
@@ -302,7 +320,7 @@ func (uc *UpstreamConfig) Init() {
|
||||
}
|
||||
}
|
||||
if uc.IPStack == "" {
|
||||
if uc.isControlD() {
|
||||
if uc.IsControlD() {
|
||||
uc.IPStack = IpStackSplit
|
||||
} else {
|
||||
uc.IPStack = IpStackBoth
|
||||
@@ -340,7 +358,7 @@ func (uc *UpstreamConfig) UpstreamSendClientInfo() bool {
|
||||
}
|
||||
switch uc.Type {
|
||||
case ResolverTypeDOH, ResolverTypeDOH3:
|
||||
if uc.isControlD() || uc.isNextDNS() {
|
||||
if uc.IsControlD() || uc.isNextDNS() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -387,7 +405,7 @@ func (uc *UpstreamConfig) UID() string {
|
||||
// The first usable IP will be used as bootstrap IP of the upstream.
|
||||
func (uc *UpstreamConfig) setupBootstrapIP(withBootstrapDNS bool) {
|
||||
b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second)
|
||||
isControlD := uc.isControlD()
|
||||
isControlD := uc.IsControlD()
|
||||
for {
|
||||
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, withBootstrapDNS)
|
||||
// For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses,
|
||||
@@ -472,6 +490,13 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
|
||||
ClientSessionCache: tls.NewLRUClientSessionCache(0),
|
||||
}
|
||||
|
||||
// Prevent bad tcp connection hanging the requests for too long.
|
||||
// See: https://github.com/golang/go/issues/36026
|
||||
if t2, err := http2.ConfigureTransports(transport); err == nil {
|
||||
t2.ReadIdleTimeout = 10 * time.Second
|
||||
t2.PingTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
dialerTimeoutMs := 2000
|
||||
if uc.Timeout > 0 && uc.Timeout < dialerTimeoutMs {
|
||||
dialerTimeoutMs = uc.Timeout
|
||||
@@ -507,38 +532,59 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
|
||||
|
||||
// Ping warms up the connection to DoH/DoH3 upstream.
|
||||
func (uc *UpstreamConfig) Ping() {
|
||||
_ = uc.ping()
|
||||
}
|
||||
|
||||
// ErrorPing is like Ping, but return an error if any.
|
||||
func (uc *UpstreamConfig) ErrorPing() error {
|
||||
return uc.ping()
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) ping() error {
|
||||
switch uc.Type {
|
||||
case ResolverTypeDOH, ResolverTypeDOH3:
|
||||
default:
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
ping := func(t http.RoundTripper) {
|
||||
ping := func(t http.RoundTripper) error {
|
||||
if t == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, "HEAD", uc.Endpoint, nil)
|
||||
resp, _ := t.RoundTrip(req)
|
||||
if resp == nil {
|
||||
return
|
||||
req, err := http.NewRequestWithContext(ctx, "HEAD", uc.Endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := t.RoundTrip(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, typ := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||
switch uc.Type {
|
||||
case ResolverTypeDOH:
|
||||
ping(uc.dohTransport(typ))
|
||||
|
||||
if err := ping(uc.dohTransport(typ)); err != nil {
|
||||
return err
|
||||
}
|
||||
case ResolverTypeDOH3:
|
||||
ping(uc.doh3Transport(typ))
|
||||
if err := ping(uc.doh3Transport(typ)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) isControlD() bool {
|
||||
// IsControlD reports whether this is a ControlD upstream.
|
||||
func (uc *UpstreamConfig) IsControlD() bool {
|
||||
domain := uc.Domain
|
||||
if domain == "" {
|
||||
if u, err := url.Parse(uc.Endpoint); err == nil {
|
||||
@@ -628,6 +674,18 @@ func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) {
|
||||
return "tcp-tls", "udp"
|
||||
}
|
||||
|
||||
// initDoHScheme initializes the endpoint scheme for DoH/DoH3 upstream if not present.
|
||||
func (uc *UpstreamConfig) initDoHScheme() {
|
||||
switch uc.Type {
|
||||
case ResolverTypeDOH, ResolverTypeDOH3:
|
||||
default:
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(uc.Endpoint, "https://") {
|
||||
uc.Endpoint = "https://" + uc.Endpoint
|
||||
}
|
||||
}
|
||||
|
||||
// Init initialized necessary values for an ListenerConfig.
|
||||
func (lc *ListenerConfig) Init() {
|
||||
if lc.Policy != nil {
|
||||
@@ -680,6 +738,7 @@ func upstreamConfigStructLevelValidation(sl validator.StructLevel) {
|
||||
return
|
||||
}
|
||||
|
||||
uc.initDoHScheme()
|
||||
// DoH/DoH3 requires endpoint is an HTTP url.
|
||||
if uc.Type == ResolverTypeDOH || uc.Type == ResolverTypeDOH3 {
|
||||
u, err := url.Parse(uc.Endpoint)
|
||||
@@ -687,10 +746,6 @@ func upstreamConfigStructLevelValidation(sl validator.StructLevel) {
|
||||
sl.ReportError(uc.Endpoint, "endpoint", "Endpoint", "http_url", "")
|
||||
return
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
sl.ReportError(uc.Endpoint, "endpoint", "Endpoint", "http_url", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build !qf
|
||||
|
||||
package ctrld
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build qf
|
||||
|
||||
package ctrld
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (uc *UpstreamConfig) setupDOH3Transport() {}
|
||||
|
||||
func (uc *UpstreamConfig) doh3Transport(dnsType uint16) http.RoundTripper { return nil }
|
||||
@@ -1,9 +1,11 @@
|
||||
package ctrld_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/spf13/viper"
|
||||
@@ -21,6 +23,8 @@ func TestLoadConfig(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "info", cfg.Service.LogLevel)
|
||||
assert.Equal(t, "/path/to/log.log", cfg.Service.LogPath)
|
||||
assert.Equal(t, false, *cfg.Service.DnsWatchdogEnabled)
|
||||
assert.Equal(t, time.Duration(20*time.Second), *cfg.Service.DnsWatchdogInvterval)
|
||||
|
||||
assert.Len(t, cfg.Network, 2)
|
||||
assert.Contains(t, cfg.Network, "0")
|
||||
@@ -102,6 +106,8 @@ func TestConfigValidation(t *testing.T) {
|
||||
{"invalid lease file format", configWithInvalidLeaseFileFormat(t), true},
|
||||
{"invalid doh/doh3 endpoint", configWithInvalidDoHEndpoint(t), true},
|
||||
{"invalid client id pref", configWithInvalidClientIDPref(t), true},
|
||||
{"doh endpoint without scheme", dohUpstreamEndpointWithoutScheme(t), false},
|
||||
{"maximum number of flush cache domains", configWithInvalidFlushCacheDomain(t), true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -121,6 +127,29 @@ func TestConfigValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiscoverOverride(t *testing.T) {
|
||||
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
|
||||
ctrld.InitConfig(v, "test_config_discover_override")
|
||||
v.SetConfigType("toml")
|
||||
configStr := `
|
||||
[service]
|
||||
discover_arp = false
|
||||
discover_dhcp = false
|
||||
discover_hosts = false
|
||||
discover_mdns = false
|
||||
discover_ptr = false
|
||||
`
|
||||
require.NoError(t, v.ReadConfig(strings.NewReader(configStr)))
|
||||
cfg := ctrld.Config{}
|
||||
require.NoError(t, v.Unmarshal(&cfg))
|
||||
|
||||
require.False(t, *cfg.Service.DiscoverARP)
|
||||
require.False(t, *cfg.Service.DiscoverDHCP)
|
||||
require.False(t, *cfg.Service.DiscoverHosts)
|
||||
require.False(t, *cfg.Service.DiscoverMDNS)
|
||||
require.False(t, *cfg.Service.DiscoverPtr)
|
||||
}
|
||||
|
||||
func defaultConfig(t *testing.T) *ctrld.Config {
|
||||
v := viper.New()
|
||||
ctrld.InitConfig(v, "test_load_default_config")
|
||||
@@ -144,6 +173,12 @@ func invalidUpstreamType(t *testing.T) *ctrld.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func dohUpstreamEndpointWithoutScheme(t *testing.T) *ctrld.Config {
|
||||
cfg := defaultConfig(t)
|
||||
cfg.Upstream["0"].Endpoint = "freedns.controld.com/p1"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func invalidUpstreamTimeout(t *testing.T) *ctrld.Config {
|
||||
cfg := defaultConfig(t)
|
||||
cfg.Upstream["0"].Timeout = -1
|
||||
@@ -235,7 +270,7 @@ func configWithInvalidLeaseFileFormat(t *testing.T) *ctrld.Config {
|
||||
|
||||
func configWithInvalidDoHEndpoint(t *testing.T) *ctrld.Config {
|
||||
cfg := defaultConfig(t)
|
||||
cfg.Upstream["0"].Endpoint = "1.1.1.1"
|
||||
cfg.Upstream["0"].Endpoint = "/1.1.1.1"
|
||||
cfg.Upstream["0"].Type = ctrld.ResolverTypeDOH
|
||||
return cfg
|
||||
}
|
||||
@@ -245,3 +280,12 @@ func configWithInvalidClientIDPref(t *testing.T) *ctrld.Config {
|
||||
cfg.Service.ClientIDPref = "foo"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func configWithInvalidFlushCacheDomain(t *testing.T) *ctrld.Config {
|
||||
cfg := defaultConfig(t)
|
||||
cfg.Service.CacheFlushDomains = make([]string, 257)
|
||||
for i := range cfg.Service.CacheFlushDomains {
|
||||
cfg.Service.CacheFlushDomains[i] = fmt.Sprintf("%d.com", i)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# - Non-cgo ctrld binary.
|
||||
#
|
||||
# CI_COMMIT_TAG is used to set the version of ctrld binary.
|
||||
FROM golang:1.20-bullseye as base
|
||||
FROM golang:bullseye as base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ The config file allows for advanced configuration of the `ctrld` utility to cove
|
||||
|
||||
|
||||
## Config Location
|
||||
`ctrld` uses [TOML](toml_link) format for its configuration file. Default configuration file is `ctrld.toml` found in following order:
|
||||
`ctrld` uses [TOML][toml_link] format for its configuration file. Default configuration file is `ctrld.toml` found in following order:
|
||||
|
||||
- `/etc/controld` on *nix.
|
||||
- User's home directory on Windows.
|
||||
@@ -157,6 +157,13 @@ stale cached records (regardless of their TTLs) until upstream comes online.
|
||||
- Required: no
|
||||
- Default: false
|
||||
|
||||
### cache_flush_domains
|
||||
When `ctrld` receives query with domain name in `cache_flush_domains`, the local cache will be discarded
|
||||
before serving the query.
|
||||
|
||||
- Type: array of strings
|
||||
- Required: no
|
||||
|
||||
### max_concurrent_requests
|
||||
The number of concurrent requests that will be handled, must be a non-negative integer.
|
||||
Tweaking this value depends on the capacity of your system.
|
||||
@@ -200,6 +207,14 @@ Perform LAN client discovery using hosts file.
|
||||
- Required: no
|
||||
- Default: true
|
||||
|
||||
### discover_refresh_interval
|
||||
Time in seconds between each discovery refresh loop to update new client information data.
|
||||
The default value is 120 seconds, lower this value to make the discovery process run more aggressively.
|
||||
|
||||
- Type: integer
|
||||
- Required: no
|
||||
- Default: 120
|
||||
|
||||
### dhcp_lease_file_path
|
||||
Relative or absolute path to a custom DHCP leases file location.
|
||||
|
||||
@@ -212,20 +227,60 @@ DHCP leases file format.
|
||||
|
||||
- Type: string
|
||||
- Required: no
|
||||
- Valid values: `dnsmasq`, `isc-dhcp`
|
||||
- Valid values: `dnsmasq`, `isc-dhcp`, `kea-dhcp4`
|
||||
- Default: ""
|
||||
|
||||
### client_id_preference
|
||||
Decide how the client ID is generated
|
||||
Decide how the client ID is generated. By default client ID will use both MAC address and Hostname i.e. `hash(mac + host)`. To override this behavior, select one of the 2 allowed values to scope client ID to just MAC address OR Hostname.
|
||||
|
||||
If `host` -> client id will only use the hostname i.e.`hash(hostname)`.
|
||||
If `mac` -> client id will only use the MAC address `hash(mac)`.
|
||||
Else -> client ID will use both Mac and Hostname i.e. `hash(mac + host)
|
||||
- Type: string
|
||||
- Required: no
|
||||
- Valid values: `mac`, `host`
|
||||
- Default: ""
|
||||
|
||||
### metrics_query_stats
|
||||
If set to `true`, collect and export the query counters, and show them in `clients list` command.
|
||||
|
||||
- Type: boolean
|
||||
- Required: no
|
||||
- Default: false
|
||||
|
||||
### metrics_listener
|
||||
Specifying the `ip` and `port` of the Prometheus metrics server. The Prometheus metrics will be available on: `http://ip:port/metrics`. You can also append `/metrics/json` to get the same data in json format.
|
||||
|
||||
- Type: string
|
||||
- Required: no
|
||||
- Default: ""
|
||||
|
||||
### dns_watchdog_enabled
|
||||
Checking DNS changes to network interfaces and reverting to ctrld's own settings.
|
||||
|
||||
The DNS watchdog process only runs on Windows and MacOS.
|
||||
|
||||
- Type: boolean
|
||||
- Required: no
|
||||
- Default: true
|
||||
|
||||
### dns_watchdog_interval
|
||||
Time duration between each DNS watchdog iteration.
|
||||
|
||||
A duration string is a possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix,
|
||||
such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
|
||||
|
||||
If the time duration is non-positive, default value will be used.
|
||||
|
||||
- Type: time duration string
|
||||
- Required: no
|
||||
- Default: 20s
|
||||
|
||||
### refetch_time
|
||||
Time in seconds between each iteration that reloads custom config if changed.
|
||||
|
||||
The value must be a positive number, any invalid value will be ignored and default value will be used.
|
||||
- Type: number
|
||||
- Required: no
|
||||
- Default: 3600
|
||||
|
||||
## Upstream
|
||||
The `[upstream]` section specifies the DNS upstream servers that `ctrld` will forward DNS requests to.
|
||||
|
||||
@@ -310,7 +365,7 @@ The protocol that `ctrld` will use to send DNS requests to upstream.
|
||||
|
||||
- Type: string
|
||||
- Required: yes
|
||||
- Valid values: `doh`, `doh3`, `dot`, `doq`, `legacy`, `os`
|
||||
- Valid values: `doh`, `doh3`, `dot`, `doq`, `legacy`
|
||||
|
||||
### ip_stack
|
||||
Specifying what kind of ip stack that `ctrld` will use to connect to upstream.
|
||||
@@ -331,7 +386,7 @@ If `ip_stack` is empty, or undefined:
|
||||
- Default value is `split` for Control D resolvers.
|
||||
|
||||
### send_client_info
|
||||
Specifying whether to include client info when sending query to upstream.
|
||||
Specifying whether to include client info when sending query to upstream. **This will only work with `doh` or `doh3` type upstreams.**
|
||||
|
||||
- Type: boolean
|
||||
- Required: no
|
||||
@@ -512,7 +567,7 @@ And within each policy, the rules are processed from top to bottom.
|
||||
### failover_rcodes
|
||||
For non success response, `failover_rcodes` allows the request to be forwarded to next upstream, if the response `RCODE` matches any value defined in `failover_rcodes`.
|
||||
|
||||
- Type: array of string
|
||||
- Type: array of strings
|
||||
- Required: no
|
||||
- Default: []
|
||||
-
|
||||
@@ -529,7 +584,7 @@ networks = [
|
||||
|
||||
If `upstream.0` returns a NXDOMAIN response, the request will be forwarded to `upstream.1` instead of returning immediately to the client.
|
||||
|
||||
See all available DNS Rcodes value [here](rcode_link).
|
||||
See all available DNS Rcodes value [here][rcode_link].
|
||||
|
||||
[toml_link]: https://toml.io/en
|
||||
[rcode_link]: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6
|
||||
|
||||
BIN
docs/ctrldsplash.png
Normal file
BIN
docs/ctrldsplash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 458 KiB |
20
doh.go
20
doh.go
@@ -60,17 +60,10 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: use sync.OnceValue when upgrading to go1.21
|
||||
var xCdOsValueOnce sync.Once
|
||||
var xCdOsValue string
|
||||
|
||||
func dohOsHeaderValue() string {
|
||||
xCdOsValueOnce.Do(func() {
|
||||
oi := osinfo.New()
|
||||
xCdOsValue = strings.Join([]string{EncodeOsNameMap[runtime.GOOS], EncodeArchNameMap[runtime.GOARCH], oi.Dist}, "-")
|
||||
})
|
||||
return xCdOsValue
|
||||
}
|
||||
var dohOsHeaderValue = sync.OnceValue(func() string {
|
||||
oi := osinfo.New()
|
||||
return strings.Join([]string{EncodeOsNameMap[runtime.GOOS], EncodeArchNameMap[runtime.GOARCH], oi.Dist}, "-")
|
||||
})()
|
||||
|
||||
func newDohResolver(uc *UpstreamConfig) *dohResolver {
|
||||
r := &dohResolver{
|
||||
@@ -154,7 +147,7 @@ func addHeader(ctx context.Context, req *http.Request, uc *UpstreamConfig) {
|
||||
if ci, ok := ctx.Value(ClientInfoCtxKey{}).(*ClientInfo); ok && ci != nil {
|
||||
printed = ci.Mac != "" || ci.IP != "" || ci.Hostname != ""
|
||||
switch {
|
||||
case uc.isControlD():
|
||||
case uc.IsControlD():
|
||||
dohHeader = newControlDHeaders(ci)
|
||||
case uc.isNextDNS():
|
||||
dohHeader = newNextDNSHeaders(ci)
|
||||
@@ -172,7 +165,6 @@ func addHeader(ctx context.Context, req *http.Request, uc *UpstreamConfig) {
|
||||
// newControlDHeaders returns DoH/Doh3 HTTP request headers for ControlD upstream.
|
||||
func newControlDHeaders(ci *ClientInfo) http.Header {
|
||||
header := make(http.Header)
|
||||
header.Set(dohOsHeader, dohOsHeaderValue())
|
||||
if ci.Mac != "" {
|
||||
header.Set(dohMacHeader, ci.Mac)
|
||||
}
|
||||
@@ -183,7 +175,7 @@ func newControlDHeaders(ci *ClientInfo) http.Header {
|
||||
header.Set(dohHostHeader, ci.Hostname)
|
||||
}
|
||||
if ci.Self {
|
||||
header.Set(dohOsHeader, dohOsHeaderValue())
|
||||
header.Set(dohOsHeader, dohOsHeaderValue)
|
||||
}
|
||||
switch ci.ClientIDPref {
|
||||
case "mac":
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func Test_dohOsHeaderValue(t *testing.T) {
|
||||
val := dohOsHeaderValue()
|
||||
val := dohOsHeaderValue
|
||||
if val == "" {
|
||||
t.Fatalf("empty %s", dohOsHeader)
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
//go:build qf
|
||||
|
||||
package ctrld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
type doqResolver struct {
|
||||
uc *UpstreamConfig
|
||||
}
|
||||
|
||||
func (r *doqResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
|
||||
return nil, errors.New("DoQ is not supported")
|
||||
}
|
||||
2
dot.go
2
dot.go
@@ -18,7 +18,7 @@ func (r *dotResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
|
||||
// dns.controld.dev first. By using a dialer with custom resolver,
|
||||
// we ensure that we can always resolve the bootstrap domain
|
||||
// regardless of the machine DNS status.
|
||||
dialer := newDialer(net.JoinHostPort(bootstrapDNS, "53"))
|
||||
dialer := newDialer(net.JoinHostPort(controldBootstrapDns, "53"))
|
||||
dnsTyp := uint16(0)
|
||||
if msg != nil && len(msg.Question) > 0 {
|
||||
dnsTyp = msg.Question[0].Qtype
|
||||
|
||||
31
go.mod
31
go.mod
@@ -1,8 +1,9 @@
|
||||
module github.com/Control-D-Inc/ctrld
|
||||
|
||||
go 1.20
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/coreos/go-systemd/v22 v22.5.0
|
||||
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf
|
||||
github.com/frankban/quicktest v1.14.5
|
||||
@@ -15,30 +16,38 @@ require (
|
||||
github.com/jaytaylor/go-hostsfile v0.0.0-20220426042432-61485ac1fa6c
|
||||
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86
|
||||
github.com/kardianos/service v1.2.1
|
||||
github.com/mdlayher/ndp v1.0.1
|
||||
github.com/miekg/dns v1.1.55
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/olekukonko/tablewriter v0.0.5
|
||||
github.com/pelletier/go-toml/v2 v2.0.8
|
||||
github.com/quic-go/quic-go v0.38.0
|
||||
github.com/prometheus/client_golang v1.15.1
|
||||
github.com/prometheus/client_model v0.4.0
|
||||
github.com/prometheus/prom2json v1.3.3
|
||||
github.com/quic-go/quic-go v0.42.0
|
||||
github.com/rs/zerolog v1.28.0
|
||||
github.com/spf13/cobra v1.7.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/spf13/viper v1.16.0
|
||||
github.com/stretchr/testify v1.8.3
|
||||
github.com/vishvananda/netlink v1.2.1-beta.2
|
||||
golang.org/x/net v0.17.0
|
||||
golang.org/x/net v0.23.0
|
||||
golang.org/x/sync v0.2.0
|
||||
golang.org/x/sys v0.13.0
|
||||
golang.org/x/sys v0.18.0
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3
|
||||
tailscale.com v1.44.0
|
||||
)
|
||||
|
||||
require (
|
||||
aead.dev/minisign v0.2.0 // indirect
|
||||
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
@@ -51,6 +60,7 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.18 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 // indirect
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 // indirect
|
||||
@@ -59,8 +69,9 @@ require (
|
||||
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.17 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/common v0.44.0 // indirect
|
||||
github.com/prometheus/procfs v0.9.0 // indirect
|
||||
github.com/quic-go/qpack v0.4.0 // indirect
|
||||
github.com/quic-go/qtls-go1-20 v0.3.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/spf13/afero v1.9.5 // indirect
|
||||
@@ -69,12 +80,14 @@ require (
|
||||
github.com/subosito/gotenv v1.4.2 // indirect
|
||||
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
go.uber.org/mock v0.4.0 // indirect
|
||||
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
|
||||
golang.org/x/crypto v0.14.0 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
|
||||
golang.org/x/mod v0.10.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
golang.org/x/mod v0.11.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/tools v0.9.1 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
80
go.sum
80
go.sum
@@ -1,3 +1,5 @@
|
||||
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
|
||||
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
@@ -38,15 +40,22 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||
github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be h1:qBKVRi7Mom5heOkyZ+NCIu9HZBiNCsRqrRe5t9pooik=
|
||||
github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
|
||||
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
|
||||
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/cilium/ebpf v0.10.0 h1:nk5HPMeoBXtOzbkZBWym+ZWq1GIiHUsBFXxwewXAHLQ=
|
||||
github.com/cilium/ebpf v0.10.0/go.mod h1:DPiVdY/kT534dgc9ERmvP8mWA+9gvwgKfRvk4nNWnoE=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
@@ -74,6 +83,7 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
|
||||
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||
@@ -98,8 +108,6 @@ github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
|
||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -114,7 +122,9 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
@@ -126,6 +136,7 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
@@ -155,6 +166,7 @@ github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8=
|
||||
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio=
|
||||
@@ -199,8 +211,12 @@ github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 h1:lez6TS6aAau+8wXUP3G9I3TGlmPFEq2CTxBaRqY6AGE=
|
||||
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y=
|
||||
github.com/mdlayher/ndp v1.0.1 h1:+yAD79/BWyFlvAoeG5ncPS0ItlHP/eVbH7bQ6/+LVA4=
|
||||
github.com/mdlayher/ndp v1.0.1/go.mod h1:rf3wKaWhAYJEXFKpgF8kQ2AxypxVbfNcZbqoAo6fVzk=
|
||||
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
|
||||
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||
github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg=
|
||||
@@ -210,6 +226,8 @@ github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U
|
||||
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
|
||||
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
@@ -217,6 +235,7 @@ github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6
|
||||
github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
|
||||
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
@@ -227,13 +246,21 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
|
||||
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
|
||||
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
|
||||
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
|
||||
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
|
||||
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
|
||||
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
|
||||
github.com/prometheus/prom2json v1.3.3 h1:IYfSMiZ7sSOfliBoo89PcufjWO4eAR0gznGcETyaUgo=
|
||||
github.com/prometheus/prom2json v1.3.3/go.mod h1:Pv4yIPktEkK7btWsrUTWDDDrnpUrAELaOCj+oFwlgmc=
|
||||
github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
|
||||
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
|
||||
github.com/quic-go/qtls-go1-20 v0.3.2 h1:rRgN3WfnKbyik4dBV8A6girlJVxGand/d+jVKbQq5GI=
|
||||
github.com/quic-go/qtls-go1-20 v0.3.2/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
|
||||
github.com/quic-go/quic-go v0.38.0 h1:T45lASr5q/TrVwt+jrVccmqHhPL2XuSyoCLVCpfOSLc=
|
||||
github.com/quic-go/quic-go v0.38.0/go.mod h1:MPCuRq7KBK2hNcfKj/1iD1BGuN3eAYMeNxp3T42LRUg=
|
||||
github.com/quic-go/quic-go v0.42.0 h1:uSfdap0eveIl8KXnipv9K7nlwZ5IqLlYOpJ58u5utpM=
|
||||
github.com/quic-go/quic-go v0.42.0/go.mod h1:132kz4kL3F9vxhW3CtQJLDVwcFe5wdWeJXXijhsO57M=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
@@ -283,13 +310,14 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
|
||||
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
|
||||
go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8=
|
||||
go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -297,11 +325,13 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -337,9 +367,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
|
||||
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
|
||||
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -372,10 +401,9 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -395,7 +423,6 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
|
||||
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -410,6 +437,7 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -435,10 +463,9 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -448,8 +475,9 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -459,11 +487,13 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -511,7 +541,6 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f
|
||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
|
||||
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -608,7 +637,10 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"github.com/Control-D-Inc/ctrld/internal/controld"
|
||||
)
|
||||
|
||||
const (
|
||||
ipV4Loopback = "127.0.0.1"
|
||||
ipv6Loopback = "::1"
|
||||
)
|
||||
|
||||
// IpResolver is the interface for retrieving IP from Mac.
|
||||
type IpResolver interface {
|
||||
fmt.Stringer
|
||||
@@ -58,10 +63,12 @@ type ipLister interface {
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
IP netip.Addr
|
||||
Mac string
|
||||
Hostname string
|
||||
Source map[string]struct{}
|
||||
IP netip.Addr
|
||||
Mac string
|
||||
Hostname string
|
||||
Source map[string]struct{}
|
||||
QueryCount int64
|
||||
IncludeQueryCount bool
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
@@ -70,10 +77,13 @@ type Table struct {
|
||||
hostnameResolvers []HostnameResolver
|
||||
refreshers []refresher
|
||||
initOnce sync.Once
|
||||
refreshInterval int
|
||||
|
||||
dhcp *dhcp
|
||||
merlin *merlinDiscover
|
||||
ubios *ubiosDiscover
|
||||
arp *arpDiscover
|
||||
ndp *ndpDiscover
|
||||
ptr *ptrDiscover
|
||||
mdns *mdns
|
||||
hf *hostsFile
|
||||
@@ -86,12 +96,17 @@ type Table struct {
|
||||
}
|
||||
|
||||
func NewTable(cfg *ctrld.Config, selfIP, cdUID string, ns []string) *Table {
|
||||
refreshInterval := cfg.Service.DiscoverRefreshInterval
|
||||
if refreshInterval <= 0 {
|
||||
refreshInterval = 2 * 60 // 2 minutes
|
||||
}
|
||||
return &Table{
|
||||
svcCfg: cfg.Service,
|
||||
quitCh: make(chan struct{}),
|
||||
selfIP: selfIP,
|
||||
cdUID: cdUID,
|
||||
ptrNameservers: ns,
|
||||
svcCfg: cfg.Service,
|
||||
quitCh: make(chan struct{}),
|
||||
selfIP: selfIP,
|
||||
cdUID: cdUID,
|
||||
ptrNameservers: ns,
|
||||
refreshInterval: refreshInterval,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +117,9 @@ func (t *Table) AddLeaseFile(name string, format ctrld.LeaseFileFormat) {
|
||||
clientInfoFiles[name] = format
|
||||
}
|
||||
|
||||
// RefreshLoop runs all the refresher to update new client info data.
|
||||
func (t *Table) RefreshLoop(ctx context.Context) {
|
||||
timer := time.NewTicker(time.Minute * 5)
|
||||
timer := time.NewTicker(time.Second * time.Duration(t.refreshInterval))
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
@@ -137,14 +153,26 @@ func (t *Table) init() {
|
||||
// Otherwise, process all possible sources in order, that means
|
||||
// the first result of IP/MAC/Hostname lookup will be used.
|
||||
//
|
||||
// Merlin custom clients.
|
||||
// Routers custom clients:
|
||||
// - Merlin
|
||||
// - Ubios
|
||||
if t.discoverDHCP() || t.discoverARP() {
|
||||
t.merlin = &merlinDiscover{}
|
||||
if err := t.merlin.refresh(); err != nil {
|
||||
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init Merlin discover")
|
||||
} else {
|
||||
t.hostnameResolvers = append(t.hostnameResolvers, t.merlin)
|
||||
t.refreshers = append(t.refreshers, t.merlin)
|
||||
t.ubios = &ubiosDiscover{}
|
||||
discovers := map[string]interface {
|
||||
refresher
|
||||
HostnameResolver
|
||||
}{
|
||||
"Merlin": t.merlin,
|
||||
"Ubios": t.ubios,
|
||||
}
|
||||
for platform, discover := range discovers {
|
||||
if err := discover.refresh(); err != nil {
|
||||
ctrld.ProxyLogger.Load().Error().Err(err).Msgf("could not init %s discover", platform)
|
||||
} else {
|
||||
t.hostnameResolvers = append(t.hostnameResolvers, discover)
|
||||
t.refreshers = append(t.refreshers, discover)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hosts file mapping.
|
||||
@@ -172,17 +200,36 @@ func (t *Table) init() {
|
||||
}
|
||||
go t.dhcp.watchChanges()
|
||||
}
|
||||
// ARP table.
|
||||
// ARP/NDP table.
|
||||
if t.discoverARP() {
|
||||
t.arp = &arpDiscover{}
|
||||
t.ndp = &ndpDiscover{}
|
||||
ctrld.ProxyLogger.Load().Debug().Msg("start arp discovery")
|
||||
if err := t.arp.refresh(); err != nil {
|
||||
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init ARP discover")
|
||||
} else {
|
||||
t.ipResolvers = append(t.ipResolvers, t.arp)
|
||||
t.macResolvers = append(t.macResolvers, t.arp)
|
||||
t.refreshers = append(t.refreshers, t.arp)
|
||||
discovers := map[string]interface {
|
||||
refresher
|
||||
IpResolver
|
||||
MacResolver
|
||||
}{
|
||||
"ARP": t.arp,
|
||||
"NDP": t.ndp,
|
||||
}
|
||||
|
||||
for protocol, discover := range discovers {
|
||||
if err := discover.refresh(); err != nil {
|
||||
ctrld.ProxyLogger.Load().Error().Err(err).Msgf("could not init %s discover", protocol)
|
||||
} else {
|
||||
t.ipResolvers = append(t.ipResolvers, discover)
|
||||
t.macResolvers = append(t.macResolvers, discover)
|
||||
t.refreshers = append(t.refreshers, discover)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
<-t.quitCh
|
||||
cancel()
|
||||
}()
|
||||
go t.ndp.listen(ctx)
|
||||
go t.ndp.subscribe(ctx)
|
||||
}
|
||||
// PTR lookup.
|
||||
if t.discoverPTR() {
|
||||
@@ -280,6 +327,16 @@ func (t *Table) LookupRFC1918IPv4(mac string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// LocalHostname returns the localhost hostname associated with loopback IP.
|
||||
func (t *Table) LocalHostname() string {
|
||||
for _, ip := range []string{ipV4Loopback, ipv6Loopback} {
|
||||
if name := t.LookupHostname(ip, ""); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type macEntry struct {
|
||||
mac string
|
||||
src string
|
||||
@@ -328,7 +385,7 @@ func (t *Table) ListClients() []*Client {
|
||||
_ = r.refresh()
|
||||
}
|
||||
ipMap := make(map[string]*Client)
|
||||
il := []ipLister{t.dhcp, t.arp, t.ptr, t.mdns, t.vni}
|
||||
il := []ipLister{t.dhcp, t.arp, t.ndp, t.ptr, t.mdns, t.vni}
|
||||
for _, ir := range il {
|
||||
for _, ip := range ir.List() {
|
||||
c, ok := ipMap[ip]
|
||||
@@ -343,6 +400,7 @@ func (t *Table) ListClients() []*Client {
|
||||
}
|
||||
}
|
||||
}
|
||||
clientsByMAC := make(map[string]*Client)
|
||||
for ip := range ipMap {
|
||||
c := ipMap[ip]
|
||||
for _, e := range t.lookupMacAll(ip) {
|
||||
@@ -356,6 +414,7 @@ func (t *Table) ListClients() []*Client {
|
||||
for _, e := range t.lookupHostnameAll(ip, c.Mac) {
|
||||
if c.Hostname == "" && e.name != "" {
|
||||
c.Hostname = e.name
|
||||
clientsByMAC[c.Mac] = c
|
||||
}
|
||||
if e.name != "" {
|
||||
c.Source[e.src] = struct{}{}
|
||||
@@ -364,6 +423,11 @@ func (t *Table) ListClients() []*Client {
|
||||
}
|
||||
clients := make([]*Client, 0, len(ipMap))
|
||||
for _, c := range ipMap {
|
||||
// If we found a client with empty hostname, use hostname from
|
||||
// an existed client which has the same MAC address.
|
||||
if cFromMac := clientsByMAC[c.Mac]; cFromMac != nil && c.Hostname == "" {
|
||||
c.Hostname = cFromMac.Hostname
|
||||
}
|
||||
clients = append(clients, c)
|
||||
}
|
||||
return clients
|
||||
|
||||
@@ -44,3 +44,31 @@ func TestTable_LookupRFC1918IPv4(t *testing.T) {
|
||||
t.Fatalf("unexpected result, want: %s, got: %s", rfc1918IPv4, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTable_ListClients(t *testing.T) {
|
||||
mac := "74:56:3c:44:eb:5e"
|
||||
ipv6_1 := "2405:4803:a04b:4190:fbe9:cd14:d522:bbae"
|
||||
ipv6_2 := "2405:4803:a04b:4190:fbe9:cd14:d522:bbab"
|
||||
table := &Table{}
|
||||
|
||||
// NDP init.
|
||||
table.ndp = &ndpDiscover{}
|
||||
table.ndp.mac.Store(ipv6_1, mac)
|
||||
table.ndp.mac.Store(ipv6_2, mac)
|
||||
table.ndp.ip.Store(mac, ipv6_1)
|
||||
table.ndp.ip.Store(mac, ipv6_2)
|
||||
table.ipResolvers = append(table.ipResolvers, table.ndp)
|
||||
table.macResolvers = append(table.macResolvers, table.ndp)
|
||||
|
||||
hostname := "foo"
|
||||
// mdns init.
|
||||
table.mdns = &mdns{}
|
||||
table.mdns.name.Store(ipv6_2, hostname)
|
||||
table.hostnameResolvers = append(table.hostnameResolvers, table.mdns)
|
||||
|
||||
for _, c := range table.ListClients() {
|
||||
if c.Hostname != hostname {
|
||||
t.Fatalf("missing hostname for client: %v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package clientinfo
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -187,6 +188,8 @@ func (d *dhcp) readLeaseFile(name string, format ctrld.LeaseFileFormat) error {
|
||||
return d.dnsmasqReadClientInfoFile(name)
|
||||
case ctrld.IscDhcpd:
|
||||
return d.iscDHCPReadClientInfoFile(name)
|
||||
case ctrld.KeaDHCP4:
|
||||
return d.keaDhcp4ReadClientInfoFile(name)
|
||||
}
|
||||
return fmt.Errorf("unsupported format: %s, file: %s", format, name)
|
||||
}
|
||||
@@ -202,7 +205,8 @@ func (d *dhcp) dnsmasqReadClientInfoFile(name string) error {
|
||||
|
||||
}
|
||||
|
||||
// dnsmasqReadClientInfoReader likes ctrld.Dnsmasq, but reading from an io.Reader instead of file.
|
||||
// dnsmasqReadClientInfoReader performs the same task as dnsmasqReadClientInfoFile,
|
||||
// but by reading from an io.Reader instead of file.
|
||||
func (d *dhcp) dnsmasqReadClientInfoReader(reader io.Reader) error {
|
||||
return lineread.Reader(reader, func(line []byte) error {
|
||||
fields := bytes.Fields(line)
|
||||
@@ -244,7 +248,8 @@ func (d *dhcp) iscDHCPReadClientInfoFile(name string) error {
|
||||
return d.iscDHCPReadClientInfoReader(f)
|
||||
}
|
||||
|
||||
// iscDHCPReadClientInfoReader likes ctrld.IscDhcpd, but reading from an io.Reader instead of file.
|
||||
// iscDHCPReadClientInfoReader performs the same task as iscDHCPReadClientInfoFile,
|
||||
// but by reading from an io.Reader instead of file.
|
||||
func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error {
|
||||
s := bufio.NewScanner(reader)
|
||||
var ip, mac, hostname string
|
||||
@@ -287,6 +292,58 @@ func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// keaDhcp4ReadClientInfoFile populates dhcp table with client info reading from kea dhcp4 lease file.
|
||||
func (d *dhcp) keaDhcp4ReadClientInfoFile(name string) error {
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return d.keaDhcp4ReadClientInfoReader(bufio.NewReader(f))
|
||||
|
||||
}
|
||||
|
||||
// keaDhcp4ReadClientInfoReader performs the same task as keaDhcp4ReadClientInfoFile,
|
||||
// but by reading from an io.Reader instead of file.
|
||||
func (d *dhcp) keaDhcp4ReadClientInfoReader(r io.Reader) error {
|
||||
cr := csv.NewReader(r)
|
||||
for {
|
||||
record, err := cr.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(record) < 9 {
|
||||
continue // hostname is at 9th field, so skipping record with not enough fields.
|
||||
}
|
||||
if record[0] == "address" {
|
||||
continue // skip header.
|
||||
}
|
||||
mac := record[1]
|
||||
if _, err := net.ParseMAC(mac); err != nil { // skip invalid MAC
|
||||
continue
|
||||
}
|
||||
ip := normalizeIP(record[0])
|
||||
if net.ParseIP(ip) == nil {
|
||||
ctrld.ProxyLogger.Load().Warn().Msgf("invalid ip address entry: %q", ip)
|
||||
ip = ""
|
||||
}
|
||||
|
||||
d.mac.Store(ip, mac)
|
||||
d.ip.Store(mac, ip)
|
||||
hostname := record[8]
|
||||
if hostname == "*" {
|
||||
continue
|
||||
}
|
||||
name := normalizeHostname(hostname)
|
||||
d.mac2name.Store(mac, name)
|
||||
d.ip2name.Store(ip, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addSelf populates current host info to dhcp, so queries from
|
||||
// the host itself can be attached with proper client info.
|
||||
func (d *dhcp) addSelf() {
|
||||
@@ -296,8 +353,8 @@ func (d *dhcp) addSelf() {
|
||||
return
|
||||
}
|
||||
hostname = normalizeHostname(hostname)
|
||||
d.ip2name.Store("127.0.0.1", hostname)
|
||||
d.ip2name.Store("::1", hostname)
|
||||
d.ip2name.Store(ipV4Loopback, hostname)
|
||||
d.ip2name.Store(ipv6Loopback, hostname)
|
||||
found := false
|
||||
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
|
||||
mac := i.HardwareAddr.String()
|
||||
@@ -318,15 +375,17 @@ func (d *dhcp) addSelf() {
|
||||
d.mac.Store(ip.String(), mac)
|
||||
d.ip.Store(mac, ip.String())
|
||||
if ip.To4() != nil {
|
||||
d.mac.Store("127.0.0.1", mac)
|
||||
d.mac.Store(ipV4Loopback, mac)
|
||||
} else {
|
||||
d.mac.Store("::1", mac)
|
||||
d.mac.Store(ipv6Loopback, mac)
|
||||
}
|
||||
d.mac2name.Store(mac, hostname)
|
||||
d.ip2name.Store(ip.String(), hostname)
|
||||
// If we have self IP set, and this IP is it, use this IP only.
|
||||
if ip.String() == d.selfIP {
|
||||
found = true
|
||||
d.mac.Store(ipV4Loopback, mac)
|
||||
d.mac.Store(ipv6Loopback, mac)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,4 +15,5 @@ var clientInfoFiles = map[string]ctrld.LeaseFileFormat{
|
||||
"/run/dhcpd.leases": ctrld.IscDhcpd, // EdgeOS
|
||||
"/var/dhcpd/var/db/dhcpd.leases": ctrld.IscDhcpd, // Pfsense
|
||||
"/home/pi/.router/run/dhcp/dnsmasq.leases": ctrld.Dnsmasq, // Firewalla
|
||||
"/var/lib/kea/dhcp4.leases": ctrld.KeaDHCP4, // Pfsense
|
||||
}
|
||||
|
||||
@@ -67,6 +67,41 @@ lease 192.168.1.2 {
|
||||
"00:00:00:00:00:04",
|
||||
"example",
|
||||
},
|
||||
{
|
||||
"kea-dhcp4 good",
|
||||
`address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
|
||||
192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0
|
||||
`,
|
||||
d.keaDhcp4ReadClientInfoReader,
|
||||
"00:00:00:00:00:05",
|
||||
"foo",
|
||||
},
|
||||
{
|
||||
"kea-dhcp4 no-header",
|
||||
`192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0`,
|
||||
d.keaDhcp4ReadClientInfoReader,
|
||||
"00:00:00:00:00:05",
|
||||
"foo",
|
||||
},
|
||||
{
|
||||
"kea-dhcp4 hostname *",
|
||||
`address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
|
||||
192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,*,0,,0
|
||||
`,
|
||||
d.keaDhcp4ReadClientInfoReader,
|
||||
"00:00:00:00:00:05",
|
||||
"*",
|
||||
},
|
||||
{
|
||||
"kea-dhcp4 bad",
|
||||
`address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
|
||||
192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0
|
||||
192.168.0.124,invalid_MAC,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0
|
||||
`,
|
||||
d.keaDhcp4ReadClientInfoReader,
|
||||
"00:00:00:00:00:05",
|
||||
"foo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -76,6 +111,12 @@ lease 192.168.1.2 {
|
||||
t.Errorf("readClientInfoReader() error = %v", err)
|
||||
}
|
||||
val, existed := d.mac2name.Load(tc.mac)
|
||||
if tc.hostname == "*" {
|
||||
if existed {
|
||||
t.Errorf("* hostname must be skipped")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !existed {
|
||||
t.Error("client info missing")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package clientinfo
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
@@ -12,9 +16,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ipv4LocalhostName = "localhost"
|
||||
ipv6LocalhostName = "ip6-localhost"
|
||||
ipv6LoopbackName = "ip6-loopback"
|
||||
ipv4LocalhostName = "localhost"
|
||||
ipv6LocalhostName = "ip6-localhost"
|
||||
ipv6LoopbackName = "ip6-loopback"
|
||||
hostEntriesConfPath = "/var/unbound/host_entries.conf"
|
||||
)
|
||||
|
||||
// hostsFile provides client discovery functionality using system hosts file.
|
||||
@@ -34,14 +39,9 @@ func (hf *hostsFile) init() error {
|
||||
if err := hf.watcher.Add(hostsfile.HostsPath); err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := hostsfile.ParseHosts(hostsfile.ReadHostsFile())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hf.mu.Lock()
|
||||
hf.m = m
|
||||
hf.mu.Unlock()
|
||||
return nil
|
||||
// Conservatively adding hostEntriesConfPath, since it is not available everywhere.
|
||||
_ = hf.watcher.Add(hostEntriesConfPath)
|
||||
return hf.refresh()
|
||||
}
|
||||
|
||||
// refresh reloads hosts file entries.
|
||||
@@ -52,6 +52,14 @@ func (hf *hostsFile) refresh() error {
|
||||
}
|
||||
hf.mu.Lock()
|
||||
hf.m = m
|
||||
// override hosts file with host_entries.conf content if present.
|
||||
hem, err := parseHostEntriesConf(hostEntriesConfPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not read host_entries.conf file")
|
||||
}
|
||||
for k, v := range hem {
|
||||
hf.m[k] = v
|
||||
}
|
||||
hf.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -87,7 +95,7 @@ func (hf *hostsFile) LookupHostnameByIP(ip string) string {
|
||||
hf.mu.Lock()
|
||||
defer hf.mu.Unlock()
|
||||
if names := hf.m[ip]; len(names) > 0 {
|
||||
isLoopback := ip == "127.0.0.1" || ip == "::1"
|
||||
isLoopback := ip == ipV4Loopback || ip == ipv6Loopback
|
||||
for _, hostname := range names {
|
||||
name := normalizeHostname(hostname)
|
||||
// Ignoring ipv4/ipv6 loopback entry.
|
||||
@@ -137,3 +145,46 @@ func isLocalhostName(hostname string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// parseHostEntriesConf parses host_entries.conf file and returns parsed result.
|
||||
func parseHostEntriesConf(path string) (map[string][]string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseHostEntriesConfFromReader(bytes.NewReader(b)), nil
|
||||
}
|
||||
|
||||
// parseHostEntriesConfFromReader is like parseHostEntriesConf, but read from an io.Reader instead of file.
|
||||
func parseHostEntriesConfFromReader(r io.Reader) map[string][]string {
|
||||
hostsMap := map[string][]string{}
|
||||
scanner := bufio.NewScanner(r)
|
||||
|
||||
localZone := ""
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if after, found := strings.CutPrefix(line, "local-zone:"); found {
|
||||
after = strings.TrimSpace(after)
|
||||
fields := strings.Fields(after)
|
||||
if len(fields) > 1 {
|
||||
localZone = strings.Trim(fields[0], `"`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Only read "local-data-ptr: ..." line, it has all necessary information.
|
||||
after, found := strings.CutPrefix(line, "local-data-ptr:")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
after = strings.TrimSpace(after)
|
||||
after = strings.Trim(after, `"`)
|
||||
fields := strings.Fields(after)
|
||||
if len(fields) != 2 {
|
||||
continue
|
||||
}
|
||||
ip := fields[0]
|
||||
name := strings.TrimSuffix(fields[1], "."+localZone)
|
||||
hostsMap[ip] = append(hostsMap[ip], name)
|
||||
}
|
||||
return hostsMap
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package clientinfo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -31,3 +32,46 @@ func Test_hostsFile_LookupHostnameByIP(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parseHostEntriesConfFromReader(t *testing.T) {
|
||||
const content = `local-zone: "localdomain" transparent
|
||||
local-data-ptr: "127.0.0.1 localhost"
|
||||
local-data: "localhost A 127.0.0.1"
|
||||
local-data: "localhost.localdomain A 127.0.0.1"
|
||||
local-data-ptr: "::1 localhost"
|
||||
local-data: "localhost AAAA ::1"
|
||||
local-data: "localhost.localdomain AAAA ::1"
|
||||
local-data-ptr: "10.0.10.227 OPNsense.localdomain"
|
||||
local-data: "OPNsense.localdomain A 10.0.10.227"
|
||||
local-data: "OPNsense A 10.0.10.227"
|
||||
local-data-ptr: "fe80::5a78:4e29:caa3:f9f7 OPNsense.localdomain"
|
||||
local-data: "OPNsense.localdomain AAAA fe80::5a78:4e29:caa3:f9f7"
|
||||
local-data: "OPNsense AAAA fe80::5a78:4e29:caa3:f9f7"
|
||||
local-data-ptr: "1.1.1.1 banana-party.local.com"
|
||||
local-data: "banana-party.local.com IN A 1.1.1.1"
|
||||
local-data-ptr: "1.1.1.1 cheese-land.lan"
|
||||
local-data: "cheese-land.lan IN A 1.1.1.1"
|
||||
`
|
||||
r := strings.NewReader(content)
|
||||
hostsMap := parseHostEntriesConfFromReader(r)
|
||||
if len(hostsMap) != 5 {
|
||||
t.Fatalf("unexpected number of entries, want 5, got: %d", len(hostsMap))
|
||||
}
|
||||
for ip, names := range hostsMap {
|
||||
switch ip {
|
||||
case "1.1.1.1":
|
||||
for _, name := range names {
|
||||
if name != "banana-party.local.com" && name != "cheese-land.lan" {
|
||||
t.Fatalf("unexpected names for 1.1.1.1: %v", names)
|
||||
}
|
||||
}
|
||||
case "10.0.10.227":
|
||||
if len(names) != 1 {
|
||||
t.Fatalf("unexpected names for 10.0.10.227: %v", names)
|
||||
}
|
||||
if names[0] != "OPNsense" {
|
||||
t.Fatalf("unexpected name: %s", names[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package clientinfo
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -107,6 +112,7 @@ func (m *mdns) init(quitCh chan struct{}) error {
|
||||
|
||||
go m.probeLoop(v4ConnList, mdnsV4Addr, quitCh)
|
||||
go m.probeLoop(v6ConnList, mdnsV6Addr, quitCh)
|
||||
go m.getDataFromAvahiDaemonCache()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -116,8 +122,8 @@ func (m *mdns) probeLoop(conns []*net.UDPConn, remoteAddr net.Addr, quitCh chan
|
||||
bo := backoff.NewBackoff("mdns probe", func(format string, args ...any) {}, time.Second*30)
|
||||
for {
|
||||
err := m.probe(conns, remoteAddr)
|
||||
if isErrNetUnreachableOrInvalid(err) {
|
||||
ctrld.ProxyLogger.Load().Warn().Msgf("stop probing %q: network unreachable or invalid", remoteAddr)
|
||||
if shouldStopProbing(err) {
|
||||
ctrld.ProxyLogger.Load().Warn().Msgf("stop probing %q: %v", remoteAddr, err)
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
@@ -159,7 +165,7 @@ func (m *mdns) readLoop(conn *net.UDPConn) {
|
||||
}
|
||||
|
||||
var ip, name string
|
||||
rrs := make([]dns.RR, 0, len(msg.Answer)+len(msg.Extra))
|
||||
var rrs []dns.RR
|
||||
rrs = append(rrs, msg.Answer...)
|
||||
rrs = append(rrs, msg.Extra...)
|
||||
for _, rr := range rrs {
|
||||
@@ -212,6 +218,44 @@ func (m *mdns) probe(conns []*net.UDPConn, remoteAddr net.Addr) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// getDataFromAvahiDaemonCache reads entries from avahi-daemon cache to update mdns data.
|
||||
func (m *mdns) getDataFromAvahiDaemonCache() {
|
||||
if _, err := exec.LookPath("avahi-browse"); err != nil {
|
||||
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not find avahi-browse binary, skipping.")
|
||||
return
|
||||
}
|
||||
// Run avahi-browse to discover services from cache:
|
||||
// - "-a" -> all services.
|
||||
// - "-r" -> resolve found services.
|
||||
// - "-p" -> parseable format.
|
||||
// - "-c" -> read from cache.
|
||||
out, err := exec.Command("avahi-browse", "-a", "-r", "-p", "-c").Output()
|
||||
if err != nil {
|
||||
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not browse services from avahi cache")
|
||||
return
|
||||
}
|
||||
m.storeDataFromAvahiBrowseOutput(bytes.NewReader(out))
|
||||
}
|
||||
|
||||
// storeDataFromAvahiBrowseOutput parses avahi-browse output from reader, then updating found data to mdns table.
|
||||
func (m *mdns) storeDataFromAvahiBrowseOutput(r io.Reader) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
fields := strings.FieldsFunc(scanner.Text(), func(r rune) bool {
|
||||
return r == ';'
|
||||
})
|
||||
if len(fields) < 8 || fields[0] != "=" {
|
||||
continue
|
||||
}
|
||||
ip := fields[7]
|
||||
name := normalizeHostname(fields[6])
|
||||
// Only using cache value if we don't have existed one.
|
||||
if _, loaded := m.name.LoadOrStore(ip, name); !loaded {
|
||||
ctrld.ProxyLogger.Load().Debug().Msgf("found hostname: %q, ip: %q via avahi cache", name, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func multicastInterfaces() ([]net.Interface, error) {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
@@ -229,10 +273,14 @@ func multicastInterfaces() ([]net.Interface, error) {
|
||||
return interfaces, nil
|
||||
}
|
||||
|
||||
func isErrNetUnreachableOrInvalid(err error) bool {
|
||||
// shouldStopProbing reports whether ctrld should stop probing mdns.
|
||||
func shouldStopProbing(err error) bool {
|
||||
var se *os.SyscallError
|
||||
if errors.As(err, &se) {
|
||||
return se.Err == syscall.ENETUNREACH || se.Err == syscall.EINVAL
|
||||
switch se.Err {
|
||||
case syscall.ENETUNREACH, syscall.EINVAL, syscall.EPERM:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
27
internal/clientinfo/mdns_test.go
Normal file
27
internal/clientinfo/mdns_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package clientinfo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_mdns_storeDataFromAvahiBrowseOutput(t *testing.T) {
|
||||
const content = `+;wlp0s20f3;IPv6;Foo\032\0402\041;_companion-link._tcp;local
|
||||
+;wlp0s20f3;IPv4;Foo\032\0402\041;_companion-link._tcp;local
|
||||
=;wlp0s20f3;IPv6;Foo\032\0402\041;_companion-link._tcp;local;Foo-2.local;192.168.1.123;64842;"rpBA=00:00:00:00:00:01" "rpHI=e6ae2cbbca0e" "rpAD=36566f4d850f" "rpVr=510.71.1" "rpHA=0ddc20fdddc8" "rpFl=0x30000" "rpHN=1d4a03afdefa" "rpMac=0"
|
||||
=;wlp0s20f3;IPv4;Foo\032\0402\041;_companion-link._tcp;local;Foo-2.local;192.168.1.123;64842;"rpBA=00:00:00:00:00:01" "rpHI=e6ae2cbbca0e" "rpAD=36566f4d850f" "rpVr=510.71.1" "rpHA=0ddc20fdddc8" "rpFl=0x30000" "rpHN=1d4a03afdefa" "rpMac=0"
|
||||
`
|
||||
m := &mdns{}
|
||||
m.storeDataFromAvahiBrowseOutput(strings.NewReader(content))
|
||||
ip := "192.168.1.123"
|
||||
val, loaded := m.name.LoadOrStore(ip, "")
|
||||
if !loaded {
|
||||
t.Fatal("missing Foo-2 data from mdns table")
|
||||
}
|
||||
|
||||
wantHostname := "Foo-2"
|
||||
hostname := val.(string)
|
||||
if hostname != wantHostname {
|
||||
t.Fatalf("unexpected hostname, want: %q, got: %q", wantHostname, hostname)
|
||||
}
|
||||
}
|
||||
249
internal/clientinfo/ndp.go
Normal file
249
internal/clientinfo/ndp.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package clientinfo
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mdlayher/ndp"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
||||
)
|
||||
|
||||
// ndpDiscover provides client discovery functionality using NDP protocol.
|
||||
type ndpDiscover struct {
|
||||
mac sync.Map // ip => mac
|
||||
ip sync.Map // mac => ip
|
||||
}
|
||||
|
||||
// refresh re-scans the NDP table.
|
||||
func (nd *ndpDiscover) refresh() error {
|
||||
nd.scan()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupIP returns the ipv6 associated with the input MAC address.
|
||||
func (nd *ndpDiscover) LookupIP(mac string) string {
|
||||
val, ok := nd.ip.Load(mac)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return val.(string)
|
||||
}
|
||||
|
||||
// LookupMac returns the MAC address of the given IP address.
|
||||
func (nd *ndpDiscover) LookupMac(ip string) string {
|
||||
val, ok := nd.mac.Load(ip)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return val.(string)
|
||||
}
|
||||
|
||||
// String returns human-readable format of ndpDiscover.
|
||||
func (nd *ndpDiscover) String() string {
|
||||
return "ndp"
|
||||
}
|
||||
|
||||
// List returns all known IP addresses.
|
||||
func (nd *ndpDiscover) List() []string {
|
||||
if nd == nil {
|
||||
return nil
|
||||
}
|
||||
var ips []string
|
||||
nd.ip.Range(func(key, value any) bool {
|
||||
ips = append(ips, value.(string))
|
||||
return true
|
||||
})
|
||||
nd.mac.Range(func(key, value any) bool {
|
||||
ips = append(ips, key.(string))
|
||||
return true
|
||||
})
|
||||
return ips
|
||||
}
|
||||
|
||||
// saveInfo saves ip and mac info to mapping table.
|
||||
func (nd *ndpDiscover) saveInfo(ip, mac string) {
|
||||
ip = normalizeIP(ip)
|
||||
// Store ip => map mapping,
|
||||
nd.mac.Store(ip, mac)
|
||||
|
||||
// Do not store mac => ip mapping if new ip is a link local unicast.
|
||||
if ctrldnet.IsLinkLocalUnicastIPv6(ip) {
|
||||
return
|
||||
}
|
||||
|
||||
// If there is old ip => mac mapping, delete it.
|
||||
if old, existed := nd.ip.Load(mac); existed {
|
||||
oldIP := old.(string)
|
||||
if oldIP != ip {
|
||||
nd.mac.Delete(oldIP)
|
||||
}
|
||||
}
|
||||
// Store mac => ip mapping.
|
||||
nd.ip.Store(mac, ip)
|
||||
}
|
||||
|
||||
// listen listens on ipv6 link local for Neighbor Solicitation message
|
||||
// to update new neighbors information to ndp table.
|
||||
func (nd *ndpDiscover) listen(ctx context.Context) {
|
||||
ifis, err := allInterfacesWithV6LinkLocal()
|
||||
if err != nil {
|
||||
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("failed to find valid ipv6 interfaces")
|
||||
return
|
||||
}
|
||||
for _, ifi := range ifis {
|
||||
go func(ifi *net.Interface) {
|
||||
nd.listenOnInterface(ctx, ifi)
|
||||
}(ifi)
|
||||
}
|
||||
}
|
||||
|
||||
func (nd *ndpDiscover) listenOnInterface(ctx context.Context, ifi *net.Interface) {
|
||||
c, ip, err := ndp.Listen(ifi, ndp.Unspecified)
|
||||
if err != nil {
|
||||
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("ndp listen failed")
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
ctrld.ProxyLogger.Load().Debug().Msgf("listening ndp on: %s", ip.String())
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = c.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
msg, _, from, readErr := c.ReadFrom()
|
||||
if readErr != nil {
|
||||
var opErr *net.OpError
|
||||
if errors.As(readErr, &opErr) && (opErr.Timeout() || opErr.Temporary()) {
|
||||
continue
|
||||
}
|
||||
ctrld.ProxyLogger.Load().Debug().Err(readErr).Msg("ndp read loop error")
|
||||
return
|
||||
}
|
||||
|
||||
// Only looks for neighbor solicitation message, since new clients
|
||||
// which join network will broadcast this message to us.
|
||||
am, ok := msg.(*ndp.NeighborSolicitation)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fromIP := from.String()
|
||||
for _, opt := range am.Options {
|
||||
if lla, ok := opt.(*ndp.LinkLayerAddress); ok {
|
||||
mac := lla.Addr.String()
|
||||
nd.saveInfo(fromIP, mac)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanWindows populates NDP table using information from "netsh" command.
|
||||
func (nd *ndpDiscover) scanWindows(r io.Reader) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
if mac := parseMAC(fields[1]); mac != "" {
|
||||
nd.saveInfo(fields[0], mac)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanUnix populates NDP table using information from "ndp" command.
|
||||
func (nd *ndpDiscover) scanUnix(r io.Reader) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Scan() // skip header
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
if mac := parseMAC(fields[1]); mac != "" {
|
||||
ip := fields[0]
|
||||
if idx := strings.IndexByte(ip, '%'); idx != -1 {
|
||||
ip = ip[:idx]
|
||||
}
|
||||
nd.saveInfo(ip, mac)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeMac ensure the given MAC address have the proper format
|
||||
// before being parsed.
|
||||
//
|
||||
// Example, changing "00:0:00:0:00:01" to "00:00:00:00:00:01", which
|
||||
// can be seen on Darwin.
|
||||
func normalizeMac(mac string) string {
|
||||
if len(mac) == 17 {
|
||||
return mac
|
||||
}
|
||||
// Windows use "-" instead of ":" as separator.
|
||||
mac = strings.ReplaceAll(mac, "-", ":")
|
||||
parts := strings.Split(mac, ":")
|
||||
if len(parts) != 6 {
|
||||
return ""
|
||||
}
|
||||
for i, c := range parts {
|
||||
if len(c) == 1 {
|
||||
parts[i] = "0" + c
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
// parseMAC parses the input MAC, doing normalization,
|
||||
// and return the result after calling net.ParseMac function.
|
||||
func parseMAC(mac string) string {
|
||||
hw, _ := net.ParseMAC(normalizeMac(mac))
|
||||
return hw.String()
|
||||
}
|
||||
|
||||
// allInterfacesWithV6LinkLocal returns all interfaces which is capable of using NDP.
|
||||
func allInterfacesWithV6LinkLocal() ([]*net.Interface, error) {
|
||||
ifis, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := make([]*net.Interface, 0, len(ifis))
|
||||
for _, ifi := range ifis {
|
||||
ifi := ifi
|
||||
// Skip if iface is down/loopback/non-multicast.
|
||||
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 || ifi.Flags&net.FlagMulticast == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := ifi.Addrs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ip, ok := netip.AddrFromSlice(ipNet.IP)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid ip address: %s", ipNet.String())
|
||||
}
|
||||
if ip.Is6() && !ip.Is4In6() {
|
||||
res = append(res, &ifi)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
64
internal/clientinfo/ndp_linux.go
Normal file
64
internal/clientinfo/ndp_linux.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package clientinfo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// scan populates NDP table using information from system mappings.
|
||||
func (nd *ndpDiscover) scan() {
|
||||
neighs, err := netlink.NeighList(0, netlink.FAMILY_V6)
|
||||
if err != nil {
|
||||
ctrld.ProxyLogger.Load().Warn().Err(err).Msg("could not get neigh list")
|
||||
return
|
||||
}
|
||||
|
||||
for _, n := range neighs {
|
||||
// Skipping non-reachable neighbors.
|
||||
if n.State&netlink.NUD_REACHABLE == 0 {
|
||||
continue
|
||||
}
|
||||
ip := n.IP.String()
|
||||
mac := n.HardwareAddr.String()
|
||||
nd.saveInfo(ip, mac)
|
||||
}
|
||||
}
|
||||
|
||||
// subscribe watches NDP table changes and update new information to local table.
|
||||
func (nd *ndpDiscover) subscribe(ctx context.Context) {
|
||||
ch := make(chan netlink.NeighUpdate)
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
if err := netlink.NeighSubscribe(ch, done); err != nil {
|
||||
ctrld.ProxyLogger.Load().Err(err).Msg("could not perform neighbor subscribing")
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case nu := <-ch:
|
||||
if nu.Family != netlink.FAMILY_V6 {
|
||||
continue
|
||||
}
|
||||
ip := normalizeIP(nu.IP.String())
|
||||
if nu.Type == unix.RTM_DELNEIGH {
|
||||
ctrld.ProxyLogger.Load().Debug().Msgf("removing NDP neighbor: %s", ip)
|
||||
nd.mac.Delete(ip)
|
||||
continue
|
||||
}
|
||||
mac := nu.HardwareAddr.String()
|
||||
switch nu.State {
|
||||
case netlink.NUD_REACHABLE:
|
||||
nd.saveInfo(ip, mac)
|
||||
case netlink.NUD_FAILED:
|
||||
ctrld.ProxyLogger.Load().Debug().Msgf("removing NDP neighbor with failed state: %s", ip)
|
||||
nd.mac.Delete(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
internal/clientinfo/ndp_others.go
Normal file
36
internal/clientinfo/ndp_others.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build !linux
|
||||
|
||||
package clientinfo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// scan populates NDP table using information from system mappings.
|
||||
func (nd *ndpDiscover) scan() {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
data, err := exec.Command("netsh", "interface", "ipv6", "show", "neighbors").Output()
|
||||
if err != nil {
|
||||
ctrld.ProxyLogger.Load().Warn().Err(err).Msg("could not query ndp table")
|
||||
return
|
||||
}
|
||||
nd.scanWindows(bytes.NewReader(data))
|
||||
default:
|
||||
data, err := exec.Command("ndp", "-an").Output()
|
||||
if err != nil {
|
||||
ctrld.ProxyLogger.Load().Warn().Err(err).Msg("could not query ndp table")
|
||||
return
|
||||
}
|
||||
nd.scanUnix(bytes.NewReader(data))
|
||||
}
|
||||
}
|
||||
|
||||
// subscribe watches NDP table changes and update new information to local table.
|
||||
// This is a stub method, and only works on Linux at this moment.
|
||||
func (nd *ndpDiscover) subscribe(ctx context.Context) {}
|
||||
66
internal/clientinfo/ndp_test.go
Normal file
66
internal/clientinfo/ndp_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package clientinfo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_ndpDiscover_scanUnix(t *testing.T) {
|
||||
r := strings.NewReader(`Neighbor Linklayer Address Netif Expire St Flgs Prbs
|
||||
2405:4802:1f90:fda0:1459:ec89:523d:3583 00:0:00:0:00:01 en0 permanent R
|
||||
2405:4802:1f90:fda0:186b:c54a:1370:c196 (incomplete) en0 expired N
|
||||
2405:4802:1f90:fda0:88de:14ef:6a8c:579a 00:0:00:0:00:02 en0 permanent R
|
||||
fe80::1%lo0 (incomplete) lo0 permanent R
|
||||
`)
|
||||
nd := &ndpDiscover{}
|
||||
nd.scanUnix(r)
|
||||
|
||||
for _, m := range []*sync.Map{&nd.mac, &nd.ip} {
|
||||
count := 0
|
||||
m.Range(func(key, value any) bool {
|
||||
count++
|
||||
return true
|
||||
})
|
||||
if count != 2 {
|
||||
t.Errorf("unexpected count, want 2, got: %d", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ndpDiscover_scanWindows(t *testing.T) {
|
||||
r := strings.NewReader(`Interface 14: Wi-Fi
|
||||
|
||||
|
||||
Internet Address Physical Address Type
|
||||
-------------------------------------------- ----------------- -----------
|
||||
2405:4802:1f90:fda0:ffff:ffff:ffff:ff88 00-00-00-00-00-00 Unreachable
|
||||
fe80::1 60-57-47-21-dd-00 Reachable (Router)
|
||||
fe80::6257:47ff:fe21:dd00 60-57-47-21-dd-00 Reachable (Router)
|
||||
ff02::1 33-33-00-00-00-01 Permanent
|
||||
ff02::2 33-33-00-00-00-02 Permanent
|
||||
ff02::c 33-33-00-00-00-0c Permanent
|
||||
`)
|
||||
nd := &ndpDiscover{}
|
||||
nd.scanWindows(r)
|
||||
|
||||
count := 0
|
||||
expectedCount := 6
|
||||
nd.mac.Range(func(key, value any) bool {
|
||||
count++
|
||||
return true
|
||||
})
|
||||
if count != expectedCount {
|
||||
t.Errorf("unexpected count, want %d, got: %d", expectedCount, count)
|
||||
}
|
||||
|
||||
count = 0
|
||||
expectedCount = 4
|
||||
nd.ip.Range(func(key, value any) bool {
|
||||
count++
|
||||
return true
|
||||
})
|
||||
if count != expectedCount {
|
||||
t.Errorf("unexpected count, want %d, got: %d", expectedCount, count)
|
||||
}
|
||||
}
|
||||
78
internal/clientinfo/ubios.go
Normal file
78
internal/clientinfo/ubios.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package clientinfo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/router"
|
||||
"github.com/Control-D-Inc/ctrld/internal/router/ubios"
|
||||
)
|
||||
|
||||
// ubiosDiscover provides client discovery functionality on Ubios routers.
|
||||
type ubiosDiscover struct {
|
||||
hostname sync.Map // mac => hostname
|
||||
}
|
||||
|
||||
// refresh reloads unifi devices from database.
|
||||
func (u *ubiosDiscover) refresh() error {
|
||||
if router.Name() != ubios.Name {
|
||||
return nil
|
||||
}
|
||||
return u.refreshDevices()
|
||||
}
|
||||
|
||||
// LookupHostnameByIP returns hostname for given IP.
|
||||
func (u *ubiosDiscover) LookupHostnameByIP(ip string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// LookupHostnameByMac returns unifi device custom hostname for the given MAC address.
|
||||
func (u *ubiosDiscover) LookupHostnameByMac(mac string) string {
|
||||
val, ok := u.hostname.Load(mac)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return val.(string)
|
||||
}
|
||||
|
||||
// refreshDevices updates unifi devices name from local mongodb.
|
||||
func (u *ubiosDiscover) refreshDevices() error {
|
||||
cmd := exec.Command("/usr/bin/mongo", "localhost:27117/ace", "--quiet", "--eval", `
|
||||
DBQuery.shellBatchSize = 256;
|
||||
db.user.find({name: {$exists: true, $ne: ""}}, {_id:0, mac:1, name:1});`)
|
||||
b, err := cmd.Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.storeDevices(bytes.NewReader(b))
|
||||
}
|
||||
|
||||
// storeDevices saves unifi devices name for caching.
|
||||
func (u *ubiosDiscover) storeDevices(r io.Reader) error {
|
||||
decoder := json.NewDecoder(r)
|
||||
device := struct {
|
||||
MAC string
|
||||
Name string
|
||||
}{}
|
||||
for {
|
||||
err := decoder.Decode(&device)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mac := strings.ToLower(device.MAC)
|
||||
u.hostname.Store(mac, normalizeHostname(device.Name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns human-readable format of ubiosDiscover.
|
||||
func (u *ubiosDiscover) String() string {
|
||||
return "ubios"
|
||||
}
|
||||
43
internal/clientinfo/ubios_test.go
Normal file
43
internal/clientinfo/ubios_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package clientinfo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_ubiosDiscover_storeDevices(t *testing.T) {
|
||||
ud := &ubiosDiscover{}
|
||||
r := strings.NewReader(`{ "mac": "00:00:00:00:00:01", "name": "device 1" }
|
||||
{ "mac": "00:00:00:00:00:02", "name": "device 2" }
|
||||
`)
|
||||
if err := ud.storeDevices(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mac string
|
||||
hostname string
|
||||
}{
|
||||
{"device 1", "00:00:00:00:00:01", "device 1"},
|
||||
{"device 2", "00:00:00:00:00:02", "device 2"},
|
||||
{"non-existed", "00:00:00:00:00:03", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := ud.LookupHostnameByMac(tc.mac); got != tc.hostname {
|
||||
t.Errorf("hostname mismatched, want: %q, got: %q", tc.hostname, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test for invalid input.
|
||||
r = strings.NewReader(`{ "mac": "00:00:00:00:00:01", "name": "device 1"`)
|
||||
if err := ud.storeDevices(r); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
} else {
|
||||
t.Log(err)
|
||||
}
|
||||
}
|
||||
@@ -26,17 +26,19 @@ const (
|
||||
apiDomainDev = "api.controld.dev"
|
||||
resolverDataURLCom = "https://api.controld.com/utility"
|
||||
resolverDataURLDev = "https://api.controld.dev/utility"
|
||||
InvalidConfigCode = 40401
|
||||
InvalidConfigCode = 40402
|
||||
)
|
||||
|
||||
// ResolverConfig represents Control D resolver data.
|
||||
type ResolverConfig struct {
|
||||
DOH string `json:"doh"`
|
||||
Ctrld struct {
|
||||
CustomConfig string `json:"custom_config"`
|
||||
CustomConfig string `json:"custom_config"`
|
||||
CustomLastUpdate int64 `json:"custom_last_update"`
|
||||
} `json:"ctrld"`
|
||||
Exclude []string `json:"exclude"`
|
||||
UID string `json:"uid"`
|
||||
Exclude []string `json:"exclude"`
|
||||
UID string `json:"uid"`
|
||||
DeactivationPin *int64 `json:"deactivation_pin,omitempty"`
|
||||
}
|
||||
|
||||
type utilityResponse struct {
|
||||
@@ -75,17 +77,28 @@ func FetchResolverConfig(rawUID, version string, cdDev bool) (*ResolverConfig, e
|
||||
req.ClientID = clientID
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
return postUtilityAPI(version, cdDev, bytes.NewReader(body))
|
||||
return postUtilityAPI(version, cdDev, false, bytes.NewReader(body))
|
||||
}
|
||||
|
||||
// FetchResolverUID fetch resolver uid from provision token.
|
||||
func FetchResolverUID(pt, version string, cdDev bool) (*ResolverConfig, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
body, _ := json.Marshal(utilityOrgRequest{ProvToken: pt, Hostname: hostname})
|
||||
return postUtilityAPI(version, cdDev, bytes.NewReader(body))
|
||||
return postUtilityAPI(version, cdDev, false, bytes.NewReader(body))
|
||||
}
|
||||
|
||||
func postUtilityAPI(version string, cdDev bool, body io.Reader) (*ResolverConfig, error) {
|
||||
// UpdateCustomLastFailed calls API to mark custom config is bad.
|
||||
func UpdateCustomLastFailed(rawUID, version string, cdDev, lastUpdatedFailed bool) (*ResolverConfig, error) {
|
||||
uid, clientID := ParseRawUID(rawUID)
|
||||
req := utilityRequest{UID: uid}
|
||||
if clientID != "" {
|
||||
req.ClientID = clientID
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
return postUtilityAPI(version, cdDev, true, bytes.NewReader(body))
|
||||
}
|
||||
|
||||
func postUtilityAPI(version string, cdDev, lastUpdatedFailed bool, body io.Reader) (*ResolverConfig, error) {
|
||||
apiUrl := resolverDataURLCom
|
||||
if cdDev {
|
||||
apiUrl = resolverDataURLDev
|
||||
@@ -97,6 +110,9 @@ func postUtilityAPI(version string, cdDev bool, body io.Reader) (*ResolverConfig
|
||||
q := req.URL.Query()
|
||||
q.Set("platform", "ctrld")
|
||||
q.Set("version", version)
|
||||
if lastUpdatedFailed {
|
||||
q.Set("custom_last_failed", "1")
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
type Cacher interface {
|
||||
Get(Key) *Value
|
||||
Add(Key, *Value)
|
||||
Purge()
|
||||
}
|
||||
|
||||
// Key is the caching key for DNS message.
|
||||
@@ -34,15 +35,22 @@ type LRUCache struct {
|
||||
cacher *lru.ARCCache[Key, *Value]
|
||||
}
|
||||
|
||||
// Get looks up key's value from cache.
|
||||
func (l *LRUCache) Get(key Key) *Value {
|
||||
v, _ := l.cacher.Get(key)
|
||||
return v
|
||||
}
|
||||
|
||||
// Add adds a value to cache.
|
||||
func (l *LRUCache) Add(key Key, value *Value) {
|
||||
l.cacher.Add(key, value)
|
||||
}
|
||||
|
||||
// Purge clears the cache.
|
||||
func (l *LRUCache) Purge() {
|
||||
l.cacher.Purge()
|
||||
}
|
||||
|
||||
// NewLRUCache creates a new LRUCache instance with given size.
|
||||
func NewLRUCache(size int) (*LRUCache, error) {
|
||||
cacher, err := lru.NewARC[Key, *Value](size)
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
|
||||
const (
|
||||
controldIPv6Test = "ipv6.controld.io"
|
||||
v4BootstrapDNS = "76.76.2.0:53"
|
||||
v6BootstrapDNS = "[2606:1a40::]:53"
|
||||
v4BootstrapDNS = "76.76.2.22:53"
|
||||
v6BootstrapDNS = "[2606:1a40::22]:53"
|
||||
)
|
||||
|
||||
var Dialer = &net.Dialer{
|
||||
@@ -115,6 +115,15 @@ func IsIPv6(ip string) bool {
|
||||
return parsedIP != nil && parsedIP.To4() == nil && parsedIP.To16() != nil
|
||||
}
|
||||
|
||||
// IsLinkLocalUnicastIPv6 checks if the provided IP is a link local unicast v6 address.
|
||||
func IsLinkLocalUnicastIPv6(ip string) bool {
|
||||
parsedIP := net.ParseIP(ip)
|
||||
if parsedIP == nil || parsedIP.To4() != nil || parsedIP.To16() == nil {
|
||||
return false
|
||||
}
|
||||
return parsedIP.To16().IsLinkLocalUnicast()
|
||||
}
|
||||
|
||||
type parallelDialerResult struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
package dnsmasq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
const CtrldMarker = `# GENERATED BY ctrld - DO NOT MODIFY`
|
||||
|
||||
const ConfigContentTmpl = `# GENERATED BY ctrld - DO NOT MODIFY
|
||||
no-resolv
|
||||
{{- range .Upstreams}}
|
||||
server={{ .IP }}#{{ .Port }}
|
||||
{{- end}}
|
||||
{{- if .SendClientInfo}}
|
||||
add-mac
|
||||
add-subnet=32,128
|
||||
{{- end}}
|
||||
{{- if .CacheDisabled}}
|
||||
cache-size=0
|
||||
{{- else}}
|
||||
max-cache-ttl=0
|
||||
{{- end}}
|
||||
`
|
||||
|
||||
@@ -45,12 +44,10 @@ if [ -n "$pid" ] && [ -f "/proc/${pid}/cmdline" ]; then
|
||||
{{- range .Upstreams}}
|
||||
pc_append "server={{ .IP }}#{{ .Port }}" "$config_file"
|
||||
{{- end}}
|
||||
{{- if .SendClientInfo}}
|
||||
pc_delete "add-mac" "$config_file"
|
||||
pc_delete "add-subnet" "$config_file"
|
||||
pc_append "add-mac" "$config_file" # add client mac
|
||||
pc_append "add-subnet=32,128" "$config_file" # add client ip
|
||||
{{- end}}
|
||||
pc_delete "dnssec" "$config_file" # disable DNSSEC
|
||||
pc_delete "trust-anchor=" "$config_file" # disable DNSSEC
|
||||
pc_delete "cache-size=" "$config_file"
|
||||
@@ -72,11 +69,18 @@ type Upstream struct {
|
||||
Port int
|
||||
}
|
||||
|
||||
// ConfTmpl generates dnsmasq configuration from ctrld config.
|
||||
func ConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) {
|
||||
return ConfTmplWitchCacheDisabled(tmplText, cfg, true)
|
||||
return ConfTmplWithCacheDisabled(tmplText, cfg, true)
|
||||
}
|
||||
|
||||
func ConfTmplWitchCacheDisabled(tmplText string, cfg *ctrld.Config, cacheDisabled bool) (string, error) {
|
||||
// ConfTmplWithCacheDisabled is like ConfTmpl, but the caller can control whether
|
||||
// dnsmasq cache is disabled using cacheDisabled parameter.
|
||||
//
|
||||
// Generally, the caller should use ConfTmpl, but on some routers which dnsmasq config may be changed
|
||||
// after ctrld started (like EdgeOS/Ubios, Firewalla ...), dnsmasq cache should not be disabled because
|
||||
// the cache-size=0 generated by ctrld will conflict with router's generated config.
|
||||
func ConfTmplWithCacheDisabled(tmplText string, cfg *ctrld.Config, cacheDisabled bool) (string, error) {
|
||||
listener := cfg.FirstListener()
|
||||
if listener == nil {
|
||||
return "", errors.New("missing listener")
|
||||
@@ -86,26 +90,27 @@ func ConfTmplWitchCacheDisabled(tmplText string, cfg *ctrld.Config, cacheDisable
|
||||
ip = "127.0.0.1"
|
||||
}
|
||||
upstreams := []Upstream{{IP: ip, Port: listener.Port}}
|
||||
return confTmpl(tmplText, upstreams, cfg.HasUpstreamSendClientInfo(), cacheDisabled)
|
||||
return confTmpl(tmplText, upstreams, cacheDisabled)
|
||||
}
|
||||
|
||||
// FirewallaConfTmpl generates dnsmasq config for Firewalla routers.
|
||||
func FirewallaConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) {
|
||||
// If ctrld listen on all interfaces, generating config for all of them.
|
||||
if lc := cfg.FirstListener(); lc != nil && (lc.IP == "0.0.0.0" || lc.IP == "") {
|
||||
return confTmpl(tmplText, firewallaUpstreams(lc.Port), cfg.HasUpstreamSendClientInfo(), true)
|
||||
return confTmpl(tmplText, firewallaUpstreams(lc.Port), false)
|
||||
}
|
||||
return ConfTmpl(tmplText, cfg)
|
||||
// Otherwise, generating config for the specific listener from ctrld's config.
|
||||
return ConfTmplWithCacheDisabled(tmplText, cfg, false)
|
||||
}
|
||||
|
||||
func confTmpl(tmplText string, upstreams []Upstream, sendClientInfo, cacheDisabled bool) (string, error) {
|
||||
func confTmpl(tmplText string, upstreams []Upstream, cacheDisabled bool) (string, error) {
|
||||
tmpl := template.Must(template.New("").Parse(tmplText))
|
||||
var to = &struct {
|
||||
SendClientInfo bool
|
||||
Upstreams []Upstream
|
||||
CacheDisabled bool
|
||||
Upstreams []Upstream
|
||||
CacheDisabled bool
|
||||
}{
|
||||
SendClientInfo: sendClientInfo,
|
||||
Upstreams: upstreams,
|
||||
CacheDisabled: cacheDisabled,
|
||||
Upstreams: upstreams,
|
||||
CacheDisabled: cacheDisabled,
|
||||
}
|
||||
var sb strings.Builder
|
||||
if err := tmpl.Execute(&sb, to); err != nil {
|
||||
@@ -136,20 +141,6 @@ func firewallaDnsmasqConfFiles() ([]string, error) {
|
||||
return filepath.Glob("/home/pi/firerouter/etc/dnsmasq.dns.*.conf")
|
||||
}
|
||||
|
||||
// firewallUpdateConf updates all firewall config files using given function.
|
||||
func firewallUpdateConf(update func(conf string) error) error {
|
||||
confFiles, err := firewallaDnsmasqConfFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, conf := range confFiles {
|
||||
if err := update(conf); err != nil {
|
||||
return fmt.Errorf("%s: %w", conf, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FirewallaSelfInterfaces returns list of interfaces that will be configured with default dnsmasq setup on Firewalla.
|
||||
func FirewallaSelfInterfaces() []*net.Interface {
|
||||
matches, err := firewallaDnsmasqConfFiles()
|
||||
@@ -166,32 +157,3 @@ func FirewallaSelfInterfaces() []*net.Interface {
|
||||
}
|
||||
return ifaces
|
||||
}
|
||||
|
||||
// FirewallaDisableCache comments out "cache-size" line in all firewalla dnsmasq config files.
|
||||
func FirewallaDisableCache() error {
|
||||
return firewallUpdateConf(DisableCache)
|
||||
}
|
||||
|
||||
// FirewallaEnableCache un-comments out "cache-size" line in all firewalla dnsmasq config files.
|
||||
func FirewallaEnableCache() error {
|
||||
return firewallUpdateConf(EnableCache)
|
||||
}
|
||||
|
||||
// DisableCache comments out "cache-size" line in dnsmasq config file.
|
||||
func DisableCache(conf string) error {
|
||||
return replaceFileContent(conf, "\ncache-size=", "\n#cache-size=")
|
||||
}
|
||||
|
||||
// EnableCache un-comments "cache-size" line in dnsmasq config file.
|
||||
func EnableCache(conf string) error {
|
||||
return replaceFileContent(conf, "\n#cache-size=", "\ncache-size=")
|
||||
}
|
||||
|
||||
func replaceFileContent(filename, old, new string) error {
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content = bytes.ReplaceAll(content, []byte(old), []byte(new))
|
||||
return os.WriteFile(filename, content, 0644)
|
||||
}
|
||||
|
||||
@@ -20,11 +20,15 @@ const (
|
||||
usgDNSMasqConfigPath = "/etc/dnsmasq.conf"
|
||||
usgDNSMasqBackupConfigPath = "/etc/dnsmasq.conf.bak"
|
||||
toggleContentFilteringLink = "https://community.ui.com/questions/UDM-Pro-disable-enable-DNS-filtering/e2cc4060-e56a-4139-b200-62d7f773ff8f"
|
||||
toggleDnsShieldLink = "https://community.ui.com/questions/UniFi-OS-3-2-7-DNS-Shield-Missing/d3a85905-4ce0-4fe4-8bf0-6cb04f21371d"
|
||||
)
|
||||
|
||||
var ErrContentFilteringEnabled = fmt.Errorf(`the "Content Filtering" feature" is enabled, which is conflicted with ctrld.\n
|
||||
To disable it, folowing instruction here: %s`, toggleContentFilteringLink)
|
||||
|
||||
var ErrDnsShieldEnabled = fmt.Errorf(`the "DNS Shield" feature" is enabled, which is conflicted with ctrld.\n
|
||||
To disable it, folowing screenshot here: %s`, toggleDnsShieldLink)
|
||||
|
||||
type EdgeOS struct {
|
||||
cfg *ctrld.Config
|
||||
isUSG bool
|
||||
@@ -50,6 +54,11 @@ func (e *EdgeOS) Install(_ *service.Config) error {
|
||||
if ContentFilteringEnabled() {
|
||||
return ErrContentFilteringEnabled
|
||||
}
|
||||
// If "DNS Shield" is enabled, UniFi OS will spawn dnscrypt-proxy process, and route all DNS queries to it. So
|
||||
// reporting an error and guiding users to disable the feature using UniFi OS web UI.
|
||||
if DnsShieldEnabled() {
|
||||
return ErrDnsShieldEnabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -109,7 +118,7 @@ func (e *EdgeOS) setupUSG() error {
|
||||
sb.WriteString(line)
|
||||
}
|
||||
|
||||
data, err := dnsmasq.ConfTmplWitchCacheDisabled(dnsmasq.ConfigContentTmpl, e.cfg, false)
|
||||
data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, e.cfg, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,7 +136,7 @@ func (e *EdgeOS) setupUSG() error {
|
||||
}
|
||||
|
||||
func (e *EdgeOS) setupUDM() error {
|
||||
data, err := dnsmasq.ConfTmplWitchCacheDisabled(dnsmasq.ConfigContentTmpl, e.cfg, false)
|
||||
data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, e.cfg, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -169,6 +178,16 @@ func ContentFilteringEnabled() bool {
|
||||
return err == nil && !st.IsDir()
|
||||
}
|
||||
|
||||
// DnsShieldEnabled reports whether DNS Shield is enabled.
|
||||
// See: https://community.ui.com/releases/UniFi-OS-Dream-Machines-3-2-7/251dfc1e-f4dd-4264-a080-3be9d8b9e02b
|
||||
func DnsShieldEnabled() bool {
|
||||
buf, err := os.ReadFile("/var/run/dnsmasq.conf.d/dns.conf")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return bytes.Contains(buf, []byte("server=127.0.0.1#5053"))
|
||||
}
|
||||
|
||||
func LeaseFileDir() string {
|
||||
if checkUSG() {
|
||||
return ""
|
||||
|
||||
@@ -65,11 +65,6 @@ func (f *Firewalla) Setup() error {
|
||||
return fmt.Errorf("writing ctrld config: %w", err)
|
||||
}
|
||||
|
||||
// Disable dnsmasq cache.
|
||||
if err := dnsmasq.FirewallaDisableCache(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Restart dnsmasq service.
|
||||
if err := restartDNSMasq(); err != nil {
|
||||
return fmt.Errorf("restartDNSMasq: %w", err)
|
||||
@@ -87,11 +82,6 @@ func (f *Firewalla) Cleanup() error {
|
||||
return fmt.Errorf("removing ctrld config: %w", err)
|
||||
}
|
||||
|
||||
// Enable dnsmasq cache.
|
||||
if err := dnsmasq.FirewallaEnableCache(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Restart dnsmasq service.
|
||||
if err := restartDNSMasq(); err != nil {
|
||||
return fmt.Errorf("restartDNSMasq: %w", err)
|
||||
|
||||
22
internal/router/netgear_orbi_voxel/procd.go
Normal file
22
internal/router/netgear_orbi_voxel/procd.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package netgear
|
||||
|
||||
const openWrtScript = `#!/bin/sh /etc/rc.common
|
||||
USE_PROCD=1
|
||||
# After dnsmasq starts
|
||||
START=61
|
||||
# Before network stops
|
||||
STOP=89
|
||||
cmd="{{.Path}}{{range .Arguments}} {{.|cmd}}{{end}}"
|
||||
name="{{.Name}}"
|
||||
pid_file="/var/run/${name}.pid"
|
||||
|
||||
start_service() {
|
||||
echo "Starting ${name}"
|
||||
procd_open_instance
|
||||
procd_set_param command ${cmd}
|
||||
procd_set_param respawn # respawn automatically if something died
|
||||
procd_set_param pidfile ${pid_file} # write a pid file on instance start and remove it on stop
|
||||
procd_close_instance
|
||||
echo "${name} has been started"
|
||||
}
|
||||
`
|
||||
220
internal/router/netgear_orbi_voxel/voxel.go
Normal file
220
internal/router/netgear_orbi_voxel/voxel.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package netgear
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/router/dnsmasq"
|
||||
"github.com/Control-D-Inc/ctrld/internal/router/nvram"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "netgear_orbi_voxel"
|
||||
netgearOrbiVoxelDNSMasqConfigPath = "/etc/dnsmasq.conf"
|
||||
netgearOrbiVoxelHomedir = "/mnt/bitdefender"
|
||||
netgearOrbiVoxelStartupScript = "/mnt/bitdefender/rc.user"
|
||||
netgearOrbiVoxelStartupScriptBackup = "/mnt/bitdefender/rc.user.bak"
|
||||
netgearOrbiVoxelStartupScriptMarker = "\n# GENERATED BY ctrld"
|
||||
)
|
||||
|
||||
var nvramKvMap = map[string]string{
|
||||
"dns_hijack": "0", // Disable dns hijacking
|
||||
}
|
||||
|
||||
type NetgearOrbiVoxel struct {
|
||||
cfg *ctrld.Config
|
||||
}
|
||||
|
||||
// New returns a router.Router for configuring/setup/run ctrld on ddwrt routers.
|
||||
func New(cfg *ctrld.Config) *NetgearOrbiVoxel {
|
||||
return &NetgearOrbiVoxel{cfg: cfg}
|
||||
}
|
||||
|
||||
func (d *NetgearOrbiVoxel) ConfigureService(svc *service.Config) error {
|
||||
if err := d.checkInstalledDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.Option["SysvScript"] = openWrtScript
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *NetgearOrbiVoxel) Install(_ *service.Config) error {
|
||||
// Ignoring error here at this moment is ok, since everything will be wiped out on reboot.
|
||||
_ = exec.Command("/etc/init.d/ctrld", "enable").Run()
|
||||
if err := d.checkInstalledDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backupVoxelStartupScript(); err != nil {
|
||||
return fmt.Errorf("backup startup script: %w", err)
|
||||
}
|
||||
if err := writeVoxelStartupScript(); err != nil {
|
||||
return fmt.Errorf("writing startup script: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *NetgearOrbiVoxel) Uninstall(_ *service.Config) error {
|
||||
if err := os.Remove(netgearOrbiVoxelStartupScript); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
err := os.Rename(netgearOrbiVoxelStartupScriptBackup, netgearOrbiVoxelStartupScript)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *NetgearOrbiVoxel) PreRun() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *NetgearOrbiVoxel) Setup() error {
|
||||
if d.cfg.FirstListener().IsDirectDnsListener() {
|
||||
return nil
|
||||
}
|
||||
// Already setup.
|
||||
if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val == "1" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, d.cfg, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentConfig, _ := os.ReadFile(netgearOrbiVoxelDNSMasqConfigPath)
|
||||
configContent := append(currentConfig, data...)
|
||||
if err := os.WriteFile(netgearOrbiVoxelDNSMasqConfigPath, configContent, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Restart dnsmasq service.
|
||||
if err := restartDNSMasq(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := nvram.SetKV(nvramKvMap, nvram.CtrldSetupKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *NetgearOrbiVoxel) Cleanup() error {
|
||||
if d.cfg.FirstListener().IsDirectDnsListener() {
|
||||
return nil
|
||||
}
|
||||
if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val != "1" {
|
||||
return nil // was restored, nothing to do.
|
||||
}
|
||||
|
||||
// Restore old configs.
|
||||
if err := nvram.Restore(nvramKvMap, nvram.CtrldSetupKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Restore dnsmasq config.
|
||||
if err := restoreDnsmasqConf(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Restart dnsmasq service.
|
||||
if err := restartDNSMasq(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkInstalledDir checks that ctrld binary was installed in the correct directory.
|
||||
func (d *NetgearOrbiVoxel) checkInstalledDir() error {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checkHomeDir: failed to get binary path %w", err)
|
||||
}
|
||||
if !strings.HasSuffix(filepath.Dir(exePath), netgearOrbiVoxelHomedir) {
|
||||
return fmt.Errorf("checkHomeDir: could not install service outside %s", netgearOrbiVoxelHomedir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupVoxelStartupScript creates a backup of original startup script if existed.
|
||||
func backupVoxelStartupScript() error {
|
||||
// Do nothing if the startup script was modified by ctrld.
|
||||
script, _ := os.ReadFile(netgearOrbiVoxelStartupScript)
|
||||
if bytes.Contains(script, []byte(netgearOrbiVoxelStartupScriptMarker)) {
|
||||
return nil
|
||||
}
|
||||
err := os.Rename(netgearOrbiVoxelStartupScript, netgearOrbiVoxelStartupScriptBackup)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("backupVoxelStartupScript: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeVoxelStartupScript writes startup script to re-install ctrld upon reboot.
|
||||
// See: https://github.com/SVoxel/ORBI-RBK50/pull/7
|
||||
func writeVoxelStartupScript() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure service: failed to get binary path %w", err)
|
||||
}
|
||||
// This is called when "ctrld start ..." runs, so recording
|
||||
// the same command line arguments to use in startup script.
|
||||
argStr := strings.Join(os.Args[1:], " ")
|
||||
script, _ := os.ReadFile(netgearOrbiVoxelStartupScriptBackup)
|
||||
script = append(script, fmt.Sprintf("%s\n%q %s\n", netgearOrbiVoxelStartupScriptMarker, exe, argStr)...)
|
||||
f, err := os.Create(netgearOrbiVoxelStartupScript)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create startup script: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(script); err != nil {
|
||||
return fmt.Errorf("failed to write startup script: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("failed to save startup script: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreDnsmasqConf restores original dnsmasq configuration.
|
||||
func restoreDnsmasqConf() error {
|
||||
f, err := os.Open(netgearOrbiVoxelDNSMasqConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var bs []byte
|
||||
buf := bytes.NewBuffer(bs)
|
||||
|
||||
removed := false
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == dnsmasq.CtrldMarker {
|
||||
removed = true
|
||||
}
|
||||
if !removed {
|
||||
_, err := buf.WriteString(line + "\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.WriteFile(netgearOrbiVoxelDNSMasqConfigPath, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
func restartDNSMasq() error {
|
||||
if out, err := exec.Command("/etc/init.d/dnsmasq", "restart").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("restartDNSMasq: %s, %w", string(out), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -18,6 +18,7 @@ start_service() {
|
||||
procd_set_param stdout 1 # forward stdout of the command to logd
|
||||
procd_set_param stderr 1 # same for stderr
|
||||
procd_set_param pidfile ${pid_file} # write a pid file on instance start and remove it on stop
|
||||
procd_set_param term_timeout 10
|
||||
procd_close_instance
|
||||
echo "${name} has been started"
|
||||
}
|
||||
|
||||
40
internal/router/os_config_freebsd.go
Normal file
40
internal/router/os_config_freebsd.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config represents /conf/config.xml file found on pfsense/opnsense.
|
||||
type Config struct {
|
||||
PfsenseUnbound *string `xml:"unbound>enable,omitempty"`
|
||||
OPNsenseUnbound *string `xml:"OPNsense>unboundplus>general>enabled,omitempty"`
|
||||
Dnsmasq *string `xml:"dnsmasq>enable,omitempty"`
|
||||
}
|
||||
|
||||
// DnsmasqEnabled reports whether dnsmasq is enabled.
|
||||
func (c *Config) DnsmasqEnabled() bool {
|
||||
if isPfsense() { // pfsense only set the attribute if dnsmasq is enabled.
|
||||
return c.Dnsmasq != nil
|
||||
}
|
||||
return c.Dnsmasq != nil && *c.Dnsmasq == "1"
|
||||
}
|
||||
|
||||
// UnboundEnabled reports whether unbound is enabled.
|
||||
func (c *Config) UnboundEnabled() bool {
|
||||
if isPfsense() { // pfsense only set the attribute if unbound is enabled.
|
||||
return c.PfsenseUnbound != nil
|
||||
}
|
||||
return c.OPNsenseUnbound != nil && *c.OPNsenseUnbound == "1"
|
||||
}
|
||||
|
||||
// currentConfig does unmarshalling /conf/config.xml file,
|
||||
// return the corresponding *Config represent it.
|
||||
func currentConfig() (*Config, error) {
|
||||
buf, _ := os.ReadFile("/conf/config.xml")
|
||||
c := Config{}
|
||||
if err := xml.Unmarshal(buf, &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
@@ -111,8 +111,16 @@ func (or *osRouter) Setup() error {
|
||||
|
||||
func (or *osRouter) Cleanup() error {
|
||||
if or.cdMode {
|
||||
_ = exec.Command(unboundRcPath, "onerestart").Run()
|
||||
_ = exec.Command(dnsmasqRcPath, "onerestart").Run()
|
||||
c, err := currentConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.UnboundEnabled() {
|
||||
_ = exec.Command(unboundRcPath, "onerestart").Run()
|
||||
}
|
||||
if c.DnsmasqEnabled() {
|
||||
_ = exec.Command(dnsmasqRcPath, "onerestart").Run()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -137,20 +145,9 @@ rcvar="${name}_enable"
|
||||
pidfile="/var/run/${name}.pid"
|
||||
child_pidfile="/var/run/${name}_child.pid"
|
||||
command="/usr/sbin/daemon"
|
||||
daemon_args="-P ${pidfile} -p ${child_pidfile} -t \"${name}: daemon\"{{if .WorkingDirectory}} -c {{.WorkingDirectory}}{{end}}"
|
||||
daemon_args="-r -P ${pidfile} -p ${child_pidfile} -t \"${name}: daemon\"{{if .WorkingDirectory}} -c {{.WorkingDirectory}}{{end}}"
|
||||
command_args="${daemon_args} {{.Path}}{{range .Arguments}} {{.}}{{end}}"
|
||||
|
||||
stop_cmd="ctrld_stop"
|
||||
|
||||
ctrld_stop() {
|
||||
pid=$(cat ${pidfile})
|
||||
child_pid=$(cat ${child_pidfile})
|
||||
if [ -e "${child_pidfile}" ]; then
|
||||
kill -s TERM "${child_pid}"
|
||||
wait_for_pids "${child_pid}" "${pidfile}"
|
||||
fi
|
||||
}
|
||||
|
||||
load_rc_config "${name}"
|
||||
run_rc_command "$1"
|
||||
`
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/Control-D-Inc/ctrld/internal/router/edgeos"
|
||||
"github.com/Control-D-Inc/ctrld/internal/router/firewalla"
|
||||
"github.com/Control-D-Inc/ctrld/internal/router/merlin"
|
||||
netgear "github.com/Control-D-Inc/ctrld/internal/router/netgear_orbi_voxel"
|
||||
"github.com/Control-D-Inc/ctrld/internal/router/openwrt"
|
||||
"github.com/Control-D-Inc/ctrld/internal/router/synology"
|
||||
"github.com/Control-D-Inc/ctrld/internal/router/tomato"
|
||||
@@ -66,10 +68,17 @@ func New(cfg *ctrld.Config, cdMode bool) Router {
|
||||
return tomato.New(cfg)
|
||||
case firewalla.Name:
|
||||
return firewalla.New(cfg)
|
||||
case netgear.Name:
|
||||
return netgear.New(cfg)
|
||||
}
|
||||
return newOsRouter(cfg, cdMode)
|
||||
}
|
||||
|
||||
// IsNetGearOrbi reports whether the router is a Netgear Orbi router.
|
||||
func IsNetGearOrbi() bool {
|
||||
return Name() == netgear.Name
|
||||
}
|
||||
|
||||
// IsGLiNet reports whether the router is an GL.iNet router.
|
||||
func IsGLiNet() bool {
|
||||
if Name() != openwrt.Name {
|
||||
@@ -90,6 +99,11 @@ func IsOldOpenwrt() bool {
|
||||
return cmd == ""
|
||||
}
|
||||
|
||||
// WaitProcessExited reports whether the "ctrld stop" command have to wait until ctrld process exited.
|
||||
func WaitProcessExited() bool {
|
||||
return Name() == openwrt.Name
|
||||
}
|
||||
|
||||
var routerPlatform atomic.Pointer[router]
|
||||
|
||||
type router struct {
|
||||
@@ -145,12 +159,22 @@ func LocalResolverIP() string {
|
||||
// HomeDir returns the home directory of ctrld on current router.
|
||||
func HomeDir() (string, error) {
|
||||
switch Name() {
|
||||
case ddwrt.Name, merlin.Name, tomato.Name:
|
||||
case ddwrt.Name, firewalla.Name, merlin.Name, netgear.Name, tomato.Name:
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Dir(exe), nil
|
||||
case edgeos.Name:
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Using binary directory as home dir if it is located in /config.
|
||||
// Otherwise, fallback to old behavior for compatibility.
|
||||
if strings.HasPrefix(exe, "/config/") {
|
||||
return filepath.Dir(exe), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -198,8 +222,11 @@ func distroName() string {
|
||||
case bytes.HasPrefix(unameO(), []byte("ASUSWRT-Merlin")):
|
||||
return merlin.Name
|
||||
case haveFile("/etc/openwrt_version"):
|
||||
if haveFile("/bin/config") { // TODO: is there any more reliable way?
|
||||
return netgear.Name
|
||||
}
|
||||
return openwrt.Name
|
||||
case haveDir("/data/unifi"):
|
||||
case isUbios():
|
||||
return ubios.Name
|
||||
case bytes.HasPrefix(unameU(), []byte("synology")):
|
||||
return synology.Name
|
||||
@@ -234,3 +261,14 @@ func unameU() []byte {
|
||||
out, _ := exec.Command("uname", "-u").Output()
|
||||
return out
|
||||
}
|
||||
|
||||
// isUbios reports whether the current machine is running on Ubios.
|
||||
func isUbios() bool {
|
||||
if haveDir("/data/unifi") {
|
||||
return true
|
||||
}
|
||||
if err := exec.Command("ubnt-device-info", "firmware").Run(); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -49,11 +49,15 @@ func (s *merlinSvc) Platform() string {
|
||||
}
|
||||
|
||||
func (s *merlinSvc) configPath() string {
|
||||
path, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
bin := s.Config.Executable
|
||||
if bin == "" {
|
||||
path, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
bin = path
|
||||
}
|
||||
return path + ".startup"
|
||||
return bin + ".startup"
|
||||
}
|
||||
|
||||
func (s *merlinSvc) template() *template.Template {
|
||||
|
||||
@@ -36,6 +36,10 @@ func (u *Ubios) Install(config *service.Config) error {
|
||||
if edgeos.ContentFilteringEnabled() {
|
||||
return edgeos.ErrContentFilteringEnabled
|
||||
}
|
||||
// See comment in (*edgeos.EdgeOS).Install method.
|
||||
if edgeos.DnsShieldEnabled() {
|
||||
return edgeos.ErrDnsShieldEnabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -51,17 +55,13 @@ func (u *Ubios) Setup() error {
|
||||
if u.cfg.FirstListener().IsDirectDnsListener() {
|
||||
return nil
|
||||
}
|
||||
data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, u.cfg)
|
||||
data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, u.cfg, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(ubiosDNSMasqConfigPath, []byte(data), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Disable dnsmasq cache.
|
||||
if err := dnsmasq.DisableCache(ubiosDNSMasqDnsConfigPath); err != nil {
|
||||
return err
|
||||
}
|
||||
// Restart dnsmasq service.
|
||||
if err := restartDNSMasq(); err != nil {
|
||||
return err
|
||||
@@ -77,10 +77,6 @@ func (u *Ubios) Cleanup() error {
|
||||
if err := os.Remove(ubiosDNSMasqConfigPath); err != nil {
|
||||
return err
|
||||
}
|
||||
// Enable dnsmasq cache.
|
||||
if err := dnsmasq.EnableCache(ubiosDNSMasqDnsConfigPath); err != nil {
|
||||
return err
|
||||
}
|
||||
// Restart dnsmasq service.
|
||||
if err := restartDNSMasq(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package ctrld
|
||||
|
||||
import (
|
||||
"net"
|
||||
"syscall"
|
||||
|
||||
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func dnsFns() []dnsFn {
|
||||
@@ -20,40 +17,23 @@ func dnsFromAdapter() []string {
|
||||
}
|
||||
ns := make([]string, 0, len(aas)*2)
|
||||
seen := make(map[string]bool)
|
||||
do := func(addr windows.SocketAddress) {
|
||||
sa, err := addr.Sockaddr.Sockaddr()
|
||||
if err != nil {
|
||||
return
|
||||
addressMap := make(map[string]struct{})
|
||||
for _, aa := range aas {
|
||||
for a := aa.FirstUnicastAddress; a != nil; a = a.Next {
|
||||
addressMap[a.Address.IP().String()] = struct{}{}
|
||||
}
|
||||
var ip net.IP
|
||||
switch sa := sa.(type) {
|
||||
case *syscall.SockaddrInet4:
|
||||
ip = net.IPv4(sa.Addr[0], sa.Addr[1], sa.Addr[2], sa.Addr[3])
|
||||
case *syscall.SockaddrInet6:
|
||||
ip = make(net.IP, net.IPv6len)
|
||||
copy(ip, sa.Addr[:])
|
||||
if ip[0] == 0xfe && ip[1] == 0xc0 {
|
||||
// Ignore these fec0/10 ones. Windows seems to
|
||||
// populate them as defaults on its misc rando
|
||||
// interfaces.
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
|
||||
}
|
||||
if ip.IsLoopback() || seen[ip.String()] {
|
||||
return
|
||||
}
|
||||
seen[ip.String()] = true
|
||||
ns = append(ns, ip.String())
|
||||
}
|
||||
for _, aa := range aas {
|
||||
for dns := aa.FirstDNSServerAddress; dns != nil; dns = dns.Next {
|
||||
do(dns.Address)
|
||||
}
|
||||
for gw := aa.FirstGatewayAddress; gw != nil; gw = gw.Next {
|
||||
do(gw.Address)
|
||||
ip := dns.Address.IP()
|
||||
if ip == nil || ip.IsLoopback() || seen[ip.String()] {
|
||||
continue
|
||||
}
|
||||
if _, ok := addressMap[ip.String()]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ip.String()] = true
|
||||
ns = append(ns, ip.String())
|
||||
}
|
||||
}
|
||||
return ns
|
||||
|
||||
95
resolver.go
95
resolver.go
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -30,18 +31,49 @@ const (
|
||||
ResolverTypePrivate = "private"
|
||||
)
|
||||
|
||||
var bootstrapDNS = "76.76.2.0"
|
||||
const (
|
||||
controldBootstrapDns = "76.76.2.22"
|
||||
controldPublicDns = "76.76.2.0"
|
||||
)
|
||||
|
||||
var controldPublicDnsWithPort = net.JoinHostPort(controldPublicDns, "53")
|
||||
|
||||
// or is the Resolver used for ResolverTypeOS.
|
||||
var or = &osResolver{nameservers: defaultNameservers()}
|
||||
|
||||
// defaultNameservers returns OS nameservers plus ctrld bootstrap nameserver.
|
||||
// defaultNameservers returns OS nameservers plus ControlD public DNS.
|
||||
func defaultNameservers() []string {
|
||||
ns := nameservers()
|
||||
ns = append(ns, net.JoinHostPort(bootstrapDNS, "53"))
|
||||
return ns
|
||||
}
|
||||
|
||||
// InitializeOsResolver initializes OS resolver using the current system DNS settings.
|
||||
// It returns the nameservers that is going to be used by the OS resolver.
|
||||
//
|
||||
// It's the caller's responsibility to ensure the system DNS is in a clean state before
|
||||
// calling this function.
|
||||
func InitializeOsResolver() []string {
|
||||
or.nameservers = or.nameservers[:0]
|
||||
for _, ns := range defaultNameservers() {
|
||||
if testNameserver(ns) {
|
||||
or.nameservers = append(or.nameservers, ns)
|
||||
}
|
||||
}
|
||||
or.nameservers = append(or.nameservers, controldPublicDnsWithPort)
|
||||
return or.nameservers
|
||||
}
|
||||
|
||||
// testPlainDnsNameserver sends a test query to DNS nameserver to check if the server is available.
|
||||
func testNameserver(addr string) bool {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(".", dns.TypeNS)
|
||||
client := new(dns.Client)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_, _, err := client.ExchangeContext(ctx, msg, addr)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Resolver is the interface that wraps the basic DNS operations.
|
||||
//
|
||||
// Resolve resolves the DNS query, return the result and the corresponding error.
|
||||
@@ -76,8 +108,9 @@ type osResolver struct {
|
||||
}
|
||||
|
||||
type osResolverResult struct {
|
||||
answer *dns.Msg
|
||||
err error
|
||||
answer *dns.Msg
|
||||
err error
|
||||
isControlDPublicDNS bool
|
||||
}
|
||||
|
||||
// Resolve resolves DNS queries using pre-configured nameservers.
|
||||
@@ -103,19 +136,34 @@ func (o *osResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error
|
||||
go func(server string) {
|
||||
defer wg.Done()
|
||||
answer, _, err := dnsClient.ExchangeContext(ctx, msg.Copy(), server)
|
||||
ch <- &osResolverResult{answer: answer, err: err}
|
||||
ch <- &osResolverResult{answer: answer, err: err, isControlDPublicDNS: server == controldPublicDnsWithPort}
|
||||
}(server)
|
||||
}
|
||||
|
||||
var (
|
||||
nonSuccessAnswer *dns.Msg
|
||||
controldSuccessAnswer *dns.Msg
|
||||
)
|
||||
errs := make([]error, 0, numServers)
|
||||
for res := range ch {
|
||||
if res.err == nil {
|
||||
cancel()
|
||||
return res.answer, res.err
|
||||
switch {
|
||||
case res.answer != nil && res.answer.Rcode == dns.RcodeSuccess:
|
||||
if res.isControlDPublicDNS {
|
||||
controldSuccessAnswer = res.answer // only use ControlD answer as last one.
|
||||
} else {
|
||||
cancel()
|
||||
return res.answer, nil
|
||||
}
|
||||
case res.answer != nil:
|
||||
nonSuccessAnswer = res.answer
|
||||
}
|
||||
errs = append(errs, res.err)
|
||||
}
|
||||
|
||||
for _, answer := range []*dns.Msg{controldSuccessAnswer, nonSuccessAnswer} {
|
||||
if answer != nil {
|
||||
return answer, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -125,7 +173,7 @@ type legacyResolver struct {
|
||||
|
||||
func (r *legacyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
|
||||
// See comment in (*dotResolver).resolve method.
|
||||
dialer := newDialer(net.JoinHostPort(bootstrapDNS, "53"))
|
||||
dialer := newDialer(net.JoinHostPort(controldBootstrapDns, "53"))
|
||||
dnsTyp := uint16(0)
|
||||
if msg != nil && len(msg.Question) > 0 {
|
||||
dnsTyp = msg.Question[0].Qtype
|
||||
@@ -163,7 +211,7 @@ func LookupIP(domain string) []string {
|
||||
func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) {
|
||||
resolver := &osResolver{nameservers: nameservers()}
|
||||
if withBootstrapDNS {
|
||||
resolver.nameservers = append([]string{net.JoinHostPort(bootstrapDNS, "53")}, resolver.nameservers...)
|
||||
resolver.nameservers = append([]string{net.JoinHostPort(controldBootstrapDns, "53")}, resolver.nameservers...)
|
||||
}
|
||||
ProxyLogger.Load().Debug().Msgf("resolving %q using bootstrap DNS %q", domain, resolver.nameservers)
|
||||
timeoutMs := 2000
|
||||
@@ -239,7 +287,7 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string)
|
||||
// - Input servers.
|
||||
func NewBootstrapResolver(servers ...string) Resolver {
|
||||
resolver := &osResolver{nameservers: nameservers()}
|
||||
resolver.nameservers = append([]string{net.JoinHostPort(bootstrapDNS, "53")}, resolver.nameservers...)
|
||||
resolver.nameservers = append([]string{controldPublicDnsWithPort}, resolver.nameservers...)
|
||||
for _, ns := range servers {
|
||||
resolver.nameservers = append([]string{net.JoinHostPort(ns, "53")}, resolver.nameservers...)
|
||||
}
|
||||
@@ -266,11 +314,11 @@ func NewPrivateResolver() Resolver {
|
||||
// - Direct listener that has ctrld as an upstream (e.g: dnsmasq).
|
||||
//
|
||||
// causing the query always succeed.
|
||||
if sliceContains(resolveConfNss, host) {
|
||||
if slices.Contains(resolveConfNss, host) {
|
||||
continue
|
||||
}
|
||||
// Ignoring local RFC 1918 addresses.
|
||||
if sliceContains(localRfc1918Addrs, host) {
|
||||
if slices.Contains(localRfc1918Addrs, host) {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
@@ -322,20 +370,3 @@ func newDialer(dnsAddress string) *net.Dialer {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(cuonglm): use slices.Contains once upgrading to go1.21
|
||||
// sliceContains reports whether v is present in s.
|
||||
func sliceContains[S ~[]E, E comparable](s S, v E) bool {
|
||||
return sliceIndex(s, v) >= 0
|
||||
}
|
||||
|
||||
// sliceIndex returns the index of the first occurrence of v in s,
|
||||
// or -1 if not present.
|
||||
func sliceIndex[S ~[]E, E comparable](s S, v E) int {
|
||||
for i := range s {
|
||||
if v == s[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package ctrld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +30,57 @@ func Test_osResolver_Resolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_osResolver_ResolveWithNonSuccessAnswer(t *testing.T) {
|
||||
ns := make([]string, 0, 2)
|
||||
servers := make([]*dns.Server, 0, 2)
|
||||
successHandler := dns.HandlerFunc(func(w dns.ResponseWriter, msg *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetRcode(msg, dns.RcodeSuccess)
|
||||
w.WriteMsg(m)
|
||||
})
|
||||
nonSuccessHandlerWithRcode := func(rcode int) dns.HandlerFunc {
|
||||
return dns.HandlerFunc(func(w dns.ResponseWriter, msg *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetRcode(msg, rcode)
|
||||
w.WriteMsg(m)
|
||||
})
|
||||
}
|
||||
|
||||
handlers := []dns.Handler{
|
||||
nonSuccessHandlerWithRcode(dns.RcodeRefused),
|
||||
nonSuccessHandlerWithRcode(dns.RcodeNameError),
|
||||
successHandler,
|
||||
}
|
||||
for i := range handlers {
|
||||
pc, err := net.ListenPacket("udp", ":0")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
s, addr, err := runLocalPacketConnTestServer(t, pc, handlers[i])
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
ns = append(ns, addr)
|
||||
servers = append(servers, s)
|
||||
}
|
||||
defer func() {
|
||||
for _, server := range servers {
|
||||
server.Shutdown()
|
||||
}
|
||||
}()
|
||||
resolver := &osResolver{nameservers: ns}
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(".", dns.TypeNS)
|
||||
answer, err := resolver.Resolve(context.Background(), msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if answer.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("unexpected return code: %s", dns.RcodeToString[answer.Rcode])
|
||||
}
|
||||
}
|
||||
|
||||
func Test_upstreamTypeFromEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -51,3 +104,33 @@ func Test_upstreamTypeFromEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runLocalPacketConnTestServer(t *testing.T, pc net.PacketConn, handler dns.Handler, opts ...func(*dns.Server)) (*dns.Server, string, error) {
|
||||
t.Helper()
|
||||
|
||||
server := &dns.Server{
|
||||
PacketConn: pc,
|
||||
ReadTimeout: time.Hour,
|
||||
WriteTimeout: time.Hour,
|
||||
Handler: handler,
|
||||
}
|
||||
|
||||
waitLock := sync.Mutex{}
|
||||
waitLock.Lock()
|
||||
server.NotifyStartedFunc = waitLock.Unlock
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(server)
|
||||
}
|
||||
|
||||
addr, closer := pc.LocalAddr().String(), pc
|
||||
go func() {
|
||||
if err := server.ActivateAndServe(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
closer.Close()
|
||||
}()
|
||||
|
||||
waitLock.Lock()
|
||||
return server, addr, nil
|
||||
}
|
||||
|
||||
@@ -72,17 +72,9 @@ build() {
|
||||
if [ "$CGO_ENABLED" = "0" ]; then
|
||||
binary=${binary}-nocgo
|
||||
fi
|
||||
GOOS=${goos} GOARCH=${goarch} GOARM=${3} "$go" generate ./...
|
||||
GOOS=${goos} GOARCH=${goarch} GOARM=${3} "$go" build -ldflags="$ldflags" -o "$binary" ./cmd/ctrld
|
||||
compress "$binary"
|
||||
|
||||
if [ -z "${CTRLD_NO_QF}" ]; then
|
||||
binary_qf=${executable_name}-qf-${goos}-${goarch}v${3}
|
||||
if [ "$CGO_ENABLED" = "0" ]; then
|
||||
binary_qf=${binary_qf}-nocgo
|
||||
fi
|
||||
GOOS=${goos} GOARCH=${goarch} GOARM=${3} "$go" build -ldflags="$ldflags" -tags=qf -o "$binary_qf" ./cmd/ctrld
|
||||
compress "$binary_qf"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# GOMIPS is required for linux/mips: https://nileshgr.com/2020/02/16/golang-on-openwrt-mips/
|
||||
@@ -90,17 +82,9 @@ build() {
|
||||
if [ "$CGO_ENABLED" = "0" ]; then
|
||||
binary=${binary}-nocgo
|
||||
fi
|
||||
GOOS=${goos} GOARCH=${goarch} GOMIPS=softfloat "$go" generate ./...
|
||||
GOOS=${goos} GOARCH=${goarch} GOMIPS=softfloat "$go" build -ldflags="$ldflags" -o "$binary" ./cmd/ctrld
|
||||
compress "$binary"
|
||||
|
||||
if [ -z "${CTRLD_NO_QF}" ]; then
|
||||
binary_qf=${executable_name}-qf-${goos}-${goarch}
|
||||
if [ "$CGO_ENABLED" = "0" ]; then
|
||||
binary_qf=${binary_qf}-nocgo
|
||||
fi
|
||||
GOOS=${goos} GOARCH=${goarch} GOMIPS=softfloat "$go" build -ldflags="$ldflags" -tags=qf -o "$binary_qf" ./cmd/ctrld
|
||||
compress "$binary_qf"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
64
scripts/build_lib.sh
Executable file
64
scripts/build_lib.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script is used to locally build Android .aar library and iOS .xcframework from ctrld source using go mobile tool.
|
||||
|
||||
# Requirements:
|
||||
# - Android NDK (version 23+)
|
||||
# - Android SDK (version 33+)
|
||||
# - Xcode 15 + Build tools
|
||||
# - Go 1.21
|
||||
# - Git
|
||||
# usage: $ ./build_lib.sh v1.3.4
|
||||
|
||||
TAG="$1"
|
||||
# Hacky way to replace version info.
|
||||
update_versionInfo() {
|
||||
local file="$1/ctrld/cmd/cli/cli.go"
|
||||
local tag="$2"
|
||||
local commit="$3"
|
||||
awk -v tag="$tag" -v commit="$commit" '
|
||||
BEGIN { version_updated = 0; commit_updated = 0 }
|
||||
/^\tversion/ {
|
||||
sub(/= ".+"/, "= \"" tag "\"");
|
||||
version_updated = 1;
|
||||
}
|
||||
/^\tcommit/ {
|
||||
sub(/= ".+"/, "= \"" commit "\"");
|
||||
commit_updated = 1;
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (version_updated == 0) {
|
||||
print "\tversion = \"" tag "\"";
|
||||
}
|
||||
if (commit_updated == 0) {
|
||||
print "\tcommit = \"" commit "\"";
|
||||
}
|
||||
}
|
||||
' "$file" > "$file.tmp" && mv "$file.tmp" "$file"
|
||||
}
|
||||
export ANDROID_NDK_HOME=~/Library/Android/sdk/ndk/23.0.7599858
|
||||
mkdir bin
|
||||
cd bin || exit
|
||||
root=$(pwd)
|
||||
# Get source from github and switch to tag
|
||||
git clone --depth 1 --branch "$TAG" https://github.com/Control-D-Inc/ctrld.git
|
||||
# Prepare gomobile tool
|
||||
sourcePath=./ctrld/cmd/ctrld_library
|
||||
cd $sourcePath || exit
|
||||
go mod tidy
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
go get golang.org/x/mobile/bind
|
||||
gomobile init
|
||||
# Prepare build info
|
||||
buildDir=$root/../build
|
||||
mkdir -p "$buildDir"
|
||||
COMMIT=$(git rev-parse HEAD)
|
||||
update_versionInfo "$root" "$TAG" "$COMMIT"
|
||||
ldflags="-s -w"
|
||||
# Build
|
||||
gomobile bind -target ios/arm64 -ldflags="$ldflags" -o "$buildDir"/ctrld-"$TAG".xcframework || exit
|
||||
gomobile bind -ldflags="$ldflags" -o "$buildDir"/ctrld-"$TAG".aar || exit
|
||||
# Clean up
|
||||
rm -r "$root"
|
||||
echo "Successfully built Ctrld library $TAG($COMMIT)."
|
||||
@@ -27,6 +27,8 @@ var sampleConfigContent = `
|
||||
[service]
|
||||
log_level = "info"
|
||||
log_path = "/path/to/log.log"
|
||||
dns_watchdog_enabled = false
|
||||
dns_watchdog_interval = "20s"
|
||||
|
||||
[network.0]
|
||||
name = "Home Wifi"
|
||||
|
||||
Reference in New Issue
Block a user