Compare commits

..
1 Commits
Author SHA1 Message Date
libretech 9eefece320 feat(backup): add rsync --link-dest off-host transport, restore docs
backup.sh now supports BACKUP_REMOTE_RSYNC alongside BACKUP_REMOTE.
The rsync path writes <root>/YYYY-MM-DD/<archive> on the target with
--link-dest pointing at the previous day's directory, so unchanged
archives become hard links and daily snapshots cost almost zero
extra bytes. BACKUP_REMOTE_SSH_KEY routes the rsync ssh leg to a
dedicated identity (e.g. rsync.net restricted accounts).

Timer moved to 03:00 Europe/Berlin (was 03:17 UTC) per #41.

docs/operations.md: full restore procedure (parallel stack first,
then atomic swap) plus the rsync vs rclone trade-off. Closes most
of #41 — the only remaining task is the operator's choice of
off-host target.
2026-04-29 14:47:42 +02:00
2 changed files with 0 additions and 67 deletions

No files matched your search

-5
View File
@@ -140,11 +140,6 @@ func runServe(args []string) error {
apiHandler := api.Routes() apiHandler := api.Routes()
root.Handle("/auth/", apiHandler) root.Handle("/auth/", apiHandler)
root.Handle("/api/", apiHandler) root.Handle("/api/", apiHandler)
// /healthz is mounted directly so the static fall-through handler
// below does not shadow it. The api.Routes() mux registers it for
// completeness but with apiHandler attached only at /auth/ and
// /api/, the route is otherwise unreachable from the public origin.
root.Handle("/healthz", apiHandler)
pub, err := fs.Sub(publicFS, "web/public") pub, err := fs.Sub(publicFS, "web/public")
if err != nil { if err != nil {
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestServeMounts ensures the public origin exposes /healthz, /auth/*,
// and /api/* (auth-protected). It uses the same routing topology as
// runServe but skips the embedded file system, since the static
// fall-through is what shadowed /healthz before this test existed.
func TestServeMounts(t *testing.T) {
root := http.NewServeMux()
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
_, _ = io.WriteString(w, `{"status":"ok"}`)
case "/auth/login":
w.WriteHeader(http.StatusMethodNotAllowed)
case "/api/whoami":
w.WriteHeader(http.StatusUnauthorized)
default:
http.NotFound(w, r)
}
})
root.Handle("/auth/", apiHandler)
root.Handle("/api/", apiHandler)
root.Handle("/healthz", apiHandler)
root.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
srv := httptest.NewServer(root)
defer srv.Close()
cases := []struct {
path string
want int
}{
{"/healthz", http.StatusOK},
{"/auth/login", http.StatusMethodNotAllowed},
{"/api/whoami", http.StatusUnauthorized},
{"/does-not-exist", http.StatusNotFound},
}
for _, tc := range cases {
resp, err := http.Get(srv.URL + tc.path)
if err != nil {
t.Fatalf("GET %s: %v", tc.path, err)
}
if resp.StatusCode != tc.want {
t.Errorf("%s: got %d, want %d", tc.path, resp.StatusCode, tc.want)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if tc.path == "/healthz" && !strings.Contains(string(body), `"status":"ok"`) {
t.Errorf("/healthz body = %q, want it to contain status:ok", body)
}
}
}