commit 0a17831daefcf230927cba4b6a2dcf0665e8fcae Author: Gustavo Maronato Date: Mon Sep 18 01:36:29 2023 -0300 initial diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6f516ca --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..1e57907 --- /dev/null +++ b/.github/workflows/go.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..14efae8 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2da1cfd --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..63b41c2 --- /dev/null +++ b/.golangci.yml @@ -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 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..27b4d7a --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.21.0 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ce072c8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.tabSize": 2 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a76fb7 --- /dev/null +++ b/Dockerfile @@ -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" ] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2f473c4 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..64a2775 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..02ae10a --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f19d8fa --- /dev/null +++ b/main.go @@ -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 [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) + }) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..12c02e6 --- /dev/null +++ b/main_test.go @@ -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) + } +} diff --git a/urns.yml b/urns.yml new file mode 100644 index 0000000..7240553 --- /dev/null +++ b/urns.yml @@ -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