From a0054868726658f53531424bc5907ceb4291c988 Mon Sep 17 00:00:00 2001 From: David Ramiro Date: Tue, 21 Feb 2023 23:07:09 +0100 Subject: [PATCH] feat: add support for porkbun --- .goreleaser.yaml | 2 +- README.md | 61 ++++++++++++------- config.sample.yml | 3 + go.mod | 2 +- internal/api/api.go | 48 +++++++++++---- internal/api/api_test.go | 4 +- internal/config/config.go | 5 ++ main.go | 6 +- main_test.go | 2 +- pkg/gandi/gandi.go | 18 +----- pkg/gandi/gandi_test.go | 2 +- pkg/porkbun/porkbun.go | 121 ++++++++++++++++++++++++++++++++++++++ 12 files changed, 218 insertions(+), 56 deletions(-) create mode 100644 pkg/porkbun/porkbun.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 94c69c6..2d9e5d3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,4 +1,4 @@ -project_name: fritzgandi +project_name: frigabun before: hooks: - go mod tidy diff --git a/README.md b/README.md index 0096c8d..7572337 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,27 @@ -# FritzGandi DynDNS Update +# frigabun -Web service to allow FritzBox routers to update Gandi DNS entries when obtaining a new IP address. +Web service to allow FritzBox routers to update Gandi and Porkbun DNS entries when obtaining a new IP address. Uses the new LiveDNS API. Written in Go 1.20 ## Requirements -- A domain name on Gandi -- Gandi API Key from [Account settings](https://account.gandi.net/) under Security +- A domain name on Gandi or Porkbun +- Gandi or Porkbun API credentials from [Account settings](https://account.gandi.net/) under Security - FritzBox router with up-to-date firmware - Optional: To build or run manually: Go 1.20 -## Usage +## Set up service -- Download the [latest](https://github.com/davidramiro/fritzgandi/releases/latest) release archive for your OS/arch +- Download the [latest](https://github.com/davidramiro/frigabun/releases/latest) release archive for your OS/arch - Unzip, rename `config.sample.yml` to `config.yml` (config is fine as default, if you want to run tests, fill in your API info) + +## FritzBox settings + - Log into your FritzBox - Navigate to `Internet` -> `Permit Access` -> `DynDNS` - Enable DynDNS and use `User-defined` as Provider + +### Gandi + - Enter the following URL: `http://{HOST}:{PORT}/api/update?apikey=&domain={DOMAIN}&subdomain={SUBDOMAIN}&ip=` - Replace the `{HOST}` and `{PORT}` with your deployment of the application - By default, the application uses port `9595` @@ -29,6 +35,21 @@ Uses the new LiveDNS API. Written in Go 1.20 - Unused, but required by the FritzBox interface - Enter your Gandi API-Key in the `Password` field +### Porkbun + +- Enter the following URL: `http://{HOST}:{PORT}/api/update?apikey=&secretapikey=&domain={DOMAIN}&subdomain={SUBDOMAIN}&ip=®istrar=porkbun` + - Replace the `{HOST}` and `{PORT}` with your deployment of the application + - By default, the application uses port `9595` + - Replace `{DOMAIN}` with your base domain + - e.g. `yourdomain.com` + - Replace `{SUBDOMAIN}` with your subdomain or comma separated subdomains + - e.g. `subdomain` or `sudomain1,subdomain2` +- Enter the full domain in the `Domain Name` field + - e.g. `subdomain.domain.com` (if you use multiple subdomains, just choose any of those) +- Enter your Porkbun API key in the `Username` field +- Enter your Porkbun API Secret Key in the `Password` field + + Your settings should look something like this: ![](https://kore.cc/fritzgandi/fbsettings.png "FritzBox DynDNS Settings") @@ -47,7 +68,7 @@ Check below for an example on how to reverse proxy to this application with NGIN ## Linux systemd Service To create a systemd service and run the application on boot, create a service file, for example under -`/etc/systemd/system/fritzgandi.service`. +`/etc/systemd/system/frigabun.service`. Service file contents: ``` @@ -55,8 +76,8 @@ Service file contents: Description=FritzGandi LiveDNS Microservice [Service] -WorkingDirectory=/your/path -ExecStart=/your/path/fritzgandi +WorkingDirectory=/path/to/frigabun +ExecStart=/path/to/frigabun/executable User=youruser Type=simple Restart=on-failure @@ -72,13 +93,13 @@ Reload daemon, start the service, check its status: ``` sudo systemctl daemon-reload -sudo systemctl start fritzgandi.service -sudo systemctl status fritzgandi +sudo systemctl start frigabun.service +sudo systemctl status frigabun ``` If all is well, enable the service to be started on boot: -`sudo systemctl enable fritzgandi` +`sudo systemctl enable frigabun` ## NGINX Reverse Proxy @@ -89,12 +110,12 @@ Shown below is an example of an NGINX + LetsEncrypt reverse proxy config for thi server { listen 443 ssl http2; listen [::]:443 ssl http2; - server_name fritzgandi.yourdomain.com; + server_name frigabun.yourdomain.com; # SSL - ssl_certificate /etc/letsencrypt/live/fritzgandi.yourdomain.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/fritzgandi.yourdomain.com/privkey.pem; - ssl_trusted_certificate /etc/letsencrypt/live/fritzgandi.yourdomain.com/chain.pem; + ssl_certificate /etc/letsencrypt/live/frigabun.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/frigabun.yourdomain.com/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/frigabun.yourdomain.com/chain.pem; # security headers add_header X-Frame-Options "DENY"; @@ -112,8 +133,8 @@ server { } # logging - access_log /var/log/nginx/fritzgandi.yourdomain.com.access.log; - error_log /var/log/nginx/fritzgandi.yourdomain.com.error.log warn; + access_log /var/log/nginx/frigabun.yourdomain.com.access.log; + error_log /var/log/nginx/frigabun.yourdomain.com.error.log warn; # reverse proxy location / { @@ -142,7 +163,7 @@ server { server { listen 80; listen [::]:80; - server_name fritzgandi.yourdomain.com; + server_name frigabun.yourdomain.com; # ACME-challenge location ^~ /.well-known/acme-challenge/ { @@ -150,7 +171,7 @@ server { } location / { - return 301 https://fritzgandi.yourdomain.com$request_uri; + return 301 https://frigabun.yourdomain.com$request_uri; } } ``` \ No newline at end of file diff --git a/config.sample.yml b/config.sample.yml index e129f47..24c8f64 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -1,6 +1,9 @@ gandi: baseurl: https://dns.api.gandi.net/api/v5 ttl: 300 +porkbun: + baseurl: https://porkbun.com/api/json/v3 + ttl: 600 api: port: 9595 hideApiKeyInLogs: true diff --git a/go.mod b/go.mod index 4e39b1d..37e0467 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/davidramiro/fritzgandi +module github.com/davidramiro/frigabun go 1.20 diff --git a/internal/api/api.go b/internal/api/api.go index 780e698..347dfd2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -6,8 +6,9 @@ import ( "strings" "github.com/asaskevich/govalidator" - "github.com/davidramiro/fritzgandi/internal/logger" - "github.com/davidramiro/fritzgandi/pkg/gandi" + "github.com/davidramiro/frigabun/internal/logger" + "github.com/davidramiro/frigabun/pkg/gandi" + "github.com/davidramiro/frigabun/pkg/porkbun" "github.com/labstack/echo/v4" ) @@ -20,8 +21,17 @@ type ApiError struct { Message string } +type UpdateRequest struct { + Domain string `query:"domain"` + Subdomains string `query:"subdomain"` + IP string `query:"ip"` + ApiKey string `query:"apikey"` + ApiSecretKey string `query:"apisecretkey"` + Registrar string `query:"registrar"` +} + func HandleUpdateRequest(c echo.Context) error { - var request gandi.UpdateRequest + var request UpdateRequest err := c.Bind(&request) if err != nil { @@ -45,15 +55,29 @@ func HandleUpdateRequest(c echo.Context) error { } for i := range subdomains { - dnsInfo := &gandi.GandiDnsInfo{ - IP: request.IP, - Domain: request.Domain, - Subdomain: subdomains[i], - ApiKey: request.ApiKey, - } - err := gandi.AddRecord(dnsInfo) - if err != nil { - return c.String(err.Code, err.Message) + if request.Registrar == "gandi" { + dnsInfo := &gandi.GandiDnsInfo{ + IP: request.IP, + Domain: request.Domain, + Subdomain: subdomains[i], + ApiKey: request.ApiKey, + } + err := gandi.AddRecord(dnsInfo) + if err != nil { + return c.String(err.Code, err.Message) + } + } else if request.Registrar == "porkbun" { + dnsInfo := &porkbun.PorkbunDnsInfo{ + IP: request.IP, + Domain: request.Domain, + Subdomain: subdomains[i], + ApiKey: request.ApiKey, + SecretApiKey: request.ApiSecretKey, + } + err := porkbun.AddRecord(dnsInfo) + if err != nil { + return c.String(err.Code, err.Message) + } } successfulUpdates++ diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 242a793..923e5b9 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -10,7 +10,7 @@ import ( "runtime" "testing" - "github.com/davidramiro/fritzgandi/internal/config" + "github.com/davidramiro/frigabun/internal/config" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" ) @@ -64,7 +64,7 @@ func TestUpdateEndpointWithValidRequest(t *testing.T) { q := make(url.Values) q.Set("ip", config.AppConfig.Test.IP) q.Set("domain", config.AppConfig.Test.Domain) - q.Set("subdomain", config.AppConfig.Test.Subdomain) + q.Set("subdomain", config.AppConfig.Test.Subdomain+"2") q.Set("apiKey", config.AppConfig.Test.ApiKey) e := echo.New() diff --git a/internal/config/config.go b/internal/config/config.go index 01cef64..5ea4897 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,11 @@ type Config struct { TTL int `yaml:"ttl"` } `yaml:"gandi"` + Porkbun struct { + BaseUrl string `yaml:"baseurl"` + TTL int `yaml:"ttl"` + } `yaml:"porkbun"` + Api struct { Port string `yaml:"port"` ApiKeyHidden bool `yaml:"hideApiKeyInLogs"` diff --git a/main.go b/main.go index cec1149..d180c52 100644 --- a/main.go +++ b/main.go @@ -4,9 +4,9 @@ import ( "regexp" "strings" - "github.com/davidramiro/fritzgandi/internal/api" - "github.com/davidramiro/fritzgandi/internal/config" - "github.com/davidramiro/fritzgandi/internal/logger" + "github.com/davidramiro/frigabun/internal/api" + "github.com/davidramiro/frigabun/internal/config" + "github.com/davidramiro/frigabun/internal/logger" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) diff --git a/main_test.go b/main_test.go index 8845780..06470e6 100644 --- a/main_test.go +++ b/main_test.go @@ -3,7 +3,7 @@ package main import ( "testing" - "github.com/davidramiro/fritzgandi/internal/config" + "github.com/davidramiro/frigabun/internal/config" "github.com/stretchr/testify/assert" ) diff --git a/pkg/gandi/gandi.go b/pkg/gandi/gandi.go index 6aee528..1400a0a 100644 --- a/pkg/gandi/gandi.go +++ b/pkg/gandi/gandi.go @@ -7,18 +7,10 @@ import ( "io" "net/http" - "github.com/davidramiro/fritzgandi/internal/config" - "github.com/davidramiro/fritzgandi/internal/logger" - "github.com/labstack/echo/v4" + "github.com/davidramiro/frigabun/internal/config" + "github.com/davidramiro/frigabun/internal/logger" ) -type UpdateRequest struct { - Domain string `query:"domain"` - Subdomains string `query:"subdomain"` - IP string `query:"ip"` - ApiKey string `query:"apikey"` -} - type GandiDnsInfo struct { IP string Domain string @@ -77,13 +69,9 @@ func AddRecord(updateRequest *GandiDnsInfo) *GandiUpdateError { if resp.StatusCode != 201 { b, _ := io.ReadAll(resp.Body) - logger.Log.Error().Err(err).Msg("gandi rejected request") + logger.Log.Error().Msg("gandi rejected request") return &GandiUpdateError{Code: resp.StatusCode, Message: "gandi rejected request: " + string(b)} } return nil } - -func Hello(c echo.Context) error { - return c.String(http.StatusOK, "Hello, World!") -} diff --git a/pkg/gandi/gandi_test.go b/pkg/gandi/gandi_test.go index 92afe28..066eeb1 100644 --- a/pkg/gandi/gandi_test.go +++ b/pkg/gandi/gandi_test.go @@ -7,7 +7,7 @@ import ( "runtime" "testing" - "github.com/davidramiro/fritzgandi/internal/config" + "github.com/davidramiro/frigabun/internal/config" "github.com/stretchr/testify/assert" ) diff --git a/pkg/porkbun/porkbun.go b/pkg/porkbun/porkbun.go new file mode 100644 index 0000000..25a7276 --- /dev/null +++ b/pkg/porkbun/porkbun.go @@ -0,0 +1,121 @@ +package porkbun + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/davidramiro/frigabun/internal/config" + "github.com/davidramiro/frigabun/internal/logger" +) + +type PorkbunDnsInfo struct { + IP string + Domain string + Subdomain string + ApiKey string + SecretApiKey string +} + +type PorkbunApiRequest struct { + Subdomain string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl"` + IP string `json:"content"` + ApiKey string `json:"apikey"` + SecretApiKey string `json:"secretapikey"` +} + +type PorkbunUpdateError struct { + Code int + Message string +} + +func AddRecord(dnsInfo *PorkbunDnsInfo) *PorkbunUpdateError { + + porkbunRequest := &PorkbunApiRequest{ + Subdomain: dnsInfo.Subdomain, + IP: dnsInfo.IP, + TTL: config.AppConfig.Porkbun.TTL, + Type: "A", + ApiKey: dnsInfo.ApiKey, + SecretApiKey: dnsInfo.SecretApiKey, + } + + deleteErr := deleteOldRecord(dnsInfo, porkbunRequest) + if deleteErr != nil { + logger.Log.Error().Msg("deleting old porkbun request failed") + return &PorkbunUpdateError{Code: 400, Message: "deleting old porkbun request failed"} + } + + postErr := postNewRecord(dnsInfo, porkbunRequest) + if postErr != nil { + logger.Log.Error().Str("err", postErr.Message).Msg("posting new porkbun record failed") + return &PorkbunUpdateError{Code: 400, Message: "posting new porkbun record failed"} + } + + return nil +} + +func deleteOldRecord(dnsInfo *PorkbunDnsInfo, porkbunRequest *PorkbunApiRequest) *PorkbunUpdateError { + endpoint := fmt.Sprintf("%s/dns/deleteByNameType/%s/A/%s", config.AppConfig.Porkbun.BaseUrl, dnsInfo.Domain, dnsInfo.Subdomain) + + logger.Log.Info().Str("subdomain", porkbunRequest.Subdomain).Str("endpoint", endpoint).Str("IP", porkbunRequest.IP).Msg("deleting old record") + + resp, err := executeRequest(endpoint, porkbunRequest) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest { + return &PorkbunUpdateError{400, "could not delete old record"} + } + + return nil +} + +func postNewRecord(dnsInfo *PorkbunDnsInfo, porkbunRequest *PorkbunApiRequest) *PorkbunUpdateError { + endpoint := fmt.Sprintf("%s/dns/create/%s", config.AppConfig.Porkbun.BaseUrl, dnsInfo.Domain) + + logger.Log.Info().Str("subdomain", porkbunRequest.Subdomain).Str("endpoint", endpoint).Str("IP", porkbunRequest.IP).Msg("creating new record") + + resp, err := executeRequest(endpoint, porkbunRequest) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + logger.Log.Error().Msg("porkbun rejected request") + return &PorkbunUpdateError{400, "could not create record: " + string(b)} + } + + return nil +} + +func executeRequest(endpoint string, porkbunRequest *PorkbunApiRequest) (*http.Response, *PorkbunUpdateError) { + body, err := json.Marshal(porkbunRequest) + if err != nil { + logger.Log.Error().Err(err).Msg("marshalling failed") + return nil, &PorkbunUpdateError{Code: 400, Message: "could not parse request"} + } + + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(body)) + if err != nil { + logger.Log.Error().Err(err).Msg("building request failed failed") + return nil, &PorkbunUpdateError{Code: 400, Message: "could not create request for gandi"} + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + client := &http.Client{} + + resp, err := client.Do(req) + if err != nil { + logger.Log.Error().Err(err).Msg("executing request failed") + return nil, &PorkbunUpdateError{Code: 500, Message: "could execute request"} + } + + return resp, nil +}