From 1378d95eaa73fca4191fdb7c419f881fe6597cee Mon Sep 17 00:00:00 2001 From: Jordan Hotmann Date: Fri, 6 Oct 2023 21:44:30 -0600 Subject: [PATCH] WIP --- README.md | 1 + docs/mikrotik.md | 33 ++++ go.mod | 1 + go.sum | 2 + internal/provider/constants/providers.go | 1 + internal/provider/provider.go | 3 + internal/provider/providers/mikrotik/api.go | 34 +++++ .../provider/providers/mikrotik/provider.go | 143 ++++++++++++++++++ 8 files changed, 218 insertions(+) create mode 100644 docs/mikrotik.md create mode 100644 internal/provider/providers/mikrotik/api.go create mode 100644 internal/provider/providers/mikrotik/provider.go 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/go.mod b/go.mod index 12905dbdf..886eee77e 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.15.0 // indirect github.com/go-logr/logr v1.4.1 // indirect + github.com/go-routeros/routeros v0.0.0-20210123142807-2a44d57c6730 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect diff --git a/go.sum b/go.sum index b897908a1..f4341eb8c 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-routeros/routeros v0.0.0-20210123142807-2a44d57c6730 h1:EuqwWLv/LPPjhvFqkeD2bz+FOlvw2DjvDI7vK8GVeyY= +github.com/go-routeros/routeros v0.0.0-20210123142807-2a44d57c6730/go.mod h1:em1mEqFKnoeQuQP9Sg7i26yaW8o05WwcNj7yLhrXxSQ= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 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..c8b9f208a --- /dev/null +++ b/internal/provider/providers/mikrotik/api.go @@ -0,0 +1,34 @@ +package mikrotik + +import ( + "github.com/go-routeros/routeros" //nolint:misspell +) + +type addressListItem struct { + id string + list string + address string +} + +func getAddressListItems(client *routeros.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.Re)) + for _, re := range reply.Re { + item := addressListItem{ + id: re.Map[".id"], + list: re.Map["list"], + address: re.Map["address"], + } + if item.id == "" || item.address == "" { + continue + } + items = append(items, item) + } + return items, nil +} diff --git a/internal/provider/providers/mikrotik/provider.go b/internal/provider/providers/mikrotik/provider.go new file mode 100644 index 000000000..dbedee5fe --- /dev/null +++ b/internal/provider/providers/mikrotik/provider.go @@ -0,0 +1,143 @@ +package mikrotik + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/netip" + "regexp" + + "github.com/go-routeros/routeros" //nolint:misspell + "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 + routerIP netip.Addr + 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) + } + + return &Provider{ + ipVersion: ipVersion, + ipv6Suffix: ipv6Suffix, + routerIP: providerSpecificSettings.RouterIP, + 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.routerIP), + IPVersion: p.ipVersion.String(), + } +} + +func (p *Provider) Update(_ context.Context, _ *http.Client, ip netip.Addr) ( + newIP netip.Addr, err error) { + client, err := routeros.Dial(p.routerIP.String()+":8728", p.username, p.password) + if err != nil { + return netip.Addr{}, fmt.Errorf("authenticating with router: %w", err) + } + defer client.Close() + + 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 +}