FIRST COMMIT

This commit is contained in:
maximo tejeda 2024-11-13 16:44:46 -04:00
commit d96142b348
20 changed files with 1386 additions and 0 deletions

0
.dir-locals.el Normal file
View File

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/.env
bin/*

14
Makefile Normal file
View File

@ -0,0 +1,14 @@
include .env
export
.phony: all clean build test clean-image build-image build-image-debug run-image run-image-debug run-local
run-local:clean build
mkdir db
@bin/ddnser
build:
@go build -o ./bin/ddnser ./cmd
test:
@go -count=1 test ./...
clean:
@rm -rf ./bin ./db

65
README.org Normal file
View File

@ -0,0 +1,65 @@
Working creating Request
#+begin_src shell
curl --request POST \
--url https://api.cloudflare.com/client/v4/zones/b4d1481be7a5d3460768b9bd84d74bfd/dns_records \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer 5LgXMn4GjuVG125RoapHUE_uTh8SjnIwVQr-3w0A" \
--data '{
"comment": "Domain verification record",
"name": "maximotejeda.com",
"proxied": true,
"settings": {},
"tags": [],
"ttl": 3600,
"content": "200.88.135.2",
"type": "A"
}'
#+end_src
#+RESULTS:
| {"result":{"id":"63aa045481a5fe258ed77dad985c09b5","zone_id":"b4d1481be7a5d3460768b9bd84d74bfd","zone_name":"maximotejeda.com","name":"maximotejeda.com","type":"A","content":"200.88.135.2","proxiable":true,"proxied":true,"ttl":1,"settings":{},"meta":{"auto_added":false,"managed_by_apps":false,"managed_by_argo_tunnel":false},"comment":"Domain verification record","tags":[],"created_on":"2024-09-24T16:11:45.71887Z","modified_on":"2024-09-24T16:11:45.71887Z","comment_modified_on":"2024-09-24T16:11:45.71887Z"},"success":true,"errors":[],"messages":[]} | | |
Query zones dns actives
#+begin_src shell
curl --request GET \
--url https://api.cloudflare.com/client/v4/zones/b4d1481be7a5d3460768b9bd84d74bfd/dns_records \
--header "Authorization: Bearer 5LgXMn4GjuVG125RoapHUE_uTh8SjnIwVQr-3w0A" \
--header 'Content-Type: application/json' \
#+end_src
#+RESULTS:
| {"result":[{"id":"63aa045481a5fe258ed77dad985c09b5" | zone_id:"b4d1481be7a5d3460768b9bd84d74bfd" | zone_name:"maximotejeda.com" | name:"maximotejeda.com" | type:"A" | content:"200.88.135.2" | proxiable:true | proxied:true | ttl:1 | settings:{} | meta:{"auto_added":false | managed_by_apps:false | managed_by_argo_tunnel:false} | comment:"Domain verification record" | tags:[] | created_on:"2024-09-24T16:11:45.71887Z" | modified_on:"2024-09-24T16:11:45.71887Z" | comment_modified_on:"2024-09-24T16:11:45.71887Z"}] | success:true | errors:[] | messages:[] | result_info:{"page":1 | per_page:100 | count:1 | total_count:1 | total_pages:1}} |
update zone
#+begin_src shell
curl --request PATCH \
--url https://api.cloudflare.com/client/v4/zones/b4d1481be7a5d3460768b9bd84d74bfd/dns_records/63aa045481a5fe258ed77dad985c09b5 \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer 5LgXMn4GjuVG125RoapHUE_uTh8SjnIwVQr-3w0A" \
--data '{
"comment": "Domain verification record",
"name": "maximotejeda.com",
"proxied": true,
"settings": {},
"tags": [],
"ttl": 3600,
"content": "200.88.135.2",
"type": "A"
}'
#+end_src
#+RESULTS:
| {"result":{"id":"63aa045481a5fe258ed77dad985c09b5" | zone_id:"b4d1481be7a5d3460768b9bd84d74bfd" | zone_name:"maximotejeda.com" | name:"maximotejeda.com" | type:"A" | content:"200.88.135.2" | proxiable:true | proxied:true | ttl:1 | settings:{} | meta:{"auto_added":false | managed_by_apps:false | managed_by_argo_tunnel:false} | comment:"Domain verification record" | tags:[] | created_on:"2024-09-24T16:11:45.71887Z" | modified_on:"2024-09-24T16:11:45.71887Z" | comment_modified_on:"2024-09-24T16:11:45.71887Z"} | success:true | errors:[] | messages:[]} |
check config for a zone
#+begin_src shell
curl --request GET \
--url https://api.cloudflare.com/client/v4/zones/b4d1481be7a5d3460768b9bd84d74bfd/dns_settings \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer 5LgXMn4GjuVG125RoapHUE_uTh8SjnIwVQr-3w0A" \
#+end_src
#+RESULTS:
| {"result":{"nameservers":{"type":"cloudflare.standard"} | foundation_dns:false | multi_provider:false | secondary_overrides:false | soa:{"mname":null | rname:"dns.cloudflare.com" | refresh:10000 | retry:2400 | expire:604800 | min_ttl:1800 | ttl:3600} | ns_ttl:86400 | zone_mode:"standard" | internal_dns:{"reference_zone_id":null} | flatten_all_cnames:false} | success:true | errors:[] | messages:[]} |

101
cmd/main.go Normal file
View File

@ -0,0 +1,101 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"github.com/maximotejeda/ddns/internal/application/core/api"
)
var (
tok string
zid string
name string
ip string
tipe string
operation string
proxied bool
helpMsg string
provider string
rID string
)
func init() {
flag.BoolVar(&proxied, "p", false, "Proxie request (shothand)")
flag.BoolVar(&proxied, "proxied", false, "Proxie request")
flag.StringVar(&name, "n", "", "REQUIRED: name subdomain (shorthand)")
flag.StringVar(&name, "name", "", "REQUIRED: name subdomain")
flag.StringVar(&ip, "ip", "", "ip to update record")
flag.StringVar(&tipe, "t", "", "type of record (shorthand)")
flag.StringVar(&tipe, "type", "", "type of record ")
flag.StringVar(&operation, "o", "show", "operation to execute (shorthand)")
flag.StringVar(&operation, "operation", "show", "operation to execute")
flag.StringVar(&tok, "tk", "", "authorization token on service (shorthand)")
flag.StringVar(&tok, "token", "", "authorization token on service")
flag.StringVar(&zid, "zid", "", "zone id for record (shorthand)")
flag.StringVar(&zid, "zone-id", "", "zone id for record")
flag.StringVar(&provider, "pv", "cf", "Dns records provider")
flag.StringVar(&rID, "rid", "", "Select record id (shorthand)")
flag.StringVar(&rID, "record-id", "", "Select record id")
}
func main() {
flag.Parse()
lvl := new(slog.LevelVar)
switch os.Getenv("ENV"){
case "debug", "dev":
lvl.Set(slog.LevelDebug)
case "prod":
lvl.Set(slog.LevelInfo)
}
log := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))
ctx := context.Background()
helpmsg := "required flag not provided "
if tok == "" {
tok = os.Getenv("TOKEN")
if tok == "" {
fmt.Print(printHelp())
fmt.Print(helpmsg + "[Token]\n")
flag.Usage()
os.Exit(1)
}
}
if zid == "" {
zid = os.Getenv("ZONEID")
if zid == "" {
fmt.Print(printHelp())
fmt.Print(helpmsg + "[zone id]\n")
flag.Usage()
os.Exit(1)
}
}
if name == "" {
name = os.Getenv("NAME")
if name == "" {
fmt.Print(printHelp())
fmt.Print(helpmsg + "[domain name]\n")
flag.Usage()
os.Exit(1)
}
}
app, err := api.NewApplication(ctx, log, provider, tok, zid)
if err != nil {
fmt.Println(zid)
panic(err)
}
app.Operation(operation, name, tipe, ip, proxied, rID)
}
func printHelp() string {
return `
Ddnser Client
Service client for cloudflare API
`
}

17
config/config.bak Normal file
View File

@ -0,0 +1,17 @@
zone_defaults:
flatten_all_cnames: false
foundation_dns: false
multi_provider: false
ns_ttl: 86400
secondary_overrides: false
zone_mode: "dns_only"
nameservers:
tipe: "cloudflare.standard"
soa:
expire: 604800
min_ttl: 1800
mname: "kristina.ns.cloudflare.com"
refresh: 10000
retry: 2400
rname: "admin.example.com"
ttl: 3500

26
config/config.go Normal file
View File

@ -0,0 +1,26 @@
package config
import (
"errors"
"fmt"
"os"
)
var (
NotFoundError = errors.New("env variable not set")
)
func getEnv(key string) string {
if key == "" {
panic(fmt.Errorf("%w : key -> %s", NotFoundError, key))
}
return os.Getenv(key)
}
func GetDomainProvider(provider string) string {
return getEnv(provider)
}
func GetServiceVerifyURL(verifyIPURL string) string {
return getEnv(verifyIPURL)
}

16
config/settings.yaml Normal file
View File

@ -0,0 +1,16 @@
flatten_all_cnames: false
foundation_dns: false
multi_provider: false
ns_ttl: 86400
secondary_overrides: false
zone_mode: "dns_only"
nameservers:
tipe: "cloudflare.standard"
soa:
expire: 604800
min_ttl: 1800
mname: "kristina.ns.cloudflare.com"
refresh: 10000
retry: 2400
rname: "admin.example.com"
ttl: 3500

7
go.mod Normal file
View File

@ -0,0 +1,7 @@
module github.com/maximotejeda/ddns
go 1.22
require gopkg.in/yaml.v3 v3.0.1
require github.com/chmike/domain v1.1.0

6
go.sum Normal file
View File

@ -0,0 +1,6 @@
github.com/chmike/domain v1.1.0 h1:615mGyA/ghxvIFBdAaYuB2azxAsUxrpm6Cv5UiL6VPo=
github.com/chmike/domain v1.1.0/go.mod h1:h558M2qGKpYRUxHHNyey6puvXkZBjvjmseOla/d1VGQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,95 @@
// Package that will query for IP
package checker
import (
"errors"
"io"
"log/slog"
"net"
"net/http"
"strings"
"github.com/maximotejeda/ddns/config"
)
type client struct {
urls []string
method string
headers map[string][]string
reqs []*http.Request
log *slog.Logger
}
type Response struct {
IP *net.IP `json:"ip,omitempty" yaml:"ip,omitempty"`
IPSTR string `json:"ip_str,omitempty" yaml:"ip_str,omitempty"`
Version string `json:"version" yaml:"version,omitempty"`
Type string `json:"type,omitempty" yaml:"type,omitempty"`
Identifyer string `json:"identifyer" yaml:"identifyer"`
}
func NewClient(log *slog.Logger, provider string) *client {
c := &client{}
c.urls = strings.Split(config.GetServiceVerifyURL("IP_VERIFY_PROVIDER_URL"), ",")
c.method = http.MethodGet
for _, url := range c.urls {
req, _ := http.NewRequest(c.method, url, nil)
c.reqs = append(c.reqs, req)
}
log = log.With("location", "Checker")
c.log = log
return c
}
// Do
// Make a request to retrieve the ip of the machine
func (c *client) Do() (res *Response, err error) {
r := &Response{}
client := http.DefaultClient
if len(c.reqs) <= 0 {
c.log.Error("no request on struct")
return nil, errors.New("no request on client struct")
}
resp, err := client.Do(c.reqs[0])
r.Identifyer = c.urls[0]
if err != nil {
c.log.Error("doing request for ip", "err", err, "identidyer", c.reqs[0])
c.log.Info("retrying on other provider")
resp, err = client.Do(c.reqs[1])
r.Identifyer = c.urls[1]
if err != nil {
c.log.Error("doing request for ip", "err", err, "identidyer", c.reqs[1])
return nil, err
}
}
defer resp.Body.Close()
ipSTR, err := io.ReadAll(resp.Body)
if err != nil {
c.log.Error("reading body", err)
}
ipi := net.ParseIP(string(ipSTR))
r.IP = &ipi
//check ip is not nil
if r.IP == nil {
return nil, errors.New("ip not found")
}
// check if ip is private
if r.IP.IsPrivate() {
return nil, errors.New("ip is a private ip")
}
// check if is loop back ip
if r.IP.IsLoopback() {
return nil, errors.New("ip is a loopback ip")
}
if r.IP.To4() != nil {
r.Version = "V4"
r.Type = "A"
}else {
r.Version = "V6"
r.Type = "AAAA"
}
return r, err
}

View File

@ -0,0 +1,326 @@
package provider
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path"
"strings"
"github.com/maximotejeda/ddns/internal/application/core/domain/cf"
"gopkg.in/yaml.v3"
)
var (
baseUrl = "https://api.cloudflare.com/client/v4/zones/"
subPath = "/dns_records/"
)
type cloudflareClient struct {
ctx context.Context
log *slog.Logger
client http.Client
token string
zoneID string
SelectdnsRecordID string
name string
settings *cf.Settings
DnsRecords []cf.ResponseBody
}
// NewCloudflareClient
// Create a new client for cloudflare API
func NewCloudflareClient(ctx context.Context, log *slog.Logger, zoneID, token string) *cloudflareClient {
c := cloudflareClient{}
if zoneID == "" || token == "" {
panic(errors.New("zoneid and token nedded to initiate client"))
}
log = log.With("location", "cloudflareClient")
c.ctx = ctx
c.log = log
c.zoneID = zoneID
c.token = token
c.client = *http.DefaultClient
return &c
}
// getConfig
// Will determine the place where config is located
//
// ["~/.local/share/ddnser/config","./config"]
func GetSettingsFile(loc string) (settings *cf.Settings) {
location := ""
p := ""
fName := "settings.yaml"
environ := os.Getenv("ENVIRONMENT")
if environ != "prod" {
p, _ = os.Getwd()
location = path.Join(p, "config", fName)
} else {
p, _ = os.UserHomeDir()
location = path.Join(p, ".local", "ddnser", "config", fName)
}
f, err := os.Open(location)
if err != nil {
panic(err)
}
decoder := yaml.NewDecoder(f)
decoder.KnownFields(false)
err = decoder.Decode(&settings)
if err != nil {
panic(err)
}
fmt.Printf("%v, error: %v\n", settings, err)
return settings
}
// GenerateDefaultSettings
// Generate default setting that can be saved oon json or yaml
func (c *cloudflareClient) GenerateDefaultSettings() *cf.Settings {
settings := cf.Settings{}
zd := &settings
zd.NSTTL = 86400
zd.ZoneMode = "standard"
zd.NameServers.Type = "cloudflare.standard"
soa := &zd.Soa
soa.RName = "dns.cloudflare.com"
soa.Refresh = 10000
soa.Retry = 2400
soa.Expire = 604800
soa.MinTTL = 1800
soa.TTL = 36000
c.settings = &settings
return &settings
}
// List
// List all the records for the ZoneID
func (c *cloudflareClient) List() (result *cf.ResponseBody, err error) {
c.log.Debug("Calling list")
req, err := http.NewRequest("GET", fmt.Sprintf("%s%s%s", baseUrl, c.zoneID, subPath), nil)
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
resp, err := c.client.Do(req)
if err != nil {
c.log.Error(err.Error())
}
result = &cf.ResponseBody{}
result, err = handleResult(c.log, resp, *result)
if err != nil {
c.log.Error("marshaling err", "error", err)
}
return result, err
}
// Export
// Export dns recors for later import
func (c *cloudflareClient) Export() ([]byte, error) {
req, err := http.NewRequest("GET", fmt.Sprintf("%s%s%sexport", baseUrl, c.zoneID, subPath), nil)
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
resp, err := c.client.Do(req)
if err != nil {
c.log.Error(err.Error())
c.log.Info(fmt.Sprintf("%#v", req))
return nil, err
}
//result := &domain.ReponseBody{}
data, err := io.ReadAll(resp.Body)
//err = json.Unmarshal(data, result)
if err != nil {
return nil, err
}
return data, nil
}
// Import
// With an export file, import that file
func (c *cloudflareClient) Import(file io.Reader) (err error) {
req, err := http.NewRequest("POST", fmt.Sprintf("%s%s%simport", baseUrl, c.zoneID, subPath), file)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
// TODO
// Import file and add it ass form data param named file
// form data named proxied
// ADD it to Request
_, err = c.client.Do(req)
if err != nil {
c.log.Error(err.Error())
c.log.Info(fmt.Sprintf("%#v", req))
return err
}
return nil
}
// Update
// update a current record
// Param:
//
// {id}
// {dns_params}
func (c *cloudflareClient) Update(id string, reqBody *cf.RequestBody) (result *cf.DetailsResult, err error) {
b, _ := json.Marshal(reqBody)
buf := bytes.NewBuffer(b)
req, err := http.NewRequest("PATCH", fmt.Sprintf("%s%s%s%s", baseUrl, c.zoneID, subPath, id), buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
result = &cf.DetailsResult{}
result, err = handleResult(c.log, resp, *result)
return result, err
}
// Create
// Create a single dns record with specified params
func (c *cloudflareClient) Create(reqBody *cf.RequestBody) (result *cf.DetailsResult, err error) {
b, _ := json.Marshal(reqBody)
buf := bytes.NewBuffer(b)
req, err := http.NewRequest("POST", fmt.Sprintf("%s%s%s", baseUrl, c.zoneID, subPath), buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
resp, err := c.client.Do(req)
if err != nil {
c.log.Error(err.Error())
c.log.Info(fmt.Sprintf("%#v", req))
return nil, err
}
result = &cf.DetailsResult{}
result, err = handleResult(c.log, resp, *result)
return result, err
}
// Delete
// Delete dns Recors expecified
// Params:
//
// {id}
func (c *cloudflareClient) Delete(id string) (result *cf.DetailsResult, err error) {
req, err := http.NewRequest("DELETE", fmt.Sprintf("%s%s%s%s", baseUrl, c.zoneID, subPath, id), nil)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
resp, err := c.client.Do(req)
if err != nil {
panic(err)
}
result = &cf.DetailsResult{}
result, err = handleResult(c.log, resp, *result)
return result, err
}
// Overwrite
// Overwrite dns records with new params
// Params:
//
// {id}
// {comment}
func (c *cloudflareClient) Overwrite(reqBody cf.RequestBody, id string) (result *cf.DetailsResult, err error) {
b, _ := json.Marshal(reqBody)
buf := bytes.NewBuffer(b)
req, err := http.NewRequest("PUT", fmt.Sprintf("%s%s%s%s", baseUrl, c.zoneID, subPath, id), buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
resp, err := c.client.Do(req)
if err != nil {
panic(err)
}
result = &cf.DetailsResult{}
result, err = handleResult(c.log, resp, *result)
return result, nil
}
// Details
// Returns a single result for a dns record domain.DetailResult
func (c *cloudflareClient) Details(id string) (result *cf.DetailsResult, err error) {
req, err := http.NewRequest("GET", fmt.Sprintf("%s%s%s%s", baseUrl, c.zoneID, subPath, id), nil)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
resp, err := c.client.Do(req)
if err != nil {
panic(err)
}
respBody, err := io.ReadAll(resp.Body)
c.log.Debug(string(respBody))
if err != nil {
panic(err)
}
result = &cf.DetailsResult{}
err = json.Unmarshal(respBody, result)
if err != nil {
return nil, err
}
return result, err
}
// Selector
func (c *cloudflareClient) Selector(subDomain, domain, comment, tipe string) (selRecord []*cf.Result, err error) {
// Select the domains to update
records, err := c.List()
for _, record := range records.Result {
if record.Type == strings.ToUpper(tipe) {
params := strings.Split(record.Name, ".")
if subDomain != "" {
if len(params) == 3 {
if strings.EqualFold(params[0], subDomain) {
selRecord = append(selRecord, &record)
}
}
} else {
if strings.EqualFold(record.Name, domain) {
selRecord = append(selRecord, &record)
}
}
}
if subDomain == "*" || domain == "*" {
selRecord = append(selRecord, &record)
}
}
return selRecord, nil
}
func handleResult[T cf.ResponseBody | cf.DetailsResult](log *slog.Logger, resp *http.Response, t T) (result *T, err error) {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Error("[handleResult]->reading body", "error", err)
return nil, err
}
defer resp.Body.Close()
err = json.Unmarshal(respBody, &t)
if err != nil {
log.Error("[handleResult]-> unmarshal body ", "error", err, "body", respBody)
return nil, err
}
return &t, err
}

View File

@ -0,0 +1,14 @@
package provider
import (
"os"
"testing"
)
func TestGetSettings(t *testing.T) {
os.Setenv("ENVIRONMENT", "dev")
s := GetSettingsFile("")
if s != nil {
t.Errorf("empty settings: %v", s)
}
}

View File

@ -0,0 +1,123 @@
package cf
type RequestBody struct {
Comment string `yaml:"comment" json:"comment,omitempty"`
DomainName string `json:"name" yaml:"name"` // domain name example.com",
Proxied bool `json:"proxied" yaml:"proxied"` //true,
Settings Settings `json:"settings,omitempty" yaml:"settings"` //{},
Tags []string `json:"tags,omitempty" yaml:"tags"` //[],
TTL int `json:"ttl" yaml:"ttl"` //3600,
Content string `json:"content" yaml:"content"` //ip to add "198.51.100.4",
Type string `json:"type" yaml:"type"` //record type "A"
}
type ResponseBody struct {
Errors []Error `json:"errors"` //"errors": [],
Messages []string `json:"messages"` //"messages": [],
Success bool `json:"success"` //"success": true,
Result []Result `json:"result"` //
}
type Result struct {
ID string `json:"ID" yaml:"ID"` //"id": "023e105f4ecef8ad9ca31a8372d0c353",
ZoneID string `json:"zone_id" yaml:"zone_id"`
ZoneName string `json:"zone_name" yaml:"zone_name"` // the base name for domain.com
Comment string `json:"comment" yaml:"comment"` // "comment": "Domain verification record",
Name string `json:"name" yaml:"name"` //domain name or sub domain "name": "test.example.com",
Proxied bool `json:"proxied" yaml:"proxied"` //"proxied": true,
Settings Settings `json:"settings" yaml:"settings"` //"settings": {},
Tags []string `json:"tags" yaml:"tags"` //"tags": [],
TTL int `json:"ttl" yaml:"ttl"` //"ttl": 3600,
Content string `json:"content" yaml:"content"` //IP "content": "198.51.100.4",
Type string `json:"type" yaml:"type"` //"type": "A",
CommentModifiedOn string `json:"comment_modified_on" yaml:"comment_modified_on"` //"comment_modified_on": "2024-01-01T05:20:00.12345Z",
CreatedOn string `json:"created_on" yaml:"created_on"` //"created_on": "2014-01-01T05:20:00.12345Z",
Meta Meta `json:"meta" yaml:"meta"` //"meta": {},
ModifiedOn string `json:"modified_on" yaml:"modified_on"` //"modified_on": "2014-01-01T05:20:00.12345Z",
Proxiable bool `json:"proxiable" yaml:"proxiable"` //"proxiable": true,
TagsModifiedOn string `json:"tags_modified_on" yaml:"tags_modified_on"` //"tags_modified_on": "2025-01-01T05:20:00.12345Z"
ResultInfo ResultInfo `json:"result_info" yaml:"result_info"`
}
type DetailsResult struct {
Errors []Error `json:"errors"` //"errors": [],
Messages []string `json:"messages"` //"messages": [],
Success bool `json:"success"` //"success": true,
Result Result `json:"result" yaml:"result"`
}
type Meta struct {
AutoAdded bool `json:"auto_added" yaml:"auto_added"`
ManagedByApps bool `json:"managed_by_apps" yaml:"managed_by_apps"`
ManagedByArgoTunnel bool `json:"managed_by_argo_tunnel" yaml:"managed_by_argo_tunnel"`
}
type ResultInfo struct {
Page int `json:"page" yaml:"page"`
PerPage int `json:"per_page" yaml:"per_page"`
Count int `json:"count" yaml:"count"`
TotalCount int `json:"total_count" yaml:"total_count"`
TotalPages int `json:"total_pages" yaml:"total_pages"`
}
// settings response example https://developers.cloudflare.com/api/operations/dns-settings-for-an-account-update-dns-settings
/*
"messages": [],
"success": true,
"result": {
"zone_defaults": {
"flatten_all_cnames": false,
"foundation_dns": false,
"multi_provider": false,
"nameservers": {
"type": "cloudflare.standard"
},
"ns_ttl": 86400,
"secondary_overrides": false,
"soa": {
"expire": 604800,
"min_ttl": 1800,
"mname": "kristina.ns.cloudflare.com",
"refresh": 10000,
"retry": 2400,
"rname": "admin.example.com",
"ttl": 3600
},
"zone_mode": "dns_only"
}
}
}
*/
type Settings1 struct {
}
type Settings struct {
FlattenAllCNames bool `json:"flatten_all_cnames" yaml:"flatten_all_cnames"` //"flatten_all_cnames": false,
FoundationDns bool `json:"foundation_dns" yaml:"foundation_dns"` //"foundation_dns": false,
MultiProvider bool `json:"multi_provider" yaml:"multi_provider"` //"multi_provider": false,
NSTTL int `json:"ns_ttl" yaml:"ns_ttl"` //"ns_ttl": 86400,
SecondaryOverrides bool `json:"secondary_overrides" yaml:"secondary_overrides"` //"secondary_overrides": false,
ZoneMode string `json:"zone_mode" yaml:"zone_mode"` //"zone_mode": "dns_only"
NameServers NameServers `json:"nameservers" yaml:"nameservers"`
Soa SOA `json:"soa" yaml:"soa"`
}
type NameServers struct {
Type string `json:"type" yaml:"tipe"`
}
type SOA struct {
Expire int `json:"expire" yaml:"expire,flow"` //"expire": 604800,
MinTTL int `json:"min_ttl" yaml:"min_ttl,flow"` //"min_ttl": 1800,
MName string `json:"mname" yaml:"mname"` //"mname": "kristina.ns.cloudflare.com",
Refresh int `json:"refresh" yaml:"refresh"` //"refresh": 10000,
Retry int `json:"retry" yaml:"retry"` //"retry": 2400,
RName string `json:"rname" yaml:"rname"` //"rname": "admin.example.com",
TTL int `json:"ttl" yaml:"ttl"` //"ttl": 3600
}
type Error struct {
Code int `json:"code" yaml:"code"`
Message string `json:"message" yaml:"message"`
}

View File

@ -0,0 +1,304 @@
package api
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"slices"
"strings"
"github.com/maximotejeda/ddns/internal/adapter/checker"
prov "github.com/maximotejeda/ddns/internal/adapter/provider"
"github.com/maximotejeda/ddns/internal/application/core/domain"
"github.com/maximotejeda/ddns/internal/application/core/domain/cf"
"github.com/maximotejeda/ddns/internal/application/core/helpers"
"github.com/maximotejeda/ddns/internal/port"
)
type Application struct {
ctx context.Context
log *slog.Logger
client port.Provider
publicIP *net.IP
zoneRecords []cf.Result
Domains map[string]domain.DomainRecords
}
// NewApplication
// Returns an instance to a new app with a provider client
// Needs some info from the account to work on
// Params:
//
// provider: provider client to interact with
// toke: token to authorize actions on provider
// id: id of user or zoner where to work with
func NewApplication(ctx context.Context, log *slog.Logger, provider, token, id string) (app *Application, err error) {
log.Debug("creating new appication")
var pClient port.Provider
switch provider {
case "cf":
pClient = prov.NewCloudflareClient(ctx, log, id, token)
}
logApp := log.With("location", "[Application]")
app = &Application{
ctx: ctx,
log: logApp,
client: pClient,
}
return app, nil
}
// Show
// Print in stdout the dns records for zone
func (app *Application) Show(name string) {
app.log.Debug("calling show")
fmt.Printf("%22s %5s \t%14s \t%s\n\n", "name", "type", "ip", "ID")
for _, it := range app.zoneRecords {
subDomainName := strings.ReplaceAll(strings.ReplaceAll(it.Name, name, ""), ".", "")
if subDomainName == "" {
subDomainName = "*root*"
}
fmt.Printf("%+22s %5s \t%14s \t%s\n", subDomainName, it.Type, it.Content, it.ID)
}
checkClient := checker.NewClient(app.log, "cf")
ip, err := checkClient.Do()
if err != nil {
app.log.Error("[checker] ip", "error", err)
}
fmt.Printf("\nrequester IP => %s\n", ip)
}
// Details ...
// Print details on a given record
func (app *Application) Details(id string) {
app.log.Debug("calling details")
resp, err := app.client.Details(id)
if err != nil {
app.log.Error("[datails]", "error", err)
}
fmt.Printf(`
Zone Name: %s - ID: %s - Domain Name: %s
Type: %s - IP: %s - Proxied: %t
Comment: %s
Created On: %s
Modified On: %s
Meta: %#v
Tags: %#v
TTL: %d
`,
resp.Result.ZoneName, resp.Result.ID, resp.Result.Name, resp.Result.Type,
resp.Result.Content, resp.Result.Proxied, resp.Result.Comment,
resp.Result.CreatedOn, resp.Result.ModifiedOn, resp.Result.Meta,
resp.Result.Tags, resp.Result.TTL)
}
// List
func (app *Application) List() {
resp, err := app.client.List()
if err != nil {
app.log.Error("[Show]", "error", err)
panic(err)
}
app.zoneRecords = resp.Result
}
// Update
func (app *Application) Update(re *cf.Result, rBody cf.RequestBody) {
rBody.Comment = "updating from app cli tool"
res, err := app.client.Update(re.ID, &rBody)
if err != nil {
app.log.Error("[Update]", "error", err)
}
app.log.Debug("updating", "record", re.Name, "IP", rBody.Content, "Type", re.Type, "ID", "re.ID")
app.log.Info("response", "success", res.Success, "errors", res.Errors)
}
// Create
func (app *Application) Create(rBody cf.RequestBody) (res *cf.DetailsResult, err error) {
rBody.Comment = "Creating from app cli tool"
res, err = app.client.Create(&rBody)
if err != nil {
app.log.Error("[Create]", "error", err)
}
return res, err
}
// Overwrite
func (app *Application) Overwrite(re *cf.Result, rBody *cf.RequestBody) {
rBody.Comment = "Overwrite from app cli tool"
res, err := app.client.Overwrite(*rBody, re.ID)
if err != nil {
app.log.Error("[Overwrite]", "error", err)
}
fmt.Println("Overwrite:")
fmt.Printf("%v", res)
}
// Delete
func (app Application) Delete(re *cf.Result) {
res, err := app.client.Delete(re.ID)
if err != nil {
app.log.Error("[Delete]", "error", err)
}
fmt.Println("Delete: ")
fmt.Printf("%v", res)
}
// Operation
// expose required
func (app *Application) Operation(op string, name, tipo, ipSTR string, proxied bool, rID string) {
var (
res *checker.Response
err error
rBody *cf.RequestBody
)
// check current ip
func() {
checker := checker.NewClient(app.log, "cf")
res, err = checker.Do()
if err != nil {
app.log.Error("checking public ip", "error", err)
panic(err)
}
}()
if ipSTR == "" {
app.publicIP = res.IP
tipo = res.Type
} else {
ip1 := net.ParseIP(ipSTR)
app.publicIP = &ip1
if ip1.To4() != nil {
tipo = "A"
} else if ip1.To16() != nil {
tipo = "AAA"
}
}
// populate struct with dns records
// and populate subdomains inside each domain
app.List()
app.Domains, _ = helpers.SubdomainIdentify(name, app.zoneRecords)
dn := helpers.SelectDomain(name, app.Domains) // will always be available if subdomain is valid
rs := app.SelectRecord(dn, tipo)
if rs == nil {
app.log.Debug("record not selected", "ip", ipSTR, "name", name, "type", tipo)
}
//fmt.Println(dn, rs, app.zoneRecords)
app.Domains, _ = helpers.SubdomainIdentify(name, app.zoneRecords)
if res == nil {
fmt.Printf("%#v", app.Domains)
panic("record does not exist")
}
switch op {
case "update":
if name == "" {
panic("name cant be empty for op update")
}
if rs == nil {
if dn == "*" {
for _, rec := range app.zoneRecords {
if rec.Content != app.publicIP.String() {
rBody = app.GenerateReqBody(rec.Name, res.Type, app.publicIP.String(), proxied)
app.Update(&rec, *rBody)
} else {
app.log.Info("same ip for", "record", rec.Name, "DstIP", rec.Content, "NewIP", app.publicIP)
}
}
} else {
app.log.Error(fmt.Sprintf("record is not created or active create first: %s -> %s", name, dn))
}
} else {
if rs.Content != app.publicIP.String() {
rBody = app.GenerateReqBody(name, res.Type, app.publicIP.String(), proxied)
app.Update(rs, *rBody)
} else {
app.log.Error("same ip on dns record")
}
}
case "create":
// as we can have more than one subdomain/with the same name and diff ip
if name == "" {
panic("name cant be empty for op create")
}
fmt.Printf("domain name: %s", dn)
rBody = app.GenerateReqBody(dn, tipo, app.publicIP.String(), proxied)
app.Create(*rBody)
case "delete":
if name == "" {
panic("name cant be empty for op delete")
}
if rs == nil {
app.log.Error(fmt.Sprintf("could not find record: %s -> %s", name, dn), "operation", "delete", "rs", rs)
} else {
app.Delete(rs)
}
case "overwrite":
if name == "" {
app.log.Error("name cant be empty for op overwrite")
}
if rs == nil {
app.log.Error(fmt.Sprintf("could not find record: %s -> %s", name, dn), "operation", "overwrite")
} else {
rBody = app.GenerateReqBody(dn, tipo, app.publicIP.String(), proxied)
app.Overwrite(rs, rBody)
}
case "details":
if name == "" {
app.log.Error("name cant be empty for op details")
}
if rs != nil {
rBody = app.GenerateReqBody(dn, tipo, app.publicIP.String(), proxied)
app.Details(rs.ID)
}
//rBody := app.GenerateReqBody(name, tipo, app.publicIP.String(), proxied)
if rs == nil {
app.log.Error("record does not exist")
}
case "show":
app.Show(name)
default:
app.log.Error("unknown operation", "operation", op)
panic("unknown operation")
}
}
// SelectRecord
func (app *Application) SelectRecord(name, tipo string) (res *cf.Result) {
res, _ = app.SelectNameAndType(name, tipo)
return res
}
// SelectNameAndType
func (app *Application) SelectNameAndType(name, tipo string) (rec *cf.Result, err error) {
i := slices.IndexFunc(app.zoneRecords, func(r cf.Result) bool {
return name == r.Name && strings.ToUpper(tipo) == r.Type
})
if i >= 0 {
return &app.zoneRecords[i], nil
} else {
return nil, errors.New("record not found")
}
}
func (app *Application) GenerateReqBody(name, tipo, ipSTR string, proxied bool) (rBody *cf.RequestBody) {
rBody = &cf.RequestBody{}
rBody.DomainName = name
rBody.Type = tipo
rBody.Content = ipSTR
rBody.Proxied = proxied
rBody.TTL = 3600
return rBody
}

View File

@ -0,0 +1,124 @@
package cf
type RequestBody struct {
Comment string `yaml:"comment" json:"comment,omitempty"`
DomainName string `json:"name" yaml:"name"` // domain name example.com",
Proxied bool `json:"proxied" yaml:"proxied"` //true,
Settings Settings `json:"settings,omitempty" yaml:"settings"` //{},
Tags []string `json:"tags,omitempty" yaml:"tags"` //[],
TTL int `json:"ttl" yaml:"ttl"` //3600,
Content string `json:"content" yaml:"content"` //ip to add "198.51.100.4",
Type string `json:"type" yaml:"type"` //record type "A"
}
type ResponseBody struct {
Errors []Error `json:"errors"` //"errors": [],
Messages []string `json:"messages"` //"messages": [],
Success bool `json:"success"` //"success": true,
Result []Result `json:"result"` //
}
type Result struct {
ID string `json:"ID" yaml:"ID"` //"id": "023e105f4ecef8ad9ca31a8372d0c353",
ZoneID string `json:"zone_id" yaml:"zone_id"`
ZoneName string `json:"zone_name" yaml:"zone_name"` // the base name for domain.com
Comment string `json:"comment" yaml:"comment"` // "comment": "Domain verification record",
Name string `json:"name" yaml:"name"` //domain name or sub domain "name": "test.example.com",
Proxied bool `json:"proxied" yaml:"proxied"` //"proxied": true,
Settings Settings `json:"settings" yaml:"settings"` //"settings": {},
Tags []string `json:"tags" yaml:"tags"` //"tags": [],
TTL int `json:"ttl" yaml:"ttl"` //"ttl": 3600,
Content string `json:"content" yaml:"content"` //IP "content": "198.51.100.4",
Type string `json:"type" yaml:"type"` //"type": "A",
CommentModifiedOn string `json:"comment_modified_on" yaml:"comment_modified_on"` //"comment_modified_on": "2024-01-01T05:20:00.12345Z",
CreatedOn string `json:"created_on" yaml:"created_on"` //"created_on": "2014-01-01T05:20:00.12345Z",
Meta Meta `json:"meta" yaml:"meta"` //"meta": {},
ModifiedOn string `json:"modified_on" yaml:"modified_on"` //"modified_on": "2014-01-01T05:20:00.12345Z",
Proxiable bool `json:"proxiable" yaml:"proxiable"` //"proxiable": true,
TagsModifiedOn string `json:"tags_modified_on" yaml:"tags_modified_on"` //"tags_modified_on": "2025-01-01T05:20:00.12345Z"
ResultInfo ResultInfo `json:"result_info" yaml:"result_info"`
}
type DetailsResult struct {
Errors []Error `json:"errors"` //"errors": [],
Messages []string `json:"messages"` //"messages": [],
Success bool `json:"success"` //"success": true,
Result Result `json:"result" yaml:"result"`
}
type Meta struct {
AutoAdded bool `json:"auto_added" yaml:"auto_added"`
ManagedByApps bool `json:"managed_by_apps" yaml:"managed_by_apps"`
ManagedByArgoTunnel bool `json:"managed_by_argo_tunnel" yaml:"managed_by_argo_tunnel"`
}
type ResultInfo struct {
Page int `json:"page" yaml:"page"`
PerPage int `json:"per_page" yaml:"per_page"`
Count int `json:"count" yaml:"count"`
TotalCount int `json:"total_count" yaml:"total_count"`
TotalPages int `json:"total_pages" yaml:"total_pages"`
}
// settings response example https://developers.cloudflare.com/api/operations/dns-settings-for-an-account-update-dns-settings
/*
"messages": [],
"success": true,
"result": {
"zone_defaults": {
"flatten_all_cnames": false,
"foundation_dns": false,
"multi_provider": false,
"nameservers": {
"type": "cloudflare.standard"
},
"ns_ttl": 86400,
"secondary_overrides": false,
"soa": {
"expire": 604800,
"min_ttl": 1800,
"mname": "kristina.ns.cloudflare.com",
"refresh": 10000,
"retry": 2400,
"rname": "admin.example.com",
"ttl": 3600
},
"zone_mode": "dns_only"
}
}
}
*/
type Settings struct {
FlattenAllCNames bool `json:"flatten_all_cnames" yaml:"flatten_all_cnames"` //"flatten_all_cnames": false,
FoundationDns bool `json:"foundation_dns" yaml:"foundation_dns"` //"foundation_dns": false,
MultiProvider bool `json:"multi_provider" yaml:"multi_provider"` //"multi_provider": false,
NSTTL int `json:"ns_ttl" yaml:"ns_ttl"` //"ns_ttl": 86400,
SecondaryOverrides bool `json:"secondary_overrides" yaml:"secondary_overrides"` //"secondary_overrides": false,
ZoneMode string `json:"zone_mode" yaml:"zone_mode"` //"zone_mode": "dns_only"
NameServers NameServers `json:"nameservers" yaml:"nameservers"`
Soa SOA `json:"soa" yaml:"soa"`
}
type NameServers struct {
Type string `json:"type" yaml:"tipe"`
}
type SOA struct {
Expire int `json:"expire" yaml:"expire,flow"` //"expire": 604800,
MinTTL int `json:"min_ttl" yaml:"min_ttl,flow"` //"min_ttl": 1800,
MName string `json:"mname" yaml:"mname"` //"mname": "kristina.ns.cloudflare.com",
Refresh int `json:"refresh" yaml:"refresh"` //"refresh": 10000,
Retry int `json:"retry" yaml:"retry"` //"retry": 2400,
RName string `json:"rname" yaml:"rname"` //"rname": "admin.example.com",
TTL int `json:"ttl" yaml:"ttl"` //"ttl": 3600
}
type Test struct {
Name string `yaml:"name"`
Age int `yaml:"age"`
}
type Error struct {
Code int `json:"code" yaml:"code"`
Message string `json:"message" yaml:"message"`
}

View File

@ -0,0 +1,20 @@
package domain
import "fmt"
type DomainRecords struct {
Domain string `json:"root_domain" yaml:"root_domain"`
Existing []Record `json:"existing" yaml:"existing"`
ToCreate []Record `json:"to_create" yaml:"to_create"`
}
type Record struct {
ID string
Name string
Content string
Type string
}
func (r *Record) String() string {
return fmt.Sprintf("%s:%s", r.Name, r.Type)
}

View File

@ -0,0 +1,102 @@
package helpers
import (
"fmt"
"slices"
"strings"
"github.com/chmike/domain"
dm "github.com/maximotejeda/ddns/internal/application/core/domain"
"github.com/maximotejeda/ddns/internal/application/core/domain/cf"
)
type result struct {
cf.Result
}
// RootIdentify
// identify root domain from a list of dns records
// basically root will be example.com
// if splitted and > 2 then is a sub domain
// else if == 2 is root domain
func RootIdentify(domains []cf.Result) (roots map[string]dm.DomainRecords, err error) {
roots = map[string]dm.DomainRecords{}
for _, reg := range domains {
name := reg.Name
nameParts := strings.Split(name, ".")
// if the name is equal to the root actual name continue
if len(nameParts) > 2 {
r := strings.Join(nameParts[len(nameParts)-2:], ".")
if _, ok := roots[r]; !ok {
roots[r] = dm.DomainRecords{Domain: r}
}
if record, ok := roots[r]; ok {
record.Existing = append(record.Existing, dm.Record{ID: reg.ID, Name: name, Type: reg.Type, Content: reg.Content})
roots[r] = record
}
} else if len(nameParts) == 2 {
if _, ok := roots[reg.Name]; !ok {
roots[reg.Name] = dm.DomainRecords{Domain: reg.Name}
}
if record, ok := roots[reg.Name]; ok {
record.Domain = reg.Name
record.Existing = append(record.Existing, dm.Record{ID: reg.ID, Name: name, Type: reg.Type, Content: reg.Content})
roots[reg.Name] = record
} else {
fmt.Println(roots[reg.Name])
}
} else {
continue
}
}
return roots, err
}
// SubDomainIdentify
// Identify subdomains from a list of subdomains
func SubdomainIdentify(name string, domains []cf.Result) (res map[string]dm.DomainRecords, err error) {
res, _ = RootIdentify(domains)
// check if name contains domain
// check if root domain list is in name
for k, r := range res {
if strings.Contains(name, r.Domain) { // if domain is on subname
if !slices.ContainsFunc(r.Existing, func(x dm.Record) bool {
return x.Name == name
}) {
r.ToCreate = append(r.ToCreate, dm.Record{Name: name})
res[k] = r
} else {
fmt.Println(slices.ContainsFunc(r.Existing, func(x dm.Record) bool {
return x.Name == name
}))
}
} else {
r.ToCreate = append(r.ToCreate, dm.Record{Name: fmt.Sprintf("%s.%s", name, r.Domain)})
res[k] = r
}
}
return res, nil
}
// SelectedDomain
// return an string
func SelectDomain(name string, domains map[string]dm.DomainRecords) string {
for k, r := range domains {
if name == "*" {
return name
}
if strings.Contains(name, k) { // if domain is on subname
return name
} else {
if err := domain.Check(name); err != nil {
panic(err)
}
return fmt.Sprintf("%s.%s", name, r.Domain)
}
}
return ""
}

5
internal/port/checker.go Normal file
View File

@ -0,0 +1,5 @@
package port
// Checker
// Type for query ip
type Checker interface{}

19
internal/port/provider.go Normal file
View File

@ -0,0 +1,19 @@
package port
import (
"io"
"github.com/maximotejeda/ddns/internal/application/core/domain/cf"
)
type Provider interface {
List() (result *cf.ResponseBody, err error)
Export() ([]byte, error)
Import(file io.Reader) (err error)
Update(id string, reqBody *cf.RequestBody) (result *cf.DetailsResult, err error)
Create(reqBody *cf.RequestBody) (result *cf.DetailsResult, err error)
Delete(id string) (result *cf.DetailsResult, err error)
Overwrite(reqBody cf.RequestBody, id string) (result *cf.DetailsResult, err error)
Details(id string) (result *cf.DetailsResult, err error)
Selector(subDomain, domain, comment, tipe string) (selRecord []*cf.Result, err error)
}