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

About

+
+

+ This is a tool to reveal certain information about your connection that the server sees. + Note that all of this information you see is sent by your client; + there was no probing/scanning or the like done from the server this site is hosted on. +

+

+ If you don't like this info being available to server administrators of the websites + you visit you may want to consider: +

+ There are, of course, many other plugins/methods but as always, due diligence is required when finding the right plugin for you. + Be sure to read multiple reviews. + Some plugins/extensions even disguise your browser as an entirely different operating system, OS version, etc. + Feel free to check back on this site after enabling them to test! (You may need to reset your browser's cache.) +

+

+ If you would like to view the server headers, then you can: +

+ There are additionally some extensions/plugins that offer this in a directly-accessible button on the toolbar. +

+
+{{- template "meta.bottom" $page }} +{{- end }} diff --git a/server/tpl/index.html.tpl b/server/tpl/index.html.tpl new file mode 100644 index 0000000..59801ed --- /dev/null +++ b/server/tpl/index.html.tpl @@ -0,0 +1,14 @@ +{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}} +{{- define "index" }} +{{- $page := . -}} +{{- $linkico := "🔗" }} +{{- template "meta.top" $page }} +
+

Client Info Revealer

+

A tool to reveal client-identifying data sent to webservers

+
+
+{{- template "meta.info" $page }} +
+{{- template "meta.bottom" $page }} +{{- end }} diff --git a/server/tpl/meta.bottom.html.tpl b/server/tpl/meta.bottom.html.tpl new file mode 100644 index 0000000..6c5531c --- /dev/null +++ b/server/tpl/meta.bottom.html.tpl @@ -0,0 +1,11 @@ +{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}} +{{- define "meta.bottom" -}} +{{- $page := . -}} +{{- $linkico := "🔗" }} + + + + +{{- end -}} diff --git a/server/tpl/meta.info.html.tpl b/server/tpl/meta.info.html.tpl new file mode 100644 index 0000000..27b18b3 --- /dev/null +++ b/server/tpl/meta.info.html.tpl @@ -0,0 +1,55 @@ +{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}} +{{- define "meta.info" -}} +{{- $page := . -}} +{{- $linkico := "🔗" }} +

Client/Browser Information{{ $linkico }}

+

+ Your IP Address is {{ $page.Info.IP.String }}. +
+ You are connecting with port {{ $page.Info.Port }} outbound. +

+ {{- if $page.Raw }} +

Raw Block ({{ $page.RawFmt }}){{ $linkico }}

+

+ {{- if $page.DoRawIndent }} +

{{ $page.Raw }}
+ {{- else }} + {{ $page.Raw }} + {{- end }} +

+ {{- end }} +

User Agent Information{{ $linkico }}

+

This is information that your browser sends to identify itself.

+

+ {{- range $idx, $ua := $page.Info.Client }} + User Agent ({{ $idx }}): +

    + {{- $flds := $ua.ToMap }} + {{- range $fld, $str := $flds }} +
  • {{ $fld }}: {{ $str }}
  • + {{- end }} +
+ {{- end }} +

+

Request Headers{{ $linkico }}

+

+ These are headers sent along with the request your browser sends for the page's content. + Note that some headers may have multiple values. +

+

+ + + + + + {{- range $hdrNm, $hdrVals := $page.Info.Req.Header }} + + {{- range $val := $hdrVals }} + + + {{- end }} + + {{- end }} +
HeaderValue
{{ $hdrNm }}{{ $val }}
+

+{{- end }} diff --git a/server/tpl/meta.top.html.tpl b/server/tpl/meta.top.html.tpl new file mode 100644 index 0000000..f396e5b --- /dev/null +++ b/server/tpl/meta.top.html.tpl @@ -0,0 +1,53 @@ +{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}} +{{- define "meta.top" -}} +{{- $page := . -}} +{{- $linkico := "🔗" -}} + + + + + {{ getTitle $page.PageType }} + + + + + + + + + +
+
+ +
+{{- end }} diff --git a/server/tpl/usage.html.tpl b/server/tpl/usage.html.tpl new file mode 100644 index 0000000..0e5ad1c --- /dev/null +++ b/server/tpl/usage.html.tpl @@ -0,0 +1,95 @@ +{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}} +{{- define "usage" }} +{{- $page := . -}} +{{- $linkico := "🔗" }} +{{- template "meta.top" $page }} +
+

Usage

+
+

Parameters{{ $linkico }}

+

+ You can control how the main page 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 Accept wasn't specified, + a fallback to text-mode (by default application/json) will be returned. +
+ + You can force a specific raw output by specifying the MIME type via + the Accept header (RFC 9110 § 12.5.1), which may be one of: +

    +
  • application/json for JSON
  • +
  • application/xml for XML
  • +
  • application/yaml for YAML
  • +
  • text/html for HTML
  • +
+ For example: Accept: application/json will return JSON. +
+ + If unspecified and it is a text-mode client (e.g. Curl), the default is application/json. + text/html may be used to force an HTML response from a text-only client, + just as one of the application/* MIME types above may be used to force that "raw" text MIME type for a "graphical" browser client. + The specification as defined by RFC 9110 § 12.5.1 is completely + valid to pass and will be parsed without error (provided the header value is RFC-compliant and IANA-compliant), + though note that application/xml and text/html's charset parameter will be entirely ignored; + the returned XML/HTML is always Unicode (with UTF-8 encoding). +
+ + If no selectable MIME type is provided but an Accept was given, an error will be returned; specifically, a + 406 status code (RFC 9110 § 15.5.7). + In this case, supported MIME types will be returned in the response's Accept header. +
+ + Note that Lynx and Elinks are considered "graphical" + browsers by this program as they are HTML-centric. +

+

+ The following parameters control/modify behavior.{{ $linkico }} +

    +
  • + mime: Specify an explicit MIME type via URL instead of the Accept header as specified above. +
      +
    • This should only be used by clients in which it is impossible or particularly cumbersome to modify/specify headers. + Accept is more performant.
    • +
    • Only the first supported instance of this parameter will be used.
    • +
    • Any of the defined MIME types above may be specified (e.g. ?mime=application/json).
    • +
    • If both this URL query parameter and the Accept header is specified, the URL query takes preference.
    • +
    +
  • +
  • + include: Include a <code> (or <pre>, depending on if indentation is needed/requested) block in the HTML for the specified MIME type as well.
  • +
      +
    • Only the first supported instance of this parameter will be used.
    • +
    • + The value must conform to the same rules/specifications as the mime parameter/Accept header. +
        +
      • include may not be text/html; it will be ignored if this is set. Just learn to ctrl+u.
      • +
      +
    • +
    • Only used if the evaluated return is HTML, ignored otherwise.
    • +
    • Indentation can be specified via the indent parameter below (since indentation is otherwise meaningless to HTML returns).
    • +
    + +
  • + indent: Enable/specify indentation for JSON and XML output; ignored for others. +
      +
    • The default is to not indent. (Commonly referred to as "condensed" or "compressed" JSON/XML.)
    • +
    • Only the first specified instance of this parameter will be used.
    • +
    • If specified with a string value, use that string as each indent. +
        +
      • Be mindful of URL query parameter encoding, + per RFC 3986 § 3.4 + and RFC 8820 § 2.4
      • +
      • For quick reference and as an example, to indent with a tab + (\t, 0x09) for each level, use ?indent=%09
      • +
      +
    • +
    • If indentation is specified without any value (?indent), the default is two + spaces (0x20); this would be represented + as ?indent=%20%20
    • +
    • ?indent= (no value specified) is equal to ?indent.
    • +
    +
  • +
+

+{{- template "meta.bottom" $page }} +{{- end }} diff --git a/server/types.go b/server/types.go new file mode 100644 index 0000000..9e3096c --- /dev/null +++ b/server/types.go @@ -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 .... + 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 +} diff --git a/version/consts.go b/version/consts.go new file mode 100644 index 0000000..04ec47a --- /dev/null +++ b/version/consts.go @@ -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[0-9+])(?P
-[0-9A-Za-z.-]+)?(?P\+[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
diff --git a/version/funcs.go b/version/funcs.go
new file mode 100644
index 0000000..6624bd3
--- /dev/null
+++ b/version/funcs.go
@@ -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
+}
diff --git a/version/funcs_buildinfo.go b/version/funcs_buildinfo.go
new file mode 100644
index 0000000..0d27cdf
--- /dev/null
+++ b/version/funcs_buildinfo.go
@@ -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
+}
diff --git a/version/types.go b/version/types.go
new file mode 100644
index 0000000..084e51d
--- /dev/null
+++ b/version/types.go
@@ -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
+}