Compare commits

...

39 Commits

Author SHA1 Message Date
7d745fa08b Bump dependencies 2025-11-04 00:03:05 +01:00
b2a27cad72 feat: Do not send metrics if the client used DNT or GPC
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-09 21:02:17 +01:00
617b68f43e feat: Switch from Loki to something else
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-09 15:31:04 +01:00
4b4bc9792b feat: Only listen on http-port if we don't have ACME disabled
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-04 13:41:29 +01:00
aaab500049 fix: Always add authorization if a token is provided 2024-02-04 13:40:34 +01:00
fbb80c622f fix: Fix wrong logging output 2024-02-04 13:34:37 +01:00
48b6585eba feat: Allow reading an access token 2024-02-04 13:26:48 +01:00
3747f02ed8 fix: Fix style issue in test
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-03 16:45:13 +01:00
b9cc7f30e8 feat: Fix and test header parsing
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-02-03 16:42:08 +01:00
5b9aaf5e24 fix: Fix CNAME parsing 2024-02-03 15:51:23 +01:00
e3032c8233 feat: Move the CNAME into the rio.json 2024-02-03 15:39:31 +01:00
cf85380ddb feaT: Allow specifying custom headers in the rio.json 2024-02-03 15:18:37 +01:00
8630855374 fix: Use the correct domain for metrics
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-03 12:23:38 +01:00
315bb39f44 feat: Pass around one big global context
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-03 12:05:10 +01:00
cb123537d5 fix: Fix typo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-02 21:36:58 +01:00
9abc268315 feat: Add more logging to the metrics code
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-02 21:28:16 +01:00
8f09aa959b feat: Implement simple page metrics
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-02 21:07:29 +01:00
0341ed8219 feat: Also send the content-length
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-14 19:29:09 +01:00
6028d3fe76 fix: Try to fix 'when' again
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-13 18:00:10 +01:00
f802efb6bd fix: Fix CI when
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-01-13 17:59:18 +01:00
183e77ea61 feat: Restrict the CI to Go files
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-01-13 17:58:21 +01:00
3af531fdcc docs: Document the alt CNAME approach 2024-01-13 17:54:02 +01:00
f264bd5604 feat: Allow usage for top-level domains
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-13 17:30:55 +01:00
9b971daf28 fix: Fix username extraction in the TLS handler 2024-01-11 22:10:32 +01:00
352f8bb4ce Merge pull request 'Wildcards' (#2) from feat/wildcards into master
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
Reviewed-on: #2
2024-01-11 21:04:49 +01:00
b8eba55999 docs: Update README
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
ci/woodpecker/pull_request_closed/woodpecker Pipeline was successful
2024-01-11 20:58:47 +01:00
55e63eee5b docs: Add documentation for the method types 2024-01-11 20:53:31 +01:00
80234fd5ba fix: Prevent "leak" of raw gitea API response for CSP
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-11 20:50:48 +01:00
25eb0de1e7 fix: Handle usernames with a dot in them
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Fixes #1.
2024-01-11 20:37:56 +01:00
fe2f418e35 feat: Prevent weird situations if we have no username 2024-01-11 20:25:50 +01:00
2cbe46dc1a feat: Spawn another HTTP server for HTTPS upgrades
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-11 16:32:28 +01:00
de14a0e68d feat: Move the path modifications around
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-06 21:38:21 +01:00
40ce4e81a8 fix: Tests
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-06 21:30:44 +01:00
e628ec7ecf What is a CNAME? 2024-01-06 21:26:09 +01:00
6f9f92e68a fix: Correctly compute the username from a CNAME
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-06 21:11:05 +01:00
996aa10866 More debugging
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-06 21:04:27 +01:00
412e5d2fac fix: Fix username extraction
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-06 20:29:18 +01:00
c0b87be246 feat: Log CNAME expectations on mismatch
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-06 20:09:59 +01:00
2fb1e36b06 fix: Handle CNAMEs with a trailing dot
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-06 20:00:55 +01:00
24 changed files with 3249 additions and 399 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Artificats # Artificats
rio /rio
# Testing stuff # Testing stuff
*.json *.json

View File

@@ -4,8 +4,12 @@ steps:
commands: commands:
- go build ./cmd/rio - go build ./cmd/rio
- go test $(go list ./... | grep -v /vendor/) - go test $(go list ./... | grep -v /vendor/)
when:
- path: "**/*.go"
lint: lint:
image: "golang:1.21.5-alpine" image: "golang:1.21.5-alpine"
commands: commands:
- go fmt $(go list ./... | grep -v /vendor/) - go fmt $(go list ./... | grep -v /vendor/)
- go vet $(go list ./... | grep -v /vendor/) - go vet $(go list ./... | grep -v /vendor/)
when:
- path: "**/*.go"

10
Containerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM golang:1.25-alpine3.22 AS builder
COPY . /app
WORKDIR /app
RUN go build ./cmd/rio
FROM alpine:3.22
COPY --from=builder /app/rio /usr/local/bin/rio
ENTRYPOINT ["/usr/local/bin/rio"]

View File

@@ -38,7 +38,8 @@ an ACME account), `--acme-file` (`ACME_FILE`; Path to the file where ACME accoun
are stored in) are additionally required. `--acme-server` (`ACME_SERVER`) should also are stored in) are additionally required. `--acme-server` (`ACME_SERVER`) should also
be set to your ACME CA's directory URL as this option defaults to the be set to your ACME CA's directory URL as this option defaults to the
[Let's Encrypt Staging Environment](https://letsencrypt.org/docs/staging-environment/). Note that using this optional implies that you accept your [Let's Encrypt Staging Environment](https://letsencrypt.org/docs/staging-environment/). Note that using this optional implies that you accept your
configured ACME CA's Terms of Service. configured ACME CA's Terms of Service. rio will also spawn an unencrypted HTTP server that is bound to the host specified with `--http-host` (`HTTP_HOST`) and
the port specified with `--http-port` (`HTTP_PORT`). The functionality of this second HTTP server is to upgrade plain HTTP requests to HTTPS requests.
You can also run with `--debug` (`DEBUG_ENABLE=1`) to enable debug logging. You can also run with `--debug` (`DEBUG_ENABLE=1`) to enable debug logging.
@@ -71,6 +72,18 @@ _acme-challenge.cooldomain.rio. 3600 IN CNAME cooldomain.rio.pages.example
This additionally requires your repository to have a file named `CNAME` that contains This additionally requires your repository to have a file named `CNAME` that contains
the domain to use (`cooldomain.rio` in this case). the domain to use (`cooldomain.rio` in this case).
### Alternate CNAME Domains
If there is a situation where you want to use a CNAME redirect on a (sub-) domain,
where having a CNAME is not feasible, you can configure an "alternate CNAME".
This is a special TXT record that contains the "CNAME" you want to specify.
```
; Example for Bind
cooldomain.rio. 3600 IN A <IPv4 of rio>
_rio-cname.cooldomain.rio. 3600 IN TXT "cname=user.pages.example.org"
```
### Repository Redirects ### Repository Redirects
If you have multiple repositories with pages (and you use a CNAME), you can additionally If you have multiple repositories with pages (and you use a CNAME), you can additionally

View File

@@ -7,12 +7,17 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"regexp"
"strings" "strings"
"sync"
"time" "time"
"git.polynom.me/rio/internal/acme" "git.polynom.me/rio/internal/acme"
"git.polynom.me/rio/internal/certificates" "git.polynom.me/rio/internal/certificates"
"git.polynom.me/rio/internal/context"
"git.polynom.me/rio/internal/dns" "git.polynom.me/rio/internal/dns"
riogitea "git.polynom.me/rio/internal/gitea"
"git.polynom.me/rio/internal/metrics"
"git.polynom.me/rio/internal/pages" "git.polynom.me/rio/internal/pages"
"git.polynom.me/rio/internal/repo" "git.polynom.me/rio/internal/repo"
"git.polynom.me/rio/internal/server" "git.polynom.me/rio/internal/server"
@@ -23,21 +28,35 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
func handleSubdomain(domain, cname, path, giteaUrl, defaultCsp string, giteaClient *repo.GiteaClient, w http.ResponseWriter) { func handleSubdomain(ctx *context.GlobalContext, domain, cname, path string, req *http.Request, w http.ResponseWriter) {
hostParts := strings.Split(domain, ".") username := ""
username := hostParts[0] if cname != "" {
// If we are accessed via a CNAME, then CNAME contains our <user>.<pages domain> value.
username = dns.ExtractUsername(ctx.PagesDomain, cname)
} else {
// If we are directly accessed, then domain contains our <user>.<pages domain> value.
username = dns.ExtractUsername(ctx.PagesDomain, domain)
}
// Strip the leading / // Strip the leading /
if path[:1] == "/" { if path[:1] == "/" {
path = path[1:] path = path[1:]
} }
// Provide a default file.
switch {
case path == "":
path = "/index.html"
case path[len(path)-1] == '/':
path = path + "index.html"
}
repo, path, err := repo.RepoFromPath( repo, path, err := repo.RepoFromPath(
username, username,
domain, domain,
cname, cname,
path, path,
giteaClient, ctx,
) )
if err != nil { if err != nil {
log.Errorf("Failed to get repo: %s", err) log.Errorf("Failed to get repo: %s", err)
@@ -45,16 +64,38 @@ func handleSubdomain(domain, cname, path, giteaUrl, defaultCsp string, giteaClie
return return
} }
pages.ServeFile(username, repo.Name, path, defaultCsp, giteaClient, w) c := &context.Context{
Username: username,
Reponame: repo.Name,
Domain: domain,
Path: path,
Referrer: req.Header.Get("Referer"),
UserAgent: req.Header.Get("User-Agent"),
DNT: req.Header.Get("DNT"),
GPC: req.Header.Get("Sec-GPC"),
Writer: w,
Global: ctx,
}
pages.ServeFile(c)
} }
func Handler(pagesDomain, giteaUrl, defaultCsp string, giteaClient *repo.GiteaClient) http.HandlerFunc { func Handler(ctx *context.GlobalContext) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Server", "rio") w.Header().Set("Server", "rio")
if strings.HasSuffix(req.Host, pagesDomain) { // Is the direct domain requested?
if req.Host == ctx.PagesDomain {
log.Debug("Direct pages domain is requested.")
// TODO: Handle
w.WriteHeader(404)
return
}
// Is a direct subdomain requested?
if strings.HasSuffix(req.Host, ctx.PagesDomain) {
log.Debug("Domain can be directly handled") log.Debug("Domain can be directly handled")
handleSubdomain(req.Host, "", req.URL.Path, giteaUrl, defaultCsp, giteaClient, w) handleSubdomain(ctx, req.Host, "", req.URL.Path, req, w)
return return
} }
@@ -66,9 +107,12 @@ func Handler(pagesDomain, giteaUrl, defaultCsp string, giteaClient *repo.GiteaCl
} }
log.Debugf("Got CNAME %s", cname) log.Debugf("Got CNAME %s", cname)
if strings.HasSuffix(cname, pagesDomain) { // Is a direct subdomain requested after CNAME lookup?
// NOTE: We now require the leading dot because a CNAME to the direct
// pages domain makes no sense.
if strings.HasSuffix(cname, "."+ctx.PagesDomain) {
log.Debugf("%s is alias of %s and can be handled after a CNAME query", req.Host, cname) log.Debugf("%s is alias of %s and can be handled after a CNAME query", req.Host, cname)
handleSubdomain(cname, cname, req.URL.Path, giteaUrl, defaultCsp, giteaClient, w) handleSubdomain(ctx, req.Host, cname, req.URL.Path, req, w)
return return
} }
@@ -77,6 +121,22 @@ func Handler(pagesDomain, giteaUrl, defaultCsp string, giteaClient *repo.GiteaCl
} }
} }
// Handle HTTP redirects to HTTPS.
func httpHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Server", "rio")
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
// Upgrade the URL
req.URL.Scheme = "https"
req.URL.Host = req.Host
w.Header().Set("Location", req.URL.String())
// Send the 301
w.WriteHeader(301)
}
}
func runServer(ctx *cli.Context) error { func runServer(ctx *cli.Context) error {
giteaUrl := ctx.String("gitea-url") giteaUrl := ctx.String("gitea-url")
domain := ctx.String("pages-domain") domain := ctx.String("pages-domain")
@@ -87,6 +147,9 @@ func runServer(ctx *cli.Context) error {
acmeDnsProvider := ctx.String("acme-dns-provider") acmeDnsProvider := ctx.String("acme-dns-provider")
acmeDisable := ctx.Bool("acme-disable") acmeDisable := ctx.Bool("acme-disable")
defaultCsp := ctx.String("default-csp") defaultCsp := ctx.String("default-csp")
metricsUrl := ctx.String("metrics-url")
metricsBotList := ctx.String("metrics-bot-list")
tokenFile := ctx.String("token-file")
// Init Logging // Init Logging
if ctx.Bool("debug") { if ctx.Bool("debug") {
@@ -95,18 +158,50 @@ func runServer(ctx *cli.Context) error {
log.SetLevel(log.InfoLevel) log.SetLevel(log.InfoLevel)
} }
// Set up the Loki metrics
var lokiConfig metrics.MetricConfig
if metricsUrl == "" {
lokiConfig = metrics.MetricConfig{
Enabled: false,
}
} else {
var patterns []regexp.Regexp
if metricsBotList != "" {
patterns, _ = metrics.ReadBotPatterns(metricsBotList)
} else {
patterns = make([]regexp.Regexp, 0)
}
log.Infof("Read %d bot patterns from disk", len(patterns))
lokiConfig = metrics.MetricConfig{
Enabled: true,
BotUserAgents: &patterns,
Url: metricsUrl,
}
}
// If specified, read in an access token
token := ""
if tokenFile != "" {
t, err := readSecret(tokenFile)
if err != nil {
log.Warnf("Failed to read secret: %v", err)
}
token = t
}
// Setup the Gitea stuff // Setup the Gitea stuff
httpClient := http.Client{Timeout: 10 * time.Second} httpClient := http.Client{Timeout: 10 * time.Second}
giteaApiClient, err := gitea.NewClient( giteaApiClient, err := gitea.NewClient(
giteaUrl, giteaUrl,
gitea.SetHTTPClient(&httpClient), gitea.SetHTTPClient(&httpClient),
gitea.SetToken(""), gitea.SetToken(token),
gitea.SetUserAgent("rio"), gitea.SetUserAgent("rio"),
) )
if err != nil { if err != nil {
return err return err
} }
giteaClient := repo.NewGiteaClient(giteaUrl, giteaApiClient) giteaClient := riogitea.NewGiteaClient(giteaUrl, token, giteaApiClient)
// Listen on the port // Listen on the port
addr := ctx.String("listen-host") + ":" + ctx.String("listen-port") addr := ctx.String("listen-host") + ":" + ctx.String("listen-port")
@@ -117,6 +212,20 @@ func runServer(ctx *cli.Context) error {
return err return err
} }
// Prepare the context
cacheCtx := context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
UsernameCache: context.MakeUsernameCache(),
}
globalCtx := &context.GlobalContext{
DefaultCSP: defaultCsp,
PagesDomain: domain,
Gitea: &giteaClient,
MetricConfig: &lokiConfig,
Cache: &cacheCtx,
}
if !acmeDisable { if !acmeDisable {
if acmeEmail == "" || acmeFile == "" || certsFile == "" || acmeDnsProvider == "" { if acmeEmail == "" || acmeFile == "" || certsFile == "" || acmeDnsProvider == "" {
return errors.New("The options acme-dns-provider, acme-file, acme-email, and certs-file are required") return errors.New("The options acme-dns-provider, acme-file, acme-email, and certs-file are required")
@@ -170,16 +279,53 @@ func runServer(ctx *cli.Context) error {
certsFile, certsFile,
&cache, &cache,
acmeClient, acmeClient,
&giteaClient, globalCtx,
) )
listener = tls.NewListener(listener, tlsConfig) listener = tls.NewListener(listener, tlsConfig)
} }
if err := http.Serve(listener, Handler(domain, giteaUrl, defaultCsp, &giteaClient)); err != nil { var waitGroup sync.WaitGroup
fmt.Printf("Listening failed") servers := 2
return err if acmeDisable {
servers = 1
}
waitGroup.Add(servers)
go func() {
defer waitGroup.Done()
log.Infof("Listening on main HTTP server %s", addr)
if err := http.Serve(listener, Handler(globalCtx)); err != nil {
log.Fatal(fmt.Errorf("Listening failed: %v", err))
}
log.Debug("Listening on main HTTP server done!")
}()
if !acmeDisable {
// Listen on the HTTP port
httpAddr := ctx.String("http-host") + ":" + ctx.String("http-port")
httpListener, err := net.Listen("tcp", httpAddr)
if err != nil {
fmt.Println(
fmt.Errorf("Failed to create HTTP listener: %v", err),
)
return err
}
go func() {
defer waitGroup.Done()
log.Debug("Listening on redirect HTTP server")
if err := http.Serve(httpListener, httpHandler()); err != nil {
log.Fatal(fmt.Errorf("Listening failed: %v", err))
}
log.Debug("Listening on redirect HTTP server done!")
}()
} }
log.Debug("Waiting...")
waitGroup.Wait()
log.Debug("Done...")
return nil return nil
} }
@@ -205,6 +351,18 @@ func main() {
EnvVars: []string{"PORT"}, EnvVars: []string{"PORT"},
Value: "8888", Value: "8888",
}, },
&cli.StringFlag{
Name: "http-host",
Usage: "The host to have unencrypted HTTP listen on",
EnvVars: []string{"HTTP_HOST"},
Value: "127.0.0.1",
},
&cli.StringFlag{
Name: "http-port",
Usage: "The port to have unencrypted HTTP listen on",
EnvVars: []string{"HTTP_PORT"},
Value: "9999",
},
&cli.StringFlag{ &cli.StringFlag{
Name: "acme-dns-provider", Name: "acme-dns-provider",
Usage: "The provider to use for DNS01 challenge solving", Usage: "The provider to use for DNS01 challenge solving",
@@ -257,6 +415,24 @@ func main() {
Value: "", Value: "",
EnvVars: []string{"DEFAULT_CSP"}, EnvVars: []string{"DEFAULT_CSP"},
}, },
&cli.StringFlag{
Name: "metrics-url",
Usage: "The URL for metric pings",
Value: "",
EnvVars: []string{"METRICS_URL"},
},
&cli.StringFlag{
Name: "metrics-bot-list",
Usage: "File to read a list of regular expressions modelling bot user agents from",
Value: "",
EnvVars: []string{"METRICS_BOT_LIST"},
},
&cli.StringFlag{
Name: "token-file",
Usage: "File containing a access token. Required for serving private repositories",
Value: "",
EnvVars: []string{"TOKEN_FILE"},
},
}, },
} }

16
cmd/rio/utils.go Normal file
View File

@@ -0,0 +1,16 @@
package main
import (
"os"
"strings"
)
// Read a secret file and return its (cleaned) content.
func readSecret(path string) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
return strings.Trim(string(content), "\n\r "), nil
}

298
go.mod
View File

@@ -1,162 +1,246 @@
module git.polynom.me/rio module git.polynom.me/rio
go 1.20 go 1.24.7
require ( require (
code.gitea.io/sdk/gitea v0.17.1 code.gitea.io/sdk/gitea v0.22.1
github.com/go-acme/lego/v4 v4.14.2 github.com/go-acme/lego/v4 v4.28.0
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/urfave/cli/v2 v2.27.1 github.com/urfave/cli/v2 v2.27.7
) )
require ( require (
cloud.google.com/go/compute v1.18.0 // indirect cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute v1.49.1 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest v0.11.24 // indirect github.com/Azure/go-autorest/autorest v0.11.30 // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.18 // indirect github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect
github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 // indirect github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect github.com/Azure/go-autorest/autorest/date v0.3.1 // indirect
github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/logger v0.2.2 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/Azure/go-autorest/tracing v0.6.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755 // indirect github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea v1.3.13 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 // indirect
github.com/aliyun/credentials-go v1.4.8 // indirect
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
github.com/aws/aws-sdk-go-v2 v1.19.0 // indirect github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.28 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.16 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.27 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.5 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.35 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.29 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.36 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.29 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect
github.com/aws/aws-sdk-go-v2/service/lightsail v1.27.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.28.4 // indirect github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.13 // indirect github.com/aws/aws-sdk-go-v2/service/route53 v1.59.2 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.13 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.19.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4 // indirect
github.com/aws/smithy-go v1.13.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.39.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/aws/smithy-go v1.23.2 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/aziontech/azionapi-go-sdk v0.143.0 // indirect
github.com/civo/civogo v0.3.11 // indirect github.com/baidubce/bce-sdk-go v0.9.250 // indirect
github.com/cloudflare/cloudflare-go v0.70.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/civo/civogo v0.6.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudflare/cloudflare-go v0.116.0 // indirect
github.com/cpu/goacmedns v0.1.1 // indirect github.com/cpu/goacmedns v0.1.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/deepmap/oapi-codegen v1.9.1 // indirect github.com/deepmap/oapi-codegen v1.16.3 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/dnsimple/dnsimple-go v1.2.0 // indirect github.com/dnsimple/dnsimple-go v1.7.0 // indirect
github.com/exoscale/egoscale v0.100.1 // indirect github.com/dnsimple/dnsimple-go/v4 v4.0.0 // indirect
github.com/exoscale/egoscale v0.102.4 // indirect
github.com/exoscale/egoscale/v3 v3.1.27 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structs v1.1.0 // indirect github.com/fatih/structs v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/ghodss/yaml v1.0.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-errors/errors v1.0.1 // indirect github.com/go-acme/alidns-20150109/v4 v4.6.1 // indirect
github.com/go-acme/tencentclouddnspod v1.1.25 // indirect
github.com/go-acme/tencentedgdeone v1.1.48 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.0 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-resty/resty/v2 v2.7.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/gophercloud/gophercloud v1.0.0 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gophercloud/utils v0.0.0-20210216074907-f6de111f2eae // indirect github.com/gophercloud/gophercloud v1.14.1 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.4 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.174 // indirect
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
github.com/infobloxopen/infoblox-go-client/v2 v2.11.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
github.com/labbsr0x/goh v1.0.1 // indirect github.com/labbsr0x/goh v1.0.1 // indirect
github.com/linode/linodego v1.17.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/linode/linodego v1.60.0 // indirect
github.com/liquidweb/go-lwApi v0.0.5 // indirect github.com/liquidweb/go-lwApi v0.0.5 // indirect
github.com/liquidweb/liquidweb-cli v0.6.9 // indirect github.com/liquidweb/liquidweb-cli v0.7.0 // indirect
github.com/liquidweb/liquidweb-go v1.6.3 // indirect github.com/liquidweb/liquidweb-go v1.6.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/magiconair/properties v1.8.10 // indirect
github.com/miekg/dns v1.1.55 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.68 // indirect
github.com/mimuret/golang-iij-dpf v0.9.1 // indirect github.com/mimuret/golang-iij-dpf v0.9.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
github.com/namedotcom/go/v4 v4.0.2 // indirect
github.com/nrdcg/auroradns v1.1.0 // indirect github.com/nrdcg/auroradns v1.1.0 // indirect
github.com/nrdcg/bunny-go v0.0.0-20230728143221-c9dda82568d9 // indirect github.com/nrdcg/bunny-go v0.1.0 // indirect
github.com/nrdcg/desec v0.7.0 // indirect github.com/nrdcg/desec v0.11.1 // indirect
github.com/nrdcg/dnspod-go v0.4.0 // indirect github.com/nrdcg/dnspod-go v0.4.0 // indirect
github.com/nrdcg/freemyip v0.2.0 // indirect github.com/nrdcg/freemyip v0.3.0 // indirect
github.com/nrdcg/goinwx v0.8.2 // indirect github.com/nrdcg/goacmedns v0.2.0 // indirect
github.com/nrdcg/namesilo v0.2.1 // indirect github.com/nrdcg/goinwx v0.11.0 // indirect
github.com/nrdcg/mailinabox v0.3.0 // indirect
github.com/nrdcg/namesilo v0.5.0 // indirect
github.com/nrdcg/nodion v0.1.0 // indirect github.com/nrdcg/nodion v0.1.0 // indirect
github.com/nrdcg/porkbun v0.2.0 // indirect github.com/nrdcg/oci-go-sdk/common/v1065 v1065.103.0 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.103.0 // indirect
github.com/nrdcg/porkbun v0.4.0 // indirect
github.com/nrdcg/vegadns v0.3.0 // indirect
github.com/nzdjb/go-metaname v1.0.0 // indirect github.com/nzdjb/go-metaname v1.0.0 // indirect
github.com/onsi/gomega v1.27.6 // indirect github.com/onsi/gomega v1.37.0 // indirect
github.com/oracle/oci-go-sdk v24.3.0+incompatible // indirect github.com/oracle/oci-go-sdk v24.3.0+incompatible // indirect
github.com/ovh/go-ovh v1.4.2 // indirect github.com/ovh/go-ovh v1.9.0 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/peterhellberg/link v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pquerna/otp v1.4.0 // indirect github.com/pquerna/otp v1.5.0 // indirect
github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sacloud/api-client-go v0.2.8 // indirect github.com/sacloud/api-client-go v0.3.3 // indirect
github.com/sacloud/go-http v0.1.6 // indirect github.com/sacloud/go-http v0.1.9 // indirect
github.com/sacloud/iaas-api-go v1.11.1 // indirect github.com/sacloud/iaas-api-go v1.20.0 // indirect
github.com/sacloud/packages-go v0.0.9 // indirect github.com/sacloud/packages-go v0.0.11 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.17 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35 // indirect
github.com/selectel/domains-go v1.1.0 // indirect
github.com/selectel/go-selvpcclient/v4 v4.1.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
github.com/softlayer/softlayer-go v1.1.2 // indirect github.com/softlayer/softlayer-go v1.2.1 // indirect
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/spf13/cast v1.3.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/stretchr/testify v1.8.4 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.490 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.490 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/transip/gotransip/v6 v6.20.0 // indirect github.com/spf13/viper v1.21.0 // indirect
github.com/ultradns/ultradns-go-sdk v1.5.0-20230427130837-23c9b0c // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.48 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.1.25 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/transip/gotransip/v6 v6.26.1 // indirect
github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419 // indirect
github.com/vinyldns/go-vinyldns v0.9.16 // indirect github.com/vinyldns/go-vinyldns v0.9.16 // indirect
github.com/volcengine/volc-sdk-golang v1.0.225 // indirect
github.com/vultr/govultr/v2 v2.17.2 // indirect github.com/vultr/govultr/v2 v2.17.2 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/vultr/govultr/v3 v3.24.0 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20220805142335-27b56ddae16f // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/yandex-cloud/go-sdk v0.0.0-20220805164847-cf028e604997 // indirect github.com/yandex-cloud/go-genproto v0.34.0 // indirect
github.com/yandex-cloud/go-sdk v0.27.0 // indirect
github.com/yandex-cloud/go-sdk/services/dns v0.0.16 // indirect
github.com/yandex-cloud/go-sdk/v2 v2.24.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
go.uber.org/ratelimit v0.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
golang.org/x/mod v0.11.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect
golang.org/x/net v0.11.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect
golang.org/x/oauth2 v0.9.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/sys v0.15.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/text v0.14.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/time v0.3.0 // indirect go.uber.org/zap v1.27.0 // indirect
golang.org/x/tools v0.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
google.golang.org/api v0.111.0 // indirect golang.org/x/crypto v0.43.0 // indirect
google.golang.org/appengine v1.6.7 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 // indirect golang.org/x/mod v0.29.0 // indirect
google.golang.org/grpc v1.53.0 // indirect golang.org/x/net v0.46.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.254.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/ns1/ns1-go.v2 v2.7.6 // indirect gopkg.in/ns1/ns1-go.v2 v2.15.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

1936
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
package context
import (
"net/http"
"git.polynom.me/rio/internal/gitea"
"git.polynom.me/rio/internal/metrics"
"github.com/patrickmn/go-cache"
)
type CacheContext struct {
// Cache for general repository information
RepositoryInformationCache cache.Cache
// Cache for path resolutions
RepositoryPathCache cache.Cache
// Cache for username lookups
UsernameCache cache.Cache
}
type GlobalContext struct {
DefaultCSP string
PagesDomain string
Gitea *gitea.GiteaClient
MetricConfig *metrics.MetricConfig
Cache *CacheContext
}
type Context struct {
Username string
Reponame string
Domain string
Path string
// HTTP Stuff
Referrer string
UserAgent string
DNT string
GPC string
Writer http.ResponseWriter
// Pointer to the global context
Global *GlobalContext
}

40
internal/context/info.go Normal file
View File

@@ -0,0 +1,40 @@
package context
import (
"time"
"github.com/patrickmn/go-cache"
)
type RepositoryInformation struct {
// Headers to include in every response
Headers map[string]string
CNAME string
}
func repoInfoKey(owner, name string) string {
return owner + ":" + name
}
func (c *CacheContext) GetRepositoryInformation(owner, repoName string) *RepositoryInformation {
data, found := c.RepositoryInformationCache.Get(repoInfoKey(owner, repoName))
if !found {
return nil
}
typedData := data.(RepositoryInformation)
return &typedData
}
func (c *CacheContext) SetRepositoryInformation(owner, repoName string, info RepositoryInformation) {
c.RepositoryInformationCache.Set(
repoInfoKey(owner, repoName),
info,
cache.DefaultExpiration,
)
}
func MakeRepoInfoCache() cache.Cache {
return *cache.New(24*time.Hour, 12*time.Hour)
}

39
internal/context/path.go Normal file
View File

@@ -0,0 +1,39 @@
package context
import (
"time"
"git.polynom.me/rio/internal/gitea"
"github.com/patrickmn/go-cache"
)
type RepositoryPathInformation struct {
Repository gitea.Repository
Path string
}
func pathInfoKey(domain, path string) string {
return domain + "/" + path
}
func (c *CacheContext) GetRepositoryPath(domain, path string) *RepositoryPathInformation {
data, found := c.RepositoryPathCache.Get(pathInfoKey(domain, path))
if !found {
return nil
}
typedData := data.(RepositoryPathInformation)
return &typedData
}
func (c *CacheContext) SetRepositoryPath(domain, path string, info RepositoryPathInformation) {
c.RepositoryPathCache.Set(
pathInfoKey(domain, path),
info,
cache.DefaultExpiration,
)
}
func MakeRepoPathCache() cache.Cache {
return *cache.New(24*time.Hour, 12*time.Hour)
}

24
internal/context/user.go Normal file
View File

@@ -0,0 +1,24 @@
package context
import (
"time"
"github.com/patrickmn/go-cache"
)
func (c *CacheContext) GetUser(username string) bool {
_, found := c.UsernameCache.Get(username)
return found
}
func (c *CacheContext) SetUser(username string) {
c.UsernameCache.Set(
username,
true,
cache.DefaultExpiration,
)
}
func MakeUsernameCache() cache.Cache {
return *cache.New(24*time.Hour, 12*time.Hour)
}

View File

@@ -2,6 +2,7 @@ package dns
import ( import (
"net" "net"
"errors"
"strings" "strings"
"time" "time"
@@ -15,6 +16,10 @@ const (
// The key that the TXT record will have to start with, e.g. // The key that the TXT record will have to start with, e.g.
// "repo=some-random-repo". // "repo=some-random-repo".
TxtRepoKey = "repo=" TxtRepoKey = "repo="
TxtCNAMERecord = "_rio-cname."
TxtCNAMEKey = "cname="
) )
var ( var (
@@ -61,11 +66,53 @@ func LookupCNAME(domain string) (string, error) {
return cname.(string), nil return cname.(string), nil
} }
cname, err := net.LookupCNAME(domain) cname, err := lookupCNAME(domain)
if err == nil { if err == nil {
cnameCache.Set(domain, cname, cache.DefaultExpiration) cnameCache.Set(domain, cname, cache.DefaultExpiration)
return cname.(string), nil return cname.(string), nil
} }
altCname, err := lookupCNAMETxt(domain)
if err == nil {
cnameCache.Set(domain, altCname, cache.DefaultExpiration)
return altCname, nil
}
return "", err
}
// Lookup the CNAME by trying to find a CNAME RR. Contrary to net.LookupCNAME,
// this method fails if we find no CNAME RR.
func lookupCNAME(domain string) (string, error) {
query, err := net.LookupCNAME(domain)
if err == nil {
if query[len(query)-1] == '.' {
query = query[:len(query)-1]
}
// Fail if we have no CNAME RR.
if query == domain {
return "", errors.New("CNAME is equal to domain")
}
return query, nil
}
return "", err
}
// Performs an alternative CNAME lookup by looking for a special TXT record.
func lookupCNAMETxt(domain string) (string, error) {
txts, err := net.LookupTXT(TxtCNAMERecord+domain)
if err == nil {
for _, txt := range txts {
if !strings.HasPrefix(txt, TxtCNAMEKey) {
continue
}
return strings.TrimPrefix(txt, TxtCNAMEKey), nil
}
}
return "", err return "", err
} }

32
internal/dns/dns_test.go Normal file
View File

@@ -0,0 +1,32 @@
package dns
import "testing"
func cleanCache() {
cnameCache.Flush()
txtRepoCache.Flush()
}
func TestAltCNAME(t *testing.T) {
defer cleanCache()
res, err := lookupCNAMETxt("moxxy.org")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if res != "moxxy.pages.polynom.me" {
t.Fatalf("Unexpected alt CNAME: %s", res)
}
}
func TestLookupCNAMEWithAlt(t *testing.T) {
defer cleanCache()
res, err := LookupCNAME("moxxy.org")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if res != "moxxy.pages.polynom.me" {
t.Fatalf("Unexpected alt CNAME: %s", res)
}
}

15
internal/dns/username.go Normal file
View File

@@ -0,0 +1,15 @@
package dns
import "strings"
// Extract the username from the domain name @domain that we're processing
// at the moment.
func ExtractUsername(pagesDomain, domain string) string {
suffixlessDomain := strings.TrimSuffix(domain, "."+pagesDomain)
usernameParts := strings.Split(suffixlessDomain, ".")
if len(usernameParts) == 1 {
return usernameParts[0]
}
return strings.Join(usernameParts, ".")
}

View File

@@ -0,0 +1,23 @@
package dns
import "testing"
func TestExtractUsernameSimple(t *testing.T) {
username := ExtractUsername(
"pages.local",
"papatutuwawa.pages.local",
)
if username != "papatutuwawa" {
t.Fatalf("Unexpected username: '%s'", username)
}
}
func TestExtractUsernameDot(t *testing.T) {
username := ExtractUsername(
"pages.local",
"polynom.me.pages.local",
)
if username != "polynom.me" {
t.Fatalf("Unexpected username: '%s'", username)
}
}

View File

@@ -1,4 +1,4 @@
package repo package gitea
import ( import (
"fmt" "fmt"
@@ -7,6 +7,7 @@ import (
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
log "github.com/sirupsen/logrus"
"git.polynom.me/rio/internal/dns" "git.polynom.me/rio/internal/dns"
) )
@@ -19,12 +20,18 @@ type GetRepositoryMethod func(username, repositoryName string) (Repository, erro
// <username>/<repository>@<branch> exists. If not, returns "", error. // <username>/<repository>@<branch> exists. If not, returns "", error.
type GetFileMethod func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) type GetFileMethod func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error)
// Returns the result of a CNAME lookup for @domain.
type LookupCNAMEMethod func(domain string) (string, error) type LookupCNAMEMethod func(domain string) (string, error)
// Return the repository the domain should point to by looking up the TXT record
// "_rio-pages.<domain>".
type LookupRepoTXTMethod func(domain string) (string, error) type LookupRepoTXTMethod func(domain string) (string, error)
// Check of the repository <username>/<repositoryName> contains the specified
// branch.
type HasBranchMethod func(username, repositoryName, branchName string) bool type HasBranchMethod func(username, repositoryName, branchName string) bool
// Check if the specified username exists.
type HasUserMethod func(username string) bool type HasUserMethod func(username string) bool
type Repository struct { type Repository struct {
@@ -32,17 +39,20 @@ type Repository struct {
} }
type GiteaClient struct { type GiteaClient struct {
getRepository GetRepositoryMethod Token string
hasBranch HasBranchMethod
hasUser HasUserMethod GetRepository GetRepositoryMethod
HasBranch HasBranchMethod
HasUser HasUserMethod
GetFile GetFileMethod GetFile GetFileMethod
lookupCNAME LookupCNAMEMethod LookupCNAME LookupCNAMEMethod
lookupRepoTXT LookupRepoTXTMethod LookupRepoTXT LookupRepoTXTMethod
} }
func NewGiteaClient(giteaUrl string, giteaClient *gitea.Client) GiteaClient { func NewGiteaClient(giteaUrl string, token string, giteaClient *gitea.Client) GiteaClient {
return GiteaClient{ return GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) { Token: token,
GetRepository: func(username, repositoryName string) (Repository, error) {
repo, _, err := giteaClient.GetRepo(username, repositoryName) repo, _, err := giteaClient.GetRepo(username, repositoryName)
if err != nil { if err != nil {
return Repository{}, err return Repository{}, err
@@ -52,7 +62,7 @@ func NewGiteaClient(giteaUrl string, giteaClient *gitea.Client) GiteaClient {
Name: repo.Name, Name: repo.Name,
}, nil }, nil
}, },
hasBranch: func(username, repositoryName, branchName string) bool { HasBranch: func(username, repositoryName, branchName string) bool {
res, _, err := giteaClient.ListRepoBranches(username, repositoryName, gitea.ListRepoBranchesOptions{}) res, _, err := giteaClient.ListRepoBranches(username, repositoryName, gitea.ListRepoBranchesOptions{})
if err != nil { if err != nil {
return false return false
@@ -65,7 +75,7 @@ func NewGiteaClient(giteaUrl string, giteaClient *gitea.Client) GiteaClient {
} }
return false return false
}, },
hasUser: func(username string) bool { HasUser: func(username string) bool {
_, _, err := giteaClient.GetUserInfo(username) _, _, err := giteaClient.GetUserInfo(username)
return err == nil return err == nil
}, },
@@ -80,12 +90,19 @@ func NewGiteaClient(giteaUrl string, giteaClient *gitea.Client) GiteaClient {
path, path,
branch, branch,
) )
log.Debugf("GetFile: Requesting '%s'", apiUrl)
client := &http.Client{} client := &http.Client{}
req, err := http.NewRequest("GET", apiUrl, nil) req, err := http.NewRequest("GET", apiUrl, nil)
if since != nil { if since != nil {
sinceFormat := since.Format(time.RFC1123) sinceFormat := since.Format(time.RFC1123)
req.Header.Add("If-Modified-Since", sinceFormat) req.Header.Add("If-Modified-Since", sinceFormat)
} }
// Add authentication, if we have a token
if token != "" {
req.Header.Add("Authorization", "token "+token)
}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return []byte{}, true, err return []byte{}, true, err
@@ -97,14 +114,16 @@ func NewGiteaClient(giteaUrl string, giteaClient *gitea.Client) GiteaClient {
return []byte{}, true, err return []byte{}, true, err
} else if resp.StatusCode == 302 { } else if resp.StatusCode == 302 {
return []byte{}, false, nil return []byte{}, false, nil
} else if resp.StatusCode == 404 {
return []byte{}, false, fmt.Errorf("File does not exist")
} else { } else {
return content, true, err return content, true, err
} }
}, },
lookupCNAME: func(domain string) (string, error) { LookupCNAME: func(domain string) (string, error) {
return dns.LookupCNAME(domain) return dns.LookupCNAME(domain)
}, },
lookupRepoTXT: func(domain string) (string, error) { LookupRepoTXT: func(domain string) (string, error) {
return dns.LookupRepoTXT(domain) return dns.LookupRepoTXT(domain)
}, },
} }

View File

@@ -0,0 +1,99 @@
package metrics
import (
"encoding/json"
"net/http"
"os"
"regexp"
"strings"
log "github.com/sirupsen/logrus"
)
type MetricConfig struct {
Url string
BotUserAgents *[]regexp.Regexp
Enabled bool
}
// Checks if we should send a metric ping to page-metrics based on the served path.
func (c *MetricConfig) ShouldSendMetrics(path, userAgent, dnt, gpc string) bool {
if !strings.HasSuffix(path, ".html") || !c.Enabled {
return false
}
// Ignore requests where the user have set "Do-Not-Track" or "Do-Not-Sell-My-Data", even though
// there is no user data and we're not selling it.
if dnt == "1" || gpc == "1" {
return false
}
// Filter out bots
for _, pattern := range *c.BotUserAgents {
if pattern.MatchString(userAgent) {
return false
}
}
return true
}
func (c *MetricConfig) SendMetricPing(domain, path, referrer string) {
data := map[string]string{
"domain": domain,
"path": path,
"referer": referrer,
}
jsonData, err := json.Marshal(data)
if err != nil {
log.Errorf("Failed to send metric ping: %v", err)
return
}
log.Debugf("Sending payload %s", string(jsonData))
// Send the ping to the server
go func() {
res, err := http.Post(
c.Url,
"application/json",
strings.NewReader(string(jsonData)),
)
if err != nil {
log.Errorf("Failed to send payload to: %v", err)
return
}
defer res.Body.Close()
if res.StatusCode != 200 {
log.Errorf("Server returned non-200 status code %d", res.StatusCode)
}
}()
}
// Reads a JSON array of bot user agents from disk and parses them
// into regular expressions.
func ReadBotPatterns(file string) ([]regexp.Regexp, error) {
content, err := os.ReadFile(file)
if err != nil {
log.Warnf("Failed to read bot metrics file: %v", err)
return []regexp.Regexp{}, err
}
var payload []string
err = json.Unmarshal(content, &payload)
if err != nil {
log.Warnf("Failed to unmarshal file: %v", err)
return []regexp.Regexp{}, err
}
patterns := make([]regexp.Regexp, 0)
for _, v := range payload {
patterns = append(
patterns,
*regexp.MustCompile(v),
)
}
return patterns, nil
}

View File

@@ -0,0 +1,32 @@
package metrics
import (
"regexp"
"testing"
)
func TestShouldPing(t *testing.T) {
cfg := MetricConfig{
Enabled: true,
Url: "",
BotUserAgents: &[]regexp.Regexp{
*regexp.MustCompile("random-bot/.*"),
},
}
if cfg.ShouldSendMetrics("/index.html", "random-bot/v23.5", "", "") {
t.Fatalf("Accepted bot user-agent")
}
if !cfg.ShouldSendMetrics("/index.html", "Firefox/...", "", "") {
t.Fatalf("Rejected real user-agent")
}
if cfg.ShouldSendMetrics("/index.html", "Firefox/...", "1", "") {
t.Fatalf("Ignored DNT")
}
if cfg.ShouldSendMetrics("/index.html", "Firefox/...", "", "1") {
t.Fatalf("Ignored GPC")
}
}

View File

@@ -3,11 +3,12 @@ package pages
import ( import (
"mime" "mime"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
"git.polynom.me/rio/internal/constants" "git.polynom.me/rio/internal/constants"
"git.polynom.me/rio/internal/repo" "git.polynom.me/rio/internal/context"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -27,31 +28,33 @@ func makePageContentCacheEntry(username, path string) string {
return username + ":" + path return username + ":" + path
} }
func addHeaders(csp, contentType string, w http.ResponseWriter) { func addHeaders(repoInfo *context.RepositoryInformation, contentType string, contentLength int, w http.ResponseWriter) {
w.Header().Set("Content-Type", contentType) // Always set a content type
if strings.Trim(contentType, " ") == "" {
w.Header().Set("Content-Type", "application/octet-stream")
} else {
w.Header().Set("Content-Type", contentType)
}
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Strict-Transport-Security", "max-age=31536000") w.Header().Set("Strict-Transport-Security", "max-age=31536000")
w.Header().Set("Content-Length", strconv.Itoa(contentLength))
if csp != "" { if repoInfo != nil {
w.Header().Set("Content-Security-Policy", csp) for key, value := range repoInfo.Headers {
w.Header().Set(key, value)
}
} }
} }
func ServeFile(username, reponame, path, defaultCsp string, giteaClient *repo.GiteaClient, w http.ResponseWriter) { func ServeFile(context *context.Context) {
// Provide a default file.
switch {
case path == "":
path = "/index.html"
case path[len(path)-1] == '/':
path = path + "index.html"
}
// Strip away a starting / as it messes with Gitea // Strip away a starting / as it messes with Gitea
path := context.Path
if path[:1] == "/" { if path[:1] == "/" {
path = path[1:] path = path[1:]
} }
key := makePageContentCacheEntry(username, path) key := makePageContentCacheEntry(context.Username, path)
entry, found := pageCache.Get(key) entry, found := pageCache.Get(key)
var content []byte var content []byte
var mimeType string var mimeType string
@@ -65,25 +68,28 @@ func ServeFile(username, reponame, path, defaultCsp string, giteaClient *repo.Gi
since = &sinceRaw since = &sinceRaw
} }
content, changed, err := giteaClient.GetFile( content, changed, err := context.Global.Gitea.GetFile(
username, context.Username,
reponame, context.Reponame,
constants.PagesBranch, constants.PagesBranch,
path, path,
since, since,
) )
csp := repo.GetCSPForRepository(username, reponame, "", giteaClient) repoInfo := context.Global.Cache.GetRepositoryInformation(
context.Username,
context.Reponame,
)
if err != nil { if err != nil {
if !found { if !found {
log.Errorf("Failed to get file %s/%s/%s (%s)", username, reponame, path, err) log.Errorf("Failed to get file %s/%s/%s (%s)", context.Username, context.Reponame, path, err)
addHeaders(csp, "text/html", w) addHeaders(repoInfo, "text/html", 0, context.Writer)
w.WriteHeader(404) context.Writer.WriteHeader(404)
} else { } else {
log.Debugf("Request failed but page %s is cached in memory", path) log.Debugf("Request failed but page %s is cached in memory", path)
addHeaders(csp, mimeType, w) addHeaders(repoInfo, mimeType, len(content), context.Writer)
w.WriteHeader(200) context.Writer.WriteHeader(200)
w.Write(content) context.Writer.Write(content)
} }
return return
@@ -91,9 +97,9 @@ func ServeFile(username, reponame, path, defaultCsp string, giteaClient *repo.Gi
if found && !changed { if found && !changed {
log.Debugf("Page %s is unchanged and cached in memory", path) log.Debugf("Page %s is unchanged and cached in memory", path)
addHeaders(csp, mimeType, w) addHeaders(repoInfo, mimeType, len(content), context.Writer)
w.WriteHeader(200) context.Writer.WriteHeader(200)
w.Write(content) context.Writer.Write(content)
return return
} }
@@ -113,7 +119,12 @@ func ServeFile(username, reponame, path, defaultCsp string, giteaClient *repo.Gi
) )
log.Debugf("Page %s requested from Gitea and cached in memory at %v", path, now) log.Debugf("Page %s requested from Gitea and cached in memory at %v", path, now)
addHeaders(csp, mimeType, w) addHeaders(repoInfo, mimeType, len(content), context.Writer)
w.WriteHeader(200) context.Writer.WriteHeader(200)
w.Write(content) context.Writer.Write(content)
// Tell Loki about if, if desired
if context.Global.MetricConfig.ShouldSendMetrics(path, context.UserAgent, context.DNT, context.GPC) {
context.Global.MetricConfig.SendMetricPing(context.Domain, path, context.Referrer)
}
} }

View File

@@ -3,124 +3,89 @@ package repo
//go:generate mockgen -destination mock_repo_test.go -package repo code.gitea.io/sdk/gitea Client //go:generate mockgen -destination mock_repo_test.go -package repo code.gitea.io/sdk/gitea Client
import ( import (
"encoding/json"
"errors" "errors"
"slices"
"strings" "strings"
"time"
"git.polynom.me/rio/internal/constants" "git.polynom.me/rio/internal/constants"
"git.polynom.me/rio/internal/context"
"git.polynom.me/rio/internal/gitea"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
var ( var (
pathCache = cache.New(1*time.Hour, 1*time.Hour) ForbiddenHeaders = []string{
"content-length",
// Caching the existence of an user "content-type",
userCache = cache.New(24*time.Hour, 12*time.Hour) "date",
"location",
// Caches the existence of a Content-Security-Policy "strict-transport-security",
// Mapping: Repository key -> CSPCacheEntry "set-cookie",
cspCache = cache.New(24*time.Hour, 12*time.Hour) }
) )
type PageCacheEntry struct { func lookupRepositoryAndCache(username, reponame, branchName, host, domain, path, cname string, ctx *context.GlobalContext) (*gitea.Repository, error) {
Repository Repository
Path string
}
type CSPCacheEntry struct {
CSP string
LastRequested time.Time
}
func makePageCacheKey(domain, path string) string {
return domain + "/" + path
}
func makeCSPCacheKey(username, repositoryName string) string {
return username + ":" + repositoryName
}
// / Try to find the repository with name @reponame of the user @username. If @cname
// / is not "", then it also verifies that the repository contains a "CNAME" with
// / the value of @cname as its content. @host, @domain, and @path are passed for
// / caching on success.
func lookupRepositoryAndCache(username, reponame, branchName, host, domain, path, cname string, giteaClient *GiteaClient) (*Repository, error) {
log.Debugf("CNAME: %s", cname) log.Debugf("CNAME: %s", cname)
log.Debugf("Looking up repository %s/%s", username, reponame) log.Debugf("Looking up repository %s/%s", username, reponame)
repo, err := giteaClient.getRepository(username, reponame) repo, err := ctx.Gitea.GetRepository(username, reponame)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !giteaClient.hasBranch(username, reponame, branchName) { if !ctx.Gitea.HasBranch(username, reponame, branchName) {
return nil, errors.New("Specified branch does not exist") return nil, errors.New("Specified branch does not exist")
} }
// Check if the CNAME file matches // Check if the CNAME file matches
if cname != "" { if cname != "" {
log.Debug("Checking CNAME") log.Debug("Checking CNAME")
file, _, err := giteaClient.GetFile( repoInfo := GetRepositoryInformation(username, reponame, ctx)
username, if repoInfo == nil {
reponame, log.Warn("Repository does not contain a rio.json file")
constants.PagesBranch, return nil, errors.New("No CNAME available in repository")
"CNAME",
nil,
)
if err != nil {
log.Errorf(
"Could not verify CNAME of %s/%s@%s: %v\n",
username,
reponame,
constants.PagesBranch,
err,
)
return nil, err
} }
cnameContent := strings.Trim( log.Debugf("CNAME Content: \"%s\"", repoInfo.CNAME)
string(file[:]), if repoInfo.CNAME != host {
"\n", log.Warnf("CNAME mismatch: Repo '%s', Host '%s'", repoInfo.CNAME, host)
)
log.Debugf("CNAME Content: %s", cnameContent)
if cnameContent != cname {
return nil, errors.New("CNAME mismatch") return nil, errors.New("CNAME mismatch")
} }
} }
// Cache data // Cache data
pathCache.Set( ctx.Cache.SetRepositoryPath(
makePageCacheKey(domain, path), domain,
PageCacheEntry{ path,
repo, context.RepositoryPathInformation{
path, Repository: repo,
Path: path,
}, },
cache.DefaultExpiration,
) )
return &repo, nil return &repo, nil
} }
func RepoFromPath(username, host, cname, path string, giteaClient *GiteaClient) (*Repository, string, error) { // host is the domain name we're accessed from. cname is the domain that host is pointing
// if, if we're accessed via a CNAME. If not, then cname is "".
func RepoFromPath(username, host, cname, path string, ctx *context.GlobalContext) (*gitea.Repository, string, error) {
domain := host domain := host
// Guess the repository // Guess the repository
key := makePageCacheKey(domain, path) entry := ctx.Cache.GetRepositoryPath(domain, path)
entry, found := pathCache.Get(key) if entry != nil {
if found { return &entry.Repository, entry.Path, nil
pageEntry := entry.(PageCacheEntry)
return &pageEntry.Repository, pageEntry.Path, nil
} }
// Allow specifying the repository name in the TXT record // Allow specifying the repository name in the TXT record
reponame := "" reponame := ""
if cname != "" { if cname != "" {
repoLookup, err := giteaClient.lookupRepoTXT(cname) repoLookup, err := ctx.Gitea.LookupRepoTXT(host)
if err == nil && repoLookup != "" { if err == nil && repoLookup != "" {
log.Infof( log.Infof(
"TXT lookup for %s resulted in choosing repository %s", "TXT lookup for %s resulted in choosing repository %s",
cname, host,
repoLookup, repoLookup,
) )
reponame = repoLookup reponame = repoLookup
@@ -128,6 +93,7 @@ func RepoFromPath(username, host, cname, path string, giteaClient *GiteaClient)
} }
pathParts := strings.Split(path, "/") pathParts := strings.Split(path, "/")
log.Debugf("reponame='%s' len(pathParts)='%d'", reponame, len(pathParts))
if reponame == "" && len(pathParts) > 1 { if reponame == "" && len(pathParts) > 1 {
log.Debugf("Trying repository %s", pathParts[0]) log.Debugf("Trying repository %s", pathParts[0])
modifiedPath := strings.Join(pathParts[1:], "/") modifiedPath := strings.Join(pathParts[1:], "/")
@@ -139,7 +105,7 @@ func RepoFromPath(username, host, cname, path string, giteaClient *GiteaClient)
domain, domain,
modifiedPath, modifiedPath,
cname, cname,
giteaClient, ctx,
) )
if err == nil { if err == nil {
return repo, modifiedPath, nil return repo, modifiedPath, nil
@@ -158,7 +124,7 @@ func RepoFromPath(username, host, cname, path string, giteaClient *GiteaClient)
domain, domain,
path, path,
cname, cname,
giteaClient, ctx,
) )
return repo, path, err return repo, path, err
} }
@@ -166,52 +132,92 @@ func RepoFromPath(username, host, cname, path string, giteaClient *GiteaClient)
// Checks if the username exists as an organisation or an user on the Gitea // Checks if the username exists as an organisation or an user on the Gitea
// instance, so that an attacker can't just request certificates for random // instance, so that an attacker can't just request certificates for random
// usernames. // usernames.
func CanRequestCertificate(username string, giteaClient *GiteaClient) bool { func CanRequestCertificate(username string, ctx *context.GlobalContext) bool {
if _, found := userCache.Get(username); found { found := ctx.Cache.GetUser(username)
if found {
return true return true
} }
hasUser := giteaClient.hasUser(username) hasUser := ctx.Gitea.HasUser(username)
if hasUser { if hasUser {
userCache.Set(username, true, cache.DefaultExpiration) ctx.Cache.SetUser(username)
} }
return hasUser return hasUser
} }
// Checks the repository username/repository@PagesBranch for a file named CSP. If it exists, func filterHeaders(headers map[string]interface{}) map[string]string {
// read it and return the value. If it does not exist, return defaultCsp. newHeaders := make(map[string]string)
func GetCSPForRepository(username, repositoryName, defaultCsp string, giteaClient *GiteaClient) string {
key := makeCSPCacheKey(username, repositoryName)
cachedCsp, found := cspCache.Get(key)
var since time.Time
if found {
since = cachedCsp.(CSPCacheEntry).LastRequested
}
fetchedCsp, changed, err := giteaClient.GetFile( for key, value := range headers {
username, if slices.Contains[[]string, string](ForbiddenHeaders, strings.ToLower(key)) {
repositoryName, continue
constants.PagesBranch,
"CSP",
&since,
)
csp := ""
if err != nil {
if found {
return cachedCsp.(CSPCacheEntry).CSP
} }
csp = defaultCsp switch value.(type) {
} else { case string:
csp = string(fetchedCsp) newHeaders[key] = value.(string)
if !found || changed {
cspCache.Set(key, CSPCacheEntry{
CSP: csp,
LastRequested: time.Now(),
}, cache.DefaultExpiration)
} }
} }
return csp return newHeaders
}
func GetRepositoryInformation(owner, repoName string, ctx *context.GlobalContext) *context.RepositoryInformation {
res := ctx.Cache.GetRepositoryInformation(owner, repoName)
if res != nil {
return res
}
fetchedConfig, _, err := ctx.Gitea.GetFile(
owner,
repoName,
constants.PagesBranch,
"rio.json",
nil,
)
if err != nil {
log.Errorf("Failed to request rio.json for %s/%s:%v", owner, repoName, err)
return nil
}
var payload map[string]interface{}
err = json.Unmarshal(fetchedConfig, &payload)
if err != nil {
log.Errorf("Failed to unmarshal rio.json for %s/%s:%v", owner, repoName, err)
return nil
}
headers, found := payload["headers"]
if !found {
log.Warnf("Did not find headers key in rio.json for %s/%s", owner, repoName)
headers = make(map[string]interface{})
} else {
switch headers.(type) {
case map[string]interface{}:
// NOOP
default:
log.Warn("headers attribute has invalid data type")
headers = make(map[string]string)
}
}
cname, found := payload["CNAME"]
if found {
switch cname.(type) {
case string:
// NOOP
default:
log.Warnf("CNAME attribute is not a string for %s/%s", owner, repoName)
cname = ""
}
} else {
cname = ""
}
info := context.RepositoryInformation{
Headers: filterHeaders(headers.(map[string]interface{})),
CNAME: cname.(string),
}
ctx.Cache.SetRepositoryInformation(owner, repoName, info)
return &info
} }

View File

@@ -2,26 +2,44 @@ package repo
import ( import (
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
"git.polynom.me/rio/internal/context"
"git.polynom.me/rio/internal/gitea"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
func clearCache() { func TestHeaderFilter(t *testing.T) {
pathCache.Flush() map1 := filterHeaders(
userCache.Flush() map[string]interface{}{
cspCache.Flush() "Content-Type": "hallo",
"content-Type": "welt",
"content-type": "uwu",
"CONTENT-TYPE": "lol",
"Content-Security-Policy": "none",
},
)
if len(map1) != 1 {
t.Fatalf("filterHeaders allowed %d != 1 headers", len(map1))
}
for key := range map1 {
if strings.ToLower(key) == "content-type" {
t.Fatalf("filterHeaders allowed Content-Type")
}
}
} }
func TestPickingCorrectRepositoryDefault(t *testing.T) { func TestPickingCorrectRepositoryDefault(t *testing.T) {
// Test that we default to the <username>.<pages domain> repository, if we have only // Test that we default to the <username>.<pages domain> repository, if we have only
// one path component. // one path component.
defer clearCache()
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
client := GiteaClient{ client := gitea.GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) { GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username != "example-user" { if username != "example-user" {
t.Fatalf("Called with unknown user %s", username) t.Fatalf("Called with unknown user %s", username)
} }
@@ -29,9 +47,9 @@ func TestPickingCorrectRepositoryDefault(t *testing.T) {
t.Fatalf("Called with unknown repository %s", repositoryName) t.Fatalf("Called with unknown repository %s", repositoryName)
} }
return Repository{}, nil return gitea.Repository{}, nil
}, },
hasBranch: func(username, repositoryName, branchName string) bool { HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" { if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true return true
} }
@@ -42,17 +60,24 @@ func TestPickingCorrectRepositoryDefault(t *testing.T) {
t.Fatal("getFile called") t.Fatal("getFile called")
return []byte{}, true, nil return []byte{}, true, nil
}, },
lookupCNAME: func(domain string) (string, error) { LookupCNAME: func(domain string) (string, error) {
t.Fatal("lookupCNAME called") t.Fatal("LookupCNAME called")
return "", nil return "", nil
}, },
lookupRepoTXT: func(domain string) (string, error) { LookupRepoTXT: func(domain string) (string, error) {
t.Fatal("lookupRepoTXT called") t.Fatal("LookupRepoTXT called")
return "", nil return "", nil
}, },
} }
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
res, path, err := RepoFromPath("example-user", "example-user.pages.example.org", "", "index.html", &client) res, path, err := RepoFromPath("example-user", "example-user.pages.example.org", "", "index.html", ctx)
if err != nil { if err != nil {
t.Fatalf("An error occured: %v", err) t.Fatalf("An error occured: %v", err)
} }
@@ -67,24 +92,22 @@ func TestPickingCorrectRepositoryDefault(t *testing.T) {
func TestPickingCorrectRepositoryDefaultSubdirectory(t *testing.T) { func TestPickingCorrectRepositoryDefaultSubdirectory(t *testing.T) {
// Test that we return the default repository when the first path component does // Test that we return the default repository when the first path component does
// not correspong to an existing repository. // not correspong to an existing repository.
defer clearCache()
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
client := GiteaClient{ client := gitea.GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) { GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username != "example-user" { if username != "example-user" {
t.Fatalf("Called with unknown user %s", username) t.Fatalf("Called with unknown user %s", username)
} }
if repositoryName == "assets" { if repositoryName == "assets" {
return Repository{}, errors.New("Repository does not exist") return gitea.Repository{}, errors.New("gitea.Repository does not exist")
} else if repositoryName == "example-user.pages.example.org" { } else if repositoryName == "example-user.pages.example.org" {
return Repository{}, nil return gitea.Repository{}, nil
} else { } else {
t.Fatalf("Called with unknown repository %s", repositoryName) t.Fatalf("Called with unknown repository %s", repositoryName)
return Repository{}, nil return gitea.Repository{}, nil
} }
}, },
hasBranch: func(username, repositoryName, branchName string) bool { HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" { if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true return true
} }
@@ -95,17 +118,24 @@ func TestPickingCorrectRepositoryDefaultSubdirectory(t *testing.T) {
t.Fatal("getFile called") t.Fatal("getFile called")
return []byte{}, true, nil return []byte{}, true, nil
}, },
lookupCNAME: func(domain string) (string, error) { LookupCNAME: func(domain string) (string, error) {
t.Fatal("lookupCNAME called") t.Fatal("LookupCNAME called")
return "", nil return "", nil
}, },
lookupRepoTXT: func(domain string) (string, error) { LookupRepoTXT: func(domain string) (string, error) {
t.Fatal("lookupRepoTXT called") t.Fatal("LookupRepoTXT called")
return "", nil return "", nil
}, },
} }
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
res, path, err := RepoFromPath("example-user", "example-user.pages.example.org", "", "assets/index.css", &client) res, path, err := RepoFromPath("example-user", "example-user.pages.example.org", "", "assets/index.css", ctx)
if err != nil { if err != nil {
t.Fatalf("An error occured: %v", err) t.Fatalf("An error occured: %v", err)
} }
@@ -120,28 +150,26 @@ func TestPickingCorrectRepositoryDefaultSubdirectory(t *testing.T) {
func TestPickingCorrectRepositorySubdirectoryNoPagesBranch(t *testing.T) { func TestPickingCorrectRepositorySubdirectoryNoPagesBranch(t *testing.T) {
// Test that we're picking the correct repository when the first path component // Test that we're picking the correct repository when the first path component
// returns a repository without a pages branch. // returns a repository without a pages branch.
defer clearCache()
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
client := GiteaClient{ client := gitea.GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) { GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username != "example-user" { if username != "example-user" {
t.Fatalf("Called with unknown user %s", username) t.Fatalf("Called with unknown user %s", username)
} }
if repositoryName == "blog" { if repositoryName == "blog" {
return Repository{ return gitea.Repository{
Name: "blog", Name: "blog",
}, nil }, nil
} else if repositoryName == "example-user.pages.example.org" { } else if repositoryName == "example-user.pages.example.org" {
return Repository{ return gitea.Repository{
Name: "example-user.pages.example.org", Name: "example-user.pages.example.org",
}, nil }, nil
} else { } else {
t.Fatalf("Called with unknown repository %s", repositoryName) t.Fatalf("Called with unknown repository %s", repositoryName)
return Repository{}, nil return gitea.Repository{}, nil
} }
}, },
hasBranch: func(username, repositoryName, branchName string) bool { HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" { if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true return true
} }
@@ -152,17 +180,24 @@ func TestPickingCorrectRepositorySubdirectoryNoPagesBranch(t *testing.T) {
t.Fatal("getFile called") t.Fatal("getFile called")
return []byte{}, true, nil return []byte{}, true, nil
}, },
lookupCNAME: func(domain string) (string, error) { LookupCNAME: func(domain string) (string, error) {
t.Fatal("lookupCNAME called") t.Fatal("LookupCNAME called")
return "", nil return "", nil
}, },
lookupRepoTXT: func(domain string) (string, error) { LookupRepoTXT: func(domain string) (string, error) {
t.Fatal("lookupRepoTXT called") t.Fatal("LookupRepoTXT called")
return "", nil return "", nil
}, },
} }
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
res, path, err := RepoFromPath("example-user", "example-user.pages.example.org", "", "blog/post1.html", &client) res, path, err := RepoFromPath("example-user", "example-user.pages.example.org", "", "blog/post1.html", ctx)
if err != nil { if err != nil {
t.Fatalf("An error occured: %v", err) t.Fatalf("An error occured: %v", err)
} }
@@ -179,21 +214,19 @@ func TestPickingCorrectRepositorySubdirectoryNoPagesBranch(t *testing.T) {
func TestPickingNoRepositoryInvalidCNAME(t *testing.T) { func TestPickingNoRepositoryInvalidCNAME(t *testing.T) {
// Test that we're not picking a repository if the CNAME validation fails. // Test that we're not picking a repository if the CNAME validation fails.
defer clearCache()
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
client := GiteaClient{ client := gitea.GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) { GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "example-user.pages.example.org" { if username == "example-user" && repositoryName == "example-user.pages.example.org" {
return Repository{ return gitea.Repository{
Name: "example-user.pages.example.org", Name: "example-user.pages.example.org",
}, nil }, nil
} else { } else {
t.Fatalf("Called with unknown repository %s", repositoryName) t.Fatalf("Called with unknown repository %s", repositoryName)
return Repository{}, nil return gitea.Repository{}, nil
} }
}, },
hasBranch: func(username, repositoryName, branchName string) bool { HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" { if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true return true
} }
@@ -201,71 +234,83 @@ func TestPickingNoRepositoryInvalidCNAME(t *testing.T) {
return false return false
}, },
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) { GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branch == "pages" && path == "CNAME" { if username == "example-user" && repositoryName == "example-user.pages.example.org" && branch == "pages" && path == "rio.json" {
return []byte("some-other-domain.local"), true, nil return []byte("{\"CNAME\": \"some-other-domain.local\"}"), true, nil
} }
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path) t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil return []byte{}, true, nil
}, },
lookupCNAME: func(domain string) (string, error) { LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME") return "", errors.New("No CNAME")
}, },
lookupRepoTXT: func(domain string) (string, error) { LookupRepoTXT: func(domain string) (string, error) {
return "", nil return "", nil
}, },
} }
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
_, _, err := RepoFromPath("example-user", "example-user.pages.example.org", "example-user.local", "index.html", &client) _, _, err := RepoFromPath("example-user", "example-user.pages.example.org", "example-user.local", "index.html", ctx)
if err == nil { if err == nil {
t.Fatal("Repository returned even though CNAME validation should fail") t.Fatal("gitea.Repository returned even though CNAME validation should fail")
} }
} }
func TestPickingRepositoryValidCNAME(t *testing.T) { func TestPickingRepositoryValidCNAME(t *testing.T) {
// Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds. // Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds.
defer clearCache()
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
client := GiteaClient{ client := gitea.GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) { GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "example-user.pages.example.org" { if username == "example-user" && repositoryName == "example-user.local" {
return Repository{ return gitea.Repository{
Name: "example-user.pages.example.org", Name: "example-user.local",
}, nil }, nil
} else { } else {
t.Fatalf("Called with unknown repository %s", repositoryName) t.Fatalf("Called with unknown repository %s", repositoryName)
return Repository{}, nil return gitea.Repository{}, nil
} }
}, },
hasBranch: func(username, repositoryName, branchName string) bool { HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" { if username == "example-user" && repositoryName == "example-user.local" && branchName == "pages" {
return true return true
} }
return false return false
}, },
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) { GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branch == "pages" && path == "CNAME" { if username == "example-user" && repositoryName == "example-user.local" && branch == "pages" && path == "rio.json" {
return []byte("example-user.local"), true, nil return []byte("{\"CNAME\": \"example-user.local\"}"), true, nil
} }
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path) t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil return []byte{}, true, nil
}, },
lookupCNAME: func(domain string) (string, error) { LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME") return "", errors.New("No CNAME")
}, },
lookupRepoTXT: func(domain string) (string, error) { LookupRepoTXT: func(domain string) (string, error) {
return "", nil return "", nil
}, },
} }
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
repo, _, err := RepoFromPath("example-user", "example-user.pages.example.org", "example-user.local", "index.html", &client) repo, _, err := RepoFromPath("example-user", "example-user.local", "example-user.pages.example.org", "index.html", ctx)
if err != nil { if err != nil {
t.Fatalf("Error returned: %v", err) t.Fatalf("Error returned: %v", err)
} }
if repo.Name != "example-user.pages.example.org" { if repo.Name != "example-user.local" {
t.Fatalf("Invalid repository name returned: %s", repo.Name) t.Fatalf("Invalid repository name returned: %s", repo.Name)
} }
} }
@@ -273,21 +318,19 @@ func TestPickingRepositoryValidCNAME(t *testing.T) {
func TestPickingRepositoryValidCNAMEWithTXTLookup(t *testing.T) { func TestPickingRepositoryValidCNAMEWithTXTLookup(t *testing.T) {
// Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds // Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds
// and the TXT lookup returns something different. // and the TXT lookup returns something different.
defer clearCache()
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
client := GiteaClient{ client := gitea.GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) { GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "some-different-repository" { if username == "example-user" && repositoryName == "some-different-repository" {
return Repository{ return gitea.Repository{
Name: "some-different-repository", Name: "some-different-repository",
}, nil }, nil
} else { } else {
t.Fatalf("Called with unknown repository %s", repositoryName) t.Fatalf("Called with unknown repository %s", repositoryName)
return Repository{}, nil return gitea.Repository{}, nil
} }
}, },
hasBranch: func(username, repositoryName, branchName string) bool { HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" { if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" {
return true return true
} }
@@ -295,25 +338,32 @@ func TestPickingRepositoryValidCNAMEWithTXTLookup(t *testing.T) {
return false return false
}, },
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) { GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "some-different-repository" && branch == "pages" && path == "CNAME" { if username == "example-user" && repositoryName == "some-different-repository" && branch == "pages" && path == "rio.json" {
return []byte("example-user.local"), true, nil return []byte("{\"CNAME\": \"example-user.local\"}"), true, nil
} }
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path) t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil return []byte{}, true, nil
}, },
lookupCNAME: func(domain string) (string, error) { LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME") return "", errors.New("No CNAME")
}, },
lookupRepoTXT: func(domain string) (string, error) { LookupRepoTXT: func(domain string) (string, error) {
if domain == "example-user.local" { if domain == "example-user.local" {
return "some-different-repository", nil return "some-different-repository", nil
} }
return "", nil return "", nil
}, },
} }
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
repo, _, err := RepoFromPath("example-user", "example-user.pages.example.org", "example-user.local", "index.html", &client) repo, _, err := RepoFromPath("example-user", "example-user.local", "example-user.pages.example.org", "index.html", ctx)
if err != nil { if err != nil {
t.Fatalf("Error returned: %v", err) t.Fatalf("Error returned: %v", err)
} }
@@ -325,20 +375,18 @@ func TestPickingRepositoryValidCNAMEWithTXTLookup(t *testing.T) {
func TestPickingRepositoryValidCNAMEWithTXTLookupAndSubdirectory(t *testing.T) { func TestPickingRepositoryValidCNAMEWithTXTLookupAndSubdirectory(t *testing.T) {
// Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds // Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds
// and the TXT lookup returns something different. Additionally, we now have a subdirectory // and the TXT lookup returns something different. Additionally, we now have a subdirectory
defer clearCache()
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
client := GiteaClient{ client := gitea.GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) { GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "some-different-repository" { if username == "example-user" && repositoryName == "some-different-repository" {
return Repository{ return gitea.Repository{
Name: "some-different-repository", Name: "some-different-repository",
}, nil }, nil
} }
return Repository{}, errors.New("Unknown repository") return gitea.Repository{}, errors.New("Unknown repository")
}, },
hasBranch: func(username, repositoryName, branchName string) bool { HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" { if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" {
return true return true
} }
@@ -346,25 +394,32 @@ func TestPickingRepositoryValidCNAMEWithTXTLookupAndSubdirectory(t *testing.T) {
return false return false
}, },
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) { GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "some-different-repository" && branch == "pages" && path == "CNAME" { if username == "example-user" && repositoryName == "some-different-repository" && branch == "pages" && path == "rio.json" {
return []byte("example-user.local"), true, nil return []byte("{\"CNAME\": \"example-user.local\"}"), true, nil
} }
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path) t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil return []byte{}, true, nil
}, },
lookupCNAME: func(domain string) (string, error) { LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME") return "", errors.New("No CNAME")
}, },
lookupRepoTXT: func(domain string) (string, error) { LookupRepoTXT: func(domain string) (string, error) {
if domain == "example-user.local" { if domain == "example-user.local" {
return "some-different-repository", nil return "some-different-repository", nil
} }
return "", nil return "", nil
}, },
} }
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
repo, _, err := RepoFromPath("example-user", "example-user.pages.example.org", "example-user.local", "blog/index.html", &client) repo, _, err := RepoFromPath("example-user", "example-user.local", "example-user.pages.example.org", "blog/index.html", ctx)
if err != nil { if err != nil {
t.Fatalf("Error returned: %v", err) t.Fatalf("Error returned: %v", err)
} }
@@ -372,3 +427,124 @@ func TestPickingRepositoryValidCNAMEWithTXTLookupAndSubdirectory(t *testing.T) {
t.Fatalf("Invalid repository name returned: %s", repo.Name) t.Fatalf("Invalid repository name returned: %s", repo.Name)
} }
} }
func TestHeaderParsingEmpty(t *testing.T) {
// Test that we are correctly handling a repository with no headers.
log.SetLevel(log.DebugLevel)
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "some-different-repository" {
return gitea.Repository{
Name: "some-different-repository",
}, nil
}
return gitea.Repository{}, errors.New("Unknown repository")
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "some-different-repository" && branch == "pages" && path == "rio.json" {
return []byte("{\"CNAME\": \"example-user.local\"}"), true, nil
}
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
LookupRepoTXT: func(domain string) (string, error) {
if domain == "example-user.local" {
return "some-different-repository", nil
}
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
info := GetRepositoryInformation("example-user", "some-different-repository", ctx)
if info == nil {
t.Fatalf("No repository information returned")
}
if len(info.Headers) > 0 {
t.Fatalf("Headers returned: %v", info.Headers)
}
}
func TestHeaderParsing(t *testing.T) {
// Test that we are correctly handling a repository with no headers.
log.SetLevel(log.DebugLevel)
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "some-different-repository" {
return gitea.Repository{
Name: "some-different-repository",
}, nil
}
return gitea.Repository{}, errors.New("Unknown repository")
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "some-different-repository" && branch == "pages" && path == "rio.json" {
return []byte("{\"CNAME\": \"example-user.local\", \"headers\": {\"X-Cool-Header\": \"Very nice!\"}}"), true, nil
}
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
LookupRepoTXT: func(domain string) (string, error) {
if domain == "example-user.local" {
return "some-different-repository", nil
}
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
info := GetRepositoryInformation("example-user", "some-different-repository", ctx)
if info == nil {
t.Fatalf("No repository information returned")
}
if len(info.Headers) != 1 {
t.Fatalf("len(info.Headers) != 1: %v", info.Headers)
}
header, found := info.Headers["X-Cool-Header"]
if !found {
t.Fatal("Header X-Cool-Header not found")
}
if header != "Very nice!" {
t.Fatalf("Invalid header value for X-Cool-Header: \"%s\"", header)
}
}

View File

@@ -2,10 +2,12 @@ package server
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"strings" "strings"
"sync" "sync"
"git.polynom.me/rio/internal/certificates" "git.polynom.me/rio/internal/certificates"
"git.polynom.me/rio/internal/context"
"git.polynom.me/rio/internal/dns" "git.polynom.me/rio/internal/dns"
"git.polynom.me/rio/internal/repo" "git.polynom.me/rio/internal/repo"
@@ -43,7 +45,6 @@ func unlockDomain(domain string) {
} }
func buildDomainList(domain, pagesDomain string) []string { func buildDomainList(domain, pagesDomain string) []string {
// TODO: For wildcards, we MUST use DNS01
if domain == pagesDomain || strings.HasSuffix(domain, pagesDomain) { if domain == pagesDomain || strings.HasSuffix(domain, pagesDomain) {
return []string{ return []string{
pagesDomain, pagesDomain,
@@ -55,7 +56,6 @@ func buildDomainList(domain, pagesDomain string) []string {
} }
func getDomainKey(domain, pagesDomain string) string { func getDomainKey(domain, pagesDomain string) string {
// TODO: For wildcards, we MUST use DNS01
if domain == pagesDomain || strings.HasSuffix(domain, pagesDomain) { if domain == pagesDomain || strings.HasSuffix(domain, pagesDomain) {
return "*." + pagesDomain return "*." + pagesDomain
} }
@@ -63,31 +63,34 @@ func getDomainKey(domain, pagesDomain string) string {
return domain return domain
} }
func MakeTlsConfig(pagesDomain, cachePath string, cache *certificates.CertificatesCache, acmeClient *lego.Client, giteaClient *repo.GiteaClient) *tls.Config { func getUsername(sni, pagesDomain string) (string, error) {
if !strings.HasSuffix(sni, pagesDomain) {
log.Debugf("'%s' is not a subdomain of '%s'", sni, pagesDomain)
// Note: We do not check err here because err != nil
// always implies that cname == "", which does not have
// pagesDomain as a suffix.
query, err := dns.LookupCNAME(sni)
if !strings.HasSuffix(query, pagesDomain) {
log.Warnf("Got ServerName for Domain %s that we're not responsible for. CNAME '%s', err: %v", sni, query, err)
return "", errors.New("CNAME does not resolve to subdomain of pages domain")
}
return dns.ExtractUsername(pagesDomain, query), nil
}
return dns.ExtractUsername(pagesDomain, sni), nil
}
func MakeTlsConfig(pagesDomain, cachePath string, cache *certificates.CertificatesCache, acmeClient *lego.Client, ctx *context.GlobalContext) *tls.Config {
return &tls.Config{ return &tls.Config{
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
// Validate that we should even care about this domain // Validate that we should even care about this domain
isPagesDomain := info.ServerName == pagesDomain isPagesDomain := info.ServerName == pagesDomain
cname := "" username, err := getUsername(info.ServerName, pagesDomain)
if !strings.HasSuffix(info.ServerName, pagesDomain) { if err != nil {
// Note: We do not check err here because err != nil log.Warnf("Failed to get username for %s: %v", info.ServerName, err)
// always implies that cname == "", which does not have return cache.FallbackCertificate.TlsCertificate, nil
// pagesDomain as a suffix.
cname, err := dns.LookupCNAME(info.ServerName)
if !strings.HasSuffix(cname, pagesDomain) {
log.Warnf("Got ServerName for Domain %s that we're not responsible for. CNAME '%s', err: %v", info.ServerName, cname, err)
return cache.FallbackCertificate.TlsCertificate, nil
}
}
// Figure out a username for later username checks
username := ""
if cname == "" {
// domain ends on pagesDomain
username = strings.Split(info.ServerName, ".")[0]
} else {
// cname ends on pagesDomain
username = strings.Split(cname, ".")[0]
} }
// Find the correct certificate // Find the correct certificate
@@ -97,7 +100,7 @@ func MakeTlsConfig(pagesDomain, cachePath string, cache *certificates.Certificat
if cert.IsValid() { if cert.IsValid() {
return cert.TlsCertificate, nil return cert.TlsCertificate, nil
} else { } else {
if !isPagesDomain && !repo.CanRequestCertificate(username, giteaClient) { if !isPagesDomain && !repo.CanRequestCertificate(username, ctx) {
log.Warnf( log.Warnf(
"Cannot renew certificate for %s because CanRequestCertificate(%s) returned false", "Cannot renew certificate for %s because CanRequestCertificate(%s) returned false",
info.ServerName, info.ServerName,
@@ -126,7 +129,7 @@ func MakeTlsConfig(pagesDomain, cachePath string, cache *certificates.Certificat
return newCert.TlsCertificate, nil return newCert.TlsCertificate, nil
} }
} else { } else {
if !isPagesDomain && !repo.CanRequestCertificate(username, giteaClient) { if !isPagesDomain && !repo.CanRequestCertificate(username, ctx) {
log.Warnf( log.Warnf(
"Cannot request certificate for %s because CanRequestCertificate(%s) returned false", "Cannot request certificate for %s because CanRequestCertificate(%s) returned false",
info.ServerName, info.ServerName,

View File

@@ -12,7 +12,7 @@ func equals(a, b []string) bool {
return false return false
} }
for i, _ := range a { for i := range a {
if a[i] != b[i] { if a[i] != b[i] {
return false return false
} }