From 666325aeb593750b2a44e38733b9ee88fe4587ea Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 17 Dec 2024 21:18:19 +0000 Subject: [PATCH] a --- .gitignore | 1 + README | 2 + go.mod | 24 ++++ go.sum | 50 ++++++++ src/commands/clean.go | 66 ++++++++++ src/commands/config.go | 68 ++++++++++ src/commands/install.go | 7 ++ src/commands/record.go | 199 +++++++++++++++++++++++++++++ src/commands/test.go | 58 +++++++++ src/embed.go | 6 + src/example.service | 12 ++ src/example.sh | 4 + src/example.toml | 23 ++++ src/global/types.go | 74 +++++++++++ src/global/vars.go | 43 +++++++ src/main.go | 119 ++++++++++++++++++ src/services/cloudflare/model.go | 210 +++++++++++++++++++++++++++++++ src/services/handler.go | 34 +++++ src/services/service.go | 124 ++++++++++++++++++ src/utils/network.go | 143 +++++++++++++++++++++ src/utils/serializer.go | 60 +++++++++ src/utils/utility.go | 98 +++++++++++++++ 22 files changed, 1425 insertions(+) create mode 100644 README create mode 100644 go.mod create mode 100644 go.sum create mode 100644 src/commands/clean.go create mode 100644 src/commands/config.go create mode 100644 src/commands/install.go create mode 100644 src/commands/record.go create mode 100644 src/commands/test.go create mode 100644 src/embed.go create mode 100644 src/example.service create mode 100644 src/example.sh create mode 100644 src/example.toml create mode 100644 src/global/types.go create mode 100644 src/global/vars.go create mode 100644 src/main.go create mode 100644 src/services/cloudflare/model.go create mode 100644 src/services/handler.go create mode 100644 src/services/service.go create mode 100644 src/utils/network.go create mode 100644 src/utils/serializer.go create mode 100644 src/utils/utility.go diff --git a/.gitignore b/.gitignore index 6f72f89..2f77c3f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +embed # Test binary, built with `go test -c` *.test diff --git a/README b/README new file mode 100644 index 0000000..bfe71af --- /dev/null +++ b/README @@ -0,0 +1,2 @@ +# ddnsu +dynamic domain name server updater is a persistent service that ensures that Cloudflare records point to a server's public IP address. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..aa4ce8a --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module ddnsu/v2 + +go 1.23.2 + +require ( + github.com/fatih/color v1.18.0 + github.com/jedib0t/go-pretty/v6 v6.6.3 + github.com/manifoldco/promptui v0.9.0 + github.com/pelletier/go-toml/v2 v2.2.3 + github.com/spf13/cobra v1.8.1 +) + +require ( + github.com/chzyer/readline v1.5.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.27.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff33ad7 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.6.3 h1:nGqgS0tgIO1Hto47HSaaK4ac/I/Bu7usmdD3qvs0WvM= +github.com/jedib0t/go-pretty/v6 v6.6.3/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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= diff --git a/src/commands/clean.go b/src/commands/clean.go new file mode 100644 index 0000000..8494d89 --- /dev/null +++ b/src/commands/clean.go @@ -0,0 +1,66 @@ +package commands + +import ( + "ddnsu/v2/src/global" + "ddnsu/v2/src/services/cloudflare" + "ddnsu/v2/src/utils" + "fmt" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func cloudflareCleanup(configuration global.DDNSUConfig, token string) { + zoneId, zErr := cloudflare.ReturnZoneIdFromDomain(configuration.Ddnsu.Domain, token) + remoteRecords, rErr := cloudflare.ListDnsRecords(zoneId, token) + + if zErr != nil || rErr != nil { + fmt.Println(color.RedString("zErr or rErr had a problem. zErr: %v. rErr: %v", zErr, rErr)) + } + + for _, rRecord := range remoteRecords { + if !strings.HasPrefix(rRecord.Comment, global.RecordManagedPrefix) { + continue + } + + existsInConfig := false + rRecordSerial := utils.SerializeRecord(global.DDNSURecord{ + Name: rRecord.Name, + Comment: rRecord.Comment, + Ttl: rRecord.Ttl, + Type: rRecord.Type, + }) + for _, lRecord := range configuration.Ddnsu.Record { + lRecordSerial := utils.SerializeRecord(global.DDNSURecord{ + Name: lRecord.Subdomain, + Comment: lRecord.Comment, + Ttl: lRecord.Ttl, + Type: lRecord.Rtype, + }) + + if lRecordSerial == rRecordSerial { + existsInConfig = true + } + } + + if !existsInConfig { + deletedRecord, errDeletingRecord := cloudflare.DeleteDnsRecord(rRecord.Id, zoneId, token) + + if !deletedRecord { + fmt.Println(color.RedString("failed to delete record: %v. error: %v", rRecordSerial, errDeletingRecord)) + } else { + fmt.Println(color.GreenString("successfully deleted record: %v", rRecordSerial)) + } + } + } +} + +func CleanupCommand(cmd *cobra.Command, args []string) { + switch strings.ToLower(args[0]) { + case "vercel": + break + case "cloudflare": + cloudflareCleanup(global.Configuration, global.Token) + } +} diff --git a/src/commands/config.go b/src/commands/config.go new file mode 100644 index 0000000..04906fc --- /dev/null +++ b/src/commands/config.go @@ -0,0 +1,68 @@ +package commands + +import ( + "ddnsu/v2/src/global" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func configRun(cmd *cobra.Command, args []string) { + cmd.Parent().Help() +} + +func viewRun(cmd *cobra.Command, args []string) { + cmd.Parent().Help() +} + +func backupRun(cmd *cobra.Command, args []string) { + + configPath := global.ConfigurationPath + newFilePath := "" + + if len(args) == 0 { + newFilePath = global.ConfigurationPath + ".bak" + } else { + newFilePath = args[0] + } + + fmt.Printf("newFilePath:%v", newFilePath) + + data, readErr := os.ReadFile(configPath) + + if readErr != nil { + fmt.Println("could not read configuration file.") + return + } + + writeErr := os.WriteFile(newFilePath, data, 0644) + + if writeErr != nil { + fmt.Println("could not write new configuration file.") + } +} + +var ConfigCommand = &cobra.Command{ + Use: "config ", + Short: "Commands to work with the config file.", + Args: cobra.MinimumNArgs(1), + Run: configRun, +} + +var ViewCommand = &cobra.Command{ + Use: "view", + Short: "View the configuration file.", + Args: cobra.ExactArgs(0), + Run: viewRun, +} +var BackupCommand = &cobra.Command{ + Use: "backup [output file]", + Short: "Backup the current configuration file.", + Args: cobra.MaximumNArgs(1), + Run: backupRun, +} +var ResetCommand = &cobra.Command{ + Use: "reset", + Short: "This will reset the current configuration file to the example one on GitHub.", +} diff --git a/src/commands/install.go b/src/commands/install.go new file mode 100644 index 0000000..42fc704 --- /dev/null +++ b/src/commands/install.go @@ -0,0 +1,7 @@ +package commands + +import "github.com/spf13/cobra" + +var InstallCommand = &cobra.Command{ + Use: "install", +} diff --git a/src/commands/record.go b/src/commands/record.go new file mode 100644 index 0000000..1553dc4 --- /dev/null +++ b/src/commands/record.go @@ -0,0 +1,199 @@ +package commands + +import ( + "ddnsu/v2/src/global" + "ddnsu/v2/src/utils" + "fmt" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/fatih/color" + "github.com/manifoldco/promptui" +) + +func recordTypeValidation(input string) error { + + if validTypes := []string{"A", "Alias", "CAA", "CNAME", "HTTPS", "MX", "SRV", "TXT", "NS"}; slices.Contains(validTypes, strings.ToUpper(input)) { + return nil + } + return fmt.Errorf("%v is not a valid record type", input) +} + +func ttlValidation(input string) error { + if input == "" { + return nil + } + + n, err := strconv.Atoi(input) + + if err != nil { + return fmt.Errorf("%v is not a number or 'auto'", input) + } + + if n < 1 { + return fmt.Errorf("TTL must be greater than or equal to 1") + } + + return nil +} + +var subdomainValidationRegex, _ = regexp.Compile(`^\*|\@$|^[A-Za-z0-9]{1,}$`) + +func subdomainValidation(input string) error { + match := subdomainValidationRegex.MatchString(input) + + if match { + return nil + } else { + return fmt.Errorf("%v is not a valid subdomain", input) + } +} + +func commentValidation(input string) error { + return nil +} + +func HandleCommand(commandType string) error { + switch commandType { + case "add": + recordTypePrompt := promptui.Prompt{ + Label: "Record Type", + Validate: recordTypeValidation, + } + recordResult, recordErr := recordTypePrompt.Run() + + ttlPrompt := promptui.Prompt{ + Label: "Time Until Live (default: 1 = auto)", + Validate: ttlValidation, + } + ttlResult, ttlErr := ttlPrompt.Run() + ttlResultI, _ := strconv.Atoi(ttlResult) + + subdomainPrompt := promptui.Prompt{ + Label: "Subdomain/Name", + Validate: subdomainValidation, + } + subdomainResult, subdomainErr := subdomainPrompt.Run() + + commentPrompt := promptui.Prompt{ + Label: "Comment (default: '')", + Validate: commentValidation, + } + commentResult, commentErr := commentPrompt.Run() + + if recordErr != nil || ttlErr != nil || subdomainErr != nil || commentErr != nil { + return fmt.Errorf("record, ttl, subdomain, or comment had an error when running the prompt") + } + + fmt.Printf("Will %v a record with:\nType: %v\nTTL: %v\nSubdomain: %v\nComment: %v\n", commandType, recordResult, ttlResult, subdomainResult, commentResult) + + newRecord := global.Record{ + Rtype: recordResult, + Ttl: ttlResultI, + Subdomain: subdomainResult, + Comment: commentResult, + } + + global.Configuration.Ddnsu.Record = append(global.Configuration.Ddnsu.Record, newRecord) + + utils.PromptWriteConfirm("", "Add this record?", global.ConfigurationPath) + case "update": + { + var records []string = make([]string, len(global.Configuration.Ddnsu.Record)) + valueColor := color.New(color.Bold, color.BgWhite).SprintfFunc() + + for i, r := range global.Configuration.Ddnsu.Record { + iS := strconv.Itoa(i) + records[i] = fmt.Sprintf("T:%v-S:%v-C:%v-T:%v-I:%v", valueColor(r.Rtype), valueColor(r.Subdomain), valueColor(r.Comment), valueColor(strconv.Itoa(r.Ttl)), iS) + + } + + recordUpdatePrompt := promptui.Select{ + Label: "Select Record", + Items: records, + } + + _, result, promptErr := recordUpdatePrompt.Run() + + if promptErr != nil { + return fmt.Errorf("creating prompt returned an error: %v", promptErr) + } + + stringSplitArray := strings.Split(result, ":") + indexSelected := stringSplitArray[len(stringSplitArray)-1] + indexSelectedI, _ := strconv.Atoi(indexSelected) + + utils.PrintFLn("Please enter the new properties of %v", result) + recordTypePrompt := promptui.Prompt{ + Label: "Record Type", + Validate: recordTypeValidation, + } + recordResult, recordErr := recordTypePrompt.Run() + + ttlPrompt := promptui.Prompt{ + Label: "Time Until Live (default: 0, auto)", + Validate: ttlValidation, + } + ttlResult, ttlErr := ttlPrompt.Run() + ttlResultI, _ := strconv.Atoi(ttlResult) + + subdomainPrompt := promptui.Prompt{ + Label: "Subdomain/Name", + Validate: subdomainValidation, + } + subdomainResult, subdomainErr := subdomainPrompt.Run() + + commentPrompt := promptui.Prompt{ + Label: "Comment (default: '')", + Validate: commentValidation, + } + commentResult, commentErr := commentPrompt.Run() + + if recordErr != nil || ttlErr != nil || subdomainErr != nil || commentErr != nil { + return fmt.Errorf("record, ttl, subdomain, or comment had an error when running the prompt") + } + + fmt.Printf("Will %v record %v to:\nType: %v\nTTL: %v\nSubdomain: %v\nComment: %v\n", commandType, result, recordResult, ttlResult, subdomainResult, commentResult) + newRecord := global.Record{ + Rtype: recordResult, + Ttl: ttlResultI, + Subdomain: subdomainResult, + Comment: commentResult, + } + global.Configuration.Ddnsu.Record[indexSelectedI] = newRecord + + utils.PromptWriteConfirm("", "Update this record?", global.ConfigurationPath) + } + case "delete": + var records []string = make([]string, len(global.Configuration.Ddnsu.Record)) + valueColor := color.New(color.Bold, color.BgWhite).SprintfFunc() + + for i, r := range global.Configuration.Ddnsu.Record { + iS := strconv.Itoa(i) + records[i] = fmt.Sprintf("T:%v-S:%v-C:%v-T:%v-I:%v", valueColor(r.Rtype), valueColor(r.Subdomain), valueColor(r.Comment), valueColor(strconv.Itoa(r.Ttl)), iS) + } + + recordUpdatePrompt := promptui.Select{ + Label: "Select Record", + Items: records, + } + + _, result, promptErr := recordUpdatePrompt.Run() + + if promptErr != nil { + return fmt.Errorf("creating prompt returned an error: %v", promptErr) + } + + stringSplitArray := strings.Split(result, ":") + indexSelected := stringSplitArray[len(stringSplitArray)-1] + indexSelectedI, _ := strconv.Atoi(indexSelected) + + global.Configuration.Ddnsu.Record = slices.Delete(global.Configuration.Ddnsu.Record, indexSelectedI, indexSelectedI+1) + + utils.PromptWriteConfirm("", "Delete this record?", global.ConfigurationPath) + } + + return nil +} diff --git a/src/commands/test.go b/src/commands/test.go new file mode 100644 index 0000000..82b9ddc --- /dev/null +++ b/src/commands/test.go @@ -0,0 +1,58 @@ +package commands + +import ( + "ddnsu/v2/src/global" + "ddnsu/v2/src/utils" + "fmt" + "os" + "strconv" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" +) + +func boldRedText(text string) string { + + return color.New(color.FgRed, color.Bold).Sprint(text) +} + +func TestCommand(cmd *cobra.Command, args []string) { + ip := utils.MakeIpConsensus() + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Type", "Subdomain", "Comment", "Value", "TTL"}) + errorPresent := false + + for _, entry := range global.Configuration.Ddnsu.Record { + recordErr := recordTypeValidation(entry.Rtype) + ttlErr := ttlValidation(strconv.Itoa(entry.Ttl)) + subdomainErr := subdomainValidation(entry.Subdomain) + + if recordErr != nil || ttlErr != nil || subdomainErr != nil { + errorPresent = true + } + + rtype := utils.IfThenElse(recordErr == nil, entry.Rtype, boldRedText(entry.Rtype)).(string) + subdomain := utils.IfThenElse(subdomainErr == nil, entry.Subdomain, boldRedText(entry.Subdomain)).(string) + comment := entry.Comment + ttl := utils.IfThenElse(ttlErr == nil, entry.Ttl, boldRedText(strconv.Itoa(entry.Ttl))) + + t.AppendRow(table.Row{ + rtype, + subdomain, + comment, + ip, + ttl, + }) + } + + t.Render() + + utils.PrintFLn("Checking Rate: %v", global.Configuration.Ddnsu.Rate) + utils.PrintFLn("Public Ip: %v", ip) + + if errorPresent { + fmt.Println("Configuration file has an invalid property somewhere. Please look through the table summary to find the entries that are red.") + } +} diff --git a/src/embed.go b/src/embed.go new file mode 100644 index 0000000..11d671d --- /dev/null +++ b/src/embed.go @@ -0,0 +1,6 @@ +package main + +import _ "embed" + +//go:embed example.toml +var ExampleEmbed []byte diff --git a/src/example.service b/src/example.service new file mode 100644 index 0000000..f3ec960 --- /dev/null +++ b/src/example.service @@ -0,0 +1,12 @@ +[Unit] +Description=My custom service +After=network.target + +[Service] +ExecStart=/usr/bin/my_custom_command +Restart=on-failure +User=my_user +Group=my_group + +[Install] +WantedBy=multi-user.target diff --git a/src/example.sh b/src/example.sh new file mode 100644 index 0000000..1fe67a6 --- /dev/null +++ b/src/example.sh @@ -0,0 +1,4 @@ +#!/bin/bash +if [ ! -f ~/.config/ddnsu/ddnsu ]; then + /usr/bin/wget -O ~/.config/ddnsu/ddnsu +fi \ No newline at end of file diff --git a/src/example.toml b/src/example.toml new file mode 100644 index 0000000..94701ee --- /dev/null +++ b/src/example.toml @@ -0,0 +1,23 @@ +[ddnsu] +version = "0.0.1" # internal tracking +use = "cloudflare" # cloudflare / vercel +domain="example.com" +ipProviders = [ # websites that provide the public ip address in the ip property + "https://api.ipify.org/?format=json", + "https://api.my-ip.io/v2/ip.json", + "https://api.myip.com", + "https://api.seeip.org/jsonip", + "https://ipwho.is" +] +rate = 60 # frequency to recheck domains (in minutes) + +[services.cloudflare] +token = "" # cloudflare authorization token +[services.vercel] +token = "" # vercel authorization token + +[[ddnsu.record]] +rtype = "A" # type of the record; can be A, Alias, CAA, CNAME, HTTPS, MX, SRV, TXT, NS +comment = "" +ttl = 1 +subdomain = "@" # subdomain (also called name) of the record diff --git a/src/global/types.go b/src/global/types.go new file mode 100644 index 0000000..2263672 --- /dev/null +++ b/src/global/types.go @@ -0,0 +1,74 @@ +package global + +type ManagedRecord struct { + Record DDNSURecord + Action string +} + +type DDNSURecord struct { + Name string + Comment string + Ttl int + Content string + Type string + Id string +} + +type CloudflareZoneResultArray struct { + Id string + Name string +} + +type CloudflareZoneResponse struct { + Success bool + Result []CloudflareZoneResultArray +} + +type CloudflareZoneRecordResult struct { + Comment string `json:"comment,omitempty"` + Content string `json:"content"` + CreatedOn string `json:"created_on"` + Id string `json:"id"` + Meta map[string]interface{} `json:"meta"` + ModifiedOn string `json:"modified_on"` + Name string `json:"name"` + Proxiable bool `json:"proxiable"` + Proxied bool `json:"proxied"` + Setttings map[string]interface{} `json:"settings,omitempty"` + Tags []string `json:"tags,omitempty"` + Ttl int `json:"ttl"` + Type string `json:"type"` + ZoneId string `json:"zone_id"` + ZoneName string `json:"zone_name"` + Priority *int `json:"priority,omitempty"` +} + +type CloudflareZoneRecordResponseMulti struct { + Errors []interface{} `json:"errors"` + Messages []interface{} `json:"messages"` + Result []CloudflareZoneRecordResult `json:"result"` + ResultInfo map[string]interface{} `json:"result_info"` + Success bool `json:"success"` +} +type CloudflareZoneRecordResponseSingle struct { + Errors []interface{} `json:"errors"` + Messages []interface{} `json:"messages"` + Result CloudflareZoneRecordResult `json:"result"` + ResultInfo map[string]interface{} `json:"result_info"` + Success bool `json:"success"` +} + +type SerializedRecords struct { + Comment string + Content string + Id string + Name string + Type string + Ttl interface{} + Managed bool +} +type SerializedDNSState struct { + Records []SerializedRecords + Provider string + PrimaryIP string +} diff --git a/src/global/vars.go b/src/global/vars.go new file mode 100644 index 0000000..901d474 --- /dev/null +++ b/src/global/vars.go @@ -0,0 +1,43 @@ +package global + +type DDNSUConfig struct { + Ddnsu Ddnsu `toml:"ddnsu"` + Services Services `toml:"services"` +} + +type Ddnsu struct { + Version string `toml:"version"` + Use string `toml:"use"` + IpProviders []string `toml:"ipProviders"` + Rate int `toml:"rate"` + Record []Record `toml:"record"` + Domain string `toml:"domain"` +} + +type Services struct { + Cloudflare Cloudflare `toml:"cloudflare"` + Vercel Vercel `toml:"vercel"` +} + +type Cloudflare struct { + Token string `toml:"token"` +} + +type Vercel struct { + Token string `toml:"token"` +} + +type Record struct { + Rtype string `toml:"rtype"` + Comment string `toml:"comment"` + Ttl int `toml:"ttl"` + Subdomain string `toml:"subdomain"` +} + +var Token string = "" +var Configuration DDNSUConfig +var ConfigurationPath string +var CloudflareZoneId string +var LastIpAddress string + +const RecordManagedPrefix = "[d]-" diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..4b47f08 --- /dev/null +++ b/src/main.go @@ -0,0 +1,119 @@ +package main + +import ( + "ddnsu/v2/src/commands" + "ddnsu/v2/src/global" + "ddnsu/v2/src/services" + "ddnsu/v2/src/utils" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var rootCommand = &cobra.Command{ + Use: "ddnsu", + Short: "Dynamic domain name server updater is a service that dynamically updates nameserver records such as Cloudflare or Vercel.", + Run: runCommand, +} + +func runCommand(cmd *cobra.Command, args []string) { + cmd.Help() + fmt.Println("Made with ❤️ by @rainwashed.") +} + +func main() { + if runtime.GOOS != "linux" { + panic(fmt.Sprintf("%v is not a valid operating system for ddnsu. Expected: linux", runtime.GOOS)) + } + + homeDir, _ := os.UserHomeDir() + configurationFilePath := filepath.Join(homeDir, ".config", "ddnsu", "config.toml") + utils.DetermineIfNeedConfigCreationAndCreateIfDoesNotExist(filepath.Join(homeDir, ".config/ddnsu"), "config.toml", ExampleEmbed) + global.ConfigurationPath = configurationFilePath + loaded, loadErr := utils.LoadConfigurationIntoGlobalVar(global.ConfigurationPath) + utils.StoreActiveTokenInGlobalVar(global.Configuration) + + if loadErr != nil || !loaded { + fmt.Println(color.RedString("Configuration file could not be loaded. There seems to be syntax issue. The location can be found at %v. The error is: %v", configurationFilePath, loadErr)) + } + + var versionCommand = &cobra.Command{ + Use: "version", + Short: "Return the current version", + Long: "Return the current version of DDNSU command line utility and check if it is up-to-date.", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, _args []string) { + utils.PrintFLn("DDNSU is currently on version %v", global.Configuration.Ddnsu.Version) + }, + } + rootCommand.AddCommand(versionCommand) + + var loginCommand = &cobra.Command{ + Use: "login ", + Short: "Login to either Vercel or Cloudflare DNS server", + Args: cobra.ExactArgs(2), + Run: services.OnLoginCommandRun, + } + rootCommand.AddCommand(loginCommand) + + var recordCommand = &cobra.Command{ + Use: "record ", + Short: "Add, delete, and modify the records that ddnsu will use.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + recordCommand := args[0] + switch strings.ToLower(recordCommand) { + case "add": + commands.HandleCommand("add") + case "delete": + commands.HandleCommand("delete") + case "update": + commands.HandleCommand("update") + default: + fmt.Println("Not a valid command") + } + + }, + } + rootCommand.AddCommand(recordCommand) + + var cleanCommand = &cobra.Command{ + Use: "clean ", + Short: "Cleanup remote records to match configuration file.", + Args: cobra.ExactArgs(1), + Run: commands.CleanupCommand, + } + rootCommand.AddCommand(cleanCommand) + + var testCommand = &cobra.Command{ + Use: "test", + Short: "Emulates what the DNS records would look like based on the current configuration.", + Args: cobra.ExactArgs(0), + Run: commands.TestCommand, + } + rootCommand.AddCommand(testCommand) + + var startCommand = &cobra.Command{ + Use: "start", + Short: "Start the DDNSU service", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + services.BeginActiveLoop(time.Duration(global.Configuration.Ddnsu.Rate)*time.Minute, global.Configuration.Ddnsu.Use) + }, + } + rootCommand.AddCommand(startCommand) + + // config subcommands + commands.ConfigCommand.AddCommand(commands.ViewCommand) + commands.ConfigCommand.AddCommand(commands.BackupCommand) + commands.ConfigCommand.AddCommand(commands.ResetCommand) + rootCommand.AddCommand(commands.ConfigCommand) + + rootCommand.Execute() +} diff --git a/src/services/cloudflare/model.go b/src/services/cloudflare/model.go new file mode 100644 index 0000000..17195ee --- /dev/null +++ b/src/services/cloudflare/model.go @@ -0,0 +1,210 @@ +package cloudflare + +import ( + "bytes" + "ddnsu/v2/src/global" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +const RequestRoot string = "https://api.cloudflare.com/client/v4" + +var client = &http.Client{} + +func TestToken(token string) (bool, error) { + req, _ := http.NewRequest("GET", fmt.Sprintf("%v/user/tokens/verify", RequestRoot), nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + + if err != nil { + fmt.Printf("err: %v\n", err) + } + + defer resp.Body.Close() + + var obj map[string]any + + json.NewDecoder(resp.Body).Decode(&obj) + + var validToken bool = obj["success"].(bool) + return validToken, nil + +} + +func ReturnZoneIdFromDomain(domain string, token string) (string, error) { + req, _ := http.NewRequest("GET", fmt.Sprintf("%v/zones", RequestRoot), nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, errResp := client.Do(req) + + if errResp != nil { + fmt.Printf("errResp: %v\n", errResp) + } + + var obj global.CloudflareZoneResponse + json.NewDecoder(resp.Body).Decode(&obj) + + defer resp.Body.Close() + + correctId := "" + + for _, result := range obj.Result { + if result.Name == domain { + correctId = result.Id + } + } + + if correctId == "" { + return "", fmt.Errorf("could not find id for domain: %v", domain) + } else { + return correctId, nil + } +} + +func AddDnsRecord(zoneId string, rtype string, name string, ttl string, comment string, value string, token string) (string, error) { + + var ttlNum int + if ttl == "auto" { + ttlNum = 0 + } else { + ttlNumT, ttlNumErr := strconv.Atoi(ttl) + ttlNum = ttlNumT + + if ttlNumErr != nil { + return "", fmt.Errorf("invalid conversion of %v to int", ttl) + } + } + + body := map[string]any{ + "type": rtype, + "name": name, + "ttl": ttlNum, + "comment": comment, + "content": value, + } + bodyMarshal, _ := json.Marshal(body) + + req, _ := http.NewRequest("POST", fmt.Sprintf("%v/zones/%v/dns_records", RequestRoot, zoneId), bytes.NewBuffer(bodyMarshal)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, errResp := client.Do(req) + + defer req.Body.Close() + + if errResp != nil { + return "", fmt.Errorf("attempting to add dns record failed with error: %v", errResp) + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("attempting to update dns record failed with message: %v", resp.Body) + } + + var result global.CloudflareZoneRecordResponseSingle + decodeErr := json.NewDecoder(resp.Body).Decode(&result) + + if decodeErr != nil { + return "", fmt.Errorf("error while parsing the body") + } + + recordId := result.Result.Id + return recordId, nil +} + +func ListDnsRecords(zoneId string, token string) ([]global.DDNSURecord, error) { + req, _ := http.NewRequest("GET", fmt.Sprintf("%v/zones/%v/dns_records", RequestRoot, zoneId), nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, errorResp := client.Do(req) + + if errorResp != nil { + return []global.DDNSURecord{}, fmt.Errorf("error in attempting to list dns records: %v", errorResp) + } + defer resp.Body.Close() + + var dnsRecords global.CloudflareZoneRecordResponseMulti + decodeErr := json.NewDecoder(resp.Body).Decode(&dnsRecords) + var records []global.DDNSURecord = make([]global.DDNSURecord, len(dnsRecords.Result)) + + if decodeErr != nil { + return []global.DDNSURecord{}, fmt.Errorf("could not decode the records json: %v", decodeErr) + } + + for i, record := range dnsRecords.Result { + // talk about this annoying fucking part + var fixedName string + nameSplitArray := strings.Split(record.Name, ".") + + if len(nameSplitArray) == 2 { + fixedName = "@" + } else { + fixedName = nameSplitArray[0] + } + + var ddnsuRecord global.DDNSURecord = global.DDNSURecord{ + Name: fixedName, + Comment: record.Comment, + Ttl: record.Ttl, + Content: record.Content, + Type: record.Type, + Id: record.Id, + } + /* + fmt.Printf("record: %v\n", record.Name) + fmt.Printf("type: %v\n", record.Type) + fmt.Printf("ttl: %v\n", record.Ttl) + fmt.Printf("value: %v\n", record.Content) + fmt.Printf("comment: %v\n", record.Comment) + fmt.Println("-----------------------------") + */ + records[i] = ddnsuRecord + + } + + return records, nil +} + +func UpdateDnsRecord(recordId string, zoneId string, newvalue string, token string) (string, error) { + body := map[string]string{ + "content": newvalue, + } + bodyMarshal, _ := json.Marshal(body) + + req, _ := http.NewRequest("PATCH", fmt.Sprintf("%v/zones/%v/dns_records/%v", RequestRoot, zoneId, recordId), bytes.NewBuffer(bodyMarshal)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, errResp := client.Do(req) + + defer req.Body.Close() + + if errResp != nil { + return "", fmt.Errorf("attempting to update dns record failed with error: %v", errResp) + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("attempting to update dns record failed with message: %v", resp.Body) + } + + var dnsRecord global.CloudflareZoneRecordResponseSingle + json.NewDecoder(resp.Body).Decode(&dnsRecord) + + return "", nil +} + +func DeleteDnsRecord(recordId string, zoneId string, token string) (bool, error) { + req, _ := http.NewRequest("DELETE", fmt.Sprintf("%v/zones/%v/dns_records/%v", RequestRoot, zoneId, recordId), nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, errorResp := client.Do(req) + + if errorResp != nil || resp.StatusCode >= 400 { + return false, fmt.Errorf("could not delete record: %v. error: %v", recordId, zoneId) + } + + defer resp.Body.Close() + + return true, nil +} diff --git a/src/services/handler.go b/src/services/handler.go new file mode 100644 index 0000000..b8f43e0 --- /dev/null +++ b/src/services/handler.go @@ -0,0 +1,34 @@ +package services + +import ( + "ddnsu/v2/src/global" + "ddnsu/v2/src/services/cloudflare" + "ddnsu/v2/src/utils" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +func OnLoginCommandRun(cmd *cobra.Command, args []string) { + provider := strings.ToLower(args[0]) + token := args[1] + + switch provider { + case "vercel": + fmt.Print("Using Vercel provider") + case "cloudflare": + validToken, err := cloudflare.TestToken(token) + + if err != nil || !validToken { + utils.PrintFLn("token does not appear to be valid or correctly-set. error: %v", err) + panic("invalid token") + } + + fmt.Println("token passes checking. adding to configuration file") + originalToken := global.Configuration.Services.Cloudflare.Token + global.Configuration.Services.Cloudflare.Token = token + + utils.PromptWriteConfirm(fmt.Sprintf("cloudflare.token='%v'", originalToken), fmt.Sprintf("cloudflare.token='%v'", global.Configuration.Services.Cloudflare.Token), global.ConfigurationPath) + } +} diff --git a/src/services/service.go b/src/services/service.go new file mode 100644 index 0000000..5732612 --- /dev/null +++ b/src/services/service.go @@ -0,0 +1,124 @@ +package services + +import ( + "ddnsu/v2/src/global" + "ddnsu/v2/src/services/cloudflare" + "ddnsu/v2/src/utils" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/fatih/color" +) + +// @return true if record should be created (does not exist in remote records), return false if record should be updated +func DetermineIfRecordShouldBeCreated(toBeCreated global.DDNSURecord, cloudflareRecords []global.DDNSURecord) bool { + toBeCreatedSerial := utils.SerializeRecord(toBeCreated) + utils.PrintFLn("To be created Serial: %v", toBeCreatedSerial) + for _, record := range cloudflareRecords { + recordSerial := utils.SerializeRecord(record) + utils.PrintFLn("Compare record serial: %v", recordSerial) + if toBeCreatedSerial == recordSerial { + return false + } + } + return true +} + +func CloudflareServiceLoop(updateFrequency time.Duration, ip string) { + cloudflareZoneId, cloudflareZoneErr := cloudflare.ReturnZoneIdFromDomain(global.Configuration.Ddnsu.Domain, global.Token) + if cloudflareZoneErr != nil { + fmt.Println(color.RedString("could not retrieve zone id for domain: %v. have you ensured that your token allows read/write access to that domain?", global.Configuration.Ddnsu.Domain)) + os.Exit(1) + } + + cloudflareRecords, cloudflareRecordsErr := cloudflare.ListDnsRecords(cloudflareZoneId, global.Token) + + if cloudflareRecordsErr != nil { + fmt.Println(color.RedString("could not retrieve dns records for zone id: %v. have you ensured that your token allows read/write access to that domain?", cloudflareZoneId)) + os.Exit(1) + } + + var managedRecords []global.ManagedRecord + + for _, configRecord := range global.Configuration.Ddnsu.Record { + configRecordConverted := global.DDNSURecord{ + Name: configRecord.Subdomain, + Comment: global.RecordManagedPrefix + configRecord.Comment, + Ttl: configRecord.Ttl, + Type: configRecord.Rtype, + Content: ip, + } + shouldBeCreated := DetermineIfRecordShouldBeCreated(configRecordConverted, cloudflareRecords) + if shouldBeCreated { + managedRecords = append(managedRecords, global.ManagedRecord{ + Record: configRecordConverted, + Action: "create", + }) + } else { + for _, record := range cloudflareRecords { + utils.PrintFLn("Record: %v", utils.SerializeRecord(record)) + if strings.HasPrefix(record.Comment, global.RecordManagedPrefix) { + managedRecords = append(managedRecords, global.ManagedRecord{ + Record: record, + Action: "update", + }) + } + } + } + } + + for _, managedRecord := range managedRecords { + serialRecord := utils.SerializeRecord(managedRecord.Record) + switch managedRecord.Action { + case "create": + _, recordAddErr := cloudflare.AddDnsRecord(cloudflareZoneId, + managedRecord.Record.Type, + managedRecord.Record.Name, + strconv.Itoa(managedRecord.Record.Ttl), + managedRecord.Record.Comment, + ip, + global.Token, + ) + + if recordAddErr != nil { + fmt.Println(color.RedString("could not add %v, with error: %v", serialRecord, recordAddErr)) + } else { + fmt.Println(color.GreenString("successfully created %v", serialRecord)) + } + case "update": + _, updateErr := cloudflare.UpdateDnsRecord(managedRecord.Record.Id, cloudflareZoneId, ip, global.Token) + + if updateErr != nil { + fmt.Println(color.RedString("could not update %v, with error: %v", serialRecord, updateErr)) + } else { + fmt.Println(color.GreenString("successfully updated %v", serialRecord)) + } + } + } +} + +func BeginActiveLoop(updateFrequency time.Duration, provider string) { + for { + fmt.Println("active service has begun") + ip := utils.MakeIpConsensus() + + if global.LastIpAddress != ip { + fmt.Printf("ip: %v", ip) + switch provider { + case "cloudflare": + CloudflareServiceLoop(updateFrequency, ip) + default: + fmt.Println(color.RedString("provider %v is not supported", provider)) + os.Exit(1) + } + + global.LastIpAddress = ip + } else { + fmt.Println(color.YellowString("ip address has not changed; it is unnecessary to update anything.")) + } + time.Sleep(updateFrequency) + } +} diff --git a/src/utils/network.go b/src/utils/network.go new file mode 100644 index 0000000..5853324 --- /dev/null +++ b/src/utils/network.go @@ -0,0 +1,143 @@ +package utils + +import ( + "context" + "ddnsu/v2/src/global" + _ "embed" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/fatih/color" +) + +func TestNetworking() bool { + netreq, netok := http.Get("https://example.com") + if netok != nil { + fmt.Println(netok.Error()) + return false + } + + if netreq.StatusCode == 200 { + return true + } else { + return false + } +} + +var client = &http.Client{ + Transport: &http.Transport{ + IdleConnTimeout: 1 * time.Second, + }, +} + +func MakeIpConsensus() string { + ipProviders := global.Configuration.Ddnsu.IpProviders + var ips map[string]int = make(map[string]int) + + // NOTE: i will become _ + for _, provider := range ipProviders { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, "GET", provider, nil) + resp, errorResp := client.Do(req) + + // TODO: make this a context goroutine some shit + + if errorResp != nil { + fmt.Printf("Provider %v returned an error when requested. Skipping, will not be added to consensus.\n", provider) + continue + } + + defer resp.Body.Close() + + type NeededResponse struct { + Ip string + } + + var responseObject = NeededResponse{} + err := json.NewDecoder(resp.Body).Decode(&responseObject) + + if err != nil { + continue + } + + ips[responseObject.Ip]++ + + } + + key := "" + value := 0 + for k, v := range ips { + if v > value { + key = k + value = v + } + } + + return key + +} + +const ( + OS_READ = 04 + OS_WRITE = 02 + OS_EX = 01 + OS_USER_SHIFT = 6 + OS_GROUP_SHIFT = 3 + OS_OTH_SHIFT = 0 + + OS_USER_R = OS_READ << OS_USER_SHIFT + OS_USER_W = OS_WRITE << OS_USER_SHIFT + OS_USER_X = OS_EX << OS_USER_SHIFT + OS_USER_RW = OS_USER_R | OS_USER_W + OS_USER_RWX = OS_USER_RW | OS_USER_X + + OS_GROUP_R = OS_READ << OS_GROUP_SHIFT + OS_GROUP_W = OS_WRITE << OS_GROUP_SHIFT + OS_GROUP_X = OS_EX << OS_GROUP_SHIFT + OS_GROUP_RW = OS_GROUP_R | OS_GROUP_W + OS_GROUP_RWX = OS_GROUP_RW | OS_GROUP_X + + OS_OTH_R = OS_READ << OS_OTH_SHIFT + OS_OTH_W = OS_WRITE << OS_OTH_SHIFT + OS_OTH_X = OS_EX << OS_OTH_SHIFT + OS_OTH_RW = OS_OTH_R | OS_OTH_W + OS_OTH_RWX = OS_OTH_RW | OS_OTH_X + + OS_ALL_R = OS_USER_R | OS_GROUP_R | OS_OTH_R + OS_ALL_W = OS_USER_W | OS_GROUP_W | OS_OTH_W + OS_ALL_X = OS_USER_X | OS_GROUP_X | OS_OTH_X + OS_ALL_RW = OS_ALL_R | OS_ALL_W + OS_ALL_RWX = OS_ALL_RW | OS_GROUP_X +) + +func DetermineIfNeedConfigCreationAndCreateIfDoesNotExist(path string, fileName string, embedContent []byte) bool { + if _, err := os.Stat(path); os.IsNotExist(err) { + _ = os.MkdirAll(path, os.FileMode(0777)) + file, err := os.Create(filepath.Join(path, fileName)) + if err != nil { + fmt.Println(color.RedString("could not create configuration file. please do so manually (stage 1).")) + os.Exit(1) + } + + _, errWrite := file.Write(embedContent) + + if errWrite != nil { + fmt.Println(color.RedString("could not create configuration file. please do so manually (stage 2).")) + } + + fmt.Println(color.New(color.FgGreen, color.Italic).Sprintf("configuration file could not be located; created an example configuration file at %v\n", filepath.Join(path, fileName))) + + defer file.Close() + + return true + } else { + return false + } +} diff --git a/src/utils/serializer.go b/src/utils/serializer.go new file mode 100644 index 0000000..d5ec081 --- /dev/null +++ b/src/utils/serializer.go @@ -0,0 +1,60 @@ +package utils + +import ( + "bytes" + "ddnsu/v2/src/global" + "encoding/gob" + "os" + "strconv" +) + +// SerializeCurrentRecordState serializes the current state of managed and unmanaged (which are not part of the DDNSU lifetime) to allow for comparsions if any data has changed. +func SerializeCurrentRecordState(state global.SerializedDNSState, outputPath string) error { + var b bytes.Buffer + enc := gob.NewEncoder(&b) + + if err := enc.Encode(state); err != nil { + return err + } + + if err := os.WriteFile(outputPath, b.Bytes(), 0644); err != nil { + return err + } + + return nil +} + +func ReadSerializedState(inputPath string) (global.SerializedDNSState, error) { + var state global.SerializedDNSState + + f, err := os.Open(inputPath) + if err != nil { + return global.SerializedDNSState{}, err + } + defer f.Close() + + dec := gob.NewDecoder(f) + + if err := dec.Decode(&state); err != nil { + return global.SerializedDNSState{}, err + } + + return state, nil +} + +func SerializeConfigurationRecordsToComparableStringArray(configuration global.DDNSUConfig) []string { + var serializedStrings []string = make([]string, len(configuration.Ddnsu.Record)) + for i, record := range configuration.Ddnsu.Record { + serialString := record.Rtype + ":" + record.Subdomain + ":" + strconv.Itoa(record.Ttl) + ":" + global.RecordManagedPrefix + record.Comment + + serializedStrings[i] = serialString + } + return serializedStrings +} + +func SerializeRecord(record global.DDNSURecord) string { + // serialString := record.Type + "-" + record.Name + "-" + strconv.Itoa(record.Ttl) + "-" + global.RecordManagedPrefix + record.Comment + serialString := record.Type + ":" + record.Name + ":" + strconv.Itoa(record.Ttl) + ":" + record.Comment + + return serialString +} diff --git a/src/utils/utility.go b/src/utils/utility.go new file mode 100644 index 0000000..72d38cf --- /dev/null +++ b/src/utils/utility.go @@ -0,0 +1,98 @@ +package utils + +import ( + "fmt" + "os" + "reflect" + + "ddnsu/v2/src/global" + + "github.com/manifoldco/promptui" + "github.com/pelletier/go-toml/v2" +) + +func PrintFLn( + format string, + a ...any, +) { + fmt.Printf(format+"\n", a...) +} + +func returnConfigFile(configFilePath string) (*global.DDNSUConfig, error) { + fileContent, readErr := os.ReadFile(configFilePath) + if readErr != nil { + return &global.DDNSUConfig{}, fmt.Errorf("error while reading the configuration file at %v: %w", configFilePath, readErr) + } + + var config global.DDNSUConfig + unmarshalErr := toml.Unmarshal(fileContent, &config) + if unmarshalErr != nil { + return &global.DDNSUConfig{}, fmt.Errorf("error while parsing the configuration file at %v: %w", configFilePath, unmarshalErr) + } + + return &config, nil +} + +func ConvertByteArrayToStruct(bytearray []byte, targetType reflect.Type) error { + return nil +} + +func LoadConfigurationIntoGlobalVar(configFilePath string) (bool, error) { + configuration, err := returnConfigFile(configFilePath) + + if err != nil { + return false, fmt.Errorf("loading configuration file had an error %v", err) + } + + // fmt.Printf("configuration: %v\n", configuration) + + global.Configuration = *configuration + + return true, nil +} + +func StoreActiveTokenInGlobalVar(configuration global.DDNSUConfig) { + provider := configuration.Ddnsu.Use + + switch provider { + case "cloudflare": + global.Token = configuration.Services.Cloudflare.Token + case "vercel": + global.Token = global.Configuration.Services.Vercel.Token + } + +} + +func FileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func PromptWriteConfirm(messageBefore string, messageAfter string, pathToWriteTo string) { + fmt.Println(messageBefore + " -→ " + messageAfter) + writePrompt := promptui.Select{ + Label: fmt.Sprintf("Confirm write to %v?", pathToWriteTo), + Items: []string{"Yes", "No"}, + } + + _, answer, _ := writePrompt.Run() + + if answer == "Yes" { + tomlRepresentation, tomlError := toml.Marshal(global.Configuration) + if tomlError != nil { + panic("error when marshalling object") + } + os.WriteFile(pathToWriteTo, tomlRepresentation, 0644) + fmt.Println("Changes have been written.") + } else { + fmt.Println("No file was changed.") + } +} + +// IfThenElse evaluates a condition, if true returns the first parameter otherwise the second +func IfThenElse(condition bool, a interface{}, b interface{}) interface{} { + if condition { + return a + } + return b +}