initial commit before refactor switch

This commit is contained in:
brent saner 2024-12-12 02:22:54 -05:00
commit db081e2699
Signed by: bts
GPG Key ID: 8C004C2F93481F6B
33 changed files with 3101 additions and 0 deletions

49
.gitignore vendored Normal file
View File

@ -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/

9
LICENSE Normal file
View File

@ -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.

22
_extras/clientinfo.env Normal file
View File

@ -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:
# * "<Client IP>:<Client Port>" (IPv4)
# * "[<Client IP>]:<Client Port>" (IPv6)
#CINFO_URI="http://127.0.0.1:1234"

View File

@ -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

25
_extras/fastcgi.inc Normal file
View File

@ -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;

74
_extras/nginx.conf Normal file
View File

@ -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:<IP>]:<Port>".
# FastCGI will use the expected "<IP>:<Port>" 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;
}
}
}

75
args/args_funcs.go Normal file
View File

@ -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
}

28
args/types.go Normal file
View File

@ -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 <host:port>.\nIf 'http', an HTTP listener is opened on the <host:port>; 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
}

87
build.sh Executable file
View File

@ -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."

18
cmd/clientinfo/consts.go Normal file
View File

@ -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
)

118
cmd/clientinfo/main.go Normal file
View File

@ -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.")
}

30
go.mod Normal file
View File

@ -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
)

66
go.sum Normal file
View File

@ -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=

88
server/consts.go Normal file
View File

@ -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,
}
)

17
server/errs.go Normal file
View File

@ -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)")
)

430
server/funcs.go Normal file
View File

@ -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 (<convertTag>:"-")
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{<type>[, <param>, ...]}
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
}

12
server/funcs_page.go Normal file
View File

@ -0,0 +1,12 @@
package server

import (
`fmt`
)

func (p *Page) RenderIP(indent uint) (s string) {

s = fmt.Sprintf("<a href=\"https://ipinfo.io/%s\">%s</a>", p.Info.IP.String(), p.Info.IP.String())

return
}

View File

@ -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
}

860
server/funcs_server.go Normal file
View File

@ -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
}

95
server/funcs_test.go Normal file
View File

@ -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))
}

18
server/funcs_tpl.go Normal file
View File

@ -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
}

160
server/funcs_xmlheaders.go Normal file
View File

@ -0,0 +1,160 @@
package server

import (
`encoding/xml`
`errors`
`io`
)

/*
MarshalXML encodes an XmlHeaders as XML in the following format:

(<headers>)
<header name="SomeHeader">
<value>SomeValue</value>
</header>
<header name="SomeMultiValueHeader">
<value>Foo</value>
<value>Bar</value>
</header>
(</headers>)

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
}

45
server/tpl/about.html.tpl Normal file
View File

@ -0,0 +1,45 @@
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
{{- define "about" }}
{{- $page := . -}}
{{- $linkico := "🔗" }}
{{ template "meta.top" $page }}
<div class="jumbotron">
<h1>About</h1>
</div>
<p>
This is a tool to reveal certain information about your connection that the server sees.
Note that all of this information you see is <i>sent by your client</i>;
there was no probing/scanning or the like done from the server this site is hosted on.
</p>
<p>
If you don't like this info being available to server administrators of the websites
you visit you may want to consider:
<ul>
<li><a href="https://www.torproject.org/">hiding your client IP address</a></li>
<li>
<a href="https://panopticlick.eff.org/self-defense">hiding your browser's metadata, which can be done via browser plugins such as:</a>
<ul>
<li><a href="https://www.eff.org/privacybadger">Privacy Badger</a></li>
<li><a href="https://addons.mozilla.org/en-US/firefox/addon/modify-headers/">Modify Headers</a></li>
<li><a href="https://www.requestly.in/">Requestly</a></li>
</ul>
</li>
</ul>
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.)
</p>
<p>
If you would like to view the <i>server</i> headers, then you can:
<ul>
<li>use a service such as <a href="https://securityheaders.io">SecurityHeaders.io</a></li>
<li>use the <code>--head</code> (<code>-i</code>, <code>-X HEAD</code>, <code>--request HEAD</code>, etc.; all do the same thing) argument to <b><code>curl</code></b></li>
<li>use the <code>-v</code> (<code>--verbose</code>) argument to <b><code>curl</code></b></li>
<li>use your browser's built-in developer console (<a href="https://firefox-source-docs.mozilla.org/devtools-user/">on Firefox</a>, <a href="https://developer.chrome.com/docs/devtools/open">on Chrome and Chrome-based browsers</a>)</li>
</ul>
There are additionally some extensions/plugins that offer this in a directly-accessible button on the toolbar.
</p>
<br />
{{- template "meta.bottom" $page }}
{{- end }}

14
server/tpl/index.html.tpl Normal file
View File

@ -0,0 +1,14 @@
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
{{- define "index" }}
{{- $page := . -}}
{{- $linkico := "🔗" }}
{{- template "meta.top" $page }}
<div class="jumbotron">
<h1>Client Info Revealer</h1>
<p class="lead">A tool to reveal client-identifying data sent to webservers</p>
</div>
<div>
{{- template "meta.info" $page }}
</div>
{{- template "meta.bottom" $page }}
{{- end }}

View File

@ -0,0 +1,11 @@
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
{{- define "meta.bottom" -}}
{{- $page := . -}}
{{- $linkico := "🔗" }}
<footer class="footer">
<p><sub>See <a href="https://pkg.go.dev/r00t2.io/clientinfo">https://pkg.go.dev/r00t2.io/clientinfo</a> for more information on this program.</sub></p>
</footer>
</div>
</body>
</html>
{{- end -}}

View File

@ -0,0 +1,55 @@
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
{{- define "meta.info" -}}
{{- $page := . -}}
{{- $linkico := "🔗" }}
<h2 id="client">Client/Browser Information<a href="#client">{{ $linkico }}</a></h2>
<p>
<b>Your IP Address is <i><a href="https://ipinfo.io/{{ $page.Info.IP.String }}">{{ $page.Info.IP.String }}</a></i>.</b>
<br/>
<i>You are connecting with port <b>{{ $page.Info.Port }}</b> outbound.</i>
</p>
{{- if $page.Raw }}
<h3 id="client_raw">Raw Block ({{ $page.RawFmt }})<a href="#client_raw">{{ $linkico }}</a></h3>
<p>
{{- if $page.DoRawIndent }}
<pre>{{ $page.Raw }}</pre>
{{- else }}
<code>{{ $page.Raw }}</code>
{{- end }}
</p>
{{- end }}
<h3 id="client_ua">User Agent Information<a href="#client_ua">{{ $linkico }}</a></h3>
<p>This is information that your browser sends to identify itself.</p>
<p>
{{- range $idx, $ua := $page.Info.Client }}
User Agent ({{ $idx }}):
<ul>
{{- $flds := $ua.ToMap }}
{{- range $fld, $str := $flds }}
<li><b>{{ $fld }}:</b> {{ $str }}</li>
{{- end }}
</ul>
{{- end }}
</p>
<h3 id="client_hdrs">Request Headers<a href="#client_hdrs">{{ $linkico }}</a></h3>
<p>
These are headers sent along with the request your browser sends for the page's content.
Note that some headers may have multiple values.
</p>
<p>
<table>
<tr>
<th>Header</th>
<th>Value</th>
</tr>
{{- range $hdrNm, $hdrVals := $page.Info.Req.Header }}
<tr>
{{- range $val := $hdrVals }}
<td>{{ $hdrNm }}</td>
<td>{{ $val }}</td>
{{- end }}
</tr>
{{- end }}
</table>
</p>
{{- end }}

View File

@ -0,0 +1,53 @@
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
{{- define "meta.top" -}}
{{- $page := . -}}
{{- $linkico := "🔗" -}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ getTitle $page.PageType }}</title>
<!-- Bootstrap core CSS -->
<!--
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
-->
<!-- Tachyons -->
<link rel="stylesheet" href="https://unpkg.com/tachyons@4.12.0/css/tachyons.min.css"/>
<!--
Custom styles for Bootstrap
-->
<!--
<link href="https://getbootstrap.com/examples/jumbotron-narrow/jumbotron-narrow.css"
rel="stylesheet">
-->
<!--
<link href="https://getbootstrap.com/docs/4.0/examples/offcanvas/offcanvas.css" rel="stylesheet">
-->
</head>
<body>
<div class="container">
<div class="header clearfix">
<nav>
<ul class="nav nav-pills pull-right">
<li role="presentation"><a href="/">Home</a></li>
<li role="presentation"><a href="/about">About</a></li>
<li role="presentation"><a href="/usage">Usage</a></li>
<ul role="presentation">
<li><a href="/?mime=application/json&indent">JSON</a></li>
<li><a href="/?mime=application/xml&indent">XML</a></li>
<li><a href="/?mime=application/yaml">YAML</a></li>
<li><a href="/?mime=text/html">HTML (This Page)</a></li>
</ul>
<!--
the following opens in a new tab/window/whatever.
the line after opens in the same tab/window/etc.
-->
<!--
<li role="presentation"><a href="https://r00t2.io/" target="_blank">r00t^2</a></li>
-->
<li role="presentation"><a href="https://r00t2.io/">r00t^2</a></li>
</ul>
</nav>
</div>
{{- end }}

95
server/tpl/usage.html.tpl Normal file
View File

@ -0,0 +1,95 @@
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
{{- define "usage" }}
{{- $page := . -}}
{{- $linkico := "🔗" }}
{{- template "meta.top" $page }}
<div class="jumbotron">
<h1>Usage</h1>
</div>
<h2 id="usage_params">Parameters<a href="#usage_params">{{ $linkico }}</a></h2>
<p>
You can control how the <a href="/">main page</a> displays/renders.
By default, it will try to "guess" what you want; e.g. if you access it in Chrome, it will return HTML but if you fetch via Curl, you'll get raw JSON
(or your specified data format; see below). If the classification of client can't be determined and an <code>Accept</code> wasn't specified,
a fallback to text-mode (by default <code>application/json</code>) will be returned.
<br/>

You can force a specific raw output by specifying the <a href="https://www.iana.org/assignments/media-types/media-types.xhtml">MIME type</a> via
<a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1">the <code>Accept</code> header (RFC 9110 &sect; 12.5.1)</a>, which may be one of:
<ul>
<li><code>application/json</code> for <a href="https://www.rfc-editor.org/rfc/rfc8259.html">JSON</a></li>
<li><code>application/xml</code> for <a href="https://www.rfc-editor.org/rfc/rfc7303.html">XML</a></li>
<li><code>application/yaml</code> for <a href="https://www.rfc-editor.org/rfc/rfc9512.html">YAML</a></li>
<li><code>text/html</code> for <a href="https://www.rfc-editor.org/rfc/rfc2854.html">HTML</a></li>
</ul>
For example: <code>Accept: application/json</code> will return JSON.
<br/>

If unspecified and it is a text-mode client (e.g. Curl), the default is <code>application/json</code>.
<code>text/html</code> may be used to force an HTML response from a text-only client,
just as one of the <code>application/*</code> MIME types above may be used to force that "raw" text MIME type for a "graphical" browser client.
The specification as defined by <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1">RFC 9110 &sect; 12.5.1</a> is completely
valid to pass and will be parsed without error (provided the header value is RFC-compliant and IANA-compliant),
though note that <code>application/xml</code> and <code>text/html</code>'s <code>charset</code> parameter will be entirely ignored;
the returned XML/HTML is <b>always</b> Unicode (with UTF-8 encoding).
<br/>

If no selectable MIME type is provided but an <code>Accept</code> was given, an error will be returned; specifically, a
<a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.7"><code>406</code> status code (RFC 9110 &sect; 15.5.7)</a>.
In this case, supported MIME types will be returned in the response's <code>Accept</code> header.
<br/>

Note that <a href="https://lynx.invisible-island.net/">Lynx</a> and <a href="http://elinks.or.cz/">Elinks</a> are considered "graphical"
browsers by this program as they are HTML-centric.
</p>
<p id="usage_params_mod">
The following parameters control/modify behavior.<a href="#usage_params_mod">{{ $linkico }}</a>
<ul>
<li>
<b>mime:</b> Specify an explicit MIME type via URL instead of the <code>Accept</code> header as specified above.
<ul>
<li>This should only be used by clients in which it is impossible or particularly cumbersome to modify/specify headers.
<code>Accept</code> is more performant.</li>
<li>Only the first supported instance of this parameter will be used.</li>
<li>Any of the defined MIME types above may be specified (e.g. <code>?mime=application/json</code>).</li>
<li>If both this URL query parameter and the <code>Accept</code> header is specified, the URL query takes preference.</li>
</ul>
</li>
<li>
<b>include:</b> Include a <code>&lt;code&gt;</code> (or <code>&lt;pre&gt;</code>, depending on if indentation is needed/requested) block in the HTML for the specified MIME type as well.</li>
<ul>
<li>Only the first supported instance of this parameter will be used.</li>
<li>
The value <b>must</b> conform to the same rules/specifications as the <code>mime</code> parameter/<code>Accept</code> header.
<ul>
<li><code>include</code> may <b>not</b> be <code>text/html</code>; it will be ignored if this is set. Just learn to <code>ctrl+u</code>.</li>
</ul>
</li>
<li>Only used if the evaluated return is HTML, ignored otherwise.</li>
<li>Indentation can be specified via the <b>indent</b> parameter below (since indentation is otherwise meaningless to HTML returns).</li>
</ul>
</li>
<li>
<b>indent:</b> Enable/specify indentation for JSON and XML output; ignored for others.
<ul>
<li>The default is to not indent. (Commonly referred to as "condensed" or "compressed" JSON/XML.)</li>
<li>Only the first specified instance of this parameter will be used.</li>
<li>If specified with a string value, use that string as each indent.
<ul>
<li>Be mindful of URL query parameter encoding,
per <a href="https://www.rfc-editor.org/rfc/rfc3986.html#section-3.4">RFC 3986 &sect; 3.4</a>
and <a href="https://www.rfc-editor.org/rfc/rfc8820.html#section-2.4">RFC 8820 &sect; 2.4</a></li>
<li>For quick reference and as an example, to indent with a <a href="https://asciiref.dev/#c9">tab</a>
(<code>\t</code>, <code>0x09</code>) for each level, use <code>?indent=%09</code></li>
</ul>
</li>
<li>If indentation is specified without any value (<code>?indent</code>), the default is two
<a href="https://asciiref.dev/#c32">spaces</a> (<code>0x20</code>); this would be represented
as <code>?indent=%20%20</code></li>
<li><code>?indent=</code> (no value specified) is equal to <code>?indent</code>.</li>
</ul>
</li>
</ul>
</p>
{{- template "meta.bottom" $page }}
{{- end }}

107
server/types.go Normal file
View File

@ -0,0 +1,107 @@
package server

import (
`encoding/xml`
`net`
`net/http`
`net/url`
`os`

`github.com/mileusna/useragent`
`r00t2.io/clientinfo/args`
`r00t2.io/goutils/logging`
)

type outerRenderer func(page *Page, resp http.ResponseWriter) (err error)
type XmlHeaders map[string][]string

// R00tInfo is the structure of data returned to the client.
type R00tInfo struct {
// XMLName is the element name/namespace of this object ("info").
XMLName xml.Name `json:"-" xml:"info" yaml:"-"`
// Client is the UA/Client info, if any passed by the client.
Client []*R00tClient `json:"ua,omitempty" xml:"ua,omitempty" yaml:"Client/User Agent,omitempty"`
// IP is the client IP address.
IP net.IP `json:"ip" xml:"ip,attr" yaml:"Client IP Address"`
// Port is the client's port number.
Port uint16 `json:"port" xml:"port,attr" yaml:"Client Port"`
// Headers are the collection of the request headers sent by the client.
Headers XmlHeaders `json:"headers" xml:"reqHeaders" yaml:"Request Headers"`
// Req contains the original request. It is not rendered but may be used for templating.
Req *http.Request `json:"-" xml:"-" yaml:"-"`
}

// R00tClient is the UA/Client info, if any passed by the client.
type R00tClient struct {
// XMLName is the element name/namespace of this object ("ua").
XMLName xml.Name `json:"-" xml:"ua" yaml:"-" uaField:"-" renderName:"-"`
// String contains the entire UA string.
String *string `json:"str,omitempty" xml:",chardata" yaml:"String"`
// ClientVer is a parsed version structure of the client version (see ClientVerStr for the combined string).
ClientVer *Ver `json:"ver,omitempty" xml:"version,omitempty" yaml:"Client Version,omitempty" uaField:"VersionNo" renderName:"-"`
// OSVer is the parsed OS version info of the client (see OsVersionStr for the combined string).
OSVer *Ver `json:"os_ver,omitempty" xml:"osVersion,omitempty" yaml:"Operating System Version,omitempty" uaField:"OSVersionNo" renderName:"-"`
// URL, if any, is the URL of the client.
URL *string `json:"url,omitempty" xml:"url,attr,omitempty" yaml:"URL,omitempty"`
// Name is the client software name.
Name *string `json:"name,omitempty" xml:"name,attr,omitempty" yaml:"Program/Name,omitempty"`
// ClientVerStr contains the full version as a string (see also Clientversion).
ClientVerStr *string `json:"ver_str,omitempty" xml:"verStr,attr,omitempty" yaml:"Client Version String,omitempty" uaField:"Version" renderName:"Client Version"`
// OS is the operating system of the client.
OS *string `json:"os,omitempty" xml:"os,attr,omitempty" yaml:"Operating System,omitempty"`
// OsVerStr is the version of the operating system of the client.
OsVerStr *string `json:"os_ver_str,omitempty" xml:"osVerStr,attr,omitempty" yaml:"Operating System Version String,omitempty" uaField:"OSVersion" renderName:"Operating System Version"`
// Dev is the device type.
Dev *string `json:"dev,omitempty" xml:"dev,attr,omitempty" yaml:"Device,omitempty" uaField:"Device"`
// IsMobile is true if this is a mobile device.
IsMobile bool `json:"mobile" xml:"mobile,attr" yaml:"Is Mobile" uaField:"Mobile"`
// sTablet is true if this is a tablet.
IsTablet bool `json:"tablet" xml:"tablet,attr" yaml:"Is Tablet" uaField:"Tablet"`
// IsDesktop is true if this is a desktop/laptop.
IsDesktop bool `json:"desktop" xml:"desktop,attr" yaml:"Is Desktop" uaField:"Desktop"`
// IsBot is true if this is a bot.
IsBot bool `json:"bot" xml:"bot,attr" yaml:"Is Bot" uaField:"Bot"`
ua *useragent.UserAgent
}

type Ver struct {
// XMLName xml.Name `json:"-" xml:"version" yaml:"-"`
Major int `json:"major" xml:"maj,attr" yaml:"Major"`
Minor int `json:"minor" xml:"min,attr" yaml:"Minor"`
Patch int `json:"patch" xml:"patch,attr" yaml:"Patch"`
}

// Page is only used for HTML rendering.
type Page struct {
Info *R00tInfo
// e.g. "index.html.tpl"; populated by handler
PageType string
// Nil unless `?include=` specified, otherwise a block of text to be wrapped in <code>...</code>.
Raw *string
// RawFmt is the MIME type for Raw, if `?include=` enabled/specified.
RawFmt *string
// Indent specifies the indentation string.
Indent string
// DoIndent indicates if indenting was enabled.
DoIndent bool
}

type Server struct {
log logging.Logger
args *args.Args
listenUri *url.URL
isHttp bool
mux *http.ServeMux
sock net.Listener
doneChan chan bool
stopChan chan os.Signal
reloadChan chan os.Signal
isStopping bool
}

// https://www.iana.org/assignments/media-types/media-types.xhtml
type parsedMIME struct {
MIME string
Weight float32 // Technically a param (q; "qualifier"?), but copied and converted here for easier sorting.
Params map[string]string
}

32
version/consts.go Normal file
View File

@ -0,0 +1,32 @@
package version

import (
"regexp"
)

/*
These variables are automatically handled by the build script.

DO NOT MODIFY THESE VARIABLES.
Refer to /build.sh for how these are generated at build time and populated.
*/
var (
sourceControl string = "git"
version string = "(unknown)"
commitHash string
commitShort string
numCommitsAfterTag string
isDirty string
buildTime string
buildUser string
buildSudoUser string
buildHost string
)

var (
patchRe *regexp.Regexp = regexp.MustCompile(`^(?P<patch>[0-9+])(?P<pre>-[0-9A-Za-z.-]+)?(?P<build>\+[0-9A-Za-z.-]+)?$`)
patchReIsolated *regexp.Regexp = regexp.MustCompile(`^([0-9]+)(?:[-+](.*)?)?$`)
)

// Ver is populated by main() from the build script and used in other places.
var Ver *BuildInfo

144
version/funcs.go Normal file
View File

@ -0,0 +1,144 @@
package version

import (
"fmt"
"strconv"
"strings"
"time"

"golang.org/x/mod/semver"
)

// Version returns the build information. See build.sh.
func Version() (b *BuildInfo, err error) {

var n int
var s string
var sb strings.Builder
var ok bool
var canonical string
var build strings.Builder
// Why a map?
// I forget but I had a reason for it once upon a time.
var raw map[string]string = map[string]string{
"sourceControl": sourceControl,
"tag": version,
"hash": commitHash,
"shortHash": commitShort,
"postTagCommits": numCommitsAfterTag,
"dirty": isDirty,
"time": buildTime,
"user": buildUser,
"sudoUser": buildSudoUser,
"host": buildHost,
}
var i BuildInfo = BuildInfo{
SourceControl: raw["sourceControl"],
TagVersion: raw["tag"],
// PostTagCommits: 0,
CommitHash: raw["hash"],
CommitId: raw["shortHash"],
BuildUser: raw["user"],
RealBuildUser: raw["sudoUser"],
// BuildTime: time.Time{},
BuildHost: raw["host"],
Dirty: false,
isDefined: false,
raw: raw,
}

if s, ok = raw["postTagCommits"]; ok && strings.TrimSpace(s) != "" {
if n, err = strconv.Atoi(s); err == nil {
i.PostTagCommits = uint(n)
}
}

if s, ok = raw["time"]; ok && strings.TrimSpace(s) != "" {
if n, err = strconv.Atoi(s); err == nil {
i.BuildTime = time.Unix(int64(n), 0).UTC()
}
}

switch strings.ToLower(raw["dirty"]) {
case "1":
i.Dirty = true
case "0", "":
i.Dirty = false
}

// Build the short form. We use this for both BuildInfo.short and BuildInfo.verSplit.
if i.TagVersion == "" {
sb.WriteString(i.SourceControl)
} else {
sb.WriteString(i.TagVersion)
}
/*
Now the mess. In order to conform to SemVer 2.0 (the spec this code targets):

1.) MAJOR.
2.) MINOR.
3.) PATCH
4.) -PRERELEASE (OPTIONAL)
(git commit, if building against a commit made past 1-3. Always included if untagged.)
5.) +BUILDINFO (OPTIONAL)
("+x[.y]", where x is # of commits past 4, or tag commit if 4 is empty. 0 is valid.
y is optional, and is the string "dirty" if it is a "dirty" build - that is, uncommitted/unstaged changes.
if x and y would be 0 and empty, respectively, then 5 is not included.)

1-3 are already written, or the source control software used if not.

Technically 4 and 5 are only included if 3 is present. We force patch to 0 if it's a tagged release and patch isn't present --
so this is not relevant.
*/
// PRERELEASE
if i.TagVersion == "" || i.PostTagCommits > 0 {
// We use the full commit hash for git versions, short identifier for tagged releases.
if i.TagVersion == "" {
i.Pre = i.CommitHash
} else {
i.Pre = i.CommitId
}
sb.WriteString(fmt.Sprintf("-%v", i.Pre))
}
// BUILD
if i.PostTagCommits > 0 || i.Dirty {
build.WriteString(strconv.Itoa(int(i.PostTagCommits)))
if i.Dirty {
build.WriteString(".dirty")
}
i.Build = build.String()
sb.WriteString(fmt.Sprintf("+%v", i.Build))
}

i.short = sb.String()
if semver.IsValid(i.short) {
// DON'T DO THIS. It strips the prerelease and build info.
// i.short = semver.Canonical(i.short)
// Do this instead.
canonical = semver.Canonical(i.short)
// Numeric versions...
if n, err = strconv.Atoi(strings.TrimPrefix(semver.Major(canonical), "v")); err != nil {
err = nil
} else {
i.Major = uint(n)
}
if n, err = strconv.Atoi(strings.Split(semver.MajorMinor(canonical), ".")[1]); err != nil {
err = nil
} else {
i.Minor = uint(n)
}
if n, err = strconv.Atoi(patchReIsolated.FindStringSubmatch(strings.Split(canonical, ".")[2])[1]); err != nil {
err = nil
} else {
i.Patch = uint(n)
}
// The other tag assignments were performed above.
}
// The default is 0 for the numerics, so no big deal.

i.isDefined = true

b = &i

return
}

103
version/funcs_buildinfo.go Normal file
View File

@ -0,0 +1,103 @@
package version

import (
"fmt"
"strings"

"golang.org/x/mod/semver"
)

// Detail returns a multiline string containing every possible piece of information we collect.
func (b *BuildInfo) Detail() (ver string) {

var sb strings.Builder

sb.WriteString(fmt.Sprintf("%v\n\n", b.short))
sb.WriteString(fmt.Sprintf("====\nSource Control: %v\n", b.SourceControl))
if b.TagVersion != "" {
if b.PostTagCommits > 0 {
sb.WriteString(fmt.Sprintf("Version Base: %v\nCommit Hash: %v\n", b.TagVersion, b.CommitHash))
} else {
sb.WriteString(fmt.Sprintf("Version: %v\n", b.TagVersion))
}
} else {
sb.WriteString(fmt.Sprintf("Version: (Unversioned)\nCommit Hash: %v\n", b.CommitHash))
}

// Post-commits
if b.TagVersion != "" {
sb.WriteString(fmt.Sprintf("# of Commits Since %v: %v\n", b.TagVersion, b.PostTagCommits))
} else {
sb.WriteString(fmt.Sprintf("# of Commits Since %v: %v\n", b.CommitId, b.PostTagCommits))
}

sb.WriteString("Uncommitted/Unstaged Changes: ")
if b.Dirty {
sb.WriteString("yes (dirty/monkeypatched build)\n")
} else {
sb.WriteString("no (clean build)\n")
}

if b.TagVersion != "" {
sb.WriteString(
fmt.Sprintf(
"====\nMajor: %v\nMinor: %v\nPatch: %v\n",
b.Major, b.Minor, b.Patch,
),
)
}
sb.WriteString("====\n")
sb.WriteString(b.Meta())

ver = sb.String()

return
}

// Short returns a uniquely identifiable version string.
func (b *BuildInfo) Short() (ver string) {

ver = b.short

return
}

// Meta returns the build/compile-time info.
func (b *BuildInfo) Meta() (meta string) {

var sb strings.Builder

if b.RealBuildUser != b.BuildUser && b.RealBuildUser != "" {
sb.WriteString(fmt.Sprintf("Real Build User: %v\n", b.RealBuildUser))
sb.WriteString(fmt.Sprintf("Sudo Build User: %v\n", b.BuildUser))
} else {
sb.WriteString(fmt.Sprintf("Build User: %v\n", b.BuildUser))
}
sb.WriteString(fmt.Sprintf("Build Time: %v\nBuild Host: %v\n", b.BuildTime, b.BuildHost))

meta = sb.String()

return
}

// getReMap gets a regex map of map[pattern]match.
func (b *BuildInfo) getReMap() (matches map[string]string) {

var s string = b.Short()
var sections []string

if !semver.IsValid(s) {
return
}

sections = strings.Split(s, ".")

// The split should contain everything in the third element.
// Or, if using a "simplified" semver, the last element.
matches = make(map[string]string)
for idx, str := range patchRe.FindStringSubmatch(sections[len(sections)-1]) {
matches[patchRe.SubexpNames()[idx]] = str
}

return
}

51
version/types.go Normal file
View File

@ -0,0 +1,51 @@
package version

import (
"time"
)

// BuildInfo contains nativized version information.
type BuildInfo struct {
// TagVersion is the most recent tag name on the current branch.
TagVersion string
// PostTagCommits is the number of commits after BuildInfo.TagVersion's commit on the current branch.
PostTagCommits uint
// CommitHash is the full commit hash.
CommitHash string
// CommitId is the "short" version of BuildInfo.CommitHash.
CommitId string
// BuildUser is the user the program was compiled under.
BuildUser string
// If compiled under sudo, BuildInfo.RealBuildUser is the user that called sudo.
RealBuildUser string
// BuildTime is the time and date of the program's build time.
BuildTime time.Time
// BuildHost is the host the binary was compiled on.
BuildHost string
// Dirty specifies if the source was "dirty" (uncommitted/unstaged etc. files) at the time of compilation.
Dirty bool
// SourceControl is the source control version used. Only relevant if not a "clean" build or untagged.
SourceControl string
// Major is the major version, expressed as an uint per spec.
Major uint
// Minor is the minor version, expressed as an uint per spec.
Minor uint
// Patch is the patch version, expressed as an uint per spec.
Patch uint
// Pre
Pre string
// Build
Build string
// isDefined specifies if this version was retrieved from the built-in values.
isDefined bool
// raw is the raw variable values.
raw map[string]string
/*
verSplit is a slice of []string{Major, Minor, Patch, PreRelease, Build}

If using an actual point release, PreRelease and Build are probably blank.
*/
verSplit [5]string
// short is the condensed version of verSplit.
short string
}