diff --git a/README.md b/README.md index 3070253..f686928 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,11 @@ The user-friendly interface allows for straightforward secure setup and manageme - [x] Simple & User friendly UI - [x] Secure authentication with TOTP -- [x] Available as Docker Container +- [x] Encryption of sensitive data - [x] Scheduled update service - [x] Supports multiple IP resolvers - [ ] Deploy as Windows Service +- [x] Available as Docker Container ## 🏷️ Supported providers @@ -126,10 +127,16 @@ Changes to the interval take effect immediately. The program must be restarted f A config.yaml file is provided to store all settings. In the absence of this file, the program generates one. Users have the option to directly modify settings within this file. It is important to note that changes made here will only take effect upon restarting the program. Default settings within the file are as follows: ```yaml +# How often the IP address is checked in seconds Interval: 600 +# Enable TOTP authentication TOTP: false +# Port for the web interface Port: 80 +# Custom IP resolver returns IPv4 address in plain text Resolver: "" +# Random key for symmetrical encryption +Cryptor: 29atdqljyqUVXNAuXltyng== ``` **3. Environment Variables** @@ -141,4 +148,5 @@ DDNS_INTERVAL=600 DDNS_TOTP=false DDNS_PORT=80 DDNS_RESOLVER=ipv4.example.com +DDNS_CRYPTOR=29atdqljyqUVXNAuXltyng== ``` \ No newline at end of file diff --git a/go.mod b/go.mod index 57fe9a7..8ca4d30 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/plaenkler/ddns-updater -go 1.21 +go 1.20 require ( github.com/aliyun/alibaba-cloud-sdk-go v1.62.514 diff --git a/pkg/cipher/cipher.go b/pkg/cipher/cipher.go new file mode 100644 index 0000000..213a464 --- /dev/null +++ b/pkg/cipher/cipher.go @@ -0,0 +1,61 @@ +package cipher + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" +) + +func Encrypt(key string, plaintext string) (string, error) { + encrypter, err := aes.NewCipher([]byte(key)) + if err != nil { + return "", fmt.Errorf("[cipher-Encrypt-1] encryption failed: %s", err) + } + gcm, err := cipher.NewGCM(encrypter) + if err != nil { + return "", fmt.Errorf("[cipher-Encrypt-2] encryption failed: %s", err) + } + nonce := make([]byte, gcm.NonceSize()) + _, err = io.ReadFull(rand.Reader, nonce) + if err != nil { + return "", fmt.Errorf("[cipher-Encrypt-3] encryption failed: %s", err) + } + return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(plaintext), nil)), nil +} + +func Decrypt(key string, ciphertext string) ([]byte, error) { + c, err := aes.NewCipher([]byte(key)) + if err != nil { + return nil, fmt.Errorf("[cipher-Decrypt-1] decryption failed: %s", err) + } + gcm, err := cipher.NewGCM(c) + if err != nil { + return nil, fmt.Errorf("[cipher-Decrypt-2] decryption failed: %s", err) + } + cipherBytes, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return nil, fmt.Errorf("[cipher-Decrypt-3] decryption failed: %s", err) + } + nonceSize := gcm.NonceSize() + if len(cipherBytes) < nonceSize { + return nil, fmt.Errorf("[cipher-Decrypt-4] decryption failed: %s", err) + } + nonce, cipherBytes := cipherBytes[:nonceSize], cipherBytes[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, cipherBytes, nil) + if err != nil { + return nil, fmt.Errorf("[cipher-Decrypt-5] decryption failed: %s", err) + } + return plaintext, nil +} + +func GenerateRandomKey(length int) (string, error) { + randomBytes := make([]byte, length) + _, err := rand.Read(randomBytes) + if err != nil { + return "", fmt.Errorf("[cipher-GenerateRandomKey-1] generating random key failed: %s", err) + } + return base64.URLEncoding.EncodeToString(randomBytes), nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 5aef045..c605d02 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,6 +8,7 @@ import ( "strconv" "sync" + "github.com/plaenkler/ddns-updater/pkg/cipher" log "github.com/plaenkler/ddns-updater/pkg/logging" "gopkg.in/yaml.v3" ) @@ -17,6 +18,7 @@ type Config struct { UseTOTP bool `yaml:"TOTP"` Port uint64 `yaml:"Port"` Resolver string `yaml:"Resolver"` + Cryptor string `yaml:"Cryptor"` } const ( @@ -64,13 +66,18 @@ func load() error { } func create() error { + cryptor, err := cipher.GenerateRandomKey(16) + if err != nil { + return err + } config := Config{ Interval: 600, UseTOTP: false, Port: 80, Resolver: "", + Cryptor: cryptor, } - err := os.MkdirAll(filepath.Dir(pathToConfig), dirPerm) + err = os.MkdirAll(filepath.Dir(pathToConfig), dirPerm) if err != nil { return err } @@ -115,6 +122,10 @@ func loadFromEnv() error { if resolver != "" { config.Resolver = resolver } + cryptor, ok := os.LookupEnv("DDNS_CRYPTOR") + if ok && cryptor != "" { + config.Cryptor = cryptor + } return nil } diff --git a/pkg/ddns/service.go b/pkg/ddns/service.go index 133a10e..f4fe9cb 100644 --- a/pkg/ddns/service.go +++ b/pkg/ddns/service.go @@ -5,6 +5,7 @@ import ( "sync" "time" + "github.com/plaenkler/ddns-updater/pkg/cipher" "github.com/plaenkler/ddns-updater/pkg/config" "github.com/plaenkler/ddns-updater/pkg/database" "github.com/plaenkler/ddns-updater/pkg/database/model" @@ -82,22 +83,27 @@ func updateDDNSEntries(db *gorm.DB, jobs []model.SyncJob, a model.IPAddress) { log.Errorf("[ddns-updateDDNSEntries-1] no updater found for job %v", job.ID) continue } + params, err := cipher.Decrypt(config.Get().Cryptor, job.Params) + if err != nil { + log.Errorf("[ddns-updateDDNSEntries-2] failed to decrypt job params for job %v: %s", job.ID, err) + continue + } request := updater.Request - err := json.Unmarshal([]byte(job.Params), request) + err = json.Unmarshal(params, request) if err != nil { - log.Errorf("[ddns-updateDDNSEntries-2] failed to unmarshal job params for job %v: %s", job.ID, err) + log.Errorf("[ddns-updateDDNSEntries-3] failed to unmarshal job params for job %v: %s", job.ID, err) continue } err = updater.Updater(request, a.Address) if err != nil { - log.Errorf("[ddns-updateDDNSEntries-3] failed to update DDNS entry for job %v: %s", job.ID, err) + log.Errorf("[ddns-updateDDNSEntries-4] failed to update DDNS entry for job %v: %s", job.ID, err) continue } err = db.Model(&job).Update("ip_address_id", a.ID).Error if err != nil { - log.Errorf("[ddns-updateDDNSEntries-4] failed to update IP address for job %v: %s", job.ID, err) + log.Errorf("[ddns-updateDDNSEntries-5] failed to update IP address for job %v: %s", job.ID, err) } - log.Infof("[ddns-updateDDNSEntries-5] updated DDNS entry for ID: %v Provider: %s Params: %+v", job.ID, job.Provider, job.Params) + log.Infof("[ddns-updateDDNSEntries-6] updated DDNS entry for ID: %v Provider: %s Params: %+v", job.ID, job.Provider, job.Params) } } diff --git a/pkg/server/routes/api/job.go b/pkg/server/routes/api/job.go index 99ffc1f..06f3aae 100644 --- a/pkg/server/routes/api/job.go +++ b/pkg/server/routes/api/job.go @@ -5,6 +5,8 @@ import ( "net/http" "strconv" + "github.com/plaenkler/ddns-updater/pkg/cipher" + "github.com/plaenkler/ddns-updater/pkg/config" "github.com/plaenkler/ddns-updater/pkg/database" "github.com/plaenkler/ddns-updater/pkg/database/model" "github.com/plaenkler/ddns-updater/pkg/ddns" @@ -34,19 +36,25 @@ func CreateJob(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } + encParams, err := cipher.Encrypt(config.Get().Cryptor, params) + if err != nil { + log.Errorf("[api-CreateJob-4] could not encrypt params: %s", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } job := model.SyncJob{ Provider: provider, - Params: params, + Params: encParams, } db := database.GetDatabase() if db == nil { - log.Errorf("[api-CreateJob-4] could not get database connection") + log.Errorf("[api-CreateJob-5] could not get database connection") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } err = db.Create(&job).Error if err != nil { - log.Errorf("[api-CreateJob-5] could not create job: %s", err) + log.Errorf("[api-CreateJob-6] could not create job: %s", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -82,27 +90,33 @@ func UpdateJob(w http.ResponseWriter, r *http.Request) { log.Errorf("[api-UpdateJob-4] could not unmarshal params: %s", err) return } + encParams, err := cipher.Encrypt(config.Get().Cryptor, params) + if err != nil { + http.Error(w, "Could not encrypt params", http.StatusInternalServerError) + log.Errorf("[api-UpdateJob-5] could not encrypt params: %s", err) + return + } job := model.SyncJob{ Model: gorm.Model{ ID: uint(id), }, Provider: provider, - Params: params, + Params: encParams, } db := database.GetDatabase() if db == nil { http.Error(w, "Could not get database connection", http.StatusInternalServerError) - log.Errorf("[api-UpdateJob-5] could not get database connection") + log.Errorf("[api-UpdateJob-6] could not get database connection") return } err = db.Save(&job).Error if err != nil { http.Error(w, "Could not update job", http.StatusInternalServerError) - log.Errorf("[api-UpdateJob-6] could not update job: %s", err) + log.Errorf("[api-UpdateJob-7] could not update job: %s", err) return } http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) - log.Infof("[api-UpdateJob-7] updated job with ID %d", job.ID) + log.Infof("[api-UpdateJob-8] updated job with ID %d", job.ID) } func DeleteJob(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/server/routes/web/index.go b/pkg/server/routes/web/index.go index 6f1d9c5..d41cd41 100644 --- a/pkg/server/routes/web/index.go +++ b/pkg/server/routes/web/index.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "github.com/plaenkler/ddns-updater/pkg/cipher" "github.com/plaenkler/ddns-updater/pkg/config" "github.com/plaenkler/ddns-updater/pkg/database" "github.com/plaenkler/ddns-updater/pkg/database/model" @@ -33,27 +34,24 @@ func ProvideIndex(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) return } - tpl, err := template.New("index").ParseFS(static, - "static/html/pages/index.html", - "static/html/partials/include.html", - "static/html/partials/navbar.html", - "static/html/partials/modals.html", - ) + addr, err := ddns.GetPublicIP() if err != nil { w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "[web-ProvideIndex-1] could not provide template: %s", err) + fmt.Fprintf(w, "[web-ProvideIndex-1] could not get public IP address: %s", err) return } - data := indexPageData{ - Config: config.Get(), - Providers: ddns.GetProviders(), - } - data.IPAddress, err = ddns.GetPublicIP() + img, err := totps.GetKeyAsQR() if err != nil { w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "[web-ProvideIndex-2] could not get public IP address: %s", err) + fmt.Fprintf(w, "[web-ProvideIndex-2] could not generate TOTP QR code: %s", err) return } + data := indexPageData{ + Config: config.Get(), + Providers: ddns.GetProviders(), + IPAddress: addr, + TOTPQR: template.URL(img), + } db := database.GetDatabase() if db == nil { w.WriteHeader(http.StatusInternalServerError) @@ -66,19 +64,25 @@ func ProvideIndex(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "[web-ProvideIndex-4] could not find jobs: %s", err) return } - err = formatParams(data.Jobs) + err = sanitizeParams(data.Jobs) if err != nil { w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, "[web-ProvideIndex-5] formatting params failed: %s", err) return } - img, err := totps.GetKeyAsQR() + tpl, err := template.New("index").Funcs(template.FuncMap{ + "formatParams": formatParams, + }).ParseFS(static, + "static/html/pages/index.html", + "static/html/partials/include.html", + "static/html/partials/navbar.html", + "static/html/partials/modals.html", + ) if err != nil { w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "[web-ProvideIndex-6] could not generate TOTP QR code: %s", err) + fmt.Fprintf(w, "[web-ProvideIndex-6] could not provide template: %s", err) return } - data.TOTPQR = template.URL(img) w.Header().Add("Content-Type", "text/html") err = tpl.Execute(w, data) if err != nil { @@ -87,21 +91,45 @@ func ProvideIndex(w http.ResponseWriter, r *http.Request) { } } -func formatParams(jobs []model.SyncJob) error { +func sanitizeParams(jobs []model.SyncJob) error { for j := range jobs { + decParams, err := cipher.Decrypt(config.Get().Cryptor, jobs[j].Params) + if err != nil { + return err + } params := make(map[string]string) - err := json.Unmarshal([]byte(jobs[j].Params), ¶ms) + err = json.Unmarshal(decParams, ¶ms) if err != nil { return err } - var paramList []string - for k, v := range params { - if strings.ToLower(k) == "password" { - continue + for k := range params { + kl := strings.ToLower(k) + if strings.Contains(kl, "password") { + params[k] = "***" + } + if strings.Contains(kl, "token") { + params[k] = "***" } - paramList = append(paramList, fmt.Sprintf("%s: %s", k, v)) } - jobs[j].Params = strings.Join(paramList, ", ") + encParams, err := json.Marshal(params) + if err != nil { + return err + } + jobs[j].Params = string(encParams) } return nil } + +func formatParams(paramsData []byte) (string, error) { + params := make(map[string]string) + err := json.Unmarshal(paramsData, ¶ms) + if err != nil { + return "", err + } + var formatted string + for key, value := range params { + formatted += fmt.Sprintf("%s: %v, ", key, value) + } + formatted = formatted[:len(formatted)-2] + return formatted, nil +} diff --git a/pkg/server/routes/web/static/html/pages/index.html b/pkg/server/routes/web/static/html/pages/index.html index 2e4c616..06c459f 100644 --- a/pkg/server/routes/web/static/html/pages/index.html +++ b/pkg/server/routes/web/static/html/pages/index.html @@ -28,7 +28,7 @@ {{.ID}} {{.Provider}} {{.UpdatedAt.Format "02.01.2006 15:04"}} - {{.Params}} + {{formatParams .Params}} {{end}} diff --git a/pkg/server/routes/web/static/js/index.js b/pkg/server/routes/web/static/js/index.js index 050aebd..1f679b3 100644 --- a/pkg/server/routes/web/static/js/index.js +++ b/pkg/server/routes/web/static/js/index.js @@ -24,7 +24,7 @@ document.querySelector('table tbody').addEventListener('click', function(event) document.getElementById('edit-id').value = row.querySelector('td:nth-child(1)').textContent; document.getElementById('delete-button').href = `/api/job/delete?ID=${row.querySelector('td:nth-child(1)').textContent}`; document.getElementById('edit-provider-select').value = row.querySelector('td:nth-child(2)').textContent; - const params = JSON.parse(row.querySelector('td:nth-child(4)').textContent); + const params = JSON.parse(document.querySelector('td:nth-child(4)').getAttribute('json')); let html = ''; for (const key in params) { html += `