Proxy requests for unknown sites via wildcard fallback URL (if any).

This commit is contained in:
Catherine
2025-09-21 02:28:03 +00:00
parent d5302e4358
commit 5765fa7ffa
7 changed files with 168 additions and 94 deletions

View File

@@ -7,6 +7,7 @@ health = "tcp/:3002"
# domain = "codeberg.page"
# clone-url = "https://codeberg.org/<user>/<project>.git"
# index-repos = ["<user>.codeberg.page", "pages"]
# fallback-proxy-to = "https://codeberg.page"
[backend]
type = "fs"

View File

@@ -171,8 +171,7 @@ func authorizeWildcardMatchHost(r *http.Request, pattern *WildcardPattern) (*Aut
return nil, err
}
hostParts := strings.Split(host, ".")
if slices.Equal(hostParts[1:], pattern.Domain) {
if _, found := pattern.Matches(host); found {
return &Authorization{}, nil
} else {
return nil, AuthError{
@@ -193,9 +192,7 @@ func authorizeWildcardMatchSite(r *http.Request, pattern *WildcardPattern) (*Aut
return nil, err
}
hostParts := strings.Split(host, ".")
if slices.Equal(hostParts[1:], pattern.Domain) {
userName := hostParts[0]
if userName, found := pattern.Matches(host); found {
var repoURLs []string
repoURLTemplate := pattern.CloneURL
if projectName == ".index" {

View File

@@ -2,6 +2,7 @@ package main
import (
"errors"
"fmt"
"io"
"slices"
"strings"
@@ -10,6 +11,15 @@ import (
var errNotFound = errors.New("not found")
func splitBlobName(name string) []string {
algo, hash, found := strings.Cut(name, "-")
if found {
return slices.Concat([]string{algo}, splitBlobName(hash))
} else {
return []string{name[0:2], name[2:4], name[4:]}
}
}
type Backend interface {
// Retrieve a blob. Returns `reader, mtime, err`.
GetBlob(name string) (io.ReadSeeker, time.Time, error)
@@ -41,11 +51,30 @@ type Backend interface {
CheckDomain(domain string) (bool, error)
}
func splitBlobName(name string) []string {
algo, hash, found := strings.Cut(name, "-")
if found {
return slices.Concat([]string{algo}, splitBlobName(hash))
} else {
return []string{name[0:2], name[2:4], name[4:]}
var backend Backend
func ConfigureBackend() error {
var err error
switch config.Backend.Type {
case "fs":
if backend, err = NewFSBackend(config.Backend.FS.Root); err != nil {
return fmt.Errorf("fs backend: %w", err)
}
case "s3":
if backend, err = NewS3Backend(
config.Backend.S3.Endpoint,
config.Backend.S3.Insecure,
config.Backend.S3.AccessKeyID,
config.Backend.S3.SecretAccessKey,
config.Backend.S3.Region,
config.Backend.S3.Bucket,
); err != nil {
return fmt.Errorf("s3 backend: %w", err)
}
default:
return fmt.Errorf("unknown backend: %s", config.Backend.Type)
}
return nil
}

View File

@@ -1,12 +1,9 @@
package main
import (
"log"
"os"
"strings"
"github.com/pelletier/go-toml/v2"
"github.com/valyala/fasttemplate"
)
type CacheConfig struct {
@@ -22,9 +19,10 @@ type Config struct {
Health string `toml:"health"`
} `toml:"listen"`
Wildcard []struct {
Domain string `toml:"domain"`
CloneURL string `toml:"clone-url"`
IndexRepos []string `toml:"index-repos"`
Domain string `toml:"domain"`
CloneURL string `toml:"clone-url"`
IndexRepos []string `toml:"index-repos"`
FallbackProxyTo string `toml:"fallback-proxy-to"`
} `toml:"wildcard"`
Backend struct {
Type string `toml:"type"`
@@ -73,70 +71,3 @@ func UpdateConfigEnv() {
updateFromEnv(&config.Backend.S3.Region, "S3_REGION")
updateFromEnv(&config.Backend.S3.Bucket, "S3_BUCKET")
}
var backend Backend
func ConfigureBackend() {
var err error
switch config.Backend.Type {
case "fs":
if backend, err = NewFSBackend(config.Backend.FS.Root); err != nil {
log.Fatalln("fs backend:", err)
}
case "s3":
if backend, err = NewS3Backend(
config.Backend.S3.Endpoint,
config.Backend.S3.Insecure,
config.Backend.S3.AccessKeyID,
config.Backend.S3.SecretAccessKey,
config.Backend.S3.Region,
config.Backend.S3.Bucket,
); err != nil {
log.Fatalln("s3 backend:", err)
}
default:
log.Fatalln("unknown backend:", config.Backend.Type)
}
}
type WildcardPattern struct {
Domain []string
CloneURL *fasttemplate.Template
IndexRepos []*fasttemplate.Template
}
func (pattern *WildcardPattern) GetHost() string {
parts := []string{"*"}
parts = append(parts, pattern.Domain...)
return strings.Join(parts, ".")
}
var wildcardPatterns []*WildcardPattern
func CompileWildcardPattern() {
for _, configWildcard := range config.Wildcard {
wildcardPattern := WildcardPattern{
Domain: strings.Split(configWildcard.Domain, "."),
}
template, err := fasttemplate.NewTemplate(configWildcard.CloneURL, "<", ">")
if err != nil {
log.Fatalf("wildcard pattern: clone URL: %s", err)
} else {
wildcardPattern.CloneURL = template
}
for _, indexRepo := range configWildcard.IndexRepos {
template, err := fasttemplate.NewTemplate(indexRepo, "<", ">")
if err != nil {
log.Fatalf("wildcard pattern: clone URL: %s", err)
} else {
wildcardPattern.IndexRepos = append(wildcardPattern.IndexRepos, template)
}
}
wildcardPatterns = append(wildcardPatterns, &wildcardPattern)
}
}

View File

@@ -89,7 +89,9 @@ func main() {
)
if *getManifest != "" {
ConfigureBackend()
if err := ConfigureBackend(); err != nil {
log.Fatalln(err)
}
webRoot := *getManifest
if !strings.Contains(webRoot, "/") {
@@ -110,8 +112,13 @@ func main() {
caddyListener := listen("caddy", config.Listen.Caddy)
healthListener := listen("health", config.Listen.Health)
ConfigureBackend()
CompileWildcardPattern()
if err := ConfigureBackend(); err != nil {
log.Fatalln(err)
}
if err := ConfigureWildcards(); err != nil {
log.Fatalln(err)
}
go serve(pagesListener, ServePages)
go serve(caddyListener, ServeCaddy)

View File

@@ -33,10 +33,6 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
return err
}
// allow JavaScript code to access responses (including errors) even across origins
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Max-Age", "86400")
sitePath, _ = strings.CutPrefix(r.URL.Path, "/")
if projectName, projectPath, found := strings.Cut(sitePath, "/"); found {
projectManifest, err := backend.GetManifest(makeWebRoot(host, projectName))
@@ -47,12 +43,20 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
if manifest == nil {
manifest, err = backend.GetManifest(makeWebRoot(host, ".index"))
if manifest == nil {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "site not found\n")
return err
if found, fallbackErr := HandleWildcardFallback(w, r); found {
return fallbackErr
} else {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "site not found\n")
return err
}
}
}
// allow JavaScript code to access responses (including errors) even across origins
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Max-Age", "86400")
if sitePath == ".git-pages" {
// metadata directory name shouldn't be served even if present in site manifest
w.WriteHeader(http.StatusNotFound)

105
src/wildcard.go Normal file
View File

@@ -0,0 +1,105 @@
package main
import (
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"slices"
"strings"
"github.com/valyala/fasttemplate"
)
type WildcardPattern struct {
Domain []string
CloneURL *fasttemplate.Template
IndexRepos []*fasttemplate.Template
FallbackURL *url.URL
}
var wildcardPatterns []*WildcardPattern
func (pattern *WildcardPattern) GetHost() string {
parts := []string{"*"}
parts = append(parts, pattern.Domain...)
return strings.Join(parts, ".")
}
// Returns `subdomain, found` where if `found == true`, `subdomain` contains the part of `host`
// corresponding to the * in the domain pattern.
func (pattern *WildcardPattern) Matches(host string) (string, bool) {
hostParts := strings.Split(host, ".")
if len(hostParts) != 1+len(pattern.Domain) || !slices.Equal(hostParts[1:], pattern.Domain) {
return "", false
}
return hostParts[0], true
}
func (pattern *WildcardPattern) IsFallbackFor(host string) bool {
if pattern.FallbackURL == nil {
return false
}
_, found := pattern.Matches(host)
return found
}
func HandleWildcardFallback(w http.ResponseWriter, r *http.Request) (bool, error) {
host, err := GetHost(r)
if err != nil {
return false, err
}
for _, pattern := range wildcardPatterns {
if pattern.IsFallbackFor(host) {
log.Printf("proxy: %s via %s", pattern.GetHost(), pattern.FallbackURL)
(&httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(pattern.FallbackURL)
r.Out.Host = r.In.Host
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
},
}).ServeHTTP(w, r)
return true, nil
}
}
return false, nil
}
func ConfigureWildcards() error {
for _, configWildcard := range config.Wildcard {
wildcardPattern := WildcardPattern{
Domain: strings.Split(configWildcard.Domain, "."),
}
template, err := fasttemplate.NewTemplate(configWildcard.CloneURL, "<", ">")
if err != nil {
return fmt.Errorf("wildcard pattern: clone URL: %w", err)
} else {
wildcardPattern.CloneURL = template
}
for _, indexRepo := range configWildcard.IndexRepos {
template, err := fasttemplate.NewTemplate(indexRepo, "<", ">")
if err != nil {
return fmt.Errorf("wildcard pattern: clone URL: %w", err)
} else {
wildcardPattern.IndexRepos = append(wildcardPattern.IndexRepos, template)
}
}
if configWildcard.FallbackProxyTo != "" {
wildcardPattern.FallbackURL, err = url.Parse(configWildcard.FallbackProxyTo)
if err != nil {
return fmt.Errorf("wildcard pattern: fallback URL: %w", err)
}
}
wildcardPatterns = append(wildcardPatterns, &wildcardPattern)
}
return nil
}