initial commit before refactor switch

This commit is contained in:
brent saner
2024-12-12 02:22:54 -05:00
commit db081e2699
33 changed files with 3101 additions and 0 deletions

88
server/consts.go Normal file
View File

@@ -0,0 +1,88 @@
package server
import (
`embed`
`encoding/json`
`encoding/xml`
`html/template`
`os`
`syscall`
sysdUtil `github.com/coreos/go-systemd/util`
`github.com/goccy/go-yaml`
)
const (
convertTag string = "uaField"
prettyTag string = "renderName"
baseTitle string = "r00t^2 Client Info Revealer"
titleSep string = " || "
xmlHdrElem string = "header"
xmlHdrElemName string = "name"
xmlHdrVal string = "value"
nilUaFieldStr string = "(N/A)"
trueUaFieldStr string = "Yes"
falseUaFieldStr string = "No"
dfltIndent string = " "
httpRealHdr string = "X-ClientInfo-RealIP"
)
var (
//go:embed "tpl"
tplDir embed.FS
tpl *template.Template = template.Must(
template.New("").
Funcs(
template.FuncMap{
"getTitle": getTitle,
},
).ParseFS(tplDir, "tpl/*.tpl"),
)
)
// Signal traps
var (
stopSigs []os.Signal = []os.Signal{
syscall.SIGQUIT,
os.Interrupt,
syscall.SIGTERM,
}
reloadSigs []os.Signal = []os.Signal{
syscall.SIGHUP,
// We also add stopSigs so we trigger the Reload loop to close. TODO.
syscall.SIGQUIT,
os.Interrupt,
syscall.SIGTERM,
}
isSystemd bool = sysdUtil.IsRunningSystemd()
)
// media/MIME types
const (
mediaJSON string = "application/json"
mediaXML string = "application/xml"
mediaYAML string = "application/yaml"
mediaHTML string = "text/html"
// TODO: plain/text? CSV? TOML?
)
var (
// mediaNoIndent covers everything (except HTML).
mediaNoIndent map[string]func(obj any) (b []byte, err error) = map[string]func(obj any) (b []byte, err error){
mediaJSON: json.Marshal,
mediaXML: xml.Marshal,
mediaYAML: yaml.Marshal,
// HTML is handled explicitly.
}
// mediaIndent only contains MIME types that support configured indents.
mediaIndent map[string]func(obj any, pfx string, indent string) (b []byte, err error) = map[string]func(obj any, pfx string, indent string) (b []byte, err error){
mediaJSON: json.MarshalIndent,
mediaXML: xml.MarshalIndent,
}
okAcceptMime []string = []string{
mediaJSON,
mediaXML,
mediaYAML,
mediaHTML,
}
)

17
server/errs.go Normal file
View File

@@ -0,0 +1,17 @@
package server
import (
`errors`
)
var (
ErrEmptyUA error = errors.New("empty user agent string")
ErrIncompatFieldType error = errors.New("a field type was passed that is incompatible with the target type")
ErrInvalidAccept error = errors.New("an Accept header was encountered that does not conform to RFC 9110§12.5.1/IANA format")
ErrInvalidScheme error = errors.New("invalid scheme for listener; must be 'unix', 'tcp', or 'http'")
ErrNoArgs error = errors.New("no args.Args passed to server creation")
ErrPtrNeeded error = errors.New("structs passed to reflection must be pointers")
ErrStructNeeded error = errors.New("pointers passed to reflection must point to structs")
ErrUnhandledField error = errors.New("unhandled field type passed to reflection")
ErrUnsupportedMIME error = errors.New("unsupported MIME type(s)")
)

430
server/funcs.go Normal file
View File

@@ -0,0 +1,430 @@
package server
import (
`fmt`
`net`
`net/http`
`net/url`
`os`
`path/filepath`
`reflect`
`sort`
`strings`
`github.com/mileusna/useragent`
`r00t2.io/clientinfo/args`
`r00t2.io/goutils/logging`
`r00t2.io/goutils/multierr`
`r00t2.io/sysutils/paths`
)
// NewClient returns a R00tClient from a UA string.
func NewClient(uaStr string) (r *R00tClient, err error) {
var newR R00tClient
var ua useragent.UserAgent
if strings.TrimSpace(uaStr) == "" {
err = ErrEmptyUA
return
}
ua = useragent.Parse(uaStr)
if err = reflectClient(&ua, &newR); err != nil {
return
}
newR.ua = &ua
r = &newR
return
}
// NewServer returns a Server ready to use. Be sure to call Close to free up resources when done.
func NewServer(log logging.Logger, cliArgs *args.Args) (srv *Server, err error) {
var s Server
var udsSockPerms args.UdsPerms
if log == nil {
log = &logging.NullLogger{}
}
if cliArgs == nil {
err = ErrNoArgs
log.Err("server.NewServer: Received error creating server: %v", err)
return
}
s = Server{
log: log,
args: cliArgs,
mux: http.NewServeMux(),
sock: nil,
reloadChan: make(chan os.Signal),
stopChan: make(chan os.Signal),
}
s.mux.HandleFunc("/", s.handleDefault)
s.mux.HandleFunc("/about", s.handleAbout)
s.mux.HandleFunc("/about.html", s.handleAbout)
s.mux.HandleFunc("/usage", s.handleUsage)
s.mux.HandleFunc("/usage.html", s.handleUsage)
s.mux.HandleFunc("/favicon.ico", s.explicit404)
if s.listenUri, err = url.Parse(cliArgs.Listen.Listen); err != nil {
s.log.Err("server.NewServer: Failed to parse listener URI: %v", err)
return
}
s.listenUri.Scheme = strings.ToLower(s.listenUri.Scheme)
switch s.listenUri.Scheme {
case "unix":
if udsSockPerms, err = cliArgs.ModesAndOwners(); err != nil {
s.log.Err("server.NewServer: Failed to parse unix socket permissions: %v", err)
return
}
if err = paths.RealPath(&s.listenUri.Path); err != nil {
s.log.Err("server.NewServer: Failed to canonize/resolve socket path '%s': %v", s.listenUri.Path, err)
return
}
// Cleanup any stale socket.
if err = s.cleanup(true); err != nil {
s.log.Err("server.NewServer: Failed to cleanup for 'unix' listener: %v", err)
return
}
if err = os.MkdirAll(filepath.Dir(s.listenUri.Path), udsSockPerms.DMode); err != nil {
s.log.Err("server.NewServer: Received error creating socket directory '%s': %v", filepath.Dir(s.listenUri.Path), err)
return
}
if err = os.Chmod(filepath.Dir(s.listenUri.Path), udsSockPerms.DMode); err != nil {
s.log.Err("server.NewServer: Received error chmodding socket directory '%s': %v", filepath.Dir(s.listenUri.Path), err)
return
}
if err = os.Chown(filepath.Dir(s.listenUri.Path), udsSockPerms.UID, udsSockPerms.DGID); err != nil {
s.log.Err("server.NewServer: Received error chowning socket directory '%s': %v", filepath.Dir(s.listenUri.Path), err)
return
}
if s.listenUri, err = url.Parse(
fmt.Sprintf(
"%s://%s",
s.listenUri.Scheme, s.listenUri.Path,
),
); err != nil {
s.log.Err("server.NewServer: Failed to re-parse listener URI: %v", err)
return
}
if s.sock, err = net.Listen("unix", s.listenUri.Path); err != nil {
s.log.Err("server.NewServer: Failed to open socket on '%s': %v", s.listenUri.Path, err)
}
if err = os.Chmod(s.listenUri.Path, udsSockPerms.FMode); err != nil {
s.log.Err("server.NewServer: Received error chmodding socket '%s': %v", filepath.Dir(s.listenUri.Path), err)
return
}
if err = os.Chown(s.listenUri.Path, udsSockPerms.UID, udsSockPerms.FGID); err != nil {
s.log.Err("server.NewServer: Received error chowning socket '%s': %v", filepath.Dir(s.listenUri.Path), err)
return
}
case "http", "tcp":
s.isHttp = s.listenUri.Scheme == "http"
if err = s.cleanup(true); err != nil {
s.log.Err("server.NewServer: Failed to cleanup for '%s' listener: %v", strings.ToUpper(s.listenUri.Scheme), err)
return
}
if s.listenUri, err = url.Parse(
fmt.Sprintf(
"%s://%s%s",
s.listenUri.Scheme, s.listenUri.Host, s.listenUri.Path,
),
); err != nil {
s.log.Err("server.NewServer: Failed to re-parse listener URI: %v", err)
return
}
if s.sock, err = net.Listen("tcp", s.listenUri.Host); err != nil {
s.log.Err("server.NewServer: Failed to open %s socket on '%s': %v", strings.ToUpper(s.listenUri.Scheme), s.listenUri.Host, err)
return
}
default:
s.log.Err("server.NewServer: Unsupported scheme: %v", s.listenUri.Scheme)
err = ErrInvalidScheme
return
}
cliArgs.Listen.Listen = s.listenUri.String()
srv = &s
return
}
/*
decideParseAccept takes the slice returned from parseAccept, runs parseAccept on it,
and chooses based on what MIME types are supported by this program.
err will be an ErrUnsupportedMIME if no supported MIME type is found.
If parsed is nil or empty, format will be defFormat and err will be nil.
*/
func decideParseAccept(parsed []*parsedMIME, defFormat string) (format string, err error) {
var customFmtFound bool
if parsed == nil || len(parsed) == 0 {
format = defFormat
return
}
for _, pf := range parsed {
switch pf.MIME {
case "*/*": // Client explicitly accept anything
format = defFormat
customFmtFound = true
case "application/*": // Use JSON
format = mediaJSON
customFmtFound = true
case "text/*": // Use HTML
format = mediaHTML
customFmtFound = true
case mediaHTML, mediaJSON, mediaXML, mediaYAML:
format = pf.MIME
customFmtFound = true
}
if customFmtFound {
break
}
}
if !customFmtFound {
format = defFormat
err = ErrUnsupportedMIME
return
}
return
}
/*
reflectClient takes a src and dst and attempts to set/convert src to dst. It is *VERY STRICT*.
It is expected that src does NOT use pointers.
...This is pretty much just custom-made for converting a useragent.UserAgent to a R00tClient.
Don't use it for anything else.
*/
func reflectClient(src, dst any) (err error) {
var dstField reflect.StructField
var dstFieldVal reflect.Value
var srcFieldVal reflect.Value
var srcField string
var ok bool
var intVal *int
var strVal *string
var boolVal *bool
var srcVal reflect.Value = reflect.ValueOf(src)
var dstVal reflect.Value = reflect.ValueOf(dst)
// Both must be ptrs to a struct
if srcVal.Kind() != reflect.Ptr || dstVal.Kind() != reflect.Ptr {
err = ErrPtrNeeded
return
}
srcVal = srcVal.Elem()
dstVal = dstVal.Elem()
/*
Now that we have the underlying type/value of the ptr above,
check for structs.
*/
if srcVal.Kind() != reflect.Struct || dstVal.Kind() != reflect.Struct {
err = ErrStructNeeded
return
}
for i := 0; i < dstVal.NumField(); i++ {
dstField = dstVal.Type().Field(i)
dstFieldVal = dstVal.Field(i)
// Skip unexported
if !dstFieldVal.CanSet() {
continue
}
srcField = dstField.Tag.Get(convertTag)
// Skip explicitly skipped (<convertTag>:"-")
if srcField == "-" {
continue
}
// If no explicit field name is present, set it to the dst field name.
if _, ok = dstField.Tag.Lookup(convertTag); !ok {
srcField = dstField.Name
}
// Get the value from src
srcFieldVal = srcVal.FieldByName(srcField)
// Skip invalid...
if !srcFieldVal.IsValid() {
continue
}
// And zero-value.
if reflect.DeepEqual(srcFieldVal.Interface(), reflect.Zero(srcFieldVal.Type()).Interface()) {
continue
}
// Structs need to recurse.
if dstFieldVal.Kind() == reflect.Ptr && dstFieldVal.Type().Elem().Kind() == reflect.Struct {
// Ensure we don't have a nil ptr
if dstFieldVal.IsNil() {
dstFieldVal.Set(reflect.New(dstFieldVal.Type().Elem()))
}
// And recurse into it.
if err = reflectClient(srcFieldVal.Addr().Interface(), dstFieldVal.Interface()); err != nil {
return
}
} else {
// Everything else gets assigned here.
switch dstFieldVal.Kind() {
case reflect.Bool:
if srcFieldVal.Kind() == reflect.Bool {
dstFieldVal.Set(reflect.ValueOf(srcFieldVal.Interface().(bool)))
} else {
err = ErrIncompatFieldType
return
}
case reflect.String:
if srcFieldVal.Kind() == reflect.String {
dstFieldVal.Set(reflect.ValueOf(srcFieldVal.Interface().(string)))
} else {
err = ErrIncompatFieldType
return
}
case reflect.Int:
if srcFieldVal.Kind() == reflect.Int {
dstFieldVal.Set(reflect.ValueOf(srcFieldVal.Interface().(int)))
} else {
err = ErrIncompatFieldType
return
}
case reflect.Ptr:
// Pointers to above
switch dstFieldVal.Type().Elem().Kind() {
case reflect.Bool:
if srcFieldVal.Kind() == reflect.Bool {
boolVal = new(bool)
*boolVal = srcFieldVal.Interface().(bool)
dstFieldVal.Set(reflect.ValueOf(boolVal))
} else {
err = ErrIncompatFieldType
return
}
case reflect.String:
if srcFieldVal.Kind() == reflect.String {
strVal = new(string)
*strVal = srcFieldVal.Interface().(string)
dstFieldVal.Set(reflect.ValueOf(strVal))
} else {
err = ErrIncompatFieldType
return
}
case reflect.Int:
if srcFieldVal.Kind() == reflect.Int {
intVal = new(int)
*intVal = srcFieldVal.Interface().(int)
dstFieldVal.Set(reflect.ValueOf(intVal))
} else {
err = ErrIncompatFieldType
return
}
default:
err = ErrUnhandledField
return
}
default:
err = ErrUnhandledField
return
}
}
}
return
}
// parseAccept parses an Accept header as per RFC 9110 § 12.5.1.
func parseAccept(hdrVal string) (parsed []*parsedMIME, err error) {
var mimes []string
var parts []string
var params []string
var paramsLen int
var kv []string
var mt *parsedMIME
var mErr *multierr.MultiError = multierr.NewMultiError(nil)
if hdrVal == "" {
return
}
mimes = strings.Split(hdrVal, ",")
for _, mime := range mimes {
mt = &parsedMIME{
MIME: "",
Weight: 1.0, // between 0.0 and 1.0
Params: nil,
}
mime = strings.TrimSpace(mime)
// Split into []string{<type>[, <param>, ...]}
parts = strings.Split(mime, ";")
if parts == nil || len(parts) < 1 {
mErr.AddError(ErrInvalidAccept)
continue
}
if parts[0] == "" {
mErr.AddError(ErrInvalidAccept)
continue
}
if len(strings.Split(parts[0], "/")) != 2 {
mErr.AddError(ErrInvalidAccept)
continue
}
mt.MIME = strings.TrimSpace(parts[0])
if len(parts) > 1 {
// Parameters were provided. We don't really use them except `q`, but...
params = parts[1:]
paramsLen = len(params)
for idx, param := range params {
param = strings.TrimSpace(param)
kv = strings.SplitN(param, "=", 2)
if len(kv) != 2 {
mErr.AddError(ErrInvalidAccept)
continue
}
if kv[0] == "q" && idx == paramsLen-1 {
// It's the weight. RFC's pretty clear it's the last param.
fmt.Sscanf(kv[1], "%f", &mt.Weight)
if mt.Weight > 1.0 || mt.Weight < 0.0 {
mErr.AddError(ErrInvalidAccept)
continue
}
} else {
if mt.Params == nil {
mt.Params = make(map[string]string)
}
mt.Params[kv[0]] = kv[1]
}
}
}
parsed = append(parsed, mt)
}
// Now sort by weight (descending).
sort.SliceStable(
parsed,
func(i, j int) (isBefore bool) {
isBefore = parsed[i].Weight > parsed[j].Weight
return
},
)
if !mErr.IsEmpty() {
err = mErr
return
}
return
}

12
server/funcs_page.go Normal file
View File

@@ -0,0 +1,12 @@
package server
import (
`fmt`
)
func (p *Page) RenderIP(indent uint) (s string) {
s = fmt.Sprintf("<a href=\"https://ipinfo.io/%s\">%s</a>", p.Info.IP.String(), p.Info.IP.String())
return
}

View File

@@ -0,0 +1,88 @@
package server
import (
`reflect`
`strings`
)
/*
ToMap generates and returns a map representation of a R00tClient.
Keys by default use the YAML tag for the name.
If they are specified with the tag `renderName:"-"`, they are skipped.
If they are specified with the tag `renderName:"Foo"`, the string "Foo" will
be used as the key instead.
Only bools, strings, and pointers thereof are allowed.
m will never be nil, but may be empty.
Currently err will always be nil but is specified for future API compatibility.
It should be handled by callers for future-proofing, as it may not always be nil
in the future.
*/
func (r *R00tClient) ToMap() (m map[string]string, err error) {
var ok bool
var tagVal string
var field reflect.StructField
var fieldVal reflect.Value
var rootVal reflect.Value
m = make(map[string]string)
if r == nil {
return
}
rootVal = reflect.ValueOf(r).Elem()
for i := 0; i < rootVal.NumField(); i++ {
field = rootVal.Type().Field(i)
fieldVal = rootVal.Field(i)
// Only exported.
if field.PkgPath != "" {
continue
}
// Get the key name.
tagVal = field.Tag.Get(prettyTag)
if tagVal == "-" {
continue
}
if _, ok = field.Tag.Lookup(prettyTag); !ok {
tagVal = field.Tag.Get("yaml")
if tagVal == "" || strings.HasPrefix(tagVal, "-") {
// Use the field name itself. YOLO
tagVal = field.Name
} else {
tagVal = strings.Split(tagVal, ",")[0]
}
}
switch fieldVal.Kind() {
case reflect.Bool:
if fieldVal.Interface().(bool) {
m[tagVal] = trueUaFieldStr
} else {
m[tagVal] = falseUaFieldStr
}
case reflect.String:
m[tagVal] = fieldVal.String()
case reflect.Ptr:
if fieldVal.IsNil() {
m[tagVal] = nilUaFieldStr
} else {
switch fieldVal.Type().Elem().Kind() {
case reflect.Bool:
if fieldVal.Elem().Bool() {
m[tagVal] = trueUaFieldStr
} else {
m[tagVal] = falseUaFieldStr
}
case reflect.String:
m[tagVal] = fieldVal.Elem().String()
}
}
}
}
return
}

860
server/funcs_server.go Normal file
View File

@@ -0,0 +1,860 @@
package server
import (
`crypto/tls`
`encoding/json`
`encoding/xml`
"errors"
"fmt"
`mime/multipart`
"net"
"net/http"
`net/http/fcgi`
"net/netip"
"net/url"
"os"
"os/signal"
"strings"
"sync"
`syscall`
sysd "github.com/coreos/go-systemd/daemon"
"github.com/davecgh/go-spew/spew"
`github.com/goccy/go-yaml`
"r00t2.io/goutils/multierr"
)
// Close cleanly closes any remnants of a Server. Stop should be used instead to cleanly shut down; this is a little more aggressive.
func (s *Server) Close() (err error) {
s.log.Debug("server.Server.Close: Closing sockets.")
if err = s.cleanup(false); err != nil {
s.log.Err("server.Server.Close: Received error closing sockets: %v", err)
}
s.log.Debug("server.Server.Close: Sockets closed.")
return
}
/*
Run starts and runs the server. This process blocks and will shutdown on a systemd notify signal or kill signal.
Non-HTML requests will be of type R00tInfo serialized to the requested MIME type.
*/
func (s *Server) Run() (err error) {
var wg sync.WaitGroup
var errChan chan error
var mErr *multierr.MultiError = multierr.NewMultiError(nil)
var numJobs int = 2 // sigs, listener
s.log.Debug("server.Server.Run: Starting server.")
signal.Notify(s.reloadChan, reloadSigs...)
signal.Notify(s.stopChan, stopSigs...)
s.doneChan = make(chan bool, 1)
errChan = make(chan error, numJobs)
wg.Add(numJobs)
// sigs
go func() {
var sigErr error
var sig os.Signal
var smErr *multierr.MultiError = multierr.NewMultiError(nil)
defer wg.Done()
sigtrap:
for !s.isStopping {
if s.isStopping {
break sigtrap
}
sig = <-s.reloadChan
s.log.Debug("server.Server.Run: Recived signal %v (%#v): %v", sig, sig, sig.String())
switch sig {
case syscall.SIGHUP:
s.log.Debug("server.Server.Run: Recived reload signal.")
if s.isStopping {
s.log.Debug("server.Server.Run: Server is stopping; abandoning reload.")
if sigErr = s.Stop(); sigErr != nil {
s.log.Err("server.Server.Run: Received error while stopping the server: %v", sigErr)
sigErr = nil
}
} else {
if sigErr = s.Reload(); sigErr != nil {
s.log.Err("server.Server.Run: Received error while reloading the server: %v", sigErr)
smErr.AddError(sigErr)
sigErr = nil
}
break sigtrap
}
default:
// Stop signal.
s.log.Debug("server.Server.Run: Recived stop signal.")
if sigErr = s.Stop(); sigErr != nil {
s.log.Err("server.Server.Run: Received error while stopping the server: %v", sigErr)
smErr.AddError(sigErr)
sigErr = nil
}
}
}
if !smErr.IsEmpty() {
errChan <- smErr
return
}
}()
// listener
go func() {
var lErr error
defer wg.Done()
if isSystemd {
var supported bool
// https://www.freedesktop.org/software/systemd/man/sd_notify.html
if supported, lErr = sysd.SdNotify(false, sysd.SdNotifyReady); lErr != nil {
s.log.Err(
"server.Server.Run: Error encountered when notifying systemd of changestate to READY (supported: %v): %v",
supported, lErr,
)
err = nil
}
}
switch s.listenUri.Scheme {
case "unix", "tcp":
if lErr = fcgi.Serve(s.sock, s.mux); lErr != nil {
if errors.Is(lErr, net.ErrClosed) {
lErr = nil
} else {
errChan <- lErr
}
return
}
case "http":
if lErr = http.Serve(s.sock, s.mux); lErr != nil {
if errors.Is(lErr, http.ErrServerClosed) || errors.Is(lErr, net.ErrClosed) {
lErr = nil
} else {
errChan <- lErr
}
return
}
}
}()
go func() {
wg.Wait()
close(errChan)
s.doneChan <- true
}()
<-s.doneChan
for i := 0; i < numJobs; i++ {
if err = <-errChan; err != nil {
mErr.AddError(err)
err = nil
}
}
if !mErr.IsEmpty() {
err = mErr
return
}
s.log.Debug("server.Server.Run: Server shut down.")
return
}
// Stop stops the server.
func (s *Server) Stop() (err error) {
s.log.Debug("server.Server.Stop: Stopping server.")
s.isStopping = true
if isSystemd {
// https://www.freedesktop.org/software/systemd/man/sd_notify.html
if _, err = sysd.SdNotify(false, sysd.SdNotifyStopping); err != nil {
s.log.Err("server.Server.stop: Received error notifying systemd of stop: %v", err)
err = nil
}
}
if err = s.Close(); err != nil {
s.log.Err("server.Server.stop: Received error closing server connections: %v", err)
err = nil
}
s.log.Debug("server.Server.Stop: Server stopped.")
return
}
// cleanup cleans up remaining sockets, closes channels, etc.
func (s *Server) cleanup(init bool) (err error) {
var mErr *multierr.MultiError = multierr.NewMultiError(nil)
s.log.Debug("server.Server.cleanup: Cleaning up sockets, etc.")
if s.sock != nil && !init {
if err = s.sock.Close(); err != nil {
s.log.Err("server.Server.cleanup: Received error closing socket: %v", err)
mErr.AddError(err)
err = nil
}
}
if s.listenUri.Scheme == "unix" {
if err = os.Remove(s.listenUri.Path); err != nil {
if !errors.Is(err, os.ErrNotExist) {
s.log.Err("server.Server.cleanup: Failed to remove UDS '%s': %v", s.listenUri.Path, err)
mErr.AddError(err)
}
err = nil
}
}
if !mErr.IsEmpty() {
err = mErr
return
}
s.log.Debug("server.Server.cleanup: Completed cleanup.")
return
}
func (s *Server) Reload() (err error) {
s.log.Debug("server.Server.Reload: Reload called, but nothing was done; this is a placeholder as there are no reload-associated operations assigned.")
if isSystemd {
// https://www.freedesktop.org/software/systemd/man/sd_notify.html
if _, err = sysd.SdNotify(false, sysd.SdNotifyReloading); err != nil {
s.log.Err("server.Server.Reload: Received error notifying systemd of reload: %v", err)
return
}
}
// TODO?
if isSystemd {
var supported bool
// https://www.freedesktop.org/software/systemd/man/sd_notify.html
if supported, err = sysd.SdNotify(false, sysd.SdNotifyReady); err != nil {
s.log.Err(
"server.Server.Reload: Error encountered when notifying systemd of changestate to READY (supported: %v): %v",
supported, err,
)
err = nil
}
}
return
}
func (s *Server) explicit404(resp http.ResponseWriter, req *http.Request) {
resp.WriteHeader(http.StatusNotFound)
}
func (s *Server) handleDefault(resp http.ResponseWriter, req *http.Request) {
var vals url.Values
var uaVals []string
var doInclude bool
var doIndent bool
var err error
var ok bool
var b []byte
var remAddrPort string
var okMedia []string
var nAP netip.AddrPort
var parsedFmts []*parsedMIME
var renderPage *Page = new(Page)
var format string = mediaJSON
var indent string = " "
var client *R00tInfo = new(R00tInfo)
renderPage.RawIndent = " "
renderPage.PageType = "index"
s.log.Debug("server.Server.handleDefault: Handling request:\n%s", spew.Sdump(req))
/*
if req.URL != nil &&
req.URL.Path != "" &&
req.URL.Path != "/" &&
req.URL.Path != "/index" &&
req.URL.Path != "/index.html" {
resp.WriteHeader(http.StatusNotFound)
}
*/
client.Req = req
remAddrPort = req.RemoteAddr
if s.isHttp && req.Header.Get(httpRealHdr) != "" {
remAddrPort = req.Header.Get(httpRealHdr)
req.Header.Del(httpRealHdr)
}
if remAddrPort != "" {
if nAP, err = netip.ParseAddrPort(remAddrPort); err != nil {
s.log.Err("server.Server.handleDefault: Failed to parse remote address '%s': %v", req.RemoteAddr, err)
// Don't return an error in case we're doing weird things like direct socket clients.
err = nil
/*
http.Error(resp, "ERROR: Failed to parse client address", http.StatusInternalServerError)
return
*/
}
client.IP = net.ParseIP(nAP.Addr().String())
client.Port = nAP.Port()
}
client.Headers = XmlHeaders(req.Header)
uaVals = req.Header.Values("User-Agent")
if uaVals != nil && len(uaVals) > 0 {
client.Client = make([]*R00tClient, len(uaVals))
for idx, ua := range uaVals {
if client.Client[idx], err = NewClient(ua); err != nil {
s.log.Err("server.Server.handleDefault: Failed to create client for '%s': %v", ua, err)
http.Error(resp, fmt.Sprintf("ERROR: Failed to parse 'User-Agent' '%s'", ua), http.StatusInternalServerError)
return
}
}
}
if client.Client != nil && len(client.Client) > 0 {
// Check the passed UAs for a browser. We then change the "default" format if so.
for _, ua := range client.Client {
if ua.IsMobile || ua.IsDesktop {
format = mediaHTML
break
}
}
}
renderPage.Info = client
vals = req.URL.Query()
// Determine the format/MIME type of the response.
if vals.Has("mime") {
format = req.URL.Query().Get("mime")
} else {
if parsedFmts, err = parseAccept(strings.Join(req.Header.Values("Accept"), ",")); err != nil {
s.log.Err("server.Server.handleDefault: Failed to parse Accept header: %v", err)
http.Error(
resp,
"ERROR: Invalid 'Accept' header value; see RFC 9110 § 12.5.1 and https://www.iana.org/assignments/media-types/media-types.xhtml",
http.StatusBadRequest,
)
return
}
if format, err = decideParseAccept(parsedFmts, mediaJSON); err != nil {
if errors.Is(err, ErrUnsupportedMIME) {
s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s'.", req.RemoteAddr)
for mt := range mediaNoIndent {
okMedia = append(okMedia, mt)
}
req.Header.Set("Accept", strings.Join(okMedia, ", "))
http.Error(resp, "ERROR: No supported MIME type specified; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
return
} else {
s.log.Err("server.Server.handleDefault: Received unknown error choosing an Accept header for '%s': %v", req.RemoteAddr, err)
http.Error(resp, "ERROR: Unknown error occurred when negotiationg MIME type.", http.StatusInternalServerError)
return
}
}
}
s.log.Debug("server.Server.handleDefault: Using format '%s' for '%s'", format, req.RemoteAddr)
// If it's HTML and they want an include, that needs to be validated too.
if format == mediaHTML && vals.Has("include") {
doInclude = true
if parsedFmts, err = parseAccept(strings.Join(vals["include"], ", ")); err != nil {
s.log.Err("server.Server.handleDefault: Failed to parse include parameter: %v", err)
http.Error(
resp,
"ERROR: Invalid 'include' parameter value; see RFC 9110 § 12.5.1 and https://www.iana.org/assignments/media-types/media-types.xhtml",
http.StatusBadRequest,
)
return
}
if renderPage.RawFmt, err = decideParseAccept(parsedFmts, format); err != nil {
if errors.Is(err, ErrUnsupportedMIME) {
s.log.Err("server.Server.handleDefault: No supported MIME type found for '%#v' 'include'.", vals["include"], req.RemoteAddr)
for mt := range mediaNoIndent {
okMedia = append(okMedia, mt)
}
req.Header.Set("Accept", strings.Join(okMedia, ", "))
http.Error(resp, "ERROR: No supported MIME type specified for 'include'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
return
} else {
s.log.Err("server.Server.handleDefault: Received unknown error choosing an include format for '%s': %v", req.RemoteAddr, err)
http.Error(resp, "ERROR: Unknown error occurred when negotiationg MIME type.", http.StatusInternalServerError)
return
}
}
// The indentation is set below.
}
// Determine indentation (if the format even supports it).
if format == mediaHTML {
if doInclude {
if _, ok = mediaIndent[renderPage.RawFmt]; ok {
doIndent = vals.Has("indent")
if doIndent {
if req.URL.Query().Get("indent") != "" {
renderPage.RawIndent = req.URL.Query().Get("indent")
renderPage.DoRawIndent = true
}
}
} else if _, ok = mediaNoIndent[renderPage.RawFmt]; !ok {
// It's not a supported MIME.
s.log.Err("server.Server.handleDefault: Requested MIME type '%s' for '%s' unsupported.", renderPage.RawFmt, req.RemoteAddr)
for mt := range mediaNoIndent {
okMedia = append(okMedia, mt)
}
req.Header.Set("Accept", strings.Join(okMedia, ", "))
http.Error(
resp,
fmt.Sprintf("ERROR: MIME type '%s' unsupported for 'include'; see Accept header in response for valid types.", renderPage.RawFmt),
http.StatusNotAcceptable,
)
return
} else {
// This seems backwards, but "non-indented" formats actually need indenting enabled so their whitespace renders properly.
renderPage.DoRawIndent = true
}
}
} else {
if _, ok = mediaIndent[format]; ok {
doIndent = vals.Has("indent")
if doIndent {
if req.URL.Query().Get("indent") != "" {
indent = req.URL.Query().Get("indent")
}
}
} else if _, ok = mediaNoIndent[format]; !ok {
// It's not a supported MIME.
s.log.Err("server.Server.handleDefault: Requested MIME type '%s' for '%s' unsupported.", format, req.RemoteAddr)
for mt := range mediaNoIndent {
okMedia = append(okMedia, mt)
}
req.Header.Set("Accept", strings.Join(okMedia, ", "))
http.Error(resp, fmt.Sprintf("ERROR: MIME type '%s' unsupported; see Accept header in response for valid types.", format), http.StatusNotAcceptable)
return
}
}
// Now render the response.
if format == mediaHTML {
// This gets special treatment since it's templated.
resp.Header().Set("Content-Type", "text/html; charset=utf-8")
if doInclude {
renderPage.Raw = new(string)
if doIndent {
if b, err = mediaIndent[renderPage.RawFmt](client, "", renderPage.RawIndent); err != nil {
s.log.Err("server.Server.handleDefault: Failed to render indented raw '%s' for '%s': %v", renderPage.RawFmt, req.RemoteAddr, err)
http.Error(resp, fmt.Sprintf("ERROR: Failed to render 'include' '%s'", renderPage.RawFmt), http.StatusInternalServerError)
return
}
} else {
if b, err = mediaNoIndent[renderPage.RawFmt](client); err != nil {
s.log.Err("server.Server.handleDefault: Failed to render raw '%s' for '%s': %v", renderPage.RawFmt, req.RemoteAddr, err)
http.Error(resp, fmt.Sprintf("ERROR: Failed to render '%s'", renderPage.RawFmt), http.StatusInternalServerError)
return
}
}
*renderPage.Raw = string(b)
}
if err = tpl.ExecuteTemplate(resp, "index", renderPage); err != nil {
s.log.Err("server.Server.handleDefault: Failed to execute template for '%s': %v", req.RemoteAddr, err)
http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
return
}
} else {
resp.Header().Set("Content-Type", format)
if doIndent {
// This was already filtered to valid specified MIME above.
if b, err = mediaIndent[format](client, "", indent); err != nil {
s.log.Err("server.Server.handleDefault: Failed to render indented '%s' for '%s': %v", format, req.RemoteAddr, err)
http.Error(resp, fmt.Sprintf("ERROR: Failed to render '%s'", format), http.StatusInternalServerError)
return
}
} else {
if b, err = mediaNoIndent[format](client); err != nil {
s.log.Err("server.Server.handleDefault: Failed to render '%s' for '%s': %v", format, req.RemoteAddr, err)
http.Error(resp, fmt.Sprintf("ERROR: Failed to render '%s'", format), http.StatusInternalServerError)
return
}
}
if _, err = resp.Write(b); err != nil {
s.log.Err("server.Server.handleDefault: Failed to serve indented '%s' to '%s': %v", format, req.RemoteAddr, err)
return
}
}
s.log.Debug("server.Server.handleDefault: Handled request:\n%s", spew.Sdump(req))
return
}
func (s *Server) handleDefaultNew(resp http.ResponseWriter, req *http.Request) {
var err error
var page *Page
var uas []string
var reqdMimes []string
var parsedUA *R00tClient
var nAP netip.AddrPort
var remAddrPort string
var parsedFmts []*parsedMIME
var renderer outerRenderer
var includeFmt string
var params url.Values = make(url.Values)
var outerFmt string = mediaJSON
s.log.Debug("server.Server.handleDefault: Handling request:\n%s", spew.Sdump(req))
page = &Page{
Info: &R00tInfo{
Client: nil,
IP: nil,
Port: 0,
Headers: XmlHeaders(req.Header),
Req: req,
},
PageType: "index",
Raw: nil,
RawFmt: nil,
Indent: "",
DoIndent: false,
}
// First the client info.
remAddrPort = req.RemoteAddr
if s.isHttp && req.Header.Get(httpRealHdr) != "" {
// TODO: WHitelist explicit reverse proxy addr(s)?
remAddrPort = req.Header.Get(httpRealHdr)
req.Header.Del(httpRealHdr)
}
if remAddrPort != "" {
if nAP, err = netip.ParseAddrPort(remAddrPort); err != nil {
s.log.Warning("server.Server.handleDefault: Failed to parse remote address '%s': %v", remAddrPort, err)
// Don't return an error in case we're doing weird things like direct socket clients.
/*
http.Error(resp, "ERROR: Failed to parse client address", http.StatusInternalServerError)
return
*/
err = nil
}
page.Info.IP = net.ParseIP(nAP.Addr().String())
page.Info.Port = nAP.Port()
}
if req.URL != nil {
params = req.URL.Query()
}
uas = req.Header.Values("User-Agent")
if uas != nil && len(uas) > 0 {
page.Info.Client = make([]*R00tClient, 0, len(uas))
for _, ua := range uas {
if parsedUA, err = NewClient(ua); err != nil {
s.log.Err("server.Server.handleDefault: Failed to create client for '%s': %v", ua, err)
http.Error(resp, fmt.Sprintf("ERROR: Failed to parse 'User-Agent' '%s'", ua), http.StatusInternalServerError)
return
}
page.Info.Client = append(page.Info.Client, parsedUA)
}
}
if page.Info.Client != nil && len(page.Info.Client) > 0 {
// Check the passed UAs for a browser. We then change the "default" format if so.
for _, ua := range page.Info.Client {
if ua.IsMobile || ua.IsDesktop {
outerFmt = mediaHTML
break
}
}
}
/*
At this point, the outer format *default*, client IP, client port, and client version (UA) is set.
From here, we handle explicit content requests/overrides.
*/
// `Accept` request header...
reqdMimes = req.Header.Values("Accept")
if reqdMimes != nil && len(reqdMimes) > 0 {
if parsedFmts, err = parseAccept(strings.Join(reqdMimes, ",")); err != nil {
s.log.Err("server.Server.handleDefault: Failed to parse Accept header '%#v' for '%s': %v", reqdMimes, remAddrPort, err)
resp.Header()["Accept"] = okAcceptMime
http.Error(
resp,
"ERROR: Invalid 'Accept' header value; see RFC 9110 § 12.5.1, https://www.iana.org/assignments/media-types/media-types.xhtml, and this response's 'Accept' header.",
http.StatusBadRequest,
)
return
}
if outerFmt, err = decideParseAccept(parsedFmts, outerFmt); err != nil {
if errors.Is(err, ErrUnsupportedMIME) {
s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s' via '%#v'.", remAddrPort, reqdMimes)
req.Header["Accept"] = okAcceptMime
http.Error(resp, "ERROR: No supported MIME type specified via request 'Accept'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
return
} else {
s.log.Err("server.Server.handleDefault: Received unknown error choosing from Accept header for '%s': %v", remAddrPort, err)
http.Error(resp, "ERROR: Unknown error occurred when negotiating MIME type.", http.StatusInternalServerError)
return
}
}
}
// `mime` URL query parameter.
if params.Has("mime") {
if parsedFmts, err = parseAccept(strings.Join(params["mime"], ",")); err != nil {
s.log.Err("server.Server.handleDefault: Failed to parse 'mime' URL parameter '%#v' for '%s': %v", params["mime"], remAddrPort, err)
resp.Header()["Accept"] = okAcceptMime
http.Error(
resp,
"ERROR: Invalid 'mime' URL parameter value; see RFC 9110 § 12.5.1, https://www.iana.org/assignments/media-types/media-types.xhtml, and this response's 'Accept' header.",
http.StatusBadRequest,
)
return
}
if outerFmt, err = decideParseAccept(parsedFmts, outerFmt); err != nil {
if errors.Is(err, ErrUnsupportedMIME) {
s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s' via '%#v'.", remAddrPort, params["mime"])
req.Header["Accept"] = okAcceptMime
http.Error(resp, "ERROR: No supported MIME type specified via URL parameter 'mime'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
return
} else {
s.log.Err("server.Server.handleDefault: Received unknown error choosing from 'mime' URL parameter for '%s': %v", remAddrPort, err)
http.Error(resp, "ERROR: Unknown error occurred when negotiating MIME type.", http.StatusInternalServerError)
return
}
}
}
// 'include' URL query parameter (only for text/html).
if outerFmt == mediaHTML && params.Has("include") {
if parsedFmts, err = parseAccept(strings.Join(params["include"], ",")); err != nil {
s.log.Err("server.Server.handleDefault: Failed to parse 'include' URL parameter '%#v' for '%s': %v", params["include"], remAddrPort, err)
resp.Header()["Accept"] = okAcceptMime
http.Error(
resp,
"ERROR: Invalid 'include' URL parameter value; see RFC 9110 § 12.5.1, https://www.iana.org/assignments/media-types/media-types.xhtml, and this response's 'Accept' header.",
http.StatusBadRequest,
)
return
}
if includeFmt, err = decideParseAccept(parsedFmts, includeFmt); err != nil {
if errors.Is(err, ErrUnsupportedMIME) {
s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s' via '%#v'.", remAddrPort, params["include"])
req.Header["Accept"] = okAcceptMime
http.Error(resp, "ERROR: No supported MIME type specified via URL parameter 'include'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
return
} else {
s.log.Err("server.Server.handleDefault: Received unknown error choosing from 'include' URL parameter for '%s': %v", remAddrPort, err)
http.Error(resp, "ERROR: Unknown error occurred when negotiating MIME type.", http.StatusInternalServerError)
return
}
}
if includeFmt != "" {
page.RawFmt = new(string)
*page.RawFmt = includeFmt
}
}
// 'indent' URL query parameter.
if params.Has("indent") {
page.DoIndent = true
if params.Get("indent") != "" {
page.Indent = params.Get("indent")
} else {
page.Indent = dfltIndent
}
}
switch outerFmt {
case mediaJSON:
renderer = s.renderJSON
case mediaHTML:
renderer = s.renderHTML
case mediaXML:
renderer = s.renderXML
case mediaYAML:
renderer = s.renderYML
default:
s.log.Err("server.Server.handleDefault: Unknown output format '%s'", outerFmt)
http.Error(resp, "ERROR: Unable to determine default renderer.", http.StatusInternalServerError)
return
}
if err = renderer(page, resp); err != nil {
s.log.Err("server.Server.handleDefault: Failed to render request from '%s' as '%s': %v", remAddrPort, outerFmt, err)
// The renderer handles the error-handling with the client.
return
}
return
}
func (s *Server) handleAbout(resp http.ResponseWriter, req *http.Request) {
var err error
var renderPage *Page = &Page{
Info: &R00tInfo{
Req: req,
},
PageType: "about",
}
s.log.Debug("server.Server.handleAbout: Handling request:\n%s", spew.Sdump(req))
resp.Header().Set("Content-Type", "text/html; charset=utf-8")
if err = tpl.ExecuteTemplate(resp, "about", renderPage); err != nil {
s.log.Err("server.Server.handleAbout: Failed to execute template for '%s': %v", req.RemoteAddr, err)
http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
return
}
s.log.Debug("server.Server.handleAbout: Handled request:\n%s", spew.Sdump(req))
return
}
func (s *Server) handleUsage(resp http.ResponseWriter, req *http.Request) {
var err error
var renderPage *Page = &Page{
Info: &R00tInfo{
Req: req,
},
PageType: "usage",
}
s.log.Debug("server.Server.handleUsage: Handling request:\n%s", spew.Sdump(req))
resp.Header().Set("Content-Type", "text/html; charset=utf-8")
if err = tpl.ExecuteTemplate(resp, "usage", renderPage); err != nil {
s.log.Err("server.Server.handleAbout: Failed to execute template for '%s': %v", req.RemoteAddr, err)
http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
return
}
s.log.Debug("server.Server.handleUsage: Handled request:\n%s", spew.Sdump(req))
return
}
func (s *Server) renderJSON(page *Page, resp http.ResponseWriter) (err error) {
var b []byte
if page.DoIndent {
if b, err = json.MarshalIndent(page.Info, "", page.Indent); err != nil {
s.log.Err("server.Server.renderJSON: Failed to render to indented JSON: %v", err)
http.Error(resp, "ERROR: Failed to render JSON", http.StatusInternalServerError)
return
}
} else {
if b, err = json.Marshal(page.Info); err != nil {
s.log.Err("server.Server.renderJSON: Failed to render to JSON: %v", err)
http.Error(resp, "ERROR: Failed to render JSON", http.StatusInternalServerError)
return
}
}
if _, err = resp.Write(b); err != nil {
s.log.Err("server.Server.renderJSON: Failed to send JSON: %v", err)
return
}
return
}
func (s *Server) renderHTML(page *Page, resp http.ResponseWriter) (err error) {
var b []byte
if page.RawFmt != nil {
switch *page.RawFmt {
case mediaHTML:
_ = "" // Explicit no-op; we're *serving* HTML.
// Indentable
case mediaJSON, mediaXML:
if page.DoIndent {
if b, err = mediaIndent[*page.RawFmt](page.Info, "", page.Indent); err != nil {
s.log.Err("server.Server.renderHTML: Failed to render to indented include '%s': %v", *page.RawFmt, err)
http.Error(resp, "ERROR: Failed to render include format", http.StatusInternalServerError)
return
}
} else {
if b, err = mediaNoIndent[*page.RawFmt](page.Indent); err != nil {
s.log.Err("server.Server.renderHTML: Failed to render to include '%s': %v", *page.RawFmt, err)
http.Error(resp, "ERROR: Failed to render include format", http.StatusInternalServerError)
return
}
}
// Non-indentable
case mediaYAML:
if b, err = mediaNoIndent[*page.RawFmt](page.Info); err != nil {
s.log.Err("server.Server.renderHTML: Failed to render to '%s': %v", *page.RawFmt, err)
}
}
page.Raw = new(string)
*page.Raw = string(b)
}
if err = tpl.ExecuteTemplate(resp, "index", page); err != nil {
s.log.Err("server.Server.renderHTML: Failed to render template: %v", err)
http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
return
}
return
}
func (s *Server) renderXML(page *Page, resp http.ResponseWriter) (err error) {
var b []byte
if page.DoIndent {
if b, err = xml.MarshalIndent(page.Info, "", page.Indent); err != nil {
s.log.Err("server.Server.renderXML: Failed to render to indented XML: %v", err)
http.Error(resp, "ERROR: Failed to render XML", http.StatusInternalServerError)
return
}
} else {
if b, err = xml.Marshal(page.Info); err != nil {
s.log.Err("server.Server.renderXML: Failed to render to XML: %v", err)
http.Error(resp, "ERROR: Failed to render XML", http.StatusInternalServerError)
return
}
}
if _, err = resp.Write(b); err != nil {
s.log.Err("server.Server.renderXML: Failed to send XML: %v", err)
return
}
return
}
func (s *Server) renderYML(page *Page, resp http.ResponseWriter) (err error) {
var b []byte
if b, err = yaml.Marshal(page.Info); err != nil {
s.log.Err("server.Server.renderJSON: Failed to render to JSON: %v", err)
http.Error(resp, "ERROR: Failed to render JSON", http.StatusInternalServerError)
return
}
if _, err = resp.Write(b); err != nil {
s.log.Err("server.Server.renderJSON: Failed to send JSON: %v", err)
return
}
return
}

95
server/funcs_test.go Normal file
View File

@@ -0,0 +1,95 @@
package server
import (
`encoding/json`
`encoding/xml`
`fmt`
"testing"
`github.com/davecgh/go-spew/spew`
`github.com/goccy/go-yaml`
)
func TestNewClient(t *testing.T) {
var err error
var b []byte
var r *R00tClient
for _, s := range []string{
"Mozilla/5.0 " +
"(X11; Linux x86_64) " +
"AppleWebKit/537.36 " +
"(KHTML, like Gecko) " +
"Chrome/131.0.0.0 " +
"Safari/537.36", // Chrome
"Mozilla/5.0 " +
"(X11; Linux x86_64; rv:133.0) " +
"Gecko/20100101 " +
"Firefox/133.0", // Firefox
"curl/8.11.0", // Curl
"Wget/1.25.0", // Wget
} {
t.Logf("Raw UA: '%s'\n\n", s)
if r, err = NewClient(s); err != nil {
t.Fatal(err)
}
if b, err = json.Marshal(r); err != nil {
t.Fatal(err)
}
fmt.Println(string(b))
t.Logf("R00tClient:\n%s\n\n\n", spew.Sdump(r))
}
}
func TestExplicitContent(t *testing.T) {
var b []byte
var err error
var r *R00tClient = &R00tClient{
ClientVer: &Ver{
Major: 1,
Minor: 2,
Patch: 3,
},
OSVer: &Ver{
Major: 9,
Minor: 8,
Patch: 7,
},
URL: new(string),
String: new(string),
Name: new(string),
ClientVerStr: new(string),
OS: new(string),
OsVerStr: new(string),
Dev: new(string),
IsMobile: false,
IsTablet: false,
IsDesktop: false,
IsBot: false,
}
*r.URL = "https://datatracker.ietf.org/doc/html/rfc2324.html"
*r.String = "(COMPLETE USER AGENT STRING)"
*r.Name = "coffee_pot"
*r.ClientVerStr = "1.2.3"
*r.OS = "JavaOS"
*r.OsVerStr = "9.8.7"
*r.Dev = "mocha-latte"
if b, err = json.MarshalIndent(r, "", " "); err != nil {
t.Fatal(err)
}
fmt.Println(string(b))
if b, err = xml.Marshal(r); err != nil {
t.Fatal(err)
}
fmt.Println(string(b))
if b, err = yaml.Marshal(r); err != nil {
t.Fatal(err)
}
fmt.Println(string(b))
}

18
server/funcs_tpl.go Normal file
View File

@@ -0,0 +1,18 @@
package server
import (
`fmt`
`strings`
)
func getTitle(subPage string) (title string) {
if subPage == "" || subPage == "index" {
title = baseTitle
return
}
title = fmt.Sprintf("%s%s%s", baseTitle, titleSep, strings.ToTitle(subPage))
return
}

160
server/funcs_xmlheaders.go Normal file
View File

@@ -0,0 +1,160 @@
package server
import (
`encoding/xml`
`errors`
`io`
)
/*
MarshalXML encodes an XmlHeaders as XML in the following format:
(<headers>)
<header name="SomeHeader">
<value>SomeValue</value>
</header>
<header name="SomeMultiValueHeader">
<value>Foo</value>
<value>Bar</value>
</header>
(</headers>)
For the above example, the field should be specified as `xml:"headers"`.
However, the parent element name may be whatever you wish.
*/
func (x XmlHeaders) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) {
var curKey string
var vals []string
var val string
var hdr xml.StartElement
var child xml.StartElement
// TODO: Does xml.EncodeElement properly escape?
// var escKBuf *bytes.Buffer
// var escVBuf *bytes.Buffer
// All values are []string, so we don't need any fancy parsing or switching or the like.
// We do need to make sure we escape, though.
if err = e.EncodeToken(start); err != nil {
return
}
if x != nil && len(x) > 0 {
// escKBuf = new(bytes.Buffer)
// escVBuf = new(bytes.Buffer)
for curKey, vals = range x {
// escKBuf.Reset()
// if err = xml.EscapeText(escKBuf, []byte(curKey)); err != nil {
// return
// }
hdr = xml.StartElement{
Name: xml.Name{
Local: xmlHdrElem,
},
Attr: []xml.Attr{
xml.Attr{
Name: xml.Name{
Local: xmlHdrElemName,
},
// Value: escKBuf.String(),
Value: curKey,
},
},
}
if err = e.EncodeToken(hdr); err != nil {
return
}
for _, val = range vals {
// escVBuf.Reset()
// if err = xml.EscapeText(escVBuf, []byte(val)); err != nil {
// return
// }
child = xml.StartElement{
Name: xml.Name{
Local: xmlHdrVal,
},
}
// if err = e.EncodeElement(escVBuf.String(), child); err != nil {
if err = e.EncodeElement(val, child); err != nil {
return
}
}
if err = e.EncodeToken(hdr.End()); err != nil {
return
}
}
}
if err = e.EncodeToken(start.End()); err != nil {
return
}
return
}
// UnmarshalXML populates an XMLHeaders from an XML representation. See MarshalXML for example XML.
func (x *XmlHeaders) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err error) {
var tok xml.Token
var xm XmlHeaders
var hdrNm string
var vals []string
var val *string
var nameFound bool
for {
if tok, err = d.Token(); err != nil {
if errors.Is(err, io.EOF) {
err = nil
break
} else {
return
}
}
switch elem := tok.(type) {
case xml.StartElement:
switch elem.Name.Local {
case xmlHdrElem:
nameFound = false
vals = nil
for _, a := range elem.Attr {
if a.Name.Local == xmlHdrElemName {
nameFound = true
hdrNm = a.Value
break
}
}
if !nameFound {
continue
}
case xmlHdrVal:
if !nameFound {
continue
}
if vals == nil {
vals = make([]string, 0, 1)
}
val = new(string)
if err = d.DecodeElement(val, &elem); err != nil {
return
}
vals = append(vals, *val)
}
case xml.EndElement:
if elem.Name.Local != xmlHdrElem {
continue
}
if xm == nil {
xm = make(XmlHeaders)
}
xm[hdrNm] = vals
}
}
if xm != nil {
*x = xm
}
return
}

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

@@ -0,0 +1,45 @@
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
{{- define "about" }}
{{- $page := . -}}
{{- $linkico := "🔗" }}
{{ template "meta.top" $page }}
<div class="jumbotron">
<h1>About</h1>
</div>
<p>
This is a tool to reveal certain information about your connection that the server sees.
Note that all of this information you see is <i>sent by your client</i>;
there was no probing/scanning or the like done from the server this site is hosted on.
</p>
<p>
If you don't like this info being available to server administrators of the websites
you visit you may want to consider:
<ul>
<li><a href="https://www.torproject.org/">hiding your client IP address</a></li>
<li>
<a href="https://panopticlick.eff.org/self-defense">hiding your browser's metadata, which can be done via browser plugins such as:</a>
<ul>
<li><a href="https://www.eff.org/privacybadger">Privacy Badger</a></li>
<li><a href="https://addons.mozilla.org/en-US/firefox/addon/modify-headers/">Modify Headers</a></li>
<li><a href="https://www.requestly.in/">Requestly</a></li>
</ul>
</li>
</ul>
There are, of course, many other plugins/methods but as always, due diligence is required when finding the right plugin for you.
Be sure to read multiple reviews.
Some plugins/extensions even disguise your browser as an entirely different operating system, OS version, etc.
Feel free to check back on this site after enabling them to test! (You may need to reset your browser's cache.)
</p>
<p>
If you would like to view the <i>server</i> headers, then you can:
<ul>
<li>use a service such as <a href="https://securityheaders.io">SecurityHeaders.io</a></li>
<li>use the <code>--head</code> (<code>-i</code>, <code>-X HEAD</code>, <code>--request HEAD</code>, etc.; all do the same thing) argument to <b><code>curl</code></b></li>
<li>use the <code>-v</code> (<code>--verbose</code>) argument to <b><code>curl</code></b></li>
<li>use your browser's built-in developer console (<a href="https://firefox-source-docs.mozilla.org/devtools-user/">on Firefox</a>, <a href="https://developer.chrome.com/docs/devtools/open">on Chrome and Chrome-based browsers</a>)</li>
</ul>
There are additionally some extensions/plugins that offer this in a directly-accessible button on the toolbar.
</p>
<br />
{{- template "meta.bottom" $page }}
{{- end }}

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

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,95 @@
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
{{- define "usage" }}
{{- $page := . -}}
{{- $linkico := "🔗" }}
{{- template "meta.top" $page }}
<div class="jumbotron">
<h1>Usage</h1>
</div>
<h2 id="usage_params">Parameters<a href="#usage_params">{{ $linkico }}</a></h2>
<p>
You can control how the <a href="/">main page</a> displays/renders.
By default, it will try to "guess" what you want; e.g. if you access it in Chrome, it will return HTML but if you fetch via Curl, you'll get raw JSON
(or your specified data format; see below). If the classification of client can't be determined and an <code>Accept</code> wasn't specified,
a fallback to text-mode (by default <code>application/json</code>) will be returned.
<br/>
You can force a specific raw output by specifying the <a href="https://www.iana.org/assignments/media-types/media-types.xhtml">MIME type</a> via
<a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1">the <code>Accept</code> header (RFC 9110 &sect; 12.5.1)</a>, which may be one of:
<ul>
<li><code>application/json</code> for <a href="https://www.rfc-editor.org/rfc/rfc8259.html">JSON</a></li>
<li><code>application/xml</code> for <a href="https://www.rfc-editor.org/rfc/rfc7303.html">XML</a></li>
<li><code>application/yaml</code> for <a href="https://www.rfc-editor.org/rfc/rfc9512.html">YAML</a></li>
<li><code>text/html</code> for <a href="https://www.rfc-editor.org/rfc/rfc2854.html">HTML</a></li>
</ul>
For example: <code>Accept: application/json</code> will return JSON.
<br/>
If unspecified and it is a text-mode client (e.g. Curl), the default is <code>application/json</code>.
<code>text/html</code> may be used to force an HTML response from a text-only client,
just as one of the <code>application/*</code> MIME types above may be used to force that "raw" text MIME type for a "graphical" browser client.
The specification as defined by <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1">RFC 9110 &sect; 12.5.1</a> is completely
valid to pass and will be parsed without error (provided the header value is RFC-compliant and IANA-compliant),
though note that <code>application/xml</code> and <code>text/html</code>'s <code>charset</code> parameter will be entirely ignored;
the returned XML/HTML is <b>always</b> Unicode (with UTF-8 encoding).
<br/>
If no selectable MIME type is provided but an <code>Accept</code> was given, an error will be returned; specifically, a
<a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.7"><code>406</code> status code (RFC 9110 &sect; 15.5.7)</a>.
In this case, supported MIME types will be returned in the response's <code>Accept</code> header.
<br/>
Note that <a href="https://lynx.invisible-island.net/">Lynx</a> and <a href="http://elinks.or.cz/">Elinks</a> are considered "graphical"
browsers by this program as they are HTML-centric.
</p>
<p id="usage_params_mod">
The following parameters control/modify behavior.<a href="#usage_params_mod">{{ $linkico }}</a>
<ul>
<li>
<b>mime:</b> Specify an explicit MIME type via URL instead of the <code>Accept</code> header as specified above.
<ul>
<li>This should only be used by clients in which it is impossible or particularly cumbersome to modify/specify headers.
<code>Accept</code> is more performant.</li>
<li>Only the first supported instance of this parameter will be used.</li>
<li>Any of the defined MIME types above may be specified (e.g. <code>?mime=application/json</code>).</li>
<li>If both this URL query parameter and the <code>Accept</code> header is specified, the URL query takes preference.</li>
</ul>
</li>
<li>
<b>include:</b> Include a <code>&lt;code&gt;</code> (or <code>&lt;pre&gt;</code>, depending on if indentation is needed/requested) block in the HTML for the specified MIME type as well.</li>
<ul>
<li>Only the first supported instance of this parameter will be used.</li>
<li>
The value <b>must</b> conform to the same rules/specifications as the <code>mime</code> parameter/<code>Accept</code> header.
<ul>
<li><code>include</code> may <b>not</b> be <code>text/html</code>; it will be ignored if this is set. Just learn to <code>ctrl+u</code>.</li>
</ul>
</li>
<li>Only used if the evaluated return is HTML, ignored otherwise.</li>
<li>Indentation can be specified via the <b>indent</b> parameter below (since indentation is otherwise meaningless to HTML returns).</li>
</ul>
</li>
<li>
<b>indent:</b> Enable/specify indentation for JSON and XML output; ignored for others.
<ul>
<li>The default is to not indent. (Commonly referred to as "condensed" or "compressed" JSON/XML.)</li>
<li>Only the first specified instance of this parameter will be used.</li>
<li>If specified with a string value, use that string as each indent.
<ul>
<li>Be mindful of URL query parameter encoding,
per <a href="https://www.rfc-editor.org/rfc/rfc3986.html#section-3.4">RFC 3986 &sect; 3.4</a>
and <a href="https://www.rfc-editor.org/rfc/rfc8820.html#section-2.4">RFC 8820 &sect; 2.4</a></li>
<li>For quick reference and as an example, to indent with a <a href="https://asciiref.dev/#c9">tab</a>
(<code>\t</code>, <code>0x09</code>) for each level, use <code>?indent=%09</code></li>
</ul>
</li>
<li>If indentation is specified without any value (<code>?indent</code>), the default is two
<a href="https://asciiref.dev/#c32">spaces</a> (<code>0x20</code>); this would be represented
as <code>?indent=%20%20</code></li>
<li><code>?indent=</code> (no value specified) is equal to <code>?indent</code>.</li>
</ul>
</li>
</ul>
</p>
{{- template "meta.bottom" $page }}
{{- end }}

107
server/types.go Normal file
View File

@@ -0,0 +1,107 @@
package server
import (
`encoding/xml`
`net`
`net/http`
`net/url`
`os`
`github.com/mileusna/useragent`
`r00t2.io/clientinfo/args`
`r00t2.io/goutils/logging`
)
type outerRenderer func(page *Page, resp http.ResponseWriter) (err error)
type XmlHeaders map[string][]string
// R00tInfo is the structure of data returned to the client.
type R00tInfo struct {
// XMLName is the element name/namespace of this object ("info").
XMLName xml.Name `json:"-" xml:"info" yaml:"-"`
// Client is the UA/Client info, if any passed by the client.
Client []*R00tClient `json:"ua,omitempty" xml:"ua,omitempty" yaml:"Client/User Agent,omitempty"`
// IP is the client IP address.
IP net.IP `json:"ip" xml:"ip,attr" yaml:"Client IP Address"`
// Port is the client's port number.
Port uint16 `json:"port" xml:"port,attr" yaml:"Client Port"`
// Headers are the collection of the request headers sent by the client.
Headers XmlHeaders `json:"headers" xml:"reqHeaders" yaml:"Request Headers"`
// Req contains the original request. It is not rendered but may be used for templating.
Req *http.Request `json:"-" xml:"-" yaml:"-"`
}
// R00tClient is the UA/Client info, if any passed by the client.
type R00tClient struct {
// XMLName is the element name/namespace of this object ("ua").
XMLName xml.Name `json:"-" xml:"ua" yaml:"-" uaField:"-" renderName:"-"`
// String contains the entire UA string.
String *string `json:"str,omitempty" xml:",chardata" yaml:"String"`
// ClientVer is a parsed version structure of the client version (see ClientVerStr for the combined string).
ClientVer *Ver `json:"ver,omitempty" xml:"version,omitempty" yaml:"Client Version,omitempty" uaField:"VersionNo" renderName:"-"`
// OSVer is the parsed OS version info of the client (see OsVersionStr for the combined string).
OSVer *Ver `json:"os_ver,omitempty" xml:"osVersion,omitempty" yaml:"Operating System Version,omitempty" uaField:"OSVersionNo" renderName:"-"`
// URL, if any, is the URL of the client.
URL *string `json:"url,omitempty" xml:"url,attr,omitempty" yaml:"URL,omitempty"`
// Name is the client software name.
Name *string `json:"name,omitempty" xml:"name,attr,omitempty" yaml:"Program/Name,omitempty"`
// ClientVerStr contains the full version as a string (see also Clientversion).
ClientVerStr *string `json:"ver_str,omitempty" xml:"verStr,attr,omitempty" yaml:"Client Version String,omitempty" uaField:"Version" renderName:"Client Version"`
// OS is the operating system of the client.
OS *string `json:"os,omitempty" xml:"os,attr,omitempty" yaml:"Operating System,omitempty"`
// OsVerStr is the version of the operating system of the client.
OsVerStr *string `json:"os_ver_str,omitempty" xml:"osVerStr,attr,omitempty" yaml:"Operating System Version String,omitempty" uaField:"OSVersion" renderName:"Operating System Version"`
// Dev is the device type.
Dev *string `json:"dev,omitempty" xml:"dev,attr,omitempty" yaml:"Device,omitempty" uaField:"Device"`
// IsMobile is true if this is a mobile device.
IsMobile bool `json:"mobile" xml:"mobile,attr" yaml:"Is Mobile" uaField:"Mobile"`
// sTablet is true if this is a tablet.
IsTablet bool `json:"tablet" xml:"tablet,attr" yaml:"Is Tablet" uaField:"Tablet"`
// IsDesktop is true if this is a desktop/laptop.
IsDesktop bool `json:"desktop" xml:"desktop,attr" yaml:"Is Desktop" uaField:"Desktop"`
// IsBot is true if this is a bot.
IsBot bool `json:"bot" xml:"bot,attr" yaml:"Is Bot" uaField:"Bot"`
ua *useragent.UserAgent
}
type Ver struct {
// XMLName xml.Name `json:"-" xml:"version" yaml:"-"`
Major int `json:"major" xml:"maj,attr" yaml:"Major"`
Minor int `json:"minor" xml:"min,attr" yaml:"Minor"`
Patch int `json:"patch" xml:"patch,attr" yaml:"Patch"`
}
// Page is only used for HTML rendering.
type Page struct {
Info *R00tInfo
// e.g. "index.html.tpl"; populated by handler
PageType string
// Nil unless `?include=` specified, otherwise a block of text to be wrapped in <code>...</code>.
Raw *string
// RawFmt is the MIME type for Raw, if `?include=` enabled/specified.
RawFmt *string
// Indent specifies the indentation string.
Indent string
// DoIndent indicates if indenting was enabled.
DoIndent bool
}
type Server struct {
log logging.Logger
args *args.Args
listenUri *url.URL
isHttp bool
mux *http.ServeMux
sock net.Listener
doneChan chan bool
stopChan chan os.Signal
reloadChan chan os.Signal
isStopping bool
}
// https://www.iana.org/assignments/media-types/media-types.xhtml
type parsedMIME struct {
MIME string
Weight float32 // Technically a param (q; "qualifier"?), but copied and converted here for easier sorting.
Params map[string]string
}