From 45c62839c99a6a29b7f919d5659393aa2814e4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Lukas=20=7E=20Znox=20/=20Pl=C3=A4nkler?= <60503970+Plaenkler@users.noreply.github.com> Date: Sun, 9 Jul 2023 13:55:30 +0200 Subject: [PATCH 01/17] [ADD] Pkg cipher --- pkg/cipher/cipher.go | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 pkg/cipher/cipher.go diff --git a/pkg/cipher/cipher.go b/pkg/cipher/cipher.go new file mode 100644 index 0000000..eb758a4 --- /dev/null +++ b/pkg/cipher/cipher.go @@ -0,0 +1,48 @@ +package cipher + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "io" +) + +func Encrypt(key, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + ciphertext := make([]byte, aes.BlockSize+len(plaintext)) + iv := ciphertext[:aes.BlockSize] + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return nil, err + } + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(ciphertext[aes.BlockSize:], plaintext) + return ciphertext, nil +} + +func Decrypt(key, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + if len(ciphertext) < aes.BlockSize { + return nil, errors.New("ciphertext too short") + } + iv := ciphertext[:aes.BlockSize] + ciphertext = ciphertext[aes.BlockSize:] + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(ciphertext, ciphertext) + return ciphertext, nil +} + +func GenerateRandomKey() ([]byte, error) { + key := make([]byte, 32) + _, err := rand.Read(key) + if err != nil { + return nil, err + } + return key, nil +} From 7f84eb5de11fef179acd0c10bc4ca14e1315ef52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Lukas=20=7E=20Znox=20/=20Pl=C3=A4nkler?= <60503970+Plaenkler@users.noreply.github.com> Date: Sat, 2 Sep 2023 17:05:36 +0200 Subject: [PATCH 02/17] [ADD] Security funcs --- pkg/security/security.go | 58 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 pkg/security/security.go diff --git a/pkg/security/security.go b/pkg/security/security.go new file mode 100644 index 0000000..70f1ded --- /dev/null +++ b/pkg/security/security.go @@ -0,0 +1,58 @@ +package security + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "io" +) + +func Encrypt(key []byte, plaintext string) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + // 96-bit nonce + nonce := make([]byte, 12) + _, err = io.ReadFull(rand.Reader, nonce) + if err != nil { + return "", err + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + return hex.EncodeToString(append(nonce, aesgcm.Seal(nil, nonce, []byte(plaintext), nil)...)), nil +} + +func Decrypt(key []byte, ciphertext string) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + // Extract nonce from the first 24 bytes + nonce, err := hex.DecodeString(ciphertext[:24]) + if err != nil { + return "", err + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + decrypted, err := aesgcm.Open(nil, nonce, []byte(ciphertext[24:]), nil) + if err != nil { + return "", err + } + return string(decrypted), nil +} + +func GenerateRandomKey(length int) (string, error) { + randomBytes := make([]byte, length) + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(randomBytes), nil +} From 31cb468ee10b1d4ea6b98e57a3d41e03de43ef62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Lukas=20=7E=20Znox=20/=20Pl=C3=A4nkler?= <60503970+Plaenkler@users.noreply.github.com> Date: Sat, 2 Sep 2023 17:09:51 +0200 Subject: [PATCH 03/17] [ADD] Symmetric key --- pkg/config/config.go | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 5aef045..92c7d2a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,14 +9,16 @@ import ( "sync" log "github.com/plaenkler/ddns-updater/pkg/logging" + "github.com/plaenkler/ddns-updater/pkg/security" "gopkg.in/yaml.v3" ) type Config struct { - Interval uint64 `yaml:"Interval"` - UseTOTP bool `yaml:"TOTP"` - Port uint64 `yaml:"Port"` - Resolver string `yaml:"Resolver"` + Interval uint64 `yaml:"Interval"` + UseTOTP bool `yaml:"TOTP"` + Port uint64 `yaml:"Port"` + Resolver string `yaml:"Resolver"` + Encryptor string `yaml:"Encryptor"` } const ( @@ -64,13 +66,18 @@ func load() error { } func create() error { + encryptor, err := security.GenerateRandomKey(8) + if err != nil { + return err + } config := Config{ - Interval: 600, - UseTOTP: false, - Port: 80, - Resolver: "", + Interval: 600, + UseTOTP: false, + Port: 80, + Resolver: "", + Encryptor: encryptor, } - 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 } + encryptor, ok := os.LookupEnv("DDNS_ENCRYPTOR") + if ok && encryptor != "" { + config.Encryptor = encryptor + } return nil } From 2e6fb7e4e3fc5dd3e41916fd1b52a795c67e2c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Lukas=20=7E=20Znox=20/=20Pl=C3=A4nkler?= <60503970+Plaenkler@users.noreply.github.com> Date: Sun, 3 Sep 2023 01:40:21 +0200 Subject: [PATCH 04/17] [UPD] Use AES-GCM --- pkg/cipher/cipher.go | 65 ++++++++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/pkg/cipher/cipher.go b/pkg/cipher/cipher.go index eb758a4..1fc8f5a 100644 --- a/pkg/cipher/cipher.go +++ b/pkg/cipher/cipher.go @@ -4,45 +4,58 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" - "errors" + "encoding/base64" + "fmt" "io" ) -func Encrypt(key, plaintext []byte) ([]byte, error) { - block, err := aes.NewCipher(key) +func Encrypt(key string, plaintext string) (string, error) { + encrypter, err := aes.NewCipher([]byte(key)) if err != nil { - return nil, err + return "", fmt.Errorf("[cipher-Encrypt-1] encryption failed: %s", err) } - ciphertext := make([]byte, aes.BlockSize+len(plaintext)) - iv := ciphertext[:aes.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return nil, 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) } - mode := cipher.NewCBCEncrypter(block, iv) - mode.CryptBlocks(ciphertext[aes.BlockSize:], plaintext) - return ciphertext, nil + return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(plaintext), nil)), nil } -func Decrypt(key, ciphertext []byte) ([]byte, error) { - block, err := aes.NewCipher(key) +func Decrypt(key string, ciphertext string) (string, error) { + c, err := aes.NewCipher([]byte(key)) + if err != nil { + return "", fmt.Errorf("[cipher-Decrypt-1] decryption failed: %s", err) + } + gcm, err := cipher.NewGCM(c) if err != nil { - return nil, err + return "", fmt.Errorf("[cipher-Decrypt-2] decryption failed: %s", err) } - if len(ciphertext) < aes.BlockSize { - return nil, errors.New("ciphertext too short") + cipherBytes, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("[cipher-Decrypt-3] decryption failed: %s", err) + } + nonceSize := gcm.NonceSize() + if len(cipherBytes) < nonceSize { + return "", 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 "", fmt.Errorf("[cipher-Decrypt-5] decryption failed: %s", err) } - iv := ciphertext[:aes.BlockSize] - ciphertext = ciphertext[aes.BlockSize:] - mode := cipher.NewCBCDecrypter(block, iv) - mode.CryptBlocks(ciphertext, ciphertext) - return ciphertext, nil + return string(plaintext), nil } -func GenerateRandomKey() ([]byte, error) { - key := make([]byte, 32) - _, err := rand.Read(key) +func GenerateRandomKey(length int) (string, error) { + randomBytes := make([]byte, length) + _, err := rand.Read(randomBytes) if err != nil { - return nil, err + return "", fmt.Errorf("[cipher-GenerateRandomKey-1] generating random key failed: %s", err) } - return key, nil + return base64.URLEncoding.EncodeToString(randomBytes), nil } From 6d80fc3b12d64a4081c36e84c8a76ce19a436cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Lukas=20=7E=20Znox=20/=20Pl=C3=A4nkler?= <60503970+Plaenkler@users.noreply.github.com> Date: Sun, 3 Sep 2023 01:40:50 +0200 Subject: [PATCH 05/17] [UPD] Encryptor -> Cryptor --- pkg/config/config.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 92c7d2a..980c0df 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,17 +8,17 @@ import ( "strconv" "sync" + "github.com/plaenkler/ddns-updater/pkg/cipher" log "github.com/plaenkler/ddns-updater/pkg/logging" - "github.com/plaenkler/ddns-updater/pkg/security" "gopkg.in/yaml.v3" ) type Config struct { - Interval uint64 `yaml:"Interval"` - UseTOTP bool `yaml:"TOTP"` - Port uint64 `yaml:"Port"` - Resolver string `yaml:"Resolver"` - Encryptor string `yaml:"Encryptor"` + Interval uint64 `yaml:"Interval"` + UseTOTP bool `yaml:"TOTP"` + Port uint64 `yaml:"Port"` + Resolver string `yaml:"Resolver"` + Cryptor string `yaml:"Cryptor"` } const ( @@ -66,16 +66,16 @@ func load() error { } func create() error { - encryptor, err := security.GenerateRandomKey(8) + Cryptor, err := cipher.GenerateRandomKey(16) if err != nil { return err } config := Config{ - Interval: 600, - UseTOTP: false, - Port: 80, - Resolver: "", - Encryptor: encryptor, + Interval: 600, + UseTOTP: false, + Port: 80, + Resolver: "", + Cryptor: Cryptor, } err = os.MkdirAll(filepath.Dir(pathToConfig), dirPerm) if err != nil { @@ -122,9 +122,9 @@ func loadFromEnv() error { if resolver != "" { config.Resolver = resolver } - encryptor, ok := os.LookupEnv("DDNS_ENCRYPTOR") - if ok && encryptor != "" { - config.Encryptor = encryptor + Cryptor, ok := os.LookupEnv("DDNS_Cryptor") + if ok && Cryptor != "" { + config.Cryptor = Cryptor } return nil } From 804269dd1f0953c263eac17df89c7e220dafc7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Lukas=20=7E=20Znox=20/=20Pl=C3=A4nkler?= <60503970+Plaenkler@users.noreply.github.com> Date: Sun, 3 Sep 2023 01:41:04 +0200 Subject: [PATCH 06/17] [REM] Security pkg --- pkg/security/security.go | 58 ---------------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 pkg/security/security.go diff --git a/pkg/security/security.go b/pkg/security/security.go deleted file mode 100644 index 70f1ded..0000000 --- a/pkg/security/security.go +++ /dev/null @@ -1,58 +0,0 @@ -package security - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/base64" - "encoding/hex" - "io" -) - -func Encrypt(key []byte, plaintext string) (string, error) { - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - // 96-bit nonce - nonce := make([]byte, 12) - _, err = io.ReadFull(rand.Reader, nonce) - if err != nil { - return "", err - } - aesgcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - return hex.EncodeToString(append(nonce, aesgcm.Seal(nil, nonce, []byte(plaintext), nil)...)), nil -} - -func Decrypt(key []byte, ciphertext string) (string, error) { - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - // Extract nonce from the first 24 bytes - nonce, err := hex.DecodeString(ciphertext[:24]) - if err != nil { - return "", err - } - aesgcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - decrypted, err := aesgcm.Open(nil, nonce, []byte(ciphertext[24:]), nil) - if err != nil { - return "", err - } - return string(decrypted), nil -} - -func GenerateRandomKey(length int) (string, error) { - randomBytes := make([]byte, length) - _, err := rand.Read(randomBytes) - if err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(randomBytes), nil -} From 3be47388242169ba12e41252ec66b45b8d9df484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Lukas=20=7E=20Znox=20/=20Pl=C3=A4nkler?= <60503970+Plaenkler@users.noreply.github.com> Date: Sun, 3 Sep 2023 01:42:32 +0200 Subject: [PATCH 07/17] [FIX] Go 1.20 Github workflows do not support 1.21 yet --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 4e2cef4f987e7eda1cb198c1f6fe3447324df4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Lukas=20=7E=20Znox=20/=20Pl=C3=A4nkler?= <60503970+Plaenkler@users.noreply.github.com> Date: Sun, 3 Sep 2023 02:24:26 +0200 Subject: [PATCH 08/17] [UPD] Encrypt job params --- pkg/server/routes/api/job.go | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) 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) { From 2be6aee023f43f4535cd8f6b826a5bbbc7bf5d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Lukas=20=7E=20Znox=20/=20Pl=C3=A4nkler?= <60503970+Plaenkler@users.noreply.github.com> Date: Sun, 3 Sep 2023 03:21:31 +0200 Subject: [PATCH 09/17] [UPD] Parameters processing --- pkg/server/routes/web/index.go | 78 +++++++++++++------ .../routes/web/static/html/pages/index.html | 2 +- pkg/server/routes/web/static/js/index.js | 2 +- 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/pkg/server/routes/web/index.go b/pkg/server/routes/web/index.go index 6f1d9c5..a0f18c8 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-2] 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-6] 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 = sanatizeParams(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-1] 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 sanatizeParams(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([]byte(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 string) (string, error) { + params := make(map[string]string) + err := json.Unmarshal([]byte(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 @@