diff --git a/README.md b/README.md index bb589b9b4..1febf2560 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ Check the documentation for your DNS provider: - [Ionos](docs/ionos.md) - [Linode](docs/linode.md) - [LuaDNS](docs/luadns.md) +- [Mikrotik](docs/mikrotik.md) - [Name.com](docs/name.com.md) - [Namecheap](docs/namecheap.md) - [Netcup](docs/netcup.md) diff --git a/docs/mikrotik.md b/docs/mikrotik.md new file mode 100644 index 000000000..462181d66 --- /dev/null +++ b/docs/mikrotik.md @@ -0,0 +1,33 @@ +# Mikrotik + +## Configuration + +### Example + +```json +{ + "settings": [ + { + "provider": "mikrotik", + "router_ip": "192.168.0.1", + "address_list": "AddressListName", + "username": "user", + "password": "secret", + "ip_version": "ipv4" + } + ] +} +``` + +### Parameters + +- `"router_ip"` is the IP address of your router +- `"address_list"` is the name of the address list +- `"username"` is the username to authenticate with +- `"password"` is the user's password + +## Domain setup + +- Create a user with read, write, and api access +- Optionally create an entry in `/ip firewall address-list` to assign your public IP, an entry will be created for you otherwise +- You can then use this address list in your hairpin NAT firewall rules diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go index 93a0c444f..e5187fb88 100644 --- a/internal/provider/constants/providers.go +++ b/internal/provider/constants/providers.go @@ -35,6 +35,7 @@ const ( Ionos models.Provider = "ionos" Linode models.Provider = "linode" LuaDNS models.Provider = "luadns" + Mikrotik models.Provider = "mikrotik" Namecheap models.Provider = "namecheap" NameCom models.Provider = "name.com" Netcup models.Provider = "netcup" diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 30efeb320..b5a631b42 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -41,6 +41,7 @@ import ( "github.com/qdm12/ddns-updater/internal/provider/providers/ionos" "github.com/qdm12/ddns-updater/internal/provider/providers/linode" "github.com/qdm12/ddns-updater/internal/provider/providers/luadns" + "github.com/qdm12/ddns-updater/internal/provider/providers/mikrotik" "github.com/qdm12/ddns-updater/internal/provider/providers/namecheap" "github.com/qdm12/ddns-updater/internal/provider/providers/namecom" "github.com/qdm12/ddns-updater/internal/provider/providers/netcup" @@ -139,6 +140,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, host string return linode.New(data, domain, host, ipVersion, ipv6Suffix) case constants.LuaDNS: return luadns.New(data, domain, host, ipVersion, ipv6Suffix) + case constants.Mikrotik: + return mikrotik.New(data, ipVersion, ipv6Suffix) case constants.Namecheap: return namecheap.New(data, domain, host) case constants.NameCom: diff --git a/internal/provider/providers/mikrotik/api.go b/internal/provider/providers/mikrotik/api.go new file mode 100644 index 000000000..533e4669d --- /dev/null +++ b/internal/provider/providers/mikrotik/api.go @@ -0,0 +1,30 @@ +package mikrotik + +type addressListItem struct { + id string + list string + address string +} + +func getAddressListItems(client *client, + addressList string) (items []addressListItem, err error) { + reply, err := client.Run("/ip/firewall/address-list/print", + "?disabled=false", "?list="+addressList) + if err != nil { + return nil, err + } + + items = make([]addressListItem, 0, len(reply.sentences)) + for _, re := range reply.sentences { + item := addressListItem{ + id: re.mapping[".id"], + list: re.mapping["list"], + address: re.mapping["address"], + } + if item.id == "" || item.address == "" { + continue + } + items = append(items, item) + } + return items, nil +} diff --git a/internal/provider/providers/mikrotik/client.go b/internal/provider/providers/mikrotik/client.go new file mode 100644 index 000000000..1e898d677 --- /dev/null +++ b/internal/provider/providers/mikrotik/client.go @@ -0,0 +1,39 @@ +package mikrotik + +import ( + "io" + "net" + "net/netip" + "sync" +) + +type client struct { + conn io.Closer + reader *reader + writer *writer + closing bool + mutex sync.Mutex +} + +func newClient(address netip.AddrPort) (c *client, err error) { + conn, err := net.Dial("tcp", address.String()) + if err != nil { + return nil, err + } + return &client{ + conn: conn, + reader: newReader(conn), + writer: newWriter(conn), + }, nil +} + +func (c *client) Close() { + c.mutex.Lock() + if c.closing { + c.mutex.Unlock() + return + } + c.closing = true + c.mutex.Unlock() + c.conn.Close() +} diff --git a/internal/provider/providers/mikrotik/errors.go b/internal/provider/providers/mikrotik/errors.go new file mode 100644 index 000000000..f1d10ed5b --- /dev/null +++ b/internal/provider/providers/mikrotik/errors.go @@ -0,0 +1,10 @@ +package mikrotik + +// UnknownReplyError records the sentence whose Word is unknown. +type UnknownReplyError struct { + Sentence *sentence +} + +func (err *UnknownReplyError) Error() string { + return "unknown RouterOS reply word: " + err.Sentence.word +} diff --git a/internal/provider/providers/mikrotik/login.go b/internal/provider/providers/mikrotik/login.go new file mode 100644 index 000000000..ddb296c68 --- /dev/null +++ b/internal/provider/providers/mikrotik/login.go @@ -0,0 +1,50 @@ +package mikrotik + +import ( + "crypto/md5" //nolint:gosec + "encoding/hex" + "errors" + "fmt" + "io" +) + +var ( + ErrLoginChallengeNoRet = errors.New("login challenge response has no ret field") +) + +func (c *client) login(username, password string) error { + reply, err := c.Run("/login", "=name="+username, "=password="+password) + if err != nil { + return err + } + ret, ok := reply.done.mapping["ret"] + if !ok { + // Login method post-6.43 one stage, cleartext and no challenge + if reply.done != nil { + return nil + } + return fmt.Errorf("%w", ErrLoginChallengeNoRet) + } + + // Login method pre-6.43 two stages, challenge + challenge, err := hex.DecodeString(ret) + if err != nil { + return fmt.Errorf("hex decoding challenge response ret field: %w", err) + } + + response := challengeResponse(challenge, password) + _, err = c.Run("/login", "=name="+username, "=response="+response) + if err != nil { + return err + } + + return nil +} + +func challengeResponse(challenge []byte, password string) string { + hasher := md5.New() //nolint:gosec + _, _ = hasher.Write([]byte{0}) + _, _ = io.WriteString(hasher, password) + _, _ = hasher.Write(challenge) + return fmt.Sprintf("00%x", hasher.Sum(nil)) +} diff --git a/internal/provider/providers/mikrotik/provider.go b/internal/provider/providers/mikrotik/provider.go new file mode 100644 index 000000000..1c36aa079 --- /dev/null +++ b/internal/provider/providers/mikrotik/provider.go @@ -0,0 +1,147 @@ +package mikrotik + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/netip" + "regexp" + + "github.com/qdm12/ddns-updater/internal/models" + "github.com/qdm12/ddns-updater/internal/provider/constants" + "github.com/qdm12/ddns-updater/internal/provider/errors" + "github.com/qdm12/ddns-updater/internal/provider/utils" + "github.com/qdm12/ddns-updater/pkg/publicip/ipversion" +) + +type Provider struct { + ipVersion ipversion.IPVersion + ipv6Suffix netip.Prefix + routerAddress netip.AddrPort + username string + password string + addressList string +} + +type settings struct { + RouterIP netip.Addr `json:"router_ip"` + Username string `json:"username"` + Password string `json:"password"` + AddressList string `json:"address_list"` +} + +func New(data json.RawMessage, ipVersion ipversion.IPVersion, + ipv6Suffix netip.Prefix) (p *Provider, err error) { + var providerSpecificSettings settings + err = json.Unmarshal(data, &providerSpecificSettings) + if err != nil { + return nil, fmt.Errorf("json decoding provider specific settings: %w", err) + } + err = validateSettings(providerSpecificSettings) + if err != nil { + return nil, fmt.Errorf("validating settings: %w", err) + } + + const routerPort = 8728 + return &Provider{ + ipVersion: ipVersion, + ipv6Suffix: ipv6Suffix, + routerAddress: netip.AddrPortFrom(providerSpecificSettings.RouterIP, routerPort), + username: providerSpecificSettings.Username, + password: providerSpecificSettings.Password, + addressList: providerSpecificSettings.AddressList, + }, nil +} + +var addressListRegex = regexp.MustCompile(`^[a-zA-Z]{2,}$`) + +func validateSettings(settings settings) error { + switch { + case !addressListRegex.MatchString(settings.AddressList): + return fmt.Errorf("%w: host %q does not match regex %q", + errors.ErrKeyNotValid, settings.AddressList, addressListRegex) + case !settings.RouterIP.IsValid(): + return fmt.Errorf("%w: router_ip cannot be empty", errors.ErrKeyNotSet) + } + return nil +} + +func (p *Provider) String() string { + return utils.ToString(p.Domain(), p.addressList, constants.Mikrotik, p.ipVersion) +} + +func (p *Provider) Domain() string { + return "N / A" +} + +func (p *Provider) Host() string { + return "N / A" +} + +func (p *Provider) IPVersion() ipversion.IPVersion { + return p.ipVersion +} + +func (p *Provider) IPv6Suffix() netip.Prefix { + return p.ipv6Suffix +} + +func (p *Provider) Proxied() bool { + return false +} + +func (p *Provider) BuildDomainName() string { + return "" +} + +func (p *Provider) HTML() models.HTMLRow { + return models.HTMLRow{ + Domain: p.Domain(), + Host: p.addressList, + Provider: fmt.Sprintf("Mikrotik", p.routerAddress), + IPVersion: p.ipVersion.String(), + } +} + +func (p *Provider) Update(_ context.Context, _ *http.Client, ip netip.Addr) ( + newIP netip.Addr, err error) { + client, err := newClient(p.routerAddress) + if err != nil { + return netip.Addr{}, fmt.Errorf("creating client: %w", err) + } + defer client.Close() + err = client.login(p.username, p.password) + if err != nil { + return netip.Addr{}, fmt.Errorf("logging in router: %w", err) + } + + addressListItems, err := getAddressListItems(client, p.addressList) + if err != nil { + return netip.Addr{}, fmt.Errorf("getting address list items: %w", err) + } + + if len(addressListItems) == 0 { + _, err = client.Run("/ip/firewall/address-list/add", + "=list="+p.addressList, "=address="+ip.String()) + if err != nil { + return netip.Addr{}, fmt.Errorf("adding address list %q: %w", + p.addressList, err) + } + return ip, nil + } + + for _, addressListItem := range addressListItems { + if addressListItem.address == ip.String() { + continue // already up to date + } + _, err = client.Run("/ip/firewall/address-list/set", + "=.id="+addressListItem.id, "=address="+ip.String()) + if err != nil { + return netip.Addr{}, fmt.Errorf("setting address in address list id %q: %w", + addressListItem.id, err) + } + } + + return ip, nil +} diff --git a/internal/provider/providers/mikrotik/reader.go b/internal/provider/providers/mikrotik/reader.go new file mode 100644 index 000000000..87220441e --- /dev/null +++ b/internal/provider/providers/mikrotik/reader.go @@ -0,0 +1,128 @@ +package mikrotik + +import ( + "bufio" + "bytes" + "fmt" + "io" + + "github.com/qdm12/ddns-updater/internal/provider/errors" +) + +type reader struct { + conn *bufio.Reader +} + +func newReader(conn io.Reader) *reader { + return &reader{ + conn: bufio.NewReader(conn), + } +} + +func (r *reader) readReply() (rep *reply, err error) { + rep = &reply{} + + var lastErr error + for { + sentence, err := r.readSentence() + if err != nil { + return nil, fmt.Errorf("reading sentence: %w", err) + } + done, err := rep.ingestSentence(sentence) + if err != nil { + err = fmt.Errorf("ingesting sentence: %w", err) + lastErr = err + } + if done { + return rep, lastErr + } + } +} + +func (r *reader) readSentence() (*sentence, error) { + sentence := newSentence() + for { + b, err := r.readWord() + if err != nil { + return nil, err + } else if len(b) == 0 { + return sentence, nil + } + // Ex.: !re, !done + if sentence.word == "" { + sentence.word = string(b) + continue + } + // Command tag + if bytes.HasPrefix(b, []byte(".tag=")) { + sentence.tag = string(b[5:]) + continue + } + // Ex.: =key=value, =key + if bytes.HasPrefix(b, []byte("=")) { + t := bytes.SplitN(b[1:], []byte("="), 2) //nolint:gomnd + if len(t) == 1 { + t = append(t, []byte{}) + } + p := pair{string(t[0]), string(t[1])} + sentence.pairs = append(sentence.pairs, p) + sentence.mapping[p.key] = p.value + continue + } + return nil, fmt.Errorf("%w: word %#q", + errors.ErrUnknownResponse, b) + } +} + +func (r *reader) readNumber(size int) (int64, error) { + b := make([]byte, size) + _, err := io.ReadFull(r.conn, b) + if err != nil { + return -1, err + } + var num int64 + for _, ch := range b { + num = num<<8 | int64(ch) //nolint:gomnd + } + return num, nil +} + +//nolint:gomnd +func (r *reader) readLength() (int64, error) { + l, err := r.readNumber(1) + if err != nil { + return -1, err + } + var n int64 + switch { + case l&0x80 == 0x00: + case (l & 0xC0) == 0x80: + n, err = r.readNumber(1) + l = l & ^0xC0 << 8 | n + case l&0xE0 == 0xC0: + n, err = r.readNumber(2) + l = l & ^0xE0 << 16 | n + case l&0xF0 == 0xE0: + n, err = r.readNumber(3) + l = l & ^0xF0 << 24 | n + case l&0xF8 == 0xF0: + l, err = r.readNumber(4) + } + if err != nil { + return -1, err + } + return l, nil +} + +func (r *reader) readWord() ([]byte, error) { + l, err := r.readLength() + if err != nil { + return nil, err + } + b := make([]byte, l) + _, err = io.ReadFull(r.conn, b) + if err != nil { + return nil, err + } + return b, nil +} diff --git a/internal/provider/providers/mikrotik/reply.go b/internal/provider/providers/mikrotik/reply.go new file mode 100644 index 000000000..323ad8fe6 --- /dev/null +++ b/internal/provider/providers/mikrotik/reply.go @@ -0,0 +1,37 @@ +package mikrotik + +import ( + "fmt" + + "github.com/qdm12/ddns-updater/internal/provider/errors" +) + +type reply struct { + sentences []*sentence + done *sentence +} + +func (r *reply) ingestSentence(sentence *sentence) (done bool, err error) { + switch sentence.word { + case "!re": + r.sentences = append(r.sentences, sentence) + case "!done": + r.done = sentence + return true, nil + case "!trap", "!fatal": + done = sentence.word == "!fatal" + message := sentence.mapping["message"] + if message == "" { + err = fmt.Errorf("%w: unknown error: %s", errors.ErrUnsuccessful, sentence) + } else { + err = fmt.Errorf("%w: %s", errors.ErrUnsuccessful, message) + } + return done, err + case "": + // empty sentences should be ignored + default: + return true, fmt.Errorf("%w: word %q", + errors.ErrUnknownResponse, sentence.word) + } + return false, nil +} diff --git a/internal/provider/providers/mikrotik/run.go b/internal/provider/providers/mikrotik/run.go new file mode 100644 index 000000000..c6d0497ce --- /dev/null +++ b/internal/provider/providers/mikrotik/run.go @@ -0,0 +1,12 @@ +package mikrotik + +func (c *client) Run(sentence ...string) (*reply, error) { + for _, word := range sentence { + c.writer.writeWord(word) + } + err := c.writer.endSentence() + if err != nil { + return nil, err + } + return c.reader.readReply() +} diff --git a/internal/provider/providers/mikrotik/sentence.go b/internal/provider/providers/mikrotik/sentence.go new file mode 100644 index 000000000..5f95bf753 --- /dev/null +++ b/internal/provider/providers/mikrotik/sentence.go @@ -0,0 +1,25 @@ +package mikrotik + +import "fmt" + +type sentence struct { + word string // word that begins with ! + tag string + pairs []pair + mapping map[string]string +} + +type pair struct { + key string + value string +} + +func newSentence() *sentence { + return &sentence{ + mapping: make(map[string]string), + } +} + +func (s *sentence) String() string { + return fmt.Sprintf("%s @%s %#q", s.word, s.tag, s.pairs) +} diff --git a/internal/provider/providers/mikrotik/writer.go b/internal/provider/providers/mikrotik/writer.go new file mode 100644 index 000000000..91ea6e8dd --- /dev/null +++ b/internal/provider/providers/mikrotik/writer.go @@ -0,0 +1,68 @@ +package mikrotik + +import ( + "bufio" + "io" +) + +type writer struct { + conn *bufio.Writer + err error +} + +func newWriter(conn io.Writer) *writer { + return &writer{ + conn: bufio.NewWriter(conn), + } +} + +// endSentence writes the end-of-sentence marker (an empty word). +// It returns the first error that occurred on calls to methods on w. +func (w *writer) endSentence() error { + w.writeWord("") + w.flush() + return w.err +} + +// writeWord writes one word. +func (w *writer) writeWord(word string) { + b := []byte(word) + w.write(encodeLength(len(b))) + w.write(b) +} + +func (w *writer) flush() { + if w.err != nil { + return + } + err := w.conn.Flush() + if err != nil { + w.err = err + } +} + +func (w *writer) write(b []byte) { + if w.err != nil { + return + } + _, err := w.conn.Write(b) + if err != nil { + w.err = err + } +} + +//nolint:gomnd +func encodeLength(l int) []byte { + switch { + case l < 0x80: + return []byte{byte(l)} + case l < 0x4000: + return []byte{byte(l>>8) | 0x80, byte(l)} + case l < 0x200000: + return []byte{byte(l>>16) | 0xC0, byte(l >> 8), byte(l)} + case l < 0x10000000: + return []byte{byte(l>>24) | 0xE0, byte(l >> 16), byte(l >> 8), byte(l)} + default: + return []byte{0xF0, byte(l >> 24), byte(l >> 16), byte(l >> 8), byte(l)} + } +}