Skip to content

Commit af2fa8b

Browse files
feat: add Mikrotik DNS provider support
1 parent 3799e79 commit af2fa8b

4 files changed

Lines changed: 271 additions & 2 deletions

File tree

cmd/ipv6ddns/example.config.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@
7474
"ttl": "1m",
7575
"proxied": false
7676
}
77+
},
78+
"myrouteros": {
79+
"provider": "mikrotik",
80+
"settings": {
81+
"address": "192.168.88.1:8729",
82+
"username": "admin",
83+
"password": "password",
84+
"zone": "lan",
85+
"ttl": "5m",
86+
"use_tls": true,
87+
"fingerprint": "a1b2c3d4..."
88+
}
7789
}
7890
},
7991
"plugins": [

ddns/mikrotik.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package ddns
2+
3+
import (
4+
"crypto/sha256"
5+
"crypto/tls"
6+
"encoding/hex"
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"net/netip"
11+
"os"
12+
"strings"
13+
"time"
14+
15+
"github.com/go-routeros/routeros/v3"
16+
"github.com/miguelangel-nubla/ipv6disc"
17+
"github.com/xeipuuv/gojsonschema"
18+
)
19+
20+
type Mikrotik struct {
21+
Address string `json:"address"`
22+
Username string `json:"username"`
23+
Password string `json:"password"`
24+
Zone string `json:"zone"`
25+
TTL time.Duration `json:"ttl"`
26+
UseTLS bool `json:"use_tls"`
27+
Fingerprint string `json:"fingerprint"`
28+
}
29+
30+
func init() {
31+
RegisterProvider("mikrotik", NewMikrotik)
32+
}
33+
34+
func NewMikrotik(settings ProviderSettings) Service {
35+
var service Mikrotik
36+
mikrotikValidateConfig(settings.(json.RawMessage))
37+
json.Unmarshal(settings.(json.RawMessage), &service)
38+
return &service
39+
}
40+
41+
func mikrotikValidateConfig(config json.RawMessage) {
42+
var configSchema = []byte(`
43+
{
44+
"$schema": "http://json-schema.org/draft-07/schema#",
45+
"type": "object",
46+
"properties": {
47+
"address": {
48+
"type": "string",
49+
"minLength": 1
50+
},
51+
"username": {
52+
"type": "string",
53+
"minLength": 1
54+
},
55+
"password": {
56+
"type": "string"
57+
},
58+
"zone": {
59+
"type": "string"
60+
},
61+
"ttl": {
62+
"type": "string",
63+
"pattern": "^([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$"
64+
},
65+
"use_tls": {
66+
"type": "boolean"
67+
},
68+
"fingerprint": {
69+
"type": "string",
70+
"pattern": "^[a-fA-F0-9]{64}$"
71+
}
72+
},
73+
"required": [
74+
"address",
75+
"username",
76+
"password",
77+
"ttl"
78+
]
79+
}
80+
`)
81+
82+
schemaLoader := gojsonschema.NewBytesLoader(configSchema)
83+
dataLoader := gojsonschema.NewBytesLoader([]byte(config))
84+
85+
result, err := gojsonschema.Validate(schemaLoader, dataLoader)
86+
if err != nil {
87+
panic(err.Error())
88+
}
89+
90+
if !result.Valid() {
91+
fmt.Printf("Mikrotik configuration is not valid.\nErrors:\n")
92+
for _, desc := range result.Errors() {
93+
fmt.Printf("- %s\n", desc)
94+
}
95+
os.Exit(1)
96+
}
97+
}
98+
99+
func (m *Mikrotik) Update(hostname string, addrCollection *ipv6disc.AddrCollection) error {
100+
var client *routeros.Client
101+
var err error
102+
103+
if m.UseTLS {
104+
tlsConfig := &tls.Config{}
105+
if m.Fingerprint != "" {
106+
tlsConfig.InsecureSkipVerify = true
107+
tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error {
108+
for _, cert := range cs.PeerCertificates {
109+
hash := sha256.Sum256(cert.Raw)
110+
if hex.EncodeToString(hash[:]) == m.Fingerprint {
111+
return nil
112+
}
113+
}
114+
return fmt.Errorf("certificate fingerprint mismatch")
115+
}
116+
}
117+
client, err = routeros.DialTLS(m.Address, m.Username, m.Password, tlsConfig)
118+
} else {
119+
client, err = routeros.Dial(m.Address, m.Username, m.Password)
120+
}
121+
122+
if err != nil {
123+
return fmt.Errorf("failed to connect to Mikrotik: %v", err)
124+
}
125+
defer client.Close()
126+
127+
fqdn := m.Domain(hostname)
128+
129+
// Fetch existing records for this hostname
130+
reply, err := client.Run("/ip/dns/static/print", "?name="+fqdn)
131+
if err != nil {
132+
return fmt.Errorf("failed to fetch DNS records: %v", err)
133+
}
134+
135+
type dnsRecord struct {
136+
id string
137+
ttl string
138+
}
139+
currentIPs := make(map[string]dnsRecord) // IP -> Record
140+
for _, re := range reply.Re {
141+
id := re.Map[".id"]
142+
addr := re.Map["address"]
143+
recordType := re.Map["type"]
144+
ttl := re.Map["ttl"]
145+
146+
// Filter only A and AAAA records
147+
if recordType != "A" && recordType != "AAAA" {
148+
continue
149+
}
150+
151+
if _, err := netip.ParseAddr(addr); err == nil {
152+
currentIPs[addr] = dnsRecord{id: id, ttl: ttl}
153+
}
154+
}
155+
156+
desiredIPs := make(map[string]bool)
157+
for _, addr := range addrCollection.Get() {
158+
ip := addr.WithZone("").String()
159+
desiredIPs[ip] = true
160+
}
161+
162+
// Create missing records
163+
for ip := range desiredIPs {
164+
if _, exists := currentIPs[ip]; !exists {
165+
// Determine type
166+
recordType := "A"
167+
if addr, err := netip.ParseAddr(ip); err == nil && addr.Is6() {
168+
recordType = "AAAA"
169+
}
170+
171+
_, err := client.Run("/ip/dns/static/add", "=name="+fqdn, "=address="+ip, "=type="+recordType, "=ttl="+m.TTL.String())
172+
if err != nil {
173+
return fmt.Errorf("failed to add DNS record %s -> %s: %v", fqdn, ip, err)
174+
}
175+
}
176+
}
177+
178+
// Remove obsolete records
179+
for ip, record := range currentIPs {
180+
if _, keep := desiredIPs[ip]; !keep {
181+
_, err := client.Run("/ip/dns/static/remove", "=.id="+record.id)
182+
if err != nil {
183+
return fmt.Errorf("failed to remove DNS record %s -> %s: %v", fqdn, ip, err)
184+
}
185+
} else {
186+
// Update TTL if needed
187+
currentTTL, err := time.ParseDuration(record.ttl)
188+
// If parsing fails we force update to be safe.
189+
if err != nil || currentTTL != m.TTL {
190+
_, err := client.Run("/ip/dns/static/set", "=.id="+record.id, "=ttl="+m.TTL.String())
191+
if err != nil {
192+
return fmt.Errorf("failed to update DNS record TTL %s -> %s: %v", fqdn, ip, err)
193+
}
194+
}
195+
}
196+
}
197+
198+
return nil
199+
}
200+
201+
func (m *Mikrotik) PrettyPrint(prefix string) ([]byte, error) {
202+
return json.MarshalIndent(m, prefix, " ")
203+
}
204+
205+
func (m *Mikrotik) UnmarshalJSON(b []byte) error {
206+
type Alias Mikrotik
207+
aux := &struct {
208+
TTL interface{} `json:"ttl"`
209+
*Alias
210+
}{
211+
Alias: (*Alias)(m),
212+
}
213+
if err := json.Unmarshal(b, &aux); err != nil {
214+
return err
215+
}
216+
217+
switch value := aux.TTL.(type) {
218+
case float64:
219+
m.TTL = time.Duration(value) * time.Second
220+
return nil
221+
case string:
222+
var err error
223+
m.TTL, err = time.ParseDuration(value)
224+
if err != nil {
225+
return err
226+
}
227+
return nil
228+
default:
229+
return errors.New("ttl invalid duration")
230+
}
231+
}
232+
233+
func (m *Mikrotik) MarshalJSON() ([]byte, error) {
234+
type Alias Mikrotik
235+
return json.Marshal(&struct {
236+
TTL int64 `json:"ttl"`
237+
*Alias
238+
}{
239+
TTL: int64(m.TTL.Seconds()),
240+
Alias: (*Alias)(m),
241+
})
242+
}
243+
244+
func (m *Mikrotik) Domain(hostname string) string {
245+
hostname = strings.Trim(hostname, ".")
246+
zone := strings.Trim(m.Zone, ".")
247+
248+
if zone == "" {
249+
return hostname
250+
}
251+
252+
if hostname == "" {
253+
return zone
254+
}
255+
256+
return strings.Join([]string{hostname, zone}, ".")
257+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.24.0
44

55
require (
66
github.com/cloudflare/cloudflare-go v0.116.0
7+
github.com/go-routeros/routeros/v3 v3.0.1
78
github.com/google/uuid v1.6.0
89
github.com/miguelangel-nubla/ipv6disc v0.4.1
910
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1
@@ -18,7 +19,6 @@ require (
1819
github.com/getkin/kin-openapi v0.128.0 // indirect
1920
github.com/go-openapi/jsonpointer v0.21.0 // indirect
2021
github.com/go-openapi/swag v0.23.0 // indirect
21-
github.com/go-routeros/routeros/v3 v3.0.1 // indirect
2222
github.com/goccy/go-json v0.10.5 // indirect
2323
github.com/google/go-querystring v1.2.0 // indirect
2424
github.com/invopop/yaml v0.3.1 // indirect

state.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func (s *State) PrettyPrint(prefix string, hideSensible bool) string {
9393
lastHw = hw
9494
}
9595

96-
fmt.Fprintf(&result, " %s", addr.Addr.Zone())
96+
fmt.Fprintf(&result, " %s", strings.Join(addr.Sources, ","))
9797
}
9898
}
9999

0 commit comments

Comments
 (0)