diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d2d3d16..897334b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -31,8 +31,16 @@ archives: - id: tar formats: [tar.gz] name_template: "{{ .ProjectName }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}" + # Owner root (uid/gid 0) instead of the CI runner's 1001, so `tar -x` run as + # root inside a container or user namespace does not fail on chown. + builds_info: + owner: root + group: root files: - - LICENSE + - src: LICENSE + info: + owner: root + group: root checksum: name_template: "SHA256SUMS" @@ -46,7 +54,7 @@ release: ## Install ```bash - tar xzf fogping-*-linux-amd64.tar.gz && ./fogping + tar --no-same-owner -xzf fogping-*-linux-amd64.tar.gz && ./fogping ``` Storage is SQLite; history is kept for 93 days by default (`--days=N` for less; 93 is the max). linux/amd64 only in this release — SQLite needs CGO, so the other platforms are diff --git a/README.md b/README.md index a66adb4..d282d61 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ One command (Linux x86-64) — downloads, unpacks and starts fogping: ```bash -mkdir -p ~/fogping && cd ~/fogping && curl -L https://github.com/githubflyideas/fogping/releases/download/v1.3.0/fogping-v1.3.0-linux-amd64.tar.gz | tar -xz && ./fogping --edit user=admin passwd=change-me +mkdir -p ~/fogping && cd ~/fogping && curl -L https://github.com/githubflyideas/fogping/releases/download/v1.3.0/fogping-v1.3.0-linux-amd64.tar.gz | tar --no-same-owner -xz && ./fogping --edit user=admin passwd=change-me ``` Or with Docker (amd64 / arm64 / armv7): @@ -48,7 +48,7 @@ Arguments can come in any order; the port is always 8518. **Compare:** the **COMPARE** menu under the chart overlays the previous period, yesterday or last week as a dashed median line. -**Export / import targets:** ✎ targets → ⇩ export / ⇧ import… moves a target list as a JSON file, shared with pingping — see [the guide](docs/GUIDE.md#export-and-import) and the [format spec](docs/targets-format.md). Ready-made lists: see presets/ (coming). +**Export / import targets:** ✎ targets → ⇩ export / ⇧ import… moves a target list as a JSON file, shared with pingping — see [the guide](docs/GUIDE.md#export-and-import) and the [format spec](docs/targets-format.md). > **PING shows 100% loss?** Allow unprivileged ICMP once: > `echo 'net.ipv4.ping_group_range = 0 2147483647' | sudo tee /etc/sysctl.d/99-fogping.conf && sudo sysctl --system` diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 9f1e1f1..96c6cda 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -63,7 +63,7 @@ sudo mkdir -p /opt/fogping && sudo chown "$USER": /opt/fogping && cd /opt/fogpin wget https://github.com/githubflyideas/fogping/releases/download/v1.3.0/fogping-v1.3.0-linux-amd64.tar.gz wget https://github.com/githubflyideas/fogping/releases/download/v1.3.0/SHA256SUMS sha256sum --ignore-missing -c SHA256SUMS # expect: fogping-v1.3.0-linux-amd64.tar.gz: OK -tar -xzf fogping-v1.3.0-linux-amd64.tar.gz +tar --no-same-owner -xzf fogping-v1.3.0-linux-amd64.tar.gz ./fogping --version ``` @@ -115,7 +115,7 @@ starts probing. The log looks like this: ``` new database — seeded a demo target (www.google.com) [Demo] probing www.google.com -fogping 1.1.2 up · 1 targets · targets editable in the web UI · listening on 0.0.0.0:8518 · data in ./data · 93-day retention +fogping X.Y.Z up · 1 targets · targets editable in the web UI · listening on 0.0.0.0:8518 · data in ./data · 93-day retention ➜ open http://localhost:8518 for the smoke graph web login enabled for 1 user(s) ``` @@ -281,8 +281,6 @@ optional. Files are limited to 1 MB and 500 targets. Full spec: > Importing makes this host probe every address in the file. Read the preview, > and only import lists you trust. -Ready-made lists: see presets/ (coming). - ## Run as a systemd service Create a dedicated user and hand it the install directory: @@ -422,7 +420,7 @@ cd /opt/fogping sudo systemctl stop fogping sudo cp -a data data.bak # optional, lets you roll back sudo wget https://github.com/githubflyideas/fogping/releases/download/vX.Y.Z/fogping-vX.Y.Z-linux-amd64.tar.gz -sudo tar -xzf fogping-vX.Y.Z-linux-amd64.tar.gz # replaces the binary only +sudo tar --no-same-owner -xzf fogping-vX.Y.Z-linux-amd64.tar.gz # replaces the binary only sudo setcap cap_net_raw+ep fogping # only if you used ICMP option B sudo systemctl start fogping ``` diff --git a/docs/targets-format.md b/docs/targets-format.md index 6612fc6..39a02ac 100644 --- a/docs/targets-format.md +++ b/docs/targets-format.md @@ -114,7 +114,3 @@ before confirming, and only import lists from sources you trust. Import also takes the same cross-site protection as the other write endpoints: a request whose `Origin` is a different site is refused, and the JSON content type means a page on another site cannot submit it with your cookie. - -## Ready-made lists - -Ready-made lists: see `presets/` (coming). diff --git a/docs_test.go b/docs_test.go new file mode 100644 index 0000000..bc8eca6 --- /dev/null +++ b/docs_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// Docs regressions: no pointer to a presets/ directory that does not exist, no +// pinned version in the sample startup log, and every documented tar extraction +// ignores archive ownership (older release tarballs carry uid/gid 1001, which a +// root user inside a container cannot chown to). +func TestDocsStayAccurate(t *testing.T) { + docs := []string{"README.md", "docs/GUIDE.md", "docs/targets-format.md"} + tarX := regexp.MustCompile(`\btar\s+(-?x\S*|-\S*x\S*)`) + for _, p := range docs { + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + s := string(b) + if _, err := os.Stat("presets"); err != nil && strings.Contains(s, "presets/") { + t.Errorf("%s mentions presets/, which does not exist", p) + } + for _, line := range strings.Split(s, "\n") { + if tarX.MatchString(line) && !strings.Contains(line, "--no-same-owner") { + t.Errorf("%s: tar extraction without --no-same-owner: %s", p, strings.TrimSpace(line)) + } + } + } + g, _ := os.ReadFile("docs/GUIDE.md") + if !strings.Contains(string(g), "fogping X.Y.Z up ·") { + t.Error("GUIDE: sample startup log should show `fogping X.Y.Z up`, not a pinned version") + } + if regexp.MustCompile(`fogping \d+\.\d+\.\d+ up`).Match(g) { + t.Error("GUIDE: sample startup log pins a version that goes stale") + } +} diff --git a/headers.go b/headers.go new file mode 100644 index 0000000..756f3b9 --- /dev/null +++ b/headers.go @@ -0,0 +1,63 @@ +package main + +import ( + "crypto/sha256" + "encoding/base64" + "net/http" + "regexp" + "strings" +) + +// Security headers. +// +// Every response says it must not be framed (X-Frame-Options plus CSP +// frame-ancestors) and must not be MIME-sniffed. The read-only UI is meant to be +// shared as a link, not embedded in someone else's page, so nothing here needs +// framing. +// +// HTML pages get a full Content-Security-Policy on top. Their inline `) + +func inlineScriptHashes(page []byte) []string { + var out []string + for _, m := range inlineScriptRe.FindAllSubmatch(page, -1) { + sum := sha256.Sum256(m[1]) + out = append(out, "'sha256-"+base64.StdEncoding.EncodeToString(sum[:])+"'") + } + return out +} + +// pageCSP is the policy for one HTML page. +func pageCSP(page []byte) string { + script := append([]string{"'self'"}, inlineScriptHashes(page)...) + return strings.Join([]string{ + "default-src 'self'", + "script-src " + strings.Join(script, " "), + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'self'", + frameOnlyCSP, + }, "; ") +} diff --git a/headers_test.go b/headers_test.go new file mode 100644 index 0000000..f7704ff --- /dev/null +++ b/headers_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" +) + +func checkSecurityHeaders(t *testing.T, label string, w *httptest.ResponseRecorder) { + t.Helper() + h := w.Header() + if got := h.Get("X-Frame-Options"); got != "DENY" { + t.Errorf("%s: X-Frame-Options = %q, want DENY", label, got) + } + if got := h.Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("%s: X-Content-Type-Options = %q, want nosniff", label, got) + } + if got := h.Get("Referrer-Policy"); got != "same-origin" { + t.Errorf("%s: Referrer-Policy = %q, want same-origin", label, got) + } + if got := h.Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors 'none'") { + t.Errorf("%s: CSP %q lacks frame-ancestors 'none'", label, got) + } +} + +func checkPageCSP(t *testing.T, label string, w *httptest.ResponseRecorder, page string) { + t.Helper() + csp := w.Header().Get("Content-Security-Policy") + b, _ := staticFS.ReadFile("static/" + page) + hashes := inlineScriptHashes(b) + if len(hashes) == 0 { + t.Fatalf("%s: no inline scripts found in %s — the matcher has stopped working", label, page) + } + for _, hs := range hashes { + if !strings.Contains(csp, hs) { + t.Errorf("%s: CSP lacks hash %s", label, hs) + } + } + for _, bad := range []string{"'unsafe-eval'", "script-src 'self' 'unsafe-inline'", "*"} { + if strings.Contains(csp, bad) { + t.Errorf("%s: CSP contains %s: %q", label, bad, csp) + } + } + if !strings.Contains(csp, "default-src 'self'") || !strings.Contains(csp, "object-src 'none'") { + t.Errorf("%s: page CSP too weak: %q", label, csp) + } +} + +func TestSecurityHeadersEverywhere(t *testing.T) { + s, _ := NewStore(t.TempDir(), nil) + defer s.Close() + run := NewRunner(ProbeCfg{Packets: 1, TimeoutMs: 50}, s, NewDetector(s)) + do := func(h http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "http://fp.local"+path, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://fp.local") + } + if cookie != "" { + req.Header.Set("Cookie", "fogping_session="+cookie) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w + } + + // Open, read-only UI (no users): the shareable page itself. + open := newMux(&Config{}, s, run, nil) + w := do(open, "GET", "/", "", "") + if w.Code != 200 { + t.Fatalf("open GET /: %d", w.Code) + } + checkSecurityHeaders(t, "open /", w) + checkPageCSP(t, "open /", w, "index.html") + + // With a login. + auth := newMux(&Config{Editable: true}, s, run, map[string]string{"admin": "pw"}) + w = do(auth, "GET", "/login", "", "") + checkSecurityHeaders(t, "/login", w) + checkPageCSP(t, "/login", w, "login.html") + + w = do(auth, "GET", "/", "", "") + if w.Code != http.StatusFound { + t.Errorf("unauthenticated GET / = %d, want 302", w.Code) + } + checkSecurityHeaders(t, "redirect /", w) + + w = do(auth, "POST", "/api/login", `{"User":"admin","Pass":"pw"}`, "") + if w.Code != 200 { + t.Fatalf("login: %d %s", w.Code, w.Body) + } + checkSecurityHeaders(t, "/api/login", w) + var tok string + for _, c := range w.Result().Cookies() { + if c.Name == "fogping_session" { + tok = c.Value + } + } + + w = do(auth, "GET", "/", "", tok) + if w.Code != 200 { + t.Fatalf("authed GET /: %d", w.Code) + } + checkSecurityHeaders(t, "authed /", w) + checkPageCSP(t, "authed /", w, "index.html") + + cases := []struct { + method, path, body, cookie string + code int + }{ + {"GET", "/api/targets", "", "", http.StatusUnauthorized}, + {"GET", "/api/targets", "", tok, 200}, + {"GET", "/api/version", "", "", 200}, + {"GET", "/api/targets/export", "", tok, 200}, + {"POST", "/api/targets", `{"name":"t1","type":"tcp","host":"127.0.0.1","port":1,"interval_sec":3600}`, tok, 200}, + {"GET", "/static/echarts.min.js", "", "", 200}, + {"GET", "/static/nope.js", "", "", 404}, + {"GET", "/nope", "", "", 404}, + } + for _, c := range cases { + w := do(auth, c.method, c.path, c.body, c.cookie) + label := c.method + " " + c.path + if c.code != 0 && w.Code != c.code { + t.Errorf("%s: status %d, want %d", label, w.Code, c.code) + } + checkSecurityHeaders(t, label, w) + } +} + +// Every `) + handler := regexp.MustCompile(`(?i)<[a-z][^>]*\son[a-z]+\s*=`) + for _, page := range []string{"index.html", "login.html"} { + b, err := staticFS.ReadFile("static/" + page) + if err != nil { + t.Fatal(err) + } + src := string(b) + inline := len(inlineScriptRe.FindAllString(src, -1)) + ext := 0 + for _, loc := range anyScript.FindAllStringIndex(src, -1) { + if external.MatchString(src[loc[0]:]) { + ext++ + } + } + if total := len(anyScript.FindAllString(src, -1)); total != inline+ext { + t.Errorf("%s: %d