diff --git a/apps/backup/README.md b/apps/backup/README.md index 2963486..8089c4e 100644 --- a/apps/backup/README.md +++ b/apps/backup/README.md @@ -22,6 +22,8 @@ All backups are client-side encrypted using AES-256-GCM. `BACKUP_ENCRYPTION_KEY` openssl rand -base64 32 ``` +On failure the program sends a message to a Nextcloud Talk conversation via a bot. `NEXTCLOUD_TALK_SECRET` (bot shared secret) and `NEXTCLOUD_TALK_TOKEN` (conversation token from the URL `/call/{token}`) must be in the `.env` file. `NEXTCLOUD_URL` is set in the CronJob manifest. + To decrypt an encrypted backup (`.tar.gz.enc`): ```bash diff --git a/apps/backup/cronjob.yaml b/apps/backup/cronjob.yaml index a488129..9244a7d 100644 --- a/apps/backup/cronjob.yaml +++ b/apps/backup/cronjob.yaml @@ -23,6 +23,9 @@ spec: envFrom: - secretRef: name: backup-env + env: + - name: NEXTCLOUD_URL + value: http://nextcloud.nextcloud.svc.cluster.local:80 args: - -node=pumbaa - -path=/etc/pop0 @@ -62,6 +65,9 @@ spec: envFrom: - secretRef: name: backup-env + env: + - name: NEXTCLOUD_URL + value: http://nextcloud.nextcloud.svc.cluster.local:80 args: - -node=timon - -path=/etc/pop0 diff --git a/apps/cloudflared/configmap.yaml b/apps/cloudflared/configmap.yaml index 50dbf24..ae10825 100644 --- a/apps/cloudflared/configmap.yaml +++ b/apps/cloudflared/configmap.yaml @@ -16,6 +16,8 @@ data: service: http://multi-scrobbler.navidrome.svc.cluster.local:9078 - hostname: cloud.cuducos.me service: http://nextcloud.nextcloud.svc.cluster.local:80 + - hostname: nextpush.cuducos.me + service: http://notify-push.nextcloud.svc.cluster.local:7867 - hostname: office.cuducos.me service: http://collabora.nextcloud.svc.cluster.local:9980 - hostname: feed.cuducos.me diff --git a/apps/nextcloud/notify-push-deployment.yaml b/apps/nextcloud/notify-push-deployment.yaml new file mode 100644 index 0000000..66a860d --- /dev/null +++ b/apps/nextcloud/notify-push-deployment.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notify-push + namespace: nextcloud +spec: + replicas: 1 + selector: + matchLabels: + app: notify-push + template: + metadata: + labels: + app: notify-push + spec: + nodeSelector: + kubernetes.io/hostname: pumbaa + containers: + - name: notify-push + image: nextcloud:33 + command: ["/var/www/html/custom_apps/notify_push/bin/x86_64/notify_push"] + args: ["/var/www/html/config/config.php"] + env: + - name: PORT + value: "7867" + - name: NEXTCLOUD_URL + value: "http://nextcloud.nextcloud.svc.cluster.local:80" + ports: + - containerPort: 7867 + volumeMounts: + - name: config + mountPath: /var/www/html/config + readOnly: true + - name: custom-apps + mountPath: /var/www/html/custom_apps + readOnly: true + - name: data + mountPath: /var/www/html/data + readOnly: true + volumes: + - name: config + hostPath: + path: /etc/pop0/nextcloud/config + type: Directory + - name: custom-apps + hostPath: + path: /etc/pop0/nextcloud/custom_apps + type: DirectoryOrCreate + - name: data + hostPath: + path: /etc/pop0/nextcloud/data + type: DirectoryOrCreate diff --git a/apps/nextcloud/notify-push-service.yaml b/apps/nextcloud/notify-push-service.yaml new file mode 100644 index 0000000..7f6d2bf --- /dev/null +++ b/apps/nextcloud/notify-push-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: notify-push + namespace: nextcloud +spec: + selector: + app: notify-push + ports: + - protocol: TCP + port: 7867 + targetPort: 7867 diff --git a/cmd/backup/backup.go b/cmd/backup/backup.go index 768deea..af8ee78 100644 --- a/cmd/backup/backup.go +++ b/cmd/backup/backup.go @@ -4,8 +4,8 @@ import ( "archive/tar" "compress/gzip" "errors" - "fmt" "io" + "log/slog" "os" "path/filepath" ) @@ -17,21 +17,21 @@ func tarball(root string, ignore []string, dst string) error { } defer func() { if err := f.Close(); err != nil { - fmt.Fprintf(os.Stderr, "closing file: %v\n", err) + slog.Error("closing file", "err", err) } }() g := gzip.NewWriter(f) defer func() { if err := g.Close(); err != nil { - fmt.Fprintf(os.Stderr, "closing gzip writer: %v\n", err) + slog.Error("closing gzip writer", "err", err) } }() t := tar.NewWriter(g) defer func() { if err := t.Close(); err != nil { - fmt.Fprintf(os.Stderr, "closing tar writer: %v\n", err) + slog.Error("closing tar writer", "err", err) } }() diff --git a/cmd/backup/main.go b/cmd/backup/main.go index 955e45a..21576b5 100644 --- a/cmd/backup/main.go +++ b/cmd/backup/main.go @@ -2,49 +2,46 @@ package main import ( "context" + "errors" "flag" "fmt" - "log" + "log/slog" "os" "path/filepath" "strings" "time" ) -func main() { - var ( - path = flag.String("path", "/etc/pop0", "path to back up") - ignore = flag.String("ignore", "", "comma-separated paths to exclude") - node = flag.String("node", "", "node name included in backup filename") - decrypt = flag.Bool("decrypt", false, "decrypt an encrypted backup file") - ) - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "backup creates a tar.gz of a local path and uploads it to S3.\n\nUsage of %s:\n", os.Args[0]) - flag.PrintDefaults() - } - flag.Parse() +var ( + path = flag.String("path", "/etc/pop0", "path to back up") + ignore = flag.String("ignore", "", "comma-separated paths to exclude") + node = flag.String("node", "", "node name included in backup filename") + decrypt = flag.Bool("decrypt", false, "decrypt an encrypted backup file") + alert = flag.Bool("test-alert", false, "send a test message to Nextcloud Talk and exit") +) - b64 := os.Getenv("BACKUP_ENCRYPTION_KEY") - if b64 == "" { - log.Fatalf("BACKUP_ENCRYPTION_KEY is required") +func run() error { + key := os.Getenv("BACKUP_ENCRYPTION_KEY") + if key == "" { + return errors.New("BACKUP_ENCRYPTION_KEY is required") } if *decrypt { args := flag.Args() if len(args) < 1 { - log.Fatalf("missing file argument in decrypt mode") + return errors.New("missing file argument in decrypt mode") } src := args[0] if !strings.HasSuffix(src, ".enc") { - log.Fatalf("input file must end with .enc extension") + return errors.New("input file must end with .enc extension") } dst := strings.TrimSuffix(src, ".enc") - log.Printf("decrypting %s to %s", src, dst) - if err := decryptFile(src, dst, b64); err != nil { - log.Fatalf("decryption failed: %v", err) + slog.Info("decrypting", "src", src, "dst", dst) + if err := decryptFile(src, dst, key); err != nil { + return fmt.Errorf("decryption failed: %w", err) } - log.Printf("decryption successful") - return + slog.Info("decryption successful") + return nil } var skip []string @@ -60,53 +57,91 @@ func main() { secret: os.Getenv("S3_SECRET"), } if cfg.bucket == "" { - log.Fatalf("S3_BUCKET is required") + return errors.New("S3_BUCKET is required") } tmp, err := os.MkdirTemp("", "pop0-backup-") if err != nil { - log.Fatalf("creating temp dir: %v", err) + return fmt.Errorf("creating temp dir: %w", err) } defer func() { if err := os.RemoveAll(tmp); err != nil { - fmt.Fprintf(os.Stderr, "removing temp dir: %v\n", err) + slog.Error("removing temp dir", "err", err) } }() out := filepath.Join(tmp, fmt.Sprintf("%s-%s.tar.gz", *node, time.Now().UTC().Format("20060102T150405Z"))) - log.Printf("creating tarball of %s", *path) + slog.Info("creating tarball", "path", *path) if err := tarball(*path, skip, out); err != nil { - log.Fatalf("creating tarball: %v", err) + return fmt.Errorf("creating tarball: %w", err) } - log.Printf("encrypting backup") + slog.Info("encrypting backup") enc := out + ".enc" - if err := encryptFile(out, enc, b64); err != nil { - log.Fatalf("encrypting backup: %v", err) + if err := encryptFile(out, enc, key); err != nil { + return fmt.Errorf("encrypting backup: %w", err) } if err := os.Remove(out); err != nil { - log.Fatalf("removing unencrypted temp backup: %v", err) + return fmt.Errorf("removing unencrypted temp backup: %w", err) } s3, err := newS3Client(cfg) if err != nil { - log.Fatalf("initializing S3 client: %v", err) + return fmt.Errorf("initializing S3 client: %w", err) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) defer cancel() - key := filepath.Base(enc) - log.Printf("uploading to s3://%s/%s", cfg.bucket, key) + k := filepath.Base(enc) + slog.Info("uploading", "bucket", cfg.bucket, "key", k) if err := s3.upload(ctx, enc); err != nil { - log.Fatalf("uploading to S3: %v", err) + return fmt.Errorf("uploading to S3: %w", err) } - log.Printf("pruning old backups") + slog.Info("pruning old backups") if err := s3.prune(ctx); err != nil { - log.Printf("pruning: %v", err) + slog.Error("pruning", "err", err) + } + + slog.Info("backup uploaded") + return nil +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "backup creates a tar.gz of a local path and uploads it to S3.\n\nUsage of %s:\n", os.Args[0]) + flag.PrintDefaults() + } + flag.Parse() + + var n *notifier + if !*decrypt { + var err error + n, err = newNotifier(*node) + if err != nil { + slog.Error("creating notifier", "err", err) + os.Exit(1) + } } - log.Printf("backup uploaded") + if *alert { + if err := n.alert("this is a test alert from the backup program"); err != nil { + slog.Error("sending alert", "err", err) + os.Exit(1) + } + slog.Info("test alert sent") + return + } + + if err := run(); err != nil { + slog.Error("backup failed", "err", err) + if n != nil { + if err := n.alert(err.Error()); err != nil { + slog.Error("sending alert", "err", err) + } + } + os.Exit(1) + } } diff --git a/cmd/backup/notify.go b/cmd/backup/notify.go new file mode 100644 index 0000000..fcf923b --- /dev/null +++ b/cmd/backup/notify.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "time" +) + +type notifier struct { + node string + url string + secret string + token string +} + +func newNotifier(node string) (*notifier, error) { + n := notifier{ + node: node, + url: os.Getenv("NEXTCLOUD_URL"), + secret: os.Getenv("NEXTCLOUD_TALK_SECRET"), + token: os.Getenv("NEXTCLOUD_TALK_TOKEN"), + } + if n.url == "" || n.secret == "" || n.token == "" { + return nil, fmt.Errorf("NEXTCLOUD_URL, NEXTCLOUD_TALK_SECRET, and NEXTCLOUD_TALK_TOKEN are required for failure alerts") + } + return &n, nil +} + +func (n *notifier) alert(msg string) error { + if n.node != "" { + msg = fmt.Sprintf("backup failed on %s: %s", n.node, msg) + } + b, err := json.Marshal(map[string]string{"message": msg}) + if err != nil { + return fmt.Errorf("marshal message: %w", err) + } + r := make([]byte, 32) + if _, err := rand.Read(r); err != nil { + return fmt.Errorf("generate random: %w", err) + } + rand := hex.EncodeToString(r) + mac := hmac.New(sha256.New, []byte(n.secret)) + mac.Write([]byte(rand)) + mac.Write([]byte(msg)) + sig := hex.EncodeToString(mac.Sum(nil)) + u := fmt.Sprintf("%s/ocs/v2.php/apps/spreed/api/v1/bot/%s/message", n.url, n.token) + req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("OCS-APIRequest", "true") + req.Header.Set("X-Nextcloud-Talk-Bot-Random", rand) + req.Header.Set("X-Nextcloud-Talk-Bot-Signature", sig) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req = req.WithContext(ctx) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Error("closing alert response body", "err", err) + } + }() + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("nextcloud returned %s", resp.Status) + } + return nil +} diff --git a/cmd/backup/notify_test.go b/cmd/backup/notify_test.go new file mode 100644 index 0000000..8032b54 --- /dev/null +++ b/cmd/backup/notify_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNotifierAlert(t *testing.T) { + var got struct { + Message string + Random string + Signature string + Token string + Accept string + OCS string + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method: got %s, want POST", r.Method) + } + got.Random = r.Header.Get("X-Nextcloud-Talk-Bot-Random") + got.Signature = r.Header.Get("X-Nextcloud-Talk-Bot-Signature") + got.Accept = r.Header.Get("Accept") + got.OCS = r.Header.Get("OCS-APIRequest") + got.Token = r.URL.Path + b, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + mac := hmac.New(sha256.New, []byte("secret")) + mac.Write([]byte(got.Random)) + mac.Write([]byte(got.Message)) + want := hex.EncodeToString(mac.Sum(nil)) + if got.Signature != want { + t.Errorf("signature: got %q, want %q", got.Signature, want) + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + n := notifier{ + node: "pumbaa", + url: srv.URL, + secret: "secret", + token: "tok123", + } + if err := n.alert("creating tarball: permission denied"); err != nil { + t.Fatalf("alert: %v", err) + } + if got.Message != "backup failed on pumbaa: creating tarball: permission denied" { + t.Errorf("message: got %q", got.Message) + } + if got.Token != "/ocs/v2.php/apps/spreed/api/v1/bot/tok123/message" { + t.Errorf("path: got %q", got.Token) + } + if got.Random == "" || got.Accept != "application/json" || got.OCS != "true" { + t.Errorf("headers missing: random=%q accept=%q ocs=%q", got.Random, got.Accept, got.OCS) + } +} + +func TestNotifierAlertServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + n := notifier{url: srv.URL, secret: "s", token: "t"} + if err := n.alert("boom"); err == nil { + t.Fatal("expected error on 500, got nil") + } +} + +func TestNewNotifierMissingEnv(t *testing.T) { + for _, k := range []string{"NEXTCLOUD_URL", "NEXTCLOUD_TALK_SECRET", "NEXTCLOUD_TALK_TOKEN"} { + t.Setenv(k, "") + } + if _, err := newNotifier("pumbaa"); err == nil { + t.Fatal("expected error when env vars missing, got nil") + } +} diff --git a/cmd/backup/upload.go b/cmd/backup/upload.go index d4847d4..228a744 100644 --- a/cmd/backup/upload.go +++ b/cmd/backup/upload.go @@ -3,7 +3,7 @@ package main import ( "context" "fmt" - "log" + "log/slog" "os" "path/filepath" "strings" @@ -59,7 +59,7 @@ func (c *s3Client) upload(ctx context.Context, p string) error { } defer func() { if err := f.Close(); err != nil { - fmt.Fprintf(os.Stderr, "closing upload file: %v\n", err) + slog.Error("closing upload file", "err", err) } }() k := filepath.Base(p) @@ -146,7 +146,7 @@ func (c *s3Client) prune(ctx context.Context) error { } if len(todo) == 0 { - log.Printf("nothing to prune") + slog.Info("nothing to prune") return nil } @@ -166,6 +166,6 @@ func (c *s3Client) prune(ctx context.Context) error { if err := g.Wait(); err != nil { return err } - log.Printf("pruned %d backups", len(todo)) + slog.Info("pruned backups", "count", len(todo)) return nil }