This commit is contained in:
Gustavo Maronato 2023-09-18 01:36:29 -03:00
commit 0a17831dae
Signed by: maronato
SSH Key Fingerprint: SHA256:2Gw7kwMz/As+2UkR1qQ/qYYhn+WNh3FGv6ozhoRrLcs
15 changed files with 1308 additions and 0 deletions

169
.dockerignore Normal file
View File

@ -0,0 +1,169 @@
# ---> DB
*.db
*.db-shm
*.db-wal
results.bin
fingers.yml
finger
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
/goshort
# ---> Node
#
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# >> Git and github
.github
.git

32
.github/workflows/go.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: Go
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: "1.21"
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.54
- name: build
run: make build
- name: test
run: make test

84
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,84 @@
name: Release
# Controls when the workflow will run
on:
release:
types:
- published
jobs:
buildpush:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- # Get the repository's code
name: Checkout
uses: actions/checkout@v2
- # https://github.com/vegardit/docker-gitea-act-runner/issues/23
name: Fix docker sock permissions
run: sudo chmod 666 /var/run/docker.sock
- # https://github.com/docker/setup-qemu-action
name: Set up QEMU
uses: docker/setup-qemu-action@v1
- # https://github.com/docker/setup-buildx-action
name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- # https://github.com/docker/login-action
name: Log in to the Container registry
uses: docker/login-action@v2
with:
# Maybe there is a default env var for this?
registry: git.maronato.dev
username: ${{ github.repository_owner }}}
# Ideally, we should only need to set "permissions: package: write", but
# Gitea is having issues with that. For now, this is a manually created
# token available user-wise, with the "package:write" permission.
password: ${{ secrets.PACKAGE_WRITE_TOKEN }}
- # https://github.com/docker/metadata-action
# Generate tags and labels for the image
# according to the commit and the branch
name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
# The container image name needs the custom registry in it.
# Maybe there is a default env var for this?
images: git.maronato.dev/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- # httos://github.com/actions/cache
name: Cache Docker layers
uses: actions/cache@v3
with:
path: |
/go/pkg/mod/
/tmp/.go-build-cache
/tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- # https://github.com/docker/build-push-action
name: Build and push
uses: docker/build-push-action@v2
with:
context: .
build-args: |
VERSION=${{ steps.meta.outputs.version }}
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- # Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

165
.gitignore vendored Normal file
View File

@ -0,0 +1,165 @@
# ---> DB
*.db
*.db-shm
*.db-wal
results.bin
fingers.yml
finger
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
/goshort
# ---> Node
#
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

104
.golangci.yml Normal file
View File

@ -0,0 +1,104 @@
linters:
disable-all: true
enable:
- asciicheck
- bidichk
- bodyclose
- containedctx
- contextcheck
# - cyclop
- decorder
# - dogsled
# - dupl
- durationcheck
- errcheck
- errchkjson
- errname
- errorlint
- execinquery
- exhaustive
- exportloopref
- forbidigo
- forcetypeassert
- gci
- gochecknoglobals
- gochecknoinits
# - gocognit
- goconst
- gocritic
- gocyclo
- godot
- goerr113
- gofmt
- gofumpt
- goheader
- goimports
- gomnd
- gomodguard
- gosec
- gosimple
- gosmopolitan
- govet
- importas
- ineffassign
- ireturn
- loggercheck
- maintidx
- makezero
- mirror
- misspell
- musttag
- nakedret
- nestif
- nilerr
- nilnil
- nlreturn
- noctx
- nolintlint
- nosprintfhostport
- prealloc
- predeclared
- promlinter
- reassign
- revive
- rowserrcheck
- sqlclosecheck
- staticcheck
- stylecheck
- tagalign
- tagliatelle
- testpackage
- thelper
- tparallel
- typecheck
- unconvert
- unparam
- unused
- usestdlibvars
# - varnamelen
- wastedassign
- whitespace
- wrapcheck
- wsl
linters-settings:
varnamelen:
min-name-length: 2
ignore-decls:
- l *zap.Logger
- l *slog.Logger
- l slog.Logger
- w http.ResponseWriter
- r chi.Router
- fs *flag.FlagSet
- r *http.Request
- eg *errgroup.Group
- sw *statuswriter.StatusWriter
- db *bun.DB
gocritic:
enabled-tags:
- diagnostic
- style
- performance
- experimental
- opinionated

1
.tool-versions Normal file
View File

@ -0,0 +1 @@
golang 1.21.0

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"editor.tabSize": 2
}

48
Dockerfile Normal file
View File

@ -0,0 +1,48 @@
# Load golang image
FROM golang:1.21-alpine as builder
RUN apk add make
ARG VERSION=undefined
WORKDIR /go/src/app
# Set our build environment
ENV GOCACHE=/tmp/.go-build-cache
# This variable communicates to the service that it's running inside
# a docker container.
ENV ENV_DOCKER=true
# Copy dockerignore files
COPY .dockerignore ./
# Install go deps using the cache
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/tmp/.go-build-cache \
go mod download -x
COPY Makefile ./
# Copy source files
COPY main.go ./
# Build it
RUN --mount=type=cache,target=/tmp/.go-build-cache \
make backend VERSION=$VERSION
# Now create a new image with just the binary
FROM gcr.io/distroless/static-debian11:nonroot
WORKDIR /app
# Set our runtime environment
ENV ENV_DOCKER=true
COPY --from=builder /go/src/app/finger /usr/local/bin/finger
HEALTHCHECK CMD [ "finger", "healthcheck" ]
EXPOSE 8080
ENTRYPOINT [ "finger" ]
CMD [ "serve" ]

23
Makefile Normal file
View File

@ -0,0 +1,23 @@
BINARY_NAME=finger
VERSION=$(shell git describe --tags --abbrev=0 || echo "undefined")
all: lint build test
build:
go build -ldflags="-X 'main.version=${VERSION}'" -o ${BINARY_NAME} main.go
test:
go test -v ./...
serve:
go run main.go serve
clean:
go clean
rm ${BINARY_NAME}
lint:
golangci-lint run
lint-fix:
golangci-lint run --fix

0
README.md Normal file
View File

10
go.mod Normal file
View File

@ -0,0 +1,10 @@
module git.maronato.dev/maronato/finger
go 1.21.0
require (
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
golang.org/x/sync v0.3.0
gopkg.in/yaml.v3 v3.0.1
)

14
go.sum Normal file
View File

@ -0,0 +1,14 @@
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3 h1:fpyiFVEJvxIFljxM4l5ANSk/UGlM1gyU+hPAr9jhB7M=
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3/go.mod h1:H/13DK46DKXy7EaIxPhk2Y0EC8aubKm35nBjBe8AAGc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

570
main.go Normal file
View File

@ -0,0 +1,570 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/mail"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"github.com/peterbourgon/ff/v4"
"github.com/peterbourgon/ff/v4/ffhelp"
"golang.org/x/exp/slog"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v3"
)
const appName = "finger"
// Version of the application.
var version = "dev"
func main() {
// Run the server
if err := Run(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func Run() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Allow graceful shutdown
trapSignalsCrossPlatform(cancel)
cfg := &Config{}
// Create a logger and add it to the context
l := NewLogger(cfg)
ctx = WithLogger(ctx, l)
// Create a new root command
subcommands := []*ff.Command{
NewServerCmd(cfg),
NewHealthcheckCmd(cfg),
}
cmd := NewRootCmd(cfg, subcommands)
// Parse and run
if err := cmd.ParseAndRun(ctx, os.Args[1:], ff.WithEnvVarPrefix("WF")); err != nil {
if errors.Is(err, ff.ErrHelp) || errors.Is(err, ff.ErrNoExec) {
fmt.Fprintf(os.Stderr, "\n%s\n", ffhelp.Command(cmd))
return nil
}
return fmt.Errorf("error running command: %w", err)
}
return nil
}
func NewServerCmd(cfg *Config) *ff.Command {
return &ff.Command{
Name: "serve",
Usage: "serve [flags]",
ShortHelp: "Start the webfinger server",
Exec: func(ctx context.Context, args []string) error {
l := LoggerFromContext(ctx)
// Parse the webfinger files
fingermap, err := ParseFingerFile(ctx, cfg)
if err != nil {
return fmt.Errorf("error parsing finger files: %w", err)
}
l.Info(fmt.Sprintf("Loaded %d webfingers", len(fingermap)))
// Start the server
if err := StartServer(ctx, cfg, fingermap); err != nil {
return fmt.Errorf("error running server: %w", err)
}
return nil
},
}
}
func NewHealthcheckCmd(cfg *Config) *ff.Command {
return &ff.Command{
Name: "healthcheck",
Usage: "healthcheck [flags]",
ShortHelp: "Check if the server is running",
Exec: func(ctx context.Context, args []string) error {
// Create a new client
client := &http.Client{
Timeout: 5 * time.Second, //nolint:gomnd // We want to use a constant
}
// Create a new request
reqURL := url.URL{
Scheme: "http",
Host: net.JoinHostPort(cfg.Host, cfg.Port),
Path: "/healthz",
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), http.NoBody)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
// Send the request
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
// Check the response
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned status %d", resp.StatusCode) //nolint:goerr113 // We want to return an error
}
return nil
},
}
}
type loggerCtxKey struct{}
// NewLogger creates a new logger with the given debug level.
func NewLogger(cfg *Config) *slog.Logger {
level := slog.LevelInfo
addSource := false
if cfg.Debug {
level = slog.LevelDebug
addSource = true
}
return slog.New(
slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
AddSource: addSource,
}),
)
}
func LoggerFromContext(ctx context.Context) *slog.Logger {
l, ok := ctx.Value(loggerCtxKey{}).(*slog.Logger)
if !ok {
panic("logger not found in context")
}
return l
}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, loggerCtxKey{}, l)
}
// https://github.com/caddyserver/caddy/blob/fbb0ecfa322aa7710a3448453fd3ae40f037b8d1/sigtrap.go#L37
// trapSignalsCrossPlatform captures SIGINT or interrupt (depending
// on the OS), which initiates a graceful shutdown. A second SIGINT
// or interrupt will forcefully exit the process immediately.
func trapSignalsCrossPlatform(cancel context.CancelFunc) {
go func() {
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGINT)
for i := 0; true; i++ {
<-shutdown
if i > 0 {
fmt.Printf("\nForce quit\n") //nolint:forbidigo // We want to print to stdout
os.Exit(1)
}
fmt.Printf("\nGracefully shutting down. Press Ctrl+C again to force quit\n") //nolint:forbidigo // We want to print to stdout
cancel()
}
}()
}
type Config struct {
Debug bool
Host string
Port string
urnPath string
fingerPath string
}
// NewRootCmd parses the command line flags and returns a Config struct.
func NewRootCmd(cfg *Config, subcommands []*ff.Command) *ff.Command {
fs := ff.NewFlagSet(appName)
for _, cmd := range subcommands {
cmd.Flags = ff.NewFlagSet(cmd.Name).SetParent(fs)
}
cmd := &ff.Command{
Name: appName,
Usage: fmt.Sprintf("%s <command> [flags]", appName),
ShortHelp: fmt.Sprintf("(%s) A webfinger server", version),
Flags: fs,
Subcommands: subcommands,
}
// Use 0.0.0.0 as the default host if on docker
defaultHost := "localhost"
if os.Getenv("ENV_DOCKER") == "true" {
defaultHost = "0.0.0.0"
}
fs.BoolVar(&cfg.Debug, 'd', "debug", "Enable debug logging")
fs.StringVar(&cfg.Host, 'h', "host", defaultHost, "Host to listen on")
fs.StringVar(&cfg.Port, 'p', "port", "8080", "Port to listen on")
fs.StringVar(&cfg.urnPath, 'u', "urn-file", "urns.yml", "Path to the URNs file")
fs.StringVar(&cfg.fingerPath, 'f', "finger-file", "fingers.yml", "Path to the fingers file")
return cmd
}
type Link struct {
Rel string `json:"rel"`
Href string `json:"href,omitempty"`
}
type WebFinger struct {
Subject string `json:"subject"`
Links []Link `json:"links,omitempty"`
Properties map[string]string `json:"properties,omitempty"`
}
type WebFingerMap map[string]*WebFinger
func ParseFingerFile(ctx context.Context, cfg *Config) (WebFingerMap, error) {
l := LoggerFromContext(ctx)
urnMap := make(map[string]string)
fingerData := make(map[string]map[string]string)
fingermap := make(WebFingerMap)
// Read URNs file
file, err := os.ReadFile(cfg.urnPath)
if err != nil {
return nil, fmt.Errorf("error opening URNs file: %w", err)
}
if err := yaml.Unmarshal(file, &urnMap); err != nil {
return nil, fmt.Errorf("error unmarshalling URNs file: %w", err)
}
// The URNs file must be a map of strings to valid URLs
for _, v := range urnMap {
if _, err := url.Parse(v); err != nil {
return nil, fmt.Errorf("error parsing URN URIs: %w", err)
}
}
l.Debug("URNs file parsed successfully", slog.Int("number", len(urnMap)), slog.Any("data", urnMap))
// Read webfingers file
file, err = os.ReadFile(cfg.fingerPath)
if err != nil {
return nil, fmt.Errorf("error opening fingers file: %w", err)
}
if err := yaml.Unmarshal(file, &fingerData); err != nil {
return nil, fmt.Errorf("error unmarshalling fingers file: %w", err)
}
l.Debug("Fingers file parsed successfully", slog.Int("number", len(fingerData)), slog.Any("data", fingerData))
// Parse the webfinger file
for k, v := range fingerData {
resource := k
// Remove leading acct: if present
if len(k) > 5 && resource[:5] == "acct:" {
resource = resource[5:]
}
// The key must be a URL or email address
if _, err := mail.ParseAddress(resource); err != nil {
if _, err := url.Parse(resource); err != nil {
return nil, fmt.Errorf("error parsing webfinger key (%s): %w", k, err)
}
} else {
// Add acct: back to the key if it is an email address
resource = fmt.Sprintf("acct:%s", resource)
}
// Create a new webfinger
webfinger := &WebFinger{
Subject: resource,
}
// Parse the fields
for field, value := range v {
fieldUrn := field
// If the key is not already an URN, try to find it in the URNs file
if _, err := url.Parse(field); err != nil {
if _, ok := urnMap[field]; ok {
fieldUrn = urnMap[field]
}
}
// If the value is a valid URI, add it to the links
if _, err := url.Parse(value); err == nil {
webfinger.Links = append(webfinger.Links, Link{
Rel: fieldUrn,
Href: value,
})
} else {
// Otherwise add it to the properties
if webfinger.Properties == nil {
webfinger.Properties = make(map[string]string)
}
webfinger.Properties[fieldUrn] = value
}
}
// Add the webfinger to the map
fingermap[resource] = webfinger
}
l.Debug("Webfinger map built successfully", slog.Int("number", len(fingermap)), slog.Any("data", fingermap))
return fingermap, nil
}
func WebfingerHandler(_ *Config, webmap WebFingerMap) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := LoggerFromContext(ctx)
// Only handle GET requests
if r.Method != http.MethodGet {
l.Debug("Method not allowed")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get the query params
q := r.URL.Query()
// Get the resource
resource := q.Get("resource")
if resource == "" {
l.Debug("No resource provided")
http.Error(w, "No resource provided", http.StatusBadRequest)
return
}
// Get and validate resource
webfinger, ok := webmap[resource]
if !ok {
l.Debug("Resource not found")
http.Error(w, "Resource not found", http.StatusNotFound)
return
}
// Set the content type
w.Header().Set("Content-Type", "application/jrd+json")
// Write the response
if err := json.NewEncoder(w).Encode(webfinger); err != nil {
l.Debug("Error encoding json")
http.Error(w, "Error encoding json", http.StatusInternalServerError)
return
}
l.Debug("Webfinger request successful")
})
}
func HealthCheckHandler(_ *Config) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
}
type ResponseWrapper struct {
http.ResponseWriter
status int
}
func WrapResponseWriter(w http.ResponseWriter) *ResponseWrapper {
return &ResponseWrapper{w, 0}
}
func (w *ResponseWrapper) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *ResponseWrapper) Status() int {
return w.status
}
func (w *ResponseWrapper) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
size, err := w.ResponseWriter.Write(b)
if err != nil {
return 0, fmt.Errorf("error writing response: %w", err)
}
return size, nil
}
func (w *ResponseWrapper) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := LoggerFromContext(ctx)
start := time.Now()
// Wrap the response writer
wrapped := WrapResponseWriter(w)
// Call the next handler
next.ServeHTTP(wrapped, r)
status := wrapped.Status()
// Log the request
lg := l.With(
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", status),
slog.String("remote", r.RemoteAddr),
slog.Duration("duration", time.Since(start)),
)
switch {
case status >= http.StatusInternalServerError:
lg.Error("Server error")
case status >= http.StatusBadRequest:
lg.Info("Client error")
default:
lg.Info("Request completed")
}
})
}
const (
// ReadTimeout is the maximum duration for reading the entire
// request, including the body.
ReadTimeout = 5 * time.Second
// WriteTimeout is the maximum duration before timing out
// writes of the response.
WriteTimeout = 10 * time.Second
// IdleTimeout is the maximum amount of time to wait for the
// next request when keep-alives are enabled.
IdleTimeout = 30 * time.Second
// ReadHeaderTimeout is the amount of time allowed to read
// request headers.
ReadHeaderTimeout = 2 * time.Second
// RequestTimeout is the maximum duration for the entire
// request.
RequestTimeout = 7 * 24 * time.Hour
)
func StartServer(ctx context.Context, cfg *Config, webmap WebFingerMap) error {
l := LoggerFromContext(ctx)
// Create the server mux
mux := http.NewServeMux()
mux.Handle("/.well-known/webfinger", WebfingerHandler(cfg, webmap))
mux.Handle("/healthz", HealthCheckHandler(cfg))
// Create a new server
srv := &http.Server{
Addr: net.JoinHostPort(cfg.Host, cfg.Port),
BaseContext: func(_ net.Listener) context.Context {
return ctx
},
Handler: LoggingMiddleware(
RecoveryHandler(
http.TimeoutHandler(mux, RequestTimeout, "request timed out"),
),
),
ReadHeaderTimeout: ReadHeaderTimeout,
ReadTimeout: ReadTimeout,
WriteTimeout: WriteTimeout,
IdleTimeout: IdleTimeout,
}
// Create the errorgroup that will manage the server execution
eg, egCtx := errgroup.WithContext(ctx)
// Start the server
eg.Go(func() error {
l.Info("Starting server", slog.String("addr", srv.Addr))
// Use the global context for the server
srv.BaseContext = func(_ net.Listener) context.Context {
return egCtx
}
return srv.ListenAndServe() //nolint:wrapcheck // We wrap the error in the errgroup
})
// Gracefully shutdown the server when the context is done
eg.Go(func() error {
// Wait for the context to be done
<-egCtx.Done()
l.Info("Shutting down server")
// Disable the cancel since we don't wan't to force
// the server to shutdown if the context is canceled.
noCancelCtx := context.WithoutCancel(egCtx)
return srv.Shutdown(noCancelCtx) //nolint:wrapcheck // We wrap the error in the errgroup
})
srv.RegisterOnShutdown(func() {
l.Info("Server shutdown complete")
})
// Ignore the error if the context was canceled
if err := eg.Wait(); err != nil && ctx.Err() == nil {
return fmt.Errorf("server exited with error: %w", err)
}
return nil
}
func RecoveryHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := LoggerFromContext(ctx)
defer func() {
err := recover()
if err != nil {
l.Error("Panic", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}()
next.ServeHTTP(w, r)
})
}

60
main_test.go Normal file
View File

@ -0,0 +1,60 @@
package main_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
finger "git.maronato.dev/maronato/finger"
)
func BenchmarkGetWebfinger(b *testing.B) {
ctx := context.Background()
cfg := &finger.Config{}
l := finger.NewLogger(cfg)
ctx = finger.WithLogger(ctx, l)
resource := "acct:user@example.com"
webmap := finger.WebFingerMap{
resource: {
Subject: resource,
Links: []finger.Link{
{
Rel: "http://webfinger.net/rel/avatar",
Href: "https://example.com/avatar.png",
},
},
Properties: map[string]string{
"example": "value",
},
},
"acct:other": {
Subject: "acct:other",
Links: []finger.Link{
{
Rel: "http://webfinger.net/rel/avatar",
Href: "https://example.com/avatar.png",
},
},
Properties: map[string]string{
"example": "value",
},
},
}
handler := finger.WebfingerHandler(&finger.Config{}, webmap)
r, _ := http.NewRequestWithContext(
ctx,
http.MethodGet,
fmt.Sprintf("/.well-known/webfinger?resource=%s", resource),
http.NoBody,
)
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
}
}

25
urns.yml Normal file
View File

@ -0,0 +1,25 @@
# maps string keys to best practice fully qualified URNs
# some references:
# http://webfinger.net/rel/
# http://www.packetizer.com/webfinger/link_relations.html
# names of people
name: "http://schema.org/name"
full_name: "http://schema.org/name"
# pictures of people
avatar: http://webfinger.net/rel/avatar
picture: http://webfinger.net/rel/avatar
photo: http://webfinger.net/rel/avatar
# homepages of people
profile_page: http://webfinger.net/rel/profile-page
profile: http://webfinger.net/rel/profile-page
website: http://webfinger.net/rel/profile-page
url: http://webfinger.net/rel/profile-page
homepage: http://webfinger.net/rel/profile-page
# OpenID Connect
openid: http://openid.net/specs/connect/1.0/issuer
open_id: http://openid.net/specs/connect/1.0/issuer