diff --git a/defs/defs.go b/defs/defs.go index 71e91f2..98da5c7 100644 --- a/defs/defs.go +++ b/defs/defs.go @@ -3,7 +3,6 @@ package defs import ( "encoding/json" "net" - "runtime" "strings" ) @@ -12,13 +11,8 @@ var ( BuildDate string ProgName string ProgVersion string - // UserAgent names the platform as well as the program, the way a browser - // does. A server's telemetry already stores this header, so reporting the - // operating system and architecture here is what lets an operator tell - // which kinds of machine are measuring against them without any change to - // what the client sends. It stays coarse deliberately: the kernel version - // or the hostname would identify the machine rather than describe it. - UserAgent = ProgName + "/" + ProgVersion + " (" + runtime.GOOS + "; " + runtime.GOARCH + ")" + // UserAgent names the platform as well as the program; see buildUserAgent. + UserAgent = buildUserAgent() ) // GetIPResults represents the returned JSON from backend server's getIP.php endpoint diff --git a/defs/useragent.go b/defs/useragent.go new file mode 100644 index 0000000..cc71216 --- /dev/null +++ b/defs/useragent.go @@ -0,0 +1,60 @@ +package defs + +import ( + "os" + "runtime" + "strings" +) + +// buildUserAgent reports the program, its version, the platform, and on Linux +// the distribution ID. The Rust client sends the same shape. +func buildUserAgent() string { + platform := runtime.GOOS + "; " + runtime.GOARCH + + if id := distributionID(); id != "" { + platform += "; " + id + } + + return ProgName + "/" + ProgVersion + " (" + platform + ")" +} + +// distributionID returns the ID from os-release, e.g. "openwrt", or "" where +// neither of its two standard locations exists. +func distributionID() string { + b, err := os.ReadFile("/etc/os-release") + if err != nil { + if b, err = os.ReadFile("/usr/lib/os-release"); err != nil { + return "" + } + } + return parseOsReleaseID(string(b)) +} + +// parseOsReleaseID extracts ID from os-release content, sanitised and capped: +// it ends up in a header built from a root-writable file. +func parseOsReleaseID(content string) string { + for _, line := range strings.Split(content, "\n") { + bare, ok := strings.CutPrefix(line, "ID=") + if !ok { + continue + } + bare = strings.Trim(strings.TrimSpace(bare), `"'`) + + var b strings.Builder + for _, r := range strings.ToLower(bare) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || + r == '.' || r == '_' || r == '-' { + b.WriteRune(r) + } + if b.Len() >= 24 { + break + } + } + + if b.Len() > 0 { + return b.String() + } + } + + return "" +} diff --git a/defs/useragent_test.go b/defs/useragent_test.go new file mode 100644 index 0000000..d7a95a5 --- /dev/null +++ b/defs/useragent_test.go @@ -0,0 +1,37 @@ +package defs + +import ( + "strings" + "testing" +) + +func TestParseOsReleaseID(t *testing.T) { + cases := []struct { + name string + content string + want string + }{ + {"openwrt", "ID=\"openwrt\"\nVERSION_ID=\"24.10.1\"\n", "openwrt"}, + {"unquoted", "ID=debian\nVERSION_ID=12\n", "debian"}, + // ID_LIKE and VERSION_ID must not be mistaken for ID. + {"id_like first", "ID_LIKE=debian\nID=ubuntu\n", "ubuntu"}, + {"version only", "VERSION_ID=1.0\n", ""}, + {"empty", "", ""}, + {"garbage id", "ID=\"???\"\n", ""}, + {"uppercase and junk", "ID='OpenWrt!'\n", "openwrt"}, + } + + for _, c := range cases { + if got := parseOsReleaseID(c.content); got != c.want { + t.Errorf("%s: parseOsReleaseID = %q, want %q", c.name, got, c.want) + } + } +} + +// The value lands in an HTTP header and comes from a root-writable file. +func TestParseOsReleaseIDIsBounded(t *testing.T) { + long := "ID=" + strings.Repeat("a", 200) + "\n" + if got := parseOsReleaseID(long); len(got) != 24 { + t.Errorf("parseOsReleaseID length = %d, want 24", len(got)) + } +}