commit db081e26993c7f1f9ca35d2ccc17e5c060a42e37
Author: brent saner
Date: Thu Dec 12 02:22:54 2024 -0500
initial commit before refactor switch
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3f5fb6d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,49 @@
+*.7z
+*.bak
+*.deb
+*.jar
+*.rar
+*.run
+*.sig
+*.tar
+*.tar.bz2
+*.tar.gz
+*.tar.xz
+*.tbz
+*.tbz2
+*.tgz
+*.txz
+*.zip
+.*.swp
+.editix
+
+# https://github.com/github/gitignore/blob/master/Go.gitignore
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+#*.test
+test.sh
+
+# Built binary
+bin/*
+poc/*
+_poc/*
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Example configs.
+_exampledata/
+
+# Don't include rendered doc
+#/README.html
+
+# Example code.
+_demo/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ddd318c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,9 @@
+Copyright (c) 2024 Brent Saner.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/_extras/clientinfo.env b/_extras/clientinfo.env
new file mode 100644
index 0000000..36970e8
--- /dev/null
+++ b/_extras/clientinfo.env
@@ -0,0 +1,22 @@
+# Enable debug mode.
+#CINFO_DEBUG=1
+
+# Use a different listening spec.
+# The default is 'unix:///var/run/clientinfo/fcgi.sock'
+##
+# The below example listens on UDS path /tmp/mysock.fcgi as FCGI.
+# Note the *three* slashes after the scheme. This is intentional and recommended,
+# otherwise the path will be treated as relative to current working directory.
+#CINFO_URI="unix:///tmp/mysock.fcgi"
+#
+# The below example listens on localhost port 4321 as FCGI.
+# TLS is currently not supported; use a reverse stream proxy to terminate if TLS is needed.
+#CINFO_URI="tcp://127.0.0.1:4321"
+#
+# The below example listens on localhost port 1234 as HTTP instead of FCGI.
+# HTTPS is currently not supported; use a reverse proxy to terminate if HTTPS is needed.
+# If reverse-proxying, BE SURE to set (NOT add) a header "X-ClientInfo-RealIP"
+# with a value in the form of:
+# * ":" (IPv4)
+# * "[]:" (IPv6)
+#CINFO_URI="http://127.0.0.1:1234"
diff --git a/_extras/clientinfo.service b/_extras/clientinfo.service
new file mode 100644
index 0000000..d48b3db
--- /dev/null
+++ b/_extras/clientinfo.service
@@ -0,0 +1,22 @@
+# This file goes in /etc/systemd/system/.
+# DO NOT PLACE IT IN /usr/lib/systemd/system/ unless you are a packager for a Linux distribution.
+# After it's in place (or updated), run:
+# systemctl daemon-reload
+[Unit]
+Description=Return Client Request Information
+
+[Service]
+Type=notify
+# These may also be "httpd", "nginx", etc.
+User=http
+Group=htttp
+Restart=on-failure
+RestartSec=10
+# Not required, but you may create this file and specify the associated commandline argument env vars.
+# This allows for easily changing the listen URI, debug mode, etc. without changing the systemd unit.
+# See clientinfo.env for an example.
+EnvironmentFile=-/etc/default/clientinfo
+ExecStart=/usr/local/bin/clientinfo
+
+[Install]
+WantedBy=multi-user.target
diff --git a/_extras/fastcgi.inc b/_extras/fastcgi.inc
new file mode 100644
index 0000000..13edc31
--- /dev/null
+++ b/_extras/fastcgi.inc
@@ -0,0 +1,25 @@
+
+fastcgi_param QUERY_STRING $query_string;
+fastcgi_param REQUEST_METHOD $request_method;
+fastcgi_param CONTENT_TYPE $content_type;
+fastcgi_param CONTENT_LENGTH $content_length;
+
+fastcgi_param SCRIPT_NAME $fastcgi_script_name;
+fastcgi_param REQUEST_URI $request_uri;
+fastcgi_param DOCUMENT_URI $document_uri;
+fastcgi_param DOCUMENT_ROOT $document_root;
+fastcgi_param SERVER_PROTOCOL $server_protocol;
+fastcgi_param REQUEST_SCHEME $scheme;
+fastcgi_param HTTPS $https if_not_empty;
+
+fastcgi_param GATEWAY_INTERFACE CGI/1.1;
+fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
+
+fastcgi_param REMOTE_ADDR $remote_addr;
+fastcgi_param REMOTE_PORT $remote_port;
+fastcgi_param SERVER_ADDR $server_addr;
+fastcgi_param SERVER_PORT $server_port;
+fastcgi_param SERVER_NAME $server_name;
+
+# PHP only, required if PHP was built with --enable-force-cgi-redirect
+fastcgi_param REDIRECT_STATUS 200;
diff --git a/_extras/nginx.conf b/_extras/nginx.conf
new file mode 100644
index 0000000..363a8c0
--- /dev/null
+++ b/_extras/nginx.conf
@@ -0,0 +1,74 @@
+worker_processes 1;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ sendfile on;
+ keepalive_timeout 65;
+
+ server {
+ listen [::]:80 ipv6only=off default_server;
+ server_name localhost _;
+
+ access_log /var/log/nginx/access.log main;
+ error_log /var/log/nginx/debug.log debug;
+ error_log /var/log/nginx/error.log;
+
+ # You MUST set this for dual-stack setups!
+ set $client_ip_port "$remote_addr:$remote_port";
+ if ($remote_addr ~ "^.+:.+$") {
+ set $client_ip_port "[$remote_addr]:$remote_port";
+ }
+
+ location / {
+ # For UDS FastCGI (the default URI).
+ # UDS FastCGI is the recommended setup, as it provides the most accuracy,
+ # requires the least amount of configuration, and is the most performant.
+ # Yes, Virginia, even more performant than localhost network ports.
+ include fastcgi.inc;
+ fastcgi_pass unix:/var/run/clientinfo/fcgi.sock;
+ #
+ # For TCP FastCGI. (See the clientinfo.env file for a corresponding URI.)
+ #include fastcgi.inc;
+ #fastcgi_pass 127.0.0.1:4321;
+ #
+ # For HTTP. (See the clientinfo.env file for a corresponding URI.)
+ # BE SURE to explicitly set (NOT add) the X-ClientInfo-RealIP header
+ # if reverse-proxying, and enclose in brackets if IPv6!
+ # (FastCGI via fastcgi.inc does this automatically for fastcgi_pass.)
+ # Note that in dual-stack NGINX (as configured here), the $client_ip_port
+ # will use IPv6-mapped IPv4 (https://www.rfc-editor.org/rfc/rfc5156.html#section-2.2)
+ # for IPv4 clients, which looks like e.g. "[::ffff:]:".
+ # FastCGI will use the expected ":" format for IPv4 clients.
+ #proxy_set_header X-ClientInfo-RealIP "$client_ip_port";
+ #proxy_http_version 1.1;
+ #proxy_pass http://127.0.0.1:1234;
+ }
+
+ # Alternatively, if you only wanted to return the client's IP,
+ # you don't even need ClientInfo, it can be returned directly.
+ # See https://nginx.org/en/docs/varindex.html
+ location /ip {
+ return 200 "$remote_addr";
+ }
+ # Likewise, ...
+ location /ipport {
+ return 200 "$client_ip_port";
+ }
+ # And so forth.
+
+ error_page 500 502 503 504 /50x.html;
+
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+ }
+}
diff --git a/args/args_funcs.go b/args/args_funcs.go
new file mode 100644
index 0000000..9e5e8af
--- /dev/null
+++ b/args/args_funcs.go
@@ -0,0 +1,75 @@
+package args
+
+import (
+ `errors`
+ `os/user`
+ `strconv`
+)
+
+// ModesAndOwners returns the evaluated file UID, file GID, dir UID, dir GID, file mode, and dir mode for a UDS socket.
+func (a *Args) ModesAndOwners() (perms UdsPerms, err error) {
+
+ var idInt int
+ var uGid int
+ var u *user.User
+ var g *user.Group
+ var nErr *strconv.NumError = new(strconv.NumError)
+
+ perms.FMode = a.SockMode
+ perms.DMode = a.SockDirMode
+
+ // UID is always this user
+ if u, err = user.Current(); err != nil {
+ return
+ }
+ if perms.UID, err = strconv.Atoi(u.Uid); err != nil {
+ return
+ }
+ if g, err = user.LookupGroupId(u.Gid); err != nil {
+ return
+ }
+ if uGid, err = strconv.Atoi(g.Gid); err != nil {
+ return
+ }
+
+ perms.FGID = uGid
+ if a.SockGrp != nil {
+ // First try a direct GID.
+ if idInt, err = strconv.Atoi(*a.SockGrp); err != nil {
+ if errors.As(err, &nErr) {
+ err = nil
+ // And then try a group name.
+ if g, err = user.LookupGroup(*a.SockGrp); err != nil {
+ return
+ }
+ if idInt, err = strconv.Atoi(g.Gid); err != nil {
+ return
+ }
+ } else {
+ return
+ }
+ }
+ perms.FGID = idInt
+ }
+ perms.DGID = uGid
+ if a.SockDirGrp != nil {
+ // First try a direct GID.
+ if idInt, err = strconv.Atoi(*a.SockDirGrp); err != nil {
+ if errors.As(err, &nErr) {
+ err = nil
+ // And then try a group name.
+ if g, err = user.LookupGroup(*a.SockDirGrp); err != nil {
+ return
+ }
+ if idInt, err = strconv.Atoi(g.Gid); err != nil {
+ return
+ }
+ } else {
+ return
+ }
+ }
+ perms.DGID = idInt
+ }
+
+ return
+}
diff --git a/args/types.go b/args/types.go
new file mode 100644
index 0000000..23ff713
--- /dev/null
+++ b/args/types.go
@@ -0,0 +1,28 @@
+package args
+
+import (
+ `io/fs`
+)
+
+type Args struct {
+ Version bool `short:"v" long:"version" description:"Print the version and exit."`
+ DetailVersion bool `short:"V" long:"detail" description:"Print detailed version info and exit."`
+ DoDebug bool `env:"CINFO_DEBUG" short:"d" long:"debug" description:"If specified, enable debug logging. This may log a LOT of information."`
+ SockMode fs.FileMode `env:"CINFO_FMODE" short:"m" long:"fmode" default:"0o0600" description:"If using a UDS, set the socket file to this permission. This should probably be either 0o0600 or 0o0660."`
+ SockDirMode fs.FileMode `env:"CINFO_DMODE" short:"M" long:"dmode" default:"0o0700" description:"If using a UDS, attempt to set the directory containing the socket to use this permission. This should probably be either 0o0700 or 0o0770."`
+ SockGrp *string `env:"CINFO_FGRP" short:"g" long:"fgroup" description:"If specified and using a UDS, attempt to set the socket to this GID/group name. (If unspecified, the default is current user's primary group.)"`
+ SockDirGrp *string `env:"CINFO_DGRP" short:"G" long:"dgroup" description:"If specified and using a UDS, attempt to set the directory containing the socket to this GID/group name. (If unspecified, the default is current user's primary group.)"`
+ Listen ListenArgs `positional-args:"true"`
+}
+
+type ListenArgs struct {
+ Listen string `env:"CINFO_URI" positional-arg-name:"LISTEN_URI" default:"unix:///var/run/clientinfo/fcgi.sock" description:"The specification to listen on.\nIf the scheme is 'unix', a FastCGI UDS/IPC socket is used (default); any host, query parameters, etc. component is ignored and the URI path is used to specify the socket.\nIf 'tcp', a FastCGI socket over TCP is opened on the .\nIf 'http', an HTTP listener is opened on the ; any path, query parameters, etc. components are ignored.\nHTTPS is unsupported; terminate with a reverse proxy. All other schemes will cause a fatal error.\nThe default is 'unix:///var/run/clientinfo/fcgi.sock'." validate:"required,uri"`
+}
+
+type UdsPerms struct {
+ UID int
+ FGID int
+ DGID int
+ FMode fs.FileMode
+ DMode fs.FileMode
+}
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..79850c2
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+set -e
+
+# This is not portable. It has bashisms.
+
+BUILD_TIME="$(date '+%s')"
+BUILD_USER="$(whoami)"
+BUILD_SUDO_USER="${SUDO_USER}"
+BUILD_HOST="$(hostname)"
+
+# Check to make sure git is available.
+if ! command -v git &> /dev/null;
+then
+ echo "Git is not available; automatic version handling unsupported."
+ echo "You must build by calling 'go build' directly in the respective directories."
+ exit 0
+fi
+
+# Check git directory/repository.
+if ! git rev-parse --is-inside-work-tree &>/dev/null;
+then
+ echo "Not running inside a git work tree; automatic version handling unsupported/build script unsupported."
+ echo "You must build by calling 'go build' directly in the respective directories instead."
+ exit 0
+fi
+
+# If it has a tag in the path of the current HEAD that matches a version string...
+# I wish git describe supported regex. It does not; only globs. Gross.
+# If there's a bug anywhere, it's here.
+if git describe --tags --abbrev=0 --match "v[0-9]*" HEAD &> /dev/null;
+then
+ # It has a tag we can use.
+ CURRENT_VER="$(git describe --tags --abbrev=0 --match "v[0-9]*" HEAD)"
+ COMMITS_SINCE="$(git rev-list --count ${CURRENT_VER}..HEAD)"
+else
+ # No tag available.
+ CURRENT_VER=""
+ COMMITS_SINCE=""
+fi
+
+# If it's dirty (staged but not committed or unstaged files)...
+if ! git diff-index --quiet HEAD;
+then
+ # It's dirty.
+ IS_DIRTY="1"
+else
+ # It's clean.
+ IS_DIRTY="0"
+fi
+
+# Get the commit hash of the *most recent* commit in the path of current HEAD...
+CURRENT_HASH="$(git rev-parse --verify HEAD)"
+# The same as above, but abbreviated.
+CURRENT_SHORT="$(git rev-parse --verify --short HEAD)"
+
+# Get the module name.
+MODPATH="$(sed -n -re 's@^\s*module\s+(.*)(//.*)?$@\1@p' go.mod)"
+
+# Build the ldflags string.
+# BEHOLD! BASH WITCHCRAFT.
+LDFLAGS_STR="\
+-X '${MODPATH}/version.sourceControl=git' \
+-X '${MODPATH}/version.version=${CURRENT_VER}' \
+-X '${MODPATH}/version.commitHash=${CURRENT_HASH}' \
+-X '${MODPATH}/version.commitShort=${CURRENT_SHORT}' \
+-X '${MODPATH}/version.numCommitsAfterTag=${COMMITS_SINCE}' \
+-X '${MODPATH}/version.isDirty=${IS_DIRTY}' \
+-X '${MODPATH}/version.buildTime=${BUILD_TIME}' \
+-X '${MODPATH}/version.buildUser=${BUILD_USER}' \
+-X '${MODPATH}/version.buildSudoUser=${BUILD_SUDO_USER}' \
+-X '${MODPATH}/version.buildHost=${BUILD_HOST}'"
+
+# And finally build.
+mkdir -p ./bin/
+export CGO_ENABLED=0
+
+cmd="clientinfo"
+# Linux
+echo -n "Building ./bin/${cmd}..."
+go build \
+ -o "./bin/${cmd}" \
+ -ldflags \
+ "${LDFLAGS_STR}" \
+ cmd/${cmd}/*.go
+echo " Done."
+
+echo "Build complete."
diff --git a/cmd/clientinfo/consts.go b/cmd/clientinfo/consts.go
new file mode 100644
index 0000000..5a9b03c
--- /dev/null
+++ b/cmd/clientinfo/consts.go
@@ -0,0 +1,18 @@
+package main
+
+import (
+ `log`
+
+ sysdUtil `github.com/coreos/go-systemd/util`
+ `github.com/go-playground/validator/v10`
+)
+
+var (
+ isSystemd bool = sysdUtil.IsRunningSystemd()
+ validate *validator.Validate = validator.New(validator.WithRequiredStructEnabled())
+)
+
+const (
+ logFlags int = log.LstdFlags | log.Lmsgprefix
+ logFlagsDebug int = logFlags | log.Llongfile
+)
diff --git a/cmd/clientinfo/main.go b/cmd/clientinfo/main.go
new file mode 100644
index 0000000..7aec626
--- /dev/null
+++ b/cmd/clientinfo/main.go
@@ -0,0 +1,118 @@
+package main
+
+import (
+ `errors`
+ `fmt`
+ `log`
+ `os`
+
+ `github.com/creasty/defaults`
+ `github.com/davecgh/go-spew/spew`
+ `github.com/jessevdk/go-flags`
+ `r00t2.io/clientinfo/args`
+ `r00t2.io/clientinfo/server`
+ `r00t2.io/clientinfo/version`
+ `r00t2.io/goutils/logging`
+ `r00t2.io/sysutils/envs`
+)
+
+func main() {
+
+ var err error
+ var logger *logging.MultiLogger
+ var logFlagsRuntime int = logFlags
+ var srv *server.Server
+ var args *args.Args = new(args.Args)
+ var flagsErr *flags.Error = new(flags.Error)
+ var parser *flags.Parser = flags.NewParser(args, flags.Default)
+
+ if _, err = parser.Parse(); err != nil {
+ switch {
+ case errors.As(err, &flagsErr):
+ switch {
+ // These print their relevant messages by themselves.
+ case errors.Is(
+ flagsErr.Type,
+ flags.ErrHelp,
+ ),
+ errors.Is(
+ flagsErr.Type,
+ flags.ErrCommandRequired,
+ ),
+ errors.Is(
+ flagsErr.Type,
+ flags.ErrRequired,
+ ):
+ return
+ default:
+ log.Panicln(err)
+ }
+ default:
+ log.Panicln(err)
+ }
+ }
+
+ if version.Ver, err = version.Version(); err != nil {
+ log.Panicln(err)
+ }
+
+ // If args.Version or args.DetailVersion are true, just print them and exit.
+ if args.DetailVersion || args.Version {
+ if args.Version {
+ fmt.Println(version.Ver.Short())
+ return
+ } else if args.DetailVersion {
+ fmt.Println(version.Ver.Detail())
+ return
+ }
+ }
+
+ // We want to set up logging before anything else.
+ if args.DoDebug {
+ logFlagsRuntime = logFlagsDebug
+ }
+ logger = logging.GetMultiLogger(args.DoDebug, "ClientInfo")
+ if err = logger.AddDefaultLogger(
+ "default",
+ logFlagsRuntime,
+ "/var/log/clientinfo/clientinfo.log", "~/logs/clientinfo.log",
+ ); err != nil {
+ log.Panicln(err)
+ }
+ if err = logger.Setup(); err != nil {
+ log.Panicln(err)
+ }
+ logger.Info("main: ClientInfo version %v", version.Ver.Short())
+ logger.Debug("main: ClientInfo version (extended):\n%v", version.Ver.Detail())
+ defer logger.Shutdown()
+
+ // TODO: WORKAROUND: https://github.com/jessevdk/go-flags/issues/408
+ if envs.HasEnv("CINFO_URI") {
+ args.Listen.Listen = os.Getenv("CINFO_URI")
+ }
+
+ if err = defaults.Set(args); err != nil {
+ logger.Err("main: Failed to set CLI arg defaults: %v", err)
+ log.Panicln(err)
+ }
+
+ logger.Debug("main: Initialized with args:\n%v", spew.Sdump(args))
+
+ if err = validate.Struct(args); err != nil {
+ logger.Err("main: Received error when validating args: %v", err)
+ log.Panicln(err)
+ }
+
+ if srv, err = server.NewServer(logger, args); err != nil {
+ logger.Err("main: Received error when creating server: %v", err)
+ log.Panicln(err)
+ }
+ logger.Debug("main: Starting server.")
+ if err = srv.Run(); err != nil {
+ logger.Err("main: Received error when running server: %v", err)
+ log.Panicln(err)
+ }
+ defer srv.Close()
+
+ logger.Debug("main: Exiting.")
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..881d224
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,30 @@
+module r00t2.io/clientinfo
+
+go 1.23.3
+
+require (
+ github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
+ github.com/creasty/defaults v1.8.0
+ github.com/davecgh/go-spew v1.1.1
+ github.com/go-playground/validator/v10 v10.23.0
+ github.com/goccy/go-yaml v1.15.7
+ github.com/jessevdk/go-flags v1.6.1
+ github.com/mileusna/useragent v1.3.5
+ golang.org/x/mod v0.22.0
+ r00t2.io/goutils v1.7.1
+ r00t2.io/sysutils v1.12.0
+)
+
+require (
+ github.com/djherbis/times v1.6.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.7 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ golang.org/x/crypto v0.30.0 // indirect
+ golang.org/x/net v0.32.0 // indirect
+ golang.org/x/sync v0.10.0 // indirect
+ golang.org/x/sys v0.28.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..d154a4a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,66 @@
+github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
+github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
+github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
+github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
+github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
+github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-yaml v1.15.7 h1:L7XuKpd/A66X4w/dlk08lVfiIADdy79a1AzRoIefC98=
+github.com/goccy/go-yaml v1.15.7/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
+github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
+github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
+golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
+golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
+golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
+golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+r00t2.io/goutils v1.7.1 h1:Yzl9rxX1sR9WT0FcjK60qqOgBoFBOGHYKZVtReVLoQc=
+r00t2.io/goutils v1.7.1/go.mod h1:9ObJI9S71wDLTOahwoOPs19DhZVYrOh4LEHmQ8SW4Lk=
+r00t2.io/sysutils v1.1.1 h1:q2P5u50HIIRk6muCPo1Gpapy6sNT4oaB1l2O/C/mi3A=
+r00t2.io/sysutils v1.1.1/go.mod h1:Wlfi1rrJpoKBOjWiYM9rw2FaiZqraD6VpXyiHgoDo/o=
+r00t2.io/sysutils v1.12.0 h1:Ce3qUOyLixE1ZtFT/+SVwOT5kSkzg5+l1VloGeGugrU=
+r00t2.io/sysutils v1.12.0/go.mod h1:bNTKNBk9MnUhj9coG9JBNicSi5FrtJHEM645um85pyw=
diff --git a/server/consts.go b/server/consts.go
new file mode 100644
index 0000000..4b72431
--- /dev/null
+++ b/server/consts.go
@@ -0,0 +1,88 @@
+package server
+
+import (
+ `embed`
+ `encoding/json`
+ `encoding/xml`
+ `html/template`
+ `os`
+ `syscall`
+
+ sysdUtil `github.com/coreos/go-systemd/util`
+ `github.com/goccy/go-yaml`
+)
+
+const (
+ convertTag string = "uaField"
+ prettyTag string = "renderName"
+ baseTitle string = "r00t^2 Client Info Revealer"
+ titleSep string = " || "
+ xmlHdrElem string = "header"
+ xmlHdrElemName string = "name"
+ xmlHdrVal string = "value"
+ nilUaFieldStr string = "(N/A)"
+ trueUaFieldStr string = "Yes"
+ falseUaFieldStr string = "No"
+ dfltIndent string = " "
+ httpRealHdr string = "X-ClientInfo-RealIP"
+)
+
+var (
+ //go:embed "tpl"
+ tplDir embed.FS
+ tpl *template.Template = template.Must(
+ template.New("").
+ Funcs(
+ template.FuncMap{
+ "getTitle": getTitle,
+ },
+ ).ParseFS(tplDir, "tpl/*.tpl"),
+ )
+)
+
+// Signal traps
+var (
+ stopSigs []os.Signal = []os.Signal{
+ syscall.SIGQUIT,
+ os.Interrupt,
+ syscall.SIGTERM,
+ }
+ reloadSigs []os.Signal = []os.Signal{
+ syscall.SIGHUP,
+ // We also add stopSigs so we trigger the Reload loop to close. TODO.
+ syscall.SIGQUIT,
+ os.Interrupt,
+ syscall.SIGTERM,
+ }
+ isSystemd bool = sysdUtil.IsRunningSystemd()
+)
+
+// media/MIME types
+const (
+ mediaJSON string = "application/json"
+ mediaXML string = "application/xml"
+ mediaYAML string = "application/yaml"
+ mediaHTML string = "text/html"
+ // TODO: plain/text? CSV? TOML?
+)
+
+var (
+ // mediaNoIndent covers everything (except HTML).
+ mediaNoIndent map[string]func(obj any) (b []byte, err error) = map[string]func(obj any) (b []byte, err error){
+ mediaJSON: json.Marshal,
+ mediaXML: xml.Marshal,
+ mediaYAML: yaml.Marshal,
+ // HTML is handled explicitly.
+ }
+ // mediaIndent only contains MIME types that support configured indents.
+ mediaIndent map[string]func(obj any, pfx string, indent string) (b []byte, err error) = map[string]func(obj any, pfx string, indent string) (b []byte, err error){
+ mediaJSON: json.MarshalIndent,
+ mediaXML: xml.MarshalIndent,
+ }
+ okAcceptMime []string = []string{
+ mediaJSON,
+ mediaXML,
+ mediaYAML,
+ mediaHTML,
+ }
+)
diff --git a/server/errs.go b/server/errs.go
new file mode 100644
index 0000000..059610a
--- /dev/null
+++ b/server/errs.go
@@ -0,0 +1,17 @@
+package server
+
+import (
+ `errors`
+)
+
+var (
+ ErrEmptyUA error = errors.New("empty user agent string")
+ ErrIncompatFieldType error = errors.New("a field type was passed that is incompatible with the target type")
+ ErrInvalidAccept error = errors.New("an Accept header was encountered that does not conform to RFC 9110§12.5.1/IANA format")
+ ErrInvalidScheme error = errors.New("invalid scheme for listener; must be 'unix', 'tcp', or 'http'")
+ ErrNoArgs error = errors.New("no args.Args passed to server creation")
+ ErrPtrNeeded error = errors.New("structs passed to reflection must be pointers")
+ ErrStructNeeded error = errors.New("pointers passed to reflection must point to structs")
+ ErrUnhandledField error = errors.New("unhandled field type passed to reflection")
+ ErrUnsupportedMIME error = errors.New("unsupported MIME type(s)")
+)
diff --git a/server/funcs.go b/server/funcs.go
new file mode 100644
index 0000000..ab7f499
--- /dev/null
+++ b/server/funcs.go
@@ -0,0 +1,430 @@
+package server
+
+import (
+ `fmt`
+ `net`
+ `net/http`
+ `net/url`
+ `os`
+ `path/filepath`
+ `reflect`
+ `sort`
+ `strings`
+
+ `github.com/mileusna/useragent`
+ `r00t2.io/clientinfo/args`
+ `r00t2.io/goutils/logging`
+ `r00t2.io/goutils/multierr`
+ `r00t2.io/sysutils/paths`
+)
+
+// NewClient returns a R00tClient from a UA string.
+func NewClient(uaStr string) (r *R00tClient, err error) {
+
+ var newR R00tClient
+ var ua useragent.UserAgent
+
+ if strings.TrimSpace(uaStr) == "" {
+ err = ErrEmptyUA
+ return
+ }
+
+ ua = useragent.Parse(uaStr)
+
+ if err = reflectClient(&ua, &newR); err != nil {
+ return
+ }
+
+ newR.ua = &ua
+
+ r = &newR
+
+ return
+}
+
+// NewServer returns a Server ready to use. Be sure to call Close to free up resources when done.
+func NewServer(log logging.Logger, cliArgs *args.Args) (srv *Server, err error) {
+
+ var s Server
+ var udsSockPerms args.UdsPerms
+
+ if log == nil {
+ log = &logging.NullLogger{}
+ }
+ if cliArgs == nil {
+ err = ErrNoArgs
+ log.Err("server.NewServer: Received error creating server: %v", err)
+ return
+ }
+
+ s = Server{
+ log: log,
+ args: cliArgs,
+ mux: http.NewServeMux(),
+ sock: nil,
+ reloadChan: make(chan os.Signal),
+ stopChan: make(chan os.Signal),
+ }
+
+ s.mux.HandleFunc("/", s.handleDefault)
+ s.mux.HandleFunc("/about", s.handleAbout)
+ s.mux.HandleFunc("/about.html", s.handleAbout)
+ s.mux.HandleFunc("/usage", s.handleUsage)
+ s.mux.HandleFunc("/usage.html", s.handleUsage)
+ s.mux.HandleFunc("/favicon.ico", s.explicit404)
+
+ if s.listenUri, err = url.Parse(cliArgs.Listen.Listen); err != nil {
+ s.log.Err("server.NewServer: Failed to parse listener URI: %v", err)
+ return
+ }
+ s.listenUri.Scheme = strings.ToLower(s.listenUri.Scheme)
+
+ switch s.listenUri.Scheme {
+ case "unix":
+ if udsSockPerms, err = cliArgs.ModesAndOwners(); err != nil {
+ s.log.Err("server.NewServer: Failed to parse unix socket permissions: %v", err)
+ return
+ }
+ if err = paths.RealPath(&s.listenUri.Path); err != nil {
+ s.log.Err("server.NewServer: Failed to canonize/resolve socket path '%s': %v", s.listenUri.Path, err)
+ return
+ }
+ // Cleanup any stale socket.
+ if err = s.cleanup(true); err != nil {
+ s.log.Err("server.NewServer: Failed to cleanup for 'unix' listener: %v", err)
+ return
+ }
+ if err = os.MkdirAll(filepath.Dir(s.listenUri.Path), udsSockPerms.DMode); err != nil {
+ s.log.Err("server.NewServer: Received error creating socket directory '%s': %v", filepath.Dir(s.listenUri.Path), err)
+ return
+ }
+
+ if err = os.Chmod(filepath.Dir(s.listenUri.Path), udsSockPerms.DMode); err != nil {
+ s.log.Err("server.NewServer: Received error chmodding socket directory '%s': %v", filepath.Dir(s.listenUri.Path), err)
+ return
+ }
+ if err = os.Chown(filepath.Dir(s.listenUri.Path), udsSockPerms.UID, udsSockPerms.DGID); err != nil {
+ s.log.Err("server.NewServer: Received error chowning socket directory '%s': %v", filepath.Dir(s.listenUri.Path), err)
+ return
+ }
+ if s.listenUri, err = url.Parse(
+ fmt.Sprintf(
+ "%s://%s",
+ s.listenUri.Scheme, s.listenUri.Path,
+ ),
+ ); err != nil {
+ s.log.Err("server.NewServer: Failed to re-parse listener URI: %v", err)
+ return
+ }
+ if s.sock, err = net.Listen("unix", s.listenUri.Path); err != nil {
+ s.log.Err("server.NewServer: Failed to open socket on '%s': %v", s.listenUri.Path, err)
+ }
+ if err = os.Chmod(s.listenUri.Path, udsSockPerms.FMode); err != nil {
+ s.log.Err("server.NewServer: Received error chmodding socket '%s': %v", filepath.Dir(s.listenUri.Path), err)
+ return
+ }
+ if err = os.Chown(s.listenUri.Path, udsSockPerms.UID, udsSockPerms.FGID); err != nil {
+ s.log.Err("server.NewServer: Received error chowning socket '%s': %v", filepath.Dir(s.listenUri.Path), err)
+ return
+ }
+ case "http", "tcp":
+ s.isHttp = s.listenUri.Scheme == "http"
+ if err = s.cleanup(true); err != nil {
+ s.log.Err("server.NewServer: Failed to cleanup for '%s' listener: %v", strings.ToUpper(s.listenUri.Scheme), err)
+ return
+ }
+ if s.listenUri, err = url.Parse(
+ fmt.Sprintf(
+ "%s://%s%s",
+ s.listenUri.Scheme, s.listenUri.Host, s.listenUri.Path,
+ ),
+ ); err != nil {
+ s.log.Err("server.NewServer: Failed to re-parse listener URI: %v", err)
+ return
+ }
+ if s.sock, err = net.Listen("tcp", s.listenUri.Host); err != nil {
+ s.log.Err("server.NewServer: Failed to open %s socket on '%s': %v", strings.ToUpper(s.listenUri.Scheme), s.listenUri.Host, err)
+ return
+ }
+ default:
+ s.log.Err("server.NewServer: Unsupported scheme: %v", s.listenUri.Scheme)
+ err = ErrInvalidScheme
+ return
+ }
+ cliArgs.Listen.Listen = s.listenUri.String()
+
+ srv = &s
+
+ return
+}
+
+/*
+ decideParseAccept takes the slice returned from parseAccept, runs parseAccept on it,
+ and chooses based on what MIME types are supported by this program.
+ err will be an ErrUnsupportedMIME if no supported MIME type is found.
+ If parsed is nil or empty, format will be defFormat and err will be nil.
+*/
+func decideParseAccept(parsed []*parsedMIME, defFormat string) (format string, err error) {
+
+ var customFmtFound bool
+
+ if parsed == nil || len(parsed) == 0 {
+ format = defFormat
+ return
+ }
+
+ for _, pf := range parsed {
+ switch pf.MIME {
+ case "*/*": // Client explicitly accept anything
+ format = defFormat
+ customFmtFound = true
+ case "application/*": // Use JSON
+ format = mediaJSON
+ customFmtFound = true
+ case "text/*": // Use HTML
+ format = mediaHTML
+ customFmtFound = true
+ case mediaHTML, mediaJSON, mediaXML, mediaYAML:
+ format = pf.MIME
+ customFmtFound = true
+ }
+ if customFmtFound {
+ break
+ }
+ }
+
+ if !customFmtFound {
+ format = defFormat
+ err = ErrUnsupportedMIME
+ return
+ }
+
+ return
+}
+
+/*
+ reflectClient takes a src and dst and attempts to set/convert src to dst. It is *VERY STRICT*.
+ It is expected that src does NOT use pointers.
+ ...This is pretty much just custom-made for converting a useragent.UserAgent to a R00tClient.
+ Don't use it for anything else.
+*/
+func reflectClient(src, dst any) (err error) {
+
+ var dstField reflect.StructField
+ var dstFieldVal reflect.Value
+ var srcFieldVal reflect.Value
+ var srcField string
+ var ok bool
+ var intVal *int
+ var strVal *string
+ var boolVal *bool
+ var srcVal reflect.Value = reflect.ValueOf(src)
+ var dstVal reflect.Value = reflect.ValueOf(dst)
+
+ // Both must be ptrs to a struct
+ if srcVal.Kind() != reflect.Ptr || dstVal.Kind() != reflect.Ptr {
+ err = ErrPtrNeeded
+ return
+ }
+
+ srcVal = srcVal.Elem()
+ dstVal = dstVal.Elem()
+
+ /*
+ Now that we have the underlying type/value of the ptr above,
+ check for structs.
+ */
+ if srcVal.Kind() != reflect.Struct || dstVal.Kind() != reflect.Struct {
+ err = ErrStructNeeded
+ return
+ }
+
+ for i := 0; i < dstVal.NumField(); i++ {
+ dstField = dstVal.Type().Field(i)
+ dstFieldVal = dstVal.Field(i)
+
+ // Skip unexported
+ if !dstFieldVal.CanSet() {
+ continue
+ }
+ srcField = dstField.Tag.Get(convertTag)
+ // Skip explicitly skipped (:"-")
+ if srcField == "-" {
+ continue
+ }
+ // If no explicit field name is present, set it to the dst field name.
+ if _, ok = dstField.Tag.Lookup(convertTag); !ok {
+ srcField = dstField.Name
+ }
+ // Get the value from src
+ srcFieldVal = srcVal.FieldByName(srcField)
+ // Skip invalid...
+ if !srcFieldVal.IsValid() {
+ continue
+ }
+ // And zero-value.
+ if reflect.DeepEqual(srcFieldVal.Interface(), reflect.Zero(srcFieldVal.Type()).Interface()) {
+ continue
+ }
+ // Structs need to recurse.
+ if dstFieldVal.Kind() == reflect.Ptr && dstFieldVal.Type().Elem().Kind() == reflect.Struct {
+ // Ensure we don't have a nil ptr
+ if dstFieldVal.IsNil() {
+ dstFieldVal.Set(reflect.New(dstFieldVal.Type().Elem()))
+ }
+ // And recurse into it.
+ if err = reflectClient(srcFieldVal.Addr().Interface(), dstFieldVal.Interface()); err != nil {
+ return
+ }
+ } else {
+ // Everything else gets assigned here.
+ switch dstFieldVal.Kind() {
+ case reflect.Bool:
+ if srcFieldVal.Kind() == reflect.Bool {
+ dstFieldVal.Set(reflect.ValueOf(srcFieldVal.Interface().(bool)))
+ } else {
+ err = ErrIncompatFieldType
+ return
+ }
+ case reflect.String:
+ if srcFieldVal.Kind() == reflect.String {
+ dstFieldVal.Set(reflect.ValueOf(srcFieldVal.Interface().(string)))
+ } else {
+ err = ErrIncompatFieldType
+ return
+ }
+ case reflect.Int:
+ if srcFieldVal.Kind() == reflect.Int {
+ dstFieldVal.Set(reflect.ValueOf(srcFieldVal.Interface().(int)))
+ } else {
+ err = ErrIncompatFieldType
+ return
+ }
+ case reflect.Ptr:
+ // Pointers to above
+ switch dstFieldVal.Type().Elem().Kind() {
+ case reflect.Bool:
+ if srcFieldVal.Kind() == reflect.Bool {
+ boolVal = new(bool)
+ *boolVal = srcFieldVal.Interface().(bool)
+ dstFieldVal.Set(reflect.ValueOf(boolVal))
+ } else {
+ err = ErrIncompatFieldType
+ return
+ }
+ case reflect.String:
+ if srcFieldVal.Kind() == reflect.String {
+ strVal = new(string)
+ *strVal = srcFieldVal.Interface().(string)
+ dstFieldVal.Set(reflect.ValueOf(strVal))
+ } else {
+ err = ErrIncompatFieldType
+ return
+ }
+ case reflect.Int:
+ if srcFieldVal.Kind() == reflect.Int {
+ intVal = new(int)
+ *intVal = srcFieldVal.Interface().(int)
+ dstFieldVal.Set(reflect.ValueOf(intVal))
+ } else {
+ err = ErrIncompatFieldType
+ return
+ }
+ default:
+ err = ErrUnhandledField
+ return
+ }
+ default:
+ err = ErrUnhandledField
+ return
+ }
+ }
+ }
+
+ return
+}
+
+// parseAccept parses an Accept header as per RFC 9110 § 12.5.1.
+func parseAccept(hdrVal string) (parsed []*parsedMIME, err error) {
+
+ var mimes []string
+ var parts []string
+ var params []string
+ var paramsLen int
+ var kv []string
+ var mt *parsedMIME
+ var mErr *multierr.MultiError = multierr.NewMultiError(nil)
+
+ if hdrVal == "" {
+ return
+ }
+
+ mimes = strings.Split(hdrVal, ",")
+
+ for _, mime := range mimes {
+ mt = &parsedMIME{
+ MIME: "",
+ Weight: 1.0, // between 0.0 and 1.0
+ Params: nil,
+ }
+ mime = strings.TrimSpace(mime)
+ // Split into []string{[, , ...]}
+ parts = strings.Split(mime, ";")
+ if parts == nil || len(parts) < 1 {
+ mErr.AddError(ErrInvalidAccept)
+ continue
+ }
+ if parts[0] == "" {
+ mErr.AddError(ErrInvalidAccept)
+ continue
+ }
+ if len(strings.Split(parts[0], "/")) != 2 {
+ mErr.AddError(ErrInvalidAccept)
+ continue
+ }
+ mt.MIME = strings.TrimSpace(parts[0])
+ if len(parts) > 1 {
+ // Parameters were provided. We don't really use them except `q`, but...
+ params = parts[1:]
+ paramsLen = len(params)
+ for idx, param := range params {
+ param = strings.TrimSpace(param)
+ kv = strings.SplitN(param, "=", 2)
+ if len(kv) != 2 {
+ mErr.AddError(ErrInvalidAccept)
+ continue
+ }
+ if kv[0] == "q" && idx == paramsLen-1 {
+ // It's the weight. RFC's pretty clear it's the last param.
+ fmt.Sscanf(kv[1], "%f", &mt.Weight)
+ if mt.Weight > 1.0 || mt.Weight < 0.0 {
+ mErr.AddError(ErrInvalidAccept)
+ continue
+ }
+ } else {
+ if mt.Params == nil {
+ mt.Params = make(map[string]string)
+ }
+ mt.Params[kv[0]] = kv[1]
+ }
+ }
+ }
+ parsed = append(parsed, mt)
+ }
+
+ // Now sort by weight (descending).
+ sort.SliceStable(
+ parsed,
+ func(i, j int) (isBefore bool) {
+ isBefore = parsed[i].Weight > parsed[j].Weight
+ return
+ },
+ )
+
+ if !mErr.IsEmpty() {
+ err = mErr
+ return
+ }
+
+ return
+}
diff --git a/server/funcs_page.go b/server/funcs_page.go
new file mode 100644
index 0000000..b8e2f2b
--- /dev/null
+++ b/server/funcs_page.go
@@ -0,0 +1,12 @@
+package server
+
+import (
+ `fmt`
+)
+
+func (p *Page) RenderIP(indent uint) (s string) {
+
+ s = fmt.Sprintf("%s", p.Info.IP.String(), p.Info.IP.String())
+
+ return
+}
diff --git a/server/funcs_r00tclient.go b/server/funcs_r00tclient.go
new file mode 100644
index 0000000..e3807d8
--- /dev/null
+++ b/server/funcs_r00tclient.go
@@ -0,0 +1,88 @@
+package server
+
+import (
+ `reflect`
+ `strings`
+)
+
+/*
+ ToMap generates and returns a map representation of a R00tClient.
+
+ Keys by default use the YAML tag for the name.
+ If they are specified with the tag `renderName:"-"`, they are skipped.
+ If they are specified with the tag `renderName:"Foo"`, the string "Foo" will
+ be used as the key instead.
+ Only bools, strings, and pointers thereof are allowed.
+
+ m will never be nil, but may be empty.
+
+ Currently err will always be nil but is specified for future API compatibility.
+ It should be handled by callers for future-proofing, as it may not always be nil
+ in the future.
+*/
+func (r *R00tClient) ToMap() (m map[string]string, err error) {
+
+ var ok bool
+ var tagVal string
+ var field reflect.StructField
+ var fieldVal reflect.Value
+ var rootVal reflect.Value
+
+ m = make(map[string]string)
+
+ if r == nil {
+ return
+ }
+ rootVal = reflect.ValueOf(r).Elem()
+
+ for i := 0; i < rootVal.NumField(); i++ {
+ field = rootVal.Type().Field(i)
+ fieldVal = rootVal.Field(i)
+
+ // Only exported.
+ if field.PkgPath != "" {
+ continue
+ }
+ // Get the key name.
+ tagVal = field.Tag.Get(prettyTag)
+ if tagVal == "-" {
+ continue
+ }
+ if _, ok = field.Tag.Lookup(prettyTag); !ok {
+ tagVal = field.Tag.Get("yaml")
+ if tagVal == "" || strings.HasPrefix(tagVal, "-") {
+ // Use the field name itself. YOLO
+ tagVal = field.Name
+ } else {
+ tagVal = strings.Split(tagVal, ",")[0]
+ }
+ }
+ switch fieldVal.Kind() {
+ case reflect.Bool:
+ if fieldVal.Interface().(bool) {
+ m[tagVal] = trueUaFieldStr
+ } else {
+ m[tagVal] = falseUaFieldStr
+ }
+ case reflect.String:
+ m[tagVal] = fieldVal.String()
+ case reflect.Ptr:
+ if fieldVal.IsNil() {
+ m[tagVal] = nilUaFieldStr
+ } else {
+ switch fieldVal.Type().Elem().Kind() {
+ case reflect.Bool:
+ if fieldVal.Elem().Bool() {
+ m[tagVal] = trueUaFieldStr
+ } else {
+ m[tagVal] = falseUaFieldStr
+ }
+ case reflect.String:
+ m[tagVal] = fieldVal.Elem().String()
+ }
+ }
+ }
+ }
+
+ return
+}
diff --git a/server/funcs_server.go b/server/funcs_server.go
new file mode 100644
index 0000000..9213d97
--- /dev/null
+++ b/server/funcs_server.go
@@ -0,0 +1,860 @@
+package server
+
+import (
+ `crypto/tls`
+ `encoding/json`
+ `encoding/xml`
+ "errors"
+ "fmt"
+ `mime/multipart`
+ "net"
+ "net/http"
+ `net/http/fcgi`
+ "net/netip"
+ "net/url"
+ "os"
+ "os/signal"
+ "strings"
+ "sync"
+ `syscall`
+
+ sysd "github.com/coreos/go-systemd/daemon"
+ "github.com/davecgh/go-spew/spew"
+ `github.com/goccy/go-yaml`
+ "r00t2.io/goutils/multierr"
+)
+
+// Close cleanly closes any remnants of a Server. Stop should be used instead to cleanly shut down; this is a little more aggressive.
+func (s *Server) Close() (err error) {
+
+ s.log.Debug("server.Server.Close: Closing sockets.")
+
+ if err = s.cleanup(false); err != nil {
+ s.log.Err("server.Server.Close: Received error closing sockets: %v", err)
+ }
+
+ s.log.Debug("server.Server.Close: Sockets closed.")
+
+ return
+}
+
+/*
+ Run starts and runs the server. This process blocks and will shutdown on a systemd notify signal or kill signal.
+ Non-HTML requests will be of type R00tInfo serialized to the requested MIME type.
+*/
+func (s *Server) Run() (err error) {
+
+ var wg sync.WaitGroup
+ var errChan chan error
+ var mErr *multierr.MultiError = multierr.NewMultiError(nil)
+ var numJobs int = 2 // sigs, listener
+
+ s.log.Debug("server.Server.Run: Starting server.")
+
+ signal.Notify(s.reloadChan, reloadSigs...)
+ signal.Notify(s.stopChan, stopSigs...)
+ s.doneChan = make(chan bool, 1)
+
+ errChan = make(chan error, numJobs)
+ wg.Add(numJobs)
+
+ // sigs
+ go func() {
+ var sigErr error
+ var sig os.Signal
+ var smErr *multierr.MultiError = multierr.NewMultiError(nil)
+
+ defer wg.Done()
+
+ sigtrap:
+ for !s.isStopping {
+ if s.isStopping {
+ break sigtrap
+ }
+ sig = <-s.reloadChan
+ s.log.Debug("server.Server.Run: Recived signal %v (%#v): %v", sig, sig, sig.String())
+ switch sig {
+ case syscall.SIGHUP:
+ s.log.Debug("server.Server.Run: Recived reload signal.")
+ if s.isStopping {
+ s.log.Debug("server.Server.Run: Server is stopping; abandoning reload.")
+ if sigErr = s.Stop(); sigErr != nil {
+ s.log.Err("server.Server.Run: Received error while stopping the server: %v", sigErr)
+ sigErr = nil
+ }
+ } else {
+ if sigErr = s.Reload(); sigErr != nil {
+ s.log.Err("server.Server.Run: Received error while reloading the server: %v", sigErr)
+ smErr.AddError(sigErr)
+ sigErr = nil
+ }
+ break sigtrap
+ }
+ default:
+ // Stop signal.
+ s.log.Debug("server.Server.Run: Recived stop signal.")
+ if sigErr = s.Stop(); sigErr != nil {
+ s.log.Err("server.Server.Run: Received error while stopping the server: %v", sigErr)
+ smErr.AddError(sigErr)
+ sigErr = nil
+ }
+ }
+ }
+
+ if !smErr.IsEmpty() {
+ errChan <- smErr
+ return
+ }
+ }()
+
+ // listener
+ go func() {
+ var lErr error
+
+ defer wg.Done()
+
+ if isSystemd {
+ var supported bool
+
+ // https://www.freedesktop.org/software/systemd/man/sd_notify.html
+ if supported, lErr = sysd.SdNotify(false, sysd.SdNotifyReady); lErr != nil {
+ s.log.Err(
+ "server.Server.Run: Error encountered when notifying systemd of changestate to READY (supported: %v): %v",
+ supported, lErr,
+ )
+ err = nil
+ }
+ }
+
+ switch s.listenUri.Scheme {
+ case "unix", "tcp":
+ if lErr = fcgi.Serve(s.sock, s.mux); lErr != nil {
+ if errors.Is(lErr, net.ErrClosed) {
+ lErr = nil
+ } else {
+ errChan <- lErr
+ }
+ return
+ }
+ case "http":
+ if lErr = http.Serve(s.sock, s.mux); lErr != nil {
+ if errors.Is(lErr, http.ErrServerClosed) || errors.Is(lErr, net.ErrClosed) {
+ lErr = nil
+ } else {
+ errChan <- lErr
+ }
+ return
+ }
+ }
+ }()
+
+ go func() {
+ wg.Wait()
+ close(errChan)
+ s.doneChan <- true
+ }()
+
+ <-s.doneChan
+
+ for i := 0; i < numJobs; i++ {
+ if err = <-errChan; err != nil {
+ mErr.AddError(err)
+ err = nil
+ }
+ }
+
+ if !mErr.IsEmpty() {
+ err = mErr
+ return
+ }
+
+ s.log.Debug("server.Server.Run: Server shut down.")
+
+ return
+}
+
+// Stop stops the server.
+func (s *Server) Stop() (err error) {
+
+ s.log.Debug("server.Server.Stop: Stopping server.")
+
+ s.isStopping = true
+
+ if isSystemd {
+ // https://www.freedesktop.org/software/systemd/man/sd_notify.html
+ if _, err = sysd.SdNotify(false, sysd.SdNotifyStopping); err != nil {
+ s.log.Err("server.Server.stop: Received error notifying systemd of stop: %v", err)
+ err = nil
+ }
+ }
+
+ if err = s.Close(); err != nil {
+ s.log.Err("server.Server.stop: Received error closing server connections: %v", err)
+ err = nil
+ }
+
+ s.log.Debug("server.Server.Stop: Server stopped.")
+
+ return
+}
+
+// cleanup cleans up remaining sockets, closes channels, etc.
+func (s *Server) cleanup(init bool) (err error) {
+
+ var mErr *multierr.MultiError = multierr.NewMultiError(nil)
+
+ s.log.Debug("server.Server.cleanup: Cleaning up sockets, etc.")
+
+ if s.sock != nil && !init {
+ if err = s.sock.Close(); err != nil {
+ s.log.Err("server.Server.cleanup: Received error closing socket: %v", err)
+ mErr.AddError(err)
+ err = nil
+ }
+ }
+ if s.listenUri.Scheme == "unix" {
+ if err = os.Remove(s.listenUri.Path); err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ s.log.Err("server.Server.cleanup: Failed to remove UDS '%s': %v", s.listenUri.Path, err)
+ mErr.AddError(err)
+ }
+ err = nil
+ }
+ }
+
+ if !mErr.IsEmpty() {
+ err = mErr
+ return
+ }
+
+ s.log.Debug("server.Server.cleanup: Completed cleanup.")
+
+ return
+}
+
+func (s *Server) Reload() (err error) {
+
+ s.log.Debug("server.Server.Reload: Reload called, but nothing was done; this is a placeholder as there are no reload-associated operations assigned.")
+ if isSystemd {
+ // https://www.freedesktop.org/software/systemd/man/sd_notify.html
+ if _, err = sysd.SdNotify(false, sysd.SdNotifyReloading); err != nil {
+ s.log.Err("server.Server.Reload: Received error notifying systemd of reload: %v", err)
+ return
+ }
+ }
+
+ // TODO?
+
+ if isSystemd {
+ var supported bool
+
+ // https://www.freedesktop.org/software/systemd/man/sd_notify.html
+ if supported, err = sysd.SdNotify(false, sysd.SdNotifyReady); err != nil {
+ s.log.Err(
+ "server.Server.Reload: Error encountered when notifying systemd of changestate to READY (supported: %v): %v",
+ supported, err,
+ )
+ err = nil
+ }
+ }
+
+ return
+}
+
+func (s *Server) explicit404(resp http.ResponseWriter, req *http.Request) {
+ resp.WriteHeader(http.StatusNotFound)
+}
+
+func (s *Server) handleDefault(resp http.ResponseWriter, req *http.Request) {
+
+ var vals url.Values
+ var uaVals []string
+ var doInclude bool
+ var doIndent bool
+ var err error
+ var ok bool
+ var b []byte
+ var remAddrPort string
+ var okMedia []string
+ var nAP netip.AddrPort
+ var parsedFmts []*parsedMIME
+ var renderPage *Page = new(Page)
+ var format string = mediaJSON
+ var indent string = " "
+ var client *R00tInfo = new(R00tInfo)
+
+ renderPage.RawIndent = " "
+ renderPage.PageType = "index"
+
+ s.log.Debug("server.Server.handleDefault: Handling request:\n%s", spew.Sdump(req))
+
+ /*
+ if req.URL != nil &&
+ req.URL.Path != "" &&
+ req.URL.Path != "/" &&
+ req.URL.Path != "/index" &&
+ req.URL.Path != "/index.html" {
+ resp.WriteHeader(http.StatusNotFound)
+ }
+ */
+
+ client.Req = req
+ remAddrPort = req.RemoteAddr
+ if s.isHttp && req.Header.Get(httpRealHdr) != "" {
+ remAddrPort = req.Header.Get(httpRealHdr)
+ req.Header.Del(httpRealHdr)
+ }
+ if remAddrPort != "" {
+ if nAP, err = netip.ParseAddrPort(remAddrPort); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to parse remote address '%s': %v", req.RemoteAddr, err)
+ // Don't return an error in case we're doing weird things like direct socket clients.
+ err = nil
+ /*
+ http.Error(resp, "ERROR: Failed to parse client address", http.StatusInternalServerError)
+ return
+ */
+ }
+ client.IP = net.ParseIP(nAP.Addr().String())
+ client.Port = nAP.Port()
+ }
+ client.Headers = XmlHeaders(req.Header)
+
+ uaVals = req.Header.Values("User-Agent")
+ if uaVals != nil && len(uaVals) > 0 {
+ client.Client = make([]*R00tClient, len(uaVals))
+ for idx, ua := range uaVals {
+ if client.Client[idx], err = NewClient(ua); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to create client for '%s': %v", ua, err)
+ http.Error(resp, fmt.Sprintf("ERROR: Failed to parse 'User-Agent' '%s'", ua), http.StatusInternalServerError)
+ return
+ }
+ }
+ }
+ if client.Client != nil && len(client.Client) > 0 {
+ // Check the passed UAs for a browser. We then change the "default" format if so.
+ for _, ua := range client.Client {
+ if ua.IsMobile || ua.IsDesktop {
+ format = mediaHTML
+ break
+ }
+ }
+ }
+ renderPage.Info = client
+
+ vals = req.URL.Query()
+
+ // Determine the format/MIME type of the response.
+ if vals.Has("mime") {
+ format = req.URL.Query().Get("mime")
+ } else {
+ if parsedFmts, err = parseAccept(strings.Join(req.Header.Values("Accept"), ",")); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to parse Accept header: %v", err)
+ http.Error(
+ resp,
+ "ERROR: Invalid 'Accept' header value; see RFC 9110 § 12.5.1 and https://www.iana.org/assignments/media-types/media-types.xhtml",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ if format, err = decideParseAccept(parsedFmts, mediaJSON); err != nil {
+ if errors.Is(err, ErrUnsupportedMIME) {
+ s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s'.", req.RemoteAddr)
+ for mt := range mediaNoIndent {
+ okMedia = append(okMedia, mt)
+ }
+ req.Header.Set("Accept", strings.Join(okMedia, ", "))
+ http.Error(resp, "ERROR: No supported MIME type specified; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
+ return
+ } else {
+ s.log.Err("server.Server.handleDefault: Received unknown error choosing an Accept header for '%s': %v", req.RemoteAddr, err)
+ http.Error(resp, "ERROR: Unknown error occurred when negotiationg MIME type.", http.StatusInternalServerError)
+ return
+ }
+ }
+ }
+ s.log.Debug("server.Server.handleDefault: Using format '%s' for '%s'", format, req.RemoteAddr)
+ // If it's HTML and they want an include, that needs to be validated too.
+ if format == mediaHTML && vals.Has("include") {
+ doInclude = true
+ if parsedFmts, err = parseAccept(strings.Join(vals["include"], ", ")); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to parse include parameter: %v", err)
+ http.Error(
+ resp,
+ "ERROR: Invalid 'include' parameter value; see RFC 9110 § 12.5.1 and https://www.iana.org/assignments/media-types/media-types.xhtml",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ if renderPage.RawFmt, err = decideParseAccept(parsedFmts, format); err != nil {
+ if errors.Is(err, ErrUnsupportedMIME) {
+ s.log.Err("server.Server.handleDefault: No supported MIME type found for '%#v' 'include'.", vals["include"], req.RemoteAddr)
+ for mt := range mediaNoIndent {
+ okMedia = append(okMedia, mt)
+ }
+ req.Header.Set("Accept", strings.Join(okMedia, ", "))
+ http.Error(resp, "ERROR: No supported MIME type specified for 'include'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
+ return
+ } else {
+ s.log.Err("server.Server.handleDefault: Received unknown error choosing an include format for '%s': %v", req.RemoteAddr, err)
+ http.Error(resp, "ERROR: Unknown error occurred when negotiationg MIME type.", http.StatusInternalServerError)
+ return
+ }
+ }
+ // The indentation is set below.
+ }
+
+ // Determine indentation (if the format even supports it).
+ if format == mediaHTML {
+ if doInclude {
+ if _, ok = mediaIndent[renderPage.RawFmt]; ok {
+ doIndent = vals.Has("indent")
+ if doIndent {
+ if req.URL.Query().Get("indent") != "" {
+ renderPage.RawIndent = req.URL.Query().Get("indent")
+ renderPage.DoRawIndent = true
+ }
+ }
+ } else if _, ok = mediaNoIndent[renderPage.RawFmt]; !ok {
+ // It's not a supported MIME.
+ s.log.Err("server.Server.handleDefault: Requested MIME type '%s' for '%s' unsupported.", renderPage.RawFmt, req.RemoteAddr)
+ for mt := range mediaNoIndent {
+ okMedia = append(okMedia, mt)
+ }
+ req.Header.Set("Accept", strings.Join(okMedia, ", "))
+ http.Error(
+ resp,
+ fmt.Sprintf("ERROR: MIME type '%s' unsupported for 'include'; see Accept header in response for valid types.", renderPage.RawFmt),
+ http.StatusNotAcceptable,
+ )
+ return
+ } else {
+ // This seems backwards, but "non-indented" formats actually need indenting enabled so their whitespace renders properly.
+ renderPage.DoRawIndent = true
+ }
+ }
+ } else {
+ if _, ok = mediaIndent[format]; ok {
+ doIndent = vals.Has("indent")
+ if doIndent {
+ if req.URL.Query().Get("indent") != "" {
+ indent = req.URL.Query().Get("indent")
+ }
+ }
+ } else if _, ok = mediaNoIndent[format]; !ok {
+ // It's not a supported MIME.
+ s.log.Err("server.Server.handleDefault: Requested MIME type '%s' for '%s' unsupported.", format, req.RemoteAddr)
+ for mt := range mediaNoIndent {
+ okMedia = append(okMedia, mt)
+ }
+ req.Header.Set("Accept", strings.Join(okMedia, ", "))
+ http.Error(resp, fmt.Sprintf("ERROR: MIME type '%s' unsupported; see Accept header in response for valid types.", format), http.StatusNotAcceptable)
+ return
+ }
+ }
+
+ // Now render the response.
+ if format == mediaHTML {
+ // This gets special treatment since it's templated.
+ resp.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if doInclude {
+ renderPage.Raw = new(string)
+ if doIndent {
+ if b, err = mediaIndent[renderPage.RawFmt](client, "", renderPage.RawIndent); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to render indented raw '%s' for '%s': %v", renderPage.RawFmt, req.RemoteAddr, err)
+ http.Error(resp, fmt.Sprintf("ERROR: Failed to render 'include' '%s'", renderPage.RawFmt), http.StatusInternalServerError)
+ return
+ }
+ } else {
+ if b, err = mediaNoIndent[renderPage.RawFmt](client); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to render raw '%s' for '%s': %v", renderPage.RawFmt, req.RemoteAddr, err)
+ http.Error(resp, fmt.Sprintf("ERROR: Failed to render '%s'", renderPage.RawFmt), http.StatusInternalServerError)
+ return
+ }
+ }
+ *renderPage.Raw = string(b)
+ }
+ if err = tpl.ExecuteTemplate(resp, "index", renderPage); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to execute template for '%s': %v", req.RemoteAddr, err)
+ http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
+ return
+ }
+ } else {
+ resp.Header().Set("Content-Type", format)
+ if doIndent {
+ // This was already filtered to valid specified MIME above.
+ if b, err = mediaIndent[format](client, "", indent); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to render indented '%s' for '%s': %v", format, req.RemoteAddr, err)
+ http.Error(resp, fmt.Sprintf("ERROR: Failed to render '%s'", format), http.StatusInternalServerError)
+ return
+ }
+ } else {
+ if b, err = mediaNoIndent[format](client); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to render '%s' for '%s': %v", format, req.RemoteAddr, err)
+ http.Error(resp, fmt.Sprintf("ERROR: Failed to render '%s'", format), http.StatusInternalServerError)
+ return
+ }
+ }
+ if _, err = resp.Write(b); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to serve indented '%s' to '%s': %v", format, req.RemoteAddr, err)
+ return
+ }
+ }
+
+ s.log.Debug("server.Server.handleDefault: Handled request:\n%s", spew.Sdump(req))
+
+ return
+}
+
+func (s *Server) handleDefaultNew(resp http.ResponseWriter, req *http.Request) {
+
+ var err error
+ var page *Page
+ var uas []string
+ var reqdMimes []string
+ var parsedUA *R00tClient
+ var nAP netip.AddrPort
+ var remAddrPort string
+ var parsedFmts []*parsedMIME
+ var renderer outerRenderer
+ var includeFmt string
+ var params url.Values = make(url.Values)
+ var outerFmt string = mediaJSON
+
+ s.log.Debug("server.Server.handleDefault: Handling request:\n%s", spew.Sdump(req))
+
+ page = &Page{
+ Info: &R00tInfo{
+ Client: nil,
+ IP: nil,
+ Port: 0,
+ Headers: XmlHeaders(req.Header),
+ Req: req,
+ },
+ PageType: "index",
+ Raw: nil,
+ RawFmt: nil,
+ Indent: "",
+ DoIndent: false,
+ }
+
+ // First the client info.
+ remAddrPort = req.RemoteAddr
+ if s.isHttp && req.Header.Get(httpRealHdr) != "" {
+ // TODO: WHitelist explicit reverse proxy addr(s)?
+ remAddrPort = req.Header.Get(httpRealHdr)
+ req.Header.Del(httpRealHdr)
+ }
+ if remAddrPort != "" {
+ if nAP, err = netip.ParseAddrPort(remAddrPort); err != nil {
+ s.log.Warning("server.Server.handleDefault: Failed to parse remote address '%s': %v", remAddrPort, err)
+ // Don't return an error in case we're doing weird things like direct socket clients.
+ /*
+ http.Error(resp, "ERROR: Failed to parse client address", http.StatusInternalServerError)
+ return
+ */
+ err = nil
+ }
+ page.Info.IP = net.ParseIP(nAP.Addr().String())
+ page.Info.Port = nAP.Port()
+ }
+ if req.URL != nil {
+ params = req.URL.Query()
+ }
+ uas = req.Header.Values("User-Agent")
+ if uas != nil && len(uas) > 0 {
+ page.Info.Client = make([]*R00tClient, 0, len(uas))
+ for _, ua := range uas {
+ if parsedUA, err = NewClient(ua); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to create client for '%s': %v", ua, err)
+ http.Error(resp, fmt.Sprintf("ERROR: Failed to parse 'User-Agent' '%s'", ua), http.StatusInternalServerError)
+ return
+ }
+ page.Info.Client = append(page.Info.Client, parsedUA)
+ }
+ }
+ if page.Info.Client != nil && len(page.Info.Client) > 0 {
+ // Check the passed UAs for a browser. We then change the "default" format if so.
+ for _, ua := range page.Info.Client {
+ if ua.IsMobile || ua.IsDesktop {
+ outerFmt = mediaHTML
+ break
+ }
+ }
+ }
+
+ /*
+ At this point, the outer format *default*, client IP, client port, and client version (UA) is set.
+ From here, we handle explicit content requests/overrides.
+ */
+ // `Accept` request header...
+ reqdMimes = req.Header.Values("Accept")
+ if reqdMimes != nil && len(reqdMimes) > 0 {
+ if parsedFmts, err = parseAccept(strings.Join(reqdMimes, ",")); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to parse Accept header '%#v' for '%s': %v", reqdMimes, remAddrPort, err)
+ resp.Header()["Accept"] = okAcceptMime
+ http.Error(
+ resp,
+ "ERROR: Invalid 'Accept' header value; see RFC 9110 § 12.5.1, https://www.iana.org/assignments/media-types/media-types.xhtml, and this response's 'Accept' header.",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ if outerFmt, err = decideParseAccept(parsedFmts, outerFmt); err != nil {
+ if errors.Is(err, ErrUnsupportedMIME) {
+ s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s' via '%#v'.", remAddrPort, reqdMimes)
+ req.Header["Accept"] = okAcceptMime
+ http.Error(resp, "ERROR: No supported MIME type specified via request 'Accept'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
+ return
+ } else {
+ s.log.Err("server.Server.handleDefault: Received unknown error choosing from Accept header for '%s': %v", remAddrPort, err)
+ http.Error(resp, "ERROR: Unknown error occurred when negotiating MIME type.", http.StatusInternalServerError)
+ return
+ }
+ }
+ }
+ // `mime` URL query parameter.
+ if params.Has("mime") {
+ if parsedFmts, err = parseAccept(strings.Join(params["mime"], ",")); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to parse 'mime' URL parameter '%#v' for '%s': %v", params["mime"], remAddrPort, err)
+ resp.Header()["Accept"] = okAcceptMime
+ http.Error(
+ resp,
+ "ERROR: Invalid 'mime' URL parameter value; see RFC 9110 § 12.5.1, https://www.iana.org/assignments/media-types/media-types.xhtml, and this response's 'Accept' header.",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ if outerFmt, err = decideParseAccept(parsedFmts, outerFmt); err != nil {
+ if errors.Is(err, ErrUnsupportedMIME) {
+ s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s' via '%#v'.", remAddrPort, params["mime"])
+ req.Header["Accept"] = okAcceptMime
+ http.Error(resp, "ERROR: No supported MIME type specified via URL parameter 'mime'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
+ return
+ } else {
+ s.log.Err("server.Server.handleDefault: Received unknown error choosing from 'mime' URL parameter for '%s': %v", remAddrPort, err)
+ http.Error(resp, "ERROR: Unknown error occurred when negotiating MIME type.", http.StatusInternalServerError)
+ return
+ }
+ }
+ }
+ // 'include' URL query parameter (only for text/html).
+ if outerFmt == mediaHTML && params.Has("include") {
+ if parsedFmts, err = parseAccept(strings.Join(params["include"], ",")); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to parse 'include' URL parameter '%#v' for '%s': %v", params["include"], remAddrPort, err)
+ resp.Header()["Accept"] = okAcceptMime
+ http.Error(
+ resp,
+ "ERROR: Invalid 'include' URL parameter value; see RFC 9110 § 12.5.1, https://www.iana.org/assignments/media-types/media-types.xhtml, and this response's 'Accept' header.",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ if includeFmt, err = decideParseAccept(parsedFmts, includeFmt); err != nil {
+ if errors.Is(err, ErrUnsupportedMIME) {
+ s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s' via '%#v'.", remAddrPort, params["include"])
+ req.Header["Accept"] = okAcceptMime
+ http.Error(resp, "ERROR: No supported MIME type specified via URL parameter 'include'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
+ return
+ } else {
+ s.log.Err("server.Server.handleDefault: Received unknown error choosing from 'include' URL parameter for '%s': %v", remAddrPort, err)
+ http.Error(resp, "ERROR: Unknown error occurred when negotiating MIME type.", http.StatusInternalServerError)
+ return
+ }
+ }
+ if includeFmt != "" {
+ page.RawFmt = new(string)
+ *page.RawFmt = includeFmt
+ }
+ }
+ // 'indent' URL query parameter.
+ if params.Has("indent") {
+ page.DoIndent = true
+ if params.Get("indent") != "" {
+ page.Indent = params.Get("indent")
+ } else {
+ page.Indent = dfltIndent
+ }
+ }
+
+ switch outerFmt {
+ case mediaJSON:
+ renderer = s.renderJSON
+ case mediaHTML:
+ renderer = s.renderHTML
+ case mediaXML:
+ renderer = s.renderXML
+ case mediaYAML:
+ renderer = s.renderYML
+ default:
+ s.log.Err("server.Server.handleDefault: Unknown output format '%s'", outerFmt)
+ http.Error(resp, "ERROR: Unable to determine default renderer.", http.StatusInternalServerError)
+ return
+ }
+
+ if err = renderer(page, resp); err != nil {
+ s.log.Err("server.Server.handleDefault: Failed to render request from '%s' as '%s': %v", remAddrPort, outerFmt, err)
+ // The renderer handles the error-handling with the client.
+ return
+ }
+
+ return
+}
+
+func (s *Server) handleAbout(resp http.ResponseWriter, req *http.Request) {
+
+ var err error
+ var renderPage *Page = &Page{
+ Info: &R00tInfo{
+ Req: req,
+ },
+ PageType: "about",
+ }
+
+ s.log.Debug("server.Server.handleAbout: Handling request:\n%s", spew.Sdump(req))
+
+ resp.Header().Set("Content-Type", "text/html; charset=utf-8")
+
+ if err = tpl.ExecuteTemplate(resp, "about", renderPage); err != nil {
+ s.log.Err("server.Server.handleAbout: Failed to execute template for '%s': %v", req.RemoteAddr, err)
+ http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
+ return
+ }
+
+ s.log.Debug("server.Server.handleAbout: Handled request:\n%s", spew.Sdump(req))
+
+ return
+}
+
+func (s *Server) handleUsage(resp http.ResponseWriter, req *http.Request) {
+
+ var err error
+ var renderPage *Page = &Page{
+ Info: &R00tInfo{
+ Req: req,
+ },
+ PageType: "usage",
+ }
+
+ s.log.Debug("server.Server.handleUsage: Handling request:\n%s", spew.Sdump(req))
+
+ resp.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err = tpl.ExecuteTemplate(resp, "usage", renderPage); err != nil {
+ s.log.Err("server.Server.handleAbout: Failed to execute template for '%s': %v", req.RemoteAddr, err)
+ http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
+ return
+ }
+
+ s.log.Debug("server.Server.handleUsage: Handled request:\n%s", spew.Sdump(req))
+
+ return
+}
+
+func (s *Server) renderJSON(page *Page, resp http.ResponseWriter) (err error) {
+
+ var b []byte
+
+ if page.DoIndent {
+ if b, err = json.MarshalIndent(page.Info, "", page.Indent); err != nil {
+ s.log.Err("server.Server.renderJSON: Failed to render to indented JSON: %v", err)
+ http.Error(resp, "ERROR: Failed to render JSON", http.StatusInternalServerError)
+ return
+ }
+ } else {
+ if b, err = json.Marshal(page.Info); err != nil {
+ s.log.Err("server.Server.renderJSON: Failed to render to JSON: %v", err)
+ http.Error(resp, "ERROR: Failed to render JSON", http.StatusInternalServerError)
+ return
+ }
+ }
+ if _, err = resp.Write(b); err != nil {
+ s.log.Err("server.Server.renderJSON: Failed to send JSON: %v", err)
+ return
+ }
+
+ return
+}
+
+func (s *Server) renderHTML(page *Page, resp http.ResponseWriter) (err error) {
+
+ var b []byte
+
+ if page.RawFmt != nil {
+ switch *page.RawFmt {
+ case mediaHTML:
+ _ = "" // Explicit no-op; we're *serving* HTML.
+ // Indentable
+ case mediaJSON, mediaXML:
+ if page.DoIndent {
+ if b, err = mediaIndent[*page.RawFmt](page.Info, "", page.Indent); err != nil {
+ s.log.Err("server.Server.renderHTML: Failed to render to indented include '%s': %v", *page.RawFmt, err)
+ http.Error(resp, "ERROR: Failed to render include format", http.StatusInternalServerError)
+ return
+ }
+ } else {
+ if b, err = mediaNoIndent[*page.RawFmt](page.Indent); err != nil {
+ s.log.Err("server.Server.renderHTML: Failed to render to include '%s': %v", *page.RawFmt, err)
+ http.Error(resp, "ERROR: Failed to render include format", http.StatusInternalServerError)
+ return
+ }
+ }
+ // Non-indentable
+ case mediaYAML:
+ if b, err = mediaNoIndent[*page.RawFmt](page.Info); err != nil {
+ s.log.Err("server.Server.renderHTML: Failed to render to '%s': %v", *page.RawFmt, err)
+ }
+ }
+ page.Raw = new(string)
+ *page.Raw = string(b)
+ }
+
+ if err = tpl.ExecuteTemplate(resp, "index", page); err != nil {
+ s.log.Err("server.Server.renderHTML: Failed to render template: %v", err)
+ http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
+ return
+ }
+
+ return
+}
+
+func (s *Server) renderXML(page *Page, resp http.ResponseWriter) (err error) {
+
+ var b []byte
+
+ if page.DoIndent {
+ if b, err = xml.MarshalIndent(page.Info, "", page.Indent); err != nil {
+ s.log.Err("server.Server.renderXML: Failed to render to indented XML: %v", err)
+ http.Error(resp, "ERROR: Failed to render XML", http.StatusInternalServerError)
+ return
+ }
+ } else {
+ if b, err = xml.Marshal(page.Info); err != nil {
+ s.log.Err("server.Server.renderXML: Failed to render to XML: %v", err)
+ http.Error(resp, "ERROR: Failed to render XML", http.StatusInternalServerError)
+ return
+ }
+ }
+ if _, err = resp.Write(b); err != nil {
+ s.log.Err("server.Server.renderXML: Failed to send XML: %v", err)
+ return
+ }
+
+ return
+}
+
+func (s *Server) renderYML(page *Page, resp http.ResponseWriter) (err error) {
+
+ var b []byte
+
+ if b, err = yaml.Marshal(page.Info); err != nil {
+ s.log.Err("server.Server.renderJSON: Failed to render to JSON: %v", err)
+ http.Error(resp, "ERROR: Failed to render JSON", http.StatusInternalServerError)
+ return
+ }
+
+ if _, err = resp.Write(b); err != nil {
+ s.log.Err("server.Server.renderJSON: Failed to send JSON: %v", err)
+ return
+ }
+
+ return
+}
diff --git a/server/funcs_test.go b/server/funcs_test.go
new file mode 100644
index 0000000..53c6e79
--- /dev/null
+++ b/server/funcs_test.go
@@ -0,0 +1,95 @@
+package server
+
+import (
+ `encoding/json`
+ `encoding/xml`
+ `fmt`
+ "testing"
+
+ `github.com/davecgh/go-spew/spew`
+ `github.com/goccy/go-yaml`
+)
+
+func TestNewClient(t *testing.T) {
+
+ var err error
+ var b []byte
+ var r *R00tClient
+
+ for _, s := range []string{
+ "Mozilla/5.0 " +
+ "(X11; Linux x86_64) " +
+ "AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) " +
+ "Chrome/131.0.0.0 " +
+ "Safari/537.36", // Chrome
+ "Mozilla/5.0 " +
+ "(X11; Linux x86_64; rv:133.0) " +
+ "Gecko/20100101 " +
+ "Firefox/133.0", // Firefox
+ "curl/8.11.0", // Curl
+ "Wget/1.25.0", // Wget
+ } {
+ t.Logf("Raw UA: '%s'\n\n", s)
+ if r, err = NewClient(s); err != nil {
+ t.Fatal(err)
+ }
+ if b, err = json.Marshal(r); err != nil {
+ t.Fatal(err)
+ }
+ fmt.Println(string(b))
+ t.Logf("R00tClient:\n%s\n\n\n", spew.Sdump(r))
+ }
+}
+
+func TestExplicitContent(t *testing.T) {
+
+ var b []byte
+ var err error
+ var r *R00tClient = &R00tClient{
+ ClientVer: &Ver{
+ Major: 1,
+ Minor: 2,
+ Patch: 3,
+ },
+ OSVer: &Ver{
+ Major: 9,
+ Minor: 8,
+ Patch: 7,
+ },
+ URL: new(string),
+ String: new(string),
+ Name: new(string),
+ ClientVerStr: new(string),
+ OS: new(string),
+ OsVerStr: new(string),
+ Dev: new(string),
+ IsMobile: false,
+ IsTablet: false,
+ IsDesktop: false,
+ IsBot: false,
+ }
+
+ *r.URL = "https://datatracker.ietf.org/doc/html/rfc2324.html"
+ *r.String = "(COMPLETE USER AGENT STRING)"
+ *r.Name = "coffee_pot"
+ *r.ClientVerStr = "1.2.3"
+ *r.OS = "JavaOS"
+ *r.OsVerStr = "9.8.7"
+ *r.Dev = "mocha-latte"
+
+ if b, err = json.MarshalIndent(r, "", " "); err != nil {
+ t.Fatal(err)
+ }
+ fmt.Println(string(b))
+
+ if b, err = xml.Marshal(r); err != nil {
+ t.Fatal(err)
+ }
+ fmt.Println(string(b))
+
+ if b, err = yaml.Marshal(r); err != nil {
+ t.Fatal(err)
+ }
+ fmt.Println(string(b))
+}
diff --git a/server/funcs_tpl.go b/server/funcs_tpl.go
new file mode 100644
index 0000000..269b22f
--- /dev/null
+++ b/server/funcs_tpl.go
@@ -0,0 +1,18 @@
+package server
+
+import (
+ `fmt`
+ `strings`
+)
+
+func getTitle(subPage string) (title string) {
+
+ if subPage == "" || subPage == "index" {
+ title = baseTitle
+ return
+ }
+
+ title = fmt.Sprintf("%s%s%s", baseTitle, titleSep, strings.ToTitle(subPage))
+
+ return
+}
diff --git a/server/funcs_xmlheaders.go b/server/funcs_xmlheaders.go
new file mode 100644
index 0000000..e5b3139
--- /dev/null
+++ b/server/funcs_xmlheaders.go
@@ -0,0 +1,160 @@
+package server
+
+import (
+ `encoding/xml`
+ `errors`
+ `io`
+)
+
+/*
+ MarshalXML encodes an XmlHeaders as XML in the following format:
+
+ ()
+
+
+ ()
+
+ For the above example, the field should be specified as `xml:"headers"`.
+ However, the parent element name may be whatever you wish.
+*/
+func (x XmlHeaders) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) {
+
+ var curKey string
+ var vals []string
+ var val string
+ var hdr xml.StartElement
+ var child xml.StartElement
+ // TODO: Does xml.EncodeElement properly escape?
+ // var escKBuf *bytes.Buffer
+ // var escVBuf *bytes.Buffer
+
+ // All values are []string, so we don't need any fancy parsing or switching or the like.
+ // We do need to make sure we escape, though.
+
+ if err = e.EncodeToken(start); err != nil {
+ return
+ }
+
+ if x != nil && len(x) > 0 {
+ // escKBuf = new(bytes.Buffer)
+ // escVBuf = new(bytes.Buffer)
+ for curKey, vals = range x {
+ // escKBuf.Reset()
+ // if err = xml.EscapeText(escKBuf, []byte(curKey)); err != nil {
+ // return
+ // }
+ hdr = xml.StartElement{
+ Name: xml.Name{
+ Local: xmlHdrElem,
+ },
+ Attr: []xml.Attr{
+ xml.Attr{
+ Name: xml.Name{
+ Local: xmlHdrElemName,
+ },
+ // Value: escKBuf.String(),
+ Value: curKey,
+ },
+ },
+ }
+ if err = e.EncodeToken(hdr); err != nil {
+ return
+ }
+ for _, val = range vals {
+ // escVBuf.Reset()
+ // if err = xml.EscapeText(escVBuf, []byte(val)); err != nil {
+ // return
+ // }
+ child = xml.StartElement{
+ Name: xml.Name{
+ Local: xmlHdrVal,
+ },
+ }
+ // if err = e.EncodeElement(escVBuf.String(), child); err != nil {
+ if err = e.EncodeElement(val, child); err != nil {
+ return
+ }
+ }
+ if err = e.EncodeToken(hdr.End()); err != nil {
+ return
+ }
+ }
+ }
+
+ if err = e.EncodeToken(start.End()); err != nil {
+ return
+ }
+
+ return
+}
+
+// UnmarshalXML populates an XMLHeaders from an XML representation. See MarshalXML for example XML.
+func (x *XmlHeaders) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err error) {
+
+ var tok xml.Token
+ var xm XmlHeaders
+ var hdrNm string
+ var vals []string
+ var val *string
+ var nameFound bool
+
+ for {
+ if tok, err = d.Token(); err != nil {
+ if errors.Is(err, io.EOF) {
+ err = nil
+ break
+ } else {
+ return
+ }
+ }
+ switch elem := tok.(type) {
+ case xml.StartElement:
+ switch elem.Name.Local {
+ case xmlHdrElem:
+ nameFound = false
+ vals = nil
+ for _, a := range elem.Attr {
+ if a.Name.Local == xmlHdrElemName {
+ nameFound = true
+ hdrNm = a.Value
+ break
+ }
+ }
+ if !nameFound {
+ continue
+ }
+ case xmlHdrVal:
+ if !nameFound {
+ continue
+ }
+ if vals == nil {
+ vals = make([]string, 0, 1)
+ }
+ val = new(string)
+ if err = d.DecodeElement(val, &elem); err != nil {
+ return
+ }
+ vals = append(vals, *val)
+ }
+ case xml.EndElement:
+ if elem.Name.Local != xmlHdrElem {
+ continue
+ }
+ if xm == nil {
+ xm = make(XmlHeaders)
+ }
+ xm[hdrNm] = vals
+ }
+ }
+
+ if xm != nil {
+ *x = xm
+ }
+
+ return
+}
diff --git a/server/tpl/about.html.tpl b/server/tpl/about.html.tpl
new file mode 100644
index 0000000..1fb97ab
--- /dev/null
+++ b/server/tpl/about.html.tpl
@@ -0,0 +1,45 @@
+{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
+{{- define "about" }}
+{{- $page := . -}}
+{{- $linkico := "🔗" }}
+{{ template "meta.top" $page }}
+
+
About
+
+
+ This is a tool to reveal certain information about your connection that the server sees.
+ Note that all of this information you see is sent by your client;
+ there was no probing/scanning or the like done from the server this site is hosted on.
+
+
+ If you don't like this info being available to server administrators of the websites
+ you visit you may want to consider:
+
+ There are, of course, many other plugins/methods but as always, due diligence is required when finding the right plugin for you.
+ Be sure to read multiple reviews.
+ Some plugins/extensions even disguise your browser as an entirely different operating system, OS version, etc.
+ Feel free to check back on this site after enabling them to test! (You may need to reset your browser's cache.)
+
+
+ If you would like to view the server headers, then you can:
+
+ There are additionally some extensions/plugins that offer this in a directly-accessible button on the toolbar.
+
+
+{{- template "meta.bottom" $page }}
+{{- end }}
diff --git a/server/tpl/index.html.tpl b/server/tpl/index.html.tpl
new file mode 100644
index 0000000..59801ed
--- /dev/null
+++ b/server/tpl/index.html.tpl
@@ -0,0 +1,14 @@
+{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
+{{- define "index" }}
+{{- $page := . -}}
+{{- $linkico := "🔗" }}
+{{- template "meta.top" $page }}
+
+
Client Info Revealer
+
A tool to reveal client-identifying data sent to webservers
+
+
+{{- template "meta.info" $page }}
+
+{{- template "meta.bottom" $page }}
+{{- end }}
diff --git a/server/tpl/meta.bottom.html.tpl b/server/tpl/meta.bottom.html.tpl
new file mode 100644
index 0000000..6c5531c
--- /dev/null
+++ b/server/tpl/meta.bottom.html.tpl
@@ -0,0 +1,11 @@
+{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
+{{- define "meta.bottom" -}}
+{{- $page := . -}}
+{{- $linkico := "🔗" }}
+
+
+