Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(provider): add support for Mikrotik routers #539

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions docs/mikrotik.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions internal/provider/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions internal/provider/providers/mikrotik/api.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions internal/provider/providers/mikrotik/client.go
Original file line number Diff line number Diff line change
@@ -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()
}
10 changes: 10 additions & 0 deletions internal/provider/providers/mikrotik/errors.go
Original file line number Diff line number Diff line change
@@ -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
}
50 changes: 50 additions & 0 deletions internal/provider/providers/mikrotik/login.go
Original file line number Diff line number Diff line change
@@ -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))
}
147 changes: 147 additions & 0 deletions internal/provider/providers/mikrotik/provider.go
Original file line number Diff line number Diff line change
@@ -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("<a href=\"http://%s\">Mikrotik</a>", 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
}
Loading