441 lines
11 KiB
Go
441 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.polynom.me/rio/internal/acme"
|
|
"git.polynom.me/rio/internal/certificates"
|
|
"git.polynom.me/rio/internal/context"
|
|
"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/repo"
|
|
"git.polynom.me/rio/internal/server"
|
|
|
|
"code.gitea.io/sdk/gitea"
|
|
legodns "github.com/go-acme/lego/v4/providers/dns"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
func handleSubdomain(ctx *context.GlobalContext, domain, cname, path string, req *http.Request, w http.ResponseWriter) {
|
|
username := ""
|
|
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 /
|
|
if 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(
|
|
username,
|
|
domain,
|
|
cname,
|
|
path,
|
|
ctx,
|
|
)
|
|
if err != nil {
|
|
log.Errorf("Failed to get repo: %s", err)
|
|
w.WriteHeader(404)
|
|
return
|
|
}
|
|
|
|
c := &context.Context{
|
|
Username: username,
|
|
Reponame: repo.Name,
|
|
Domain: domain,
|
|
Path: path,
|
|
Referrer: req.Header.Get("Referer"),
|
|
UserAgent: req.Header.Get("User-Agent"),
|
|
Writer: w,
|
|
Global: ctx,
|
|
}
|
|
pages.ServeFile(c)
|
|
}
|
|
|
|
func Handler(ctx *context.GlobalContext) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Server", "rio")
|
|
|
|
// 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")
|
|
handleSubdomain(ctx, req.Host, "", req.URL.Path, req, w)
|
|
return
|
|
}
|
|
|
|
cname, err := dns.LookupCNAME(req.Host)
|
|
if err != nil {
|
|
log.Warningf("CNAME request failed, we don't handle %s", req.Host)
|
|
w.WriteHeader(400)
|
|
return
|
|
}
|
|
log.Debugf("Got CNAME %s", cname)
|
|
|
|
// 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)
|
|
handleSubdomain(ctx, req.Host, cname, req.URL.Path, req, w)
|
|
return
|
|
}
|
|
|
|
log.Errorf("Not handling %s", req.Host)
|
|
w.WriteHeader(404)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
giteaUrl := ctx.String("gitea-url")
|
|
domain := ctx.String("pages-domain")
|
|
certsFile := ctx.String("certs-file")
|
|
acmeEmail := ctx.String("acme-email")
|
|
acmeServer := ctx.String("acme-server")
|
|
acmeFile := ctx.String("acme-file")
|
|
acmeDnsProvider := ctx.String("acme-dns-provider")
|
|
acmeDisable := ctx.Bool("acme-disable")
|
|
defaultCsp := ctx.String("default-csp")
|
|
lokiUrl := ctx.String("loki-url")
|
|
metricsBotList := ctx.String("metrics-bot-list")
|
|
tokenFile := ctx.String("token-file")
|
|
|
|
// Init Logging
|
|
if ctx.Bool("debug") {
|
|
log.SetLevel(log.DebugLevel)
|
|
} else {
|
|
log.SetLevel(log.InfoLevel)
|
|
}
|
|
|
|
// Set up the Loki metrics
|
|
var lokiConfig metrics.LokiMetricConfig
|
|
if lokiUrl == "" {
|
|
lokiConfig = metrics.LokiMetricConfig{
|
|
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.LokiMetricConfig{
|
|
Enabled: true,
|
|
BotUserAgents: &patterns,
|
|
Url: lokiUrl,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
httpClient := http.Client{Timeout: 10 * time.Second}
|
|
giteaApiClient, err := gitea.NewClient(
|
|
giteaUrl,
|
|
gitea.SetHTTPClient(&httpClient),
|
|
gitea.SetToken(token),
|
|
gitea.SetUserAgent("rio"),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
giteaClient := riogitea.NewGiteaClient(giteaUrl, token, giteaApiClient)
|
|
|
|
// Listen on the port
|
|
addr := ctx.String("listen-host") + ":" + ctx.String("listen-port")
|
|
listener, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
errMsg := fmt.Errorf("Failed to create listener: %v", err)
|
|
fmt.Println(errMsg.Error())
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 acmeEmail == "" || acmeFile == "" || certsFile == "" || acmeDnsProvider == "" {
|
|
return errors.New("The options acme-dns-provider, acme-file, acme-email, and certs-file are required")
|
|
}
|
|
|
|
cache, err := certificates.CertificateCacheFromFile(certsFile)
|
|
if err != nil {
|
|
log.Debugf("Generating cert")
|
|
fallback, err := certificates.MakeFallbackCertificate(domain)
|
|
if err != nil {
|
|
log.Fatalf("Failed to generate fallback certificate: %v", err)
|
|
return err
|
|
}
|
|
|
|
cache.FallbackCertificate = fallback
|
|
cache.Certificates = make(map[string]certificates.CertificateWrapper)
|
|
cache.FlushToDisk(certsFile)
|
|
log.Debug("Certificate wrote to disk")
|
|
} else {
|
|
log.Debug("Certificate store read from disk")
|
|
}
|
|
|
|
// Create an ACME client, if we failed to load one
|
|
acmeClient, err := acme.ClientFromFile(acmeFile, acmeServer)
|
|
if err != nil {
|
|
log.Warn("Failed to load ACME client data from disk. Generating new account")
|
|
acmeClient, err = acme.GenerateNewAccount(acmeEmail, acmeFile, acmeServer)
|
|
if err != nil {
|
|
log.Fatalf("Failed to generate new ACME client: %v", err)
|
|
return err
|
|
}
|
|
log.Info("ACME account registered")
|
|
} else {
|
|
log.Info("ACME client data read from disk")
|
|
}
|
|
|
|
// Set up the DNS01 challenge solver
|
|
provider, err := legodns.NewDNSChallengeProviderByName(acmeDnsProvider)
|
|
if err != nil {
|
|
log.Fatalf("Failed to create DNS01 challenge provider: %v", err)
|
|
return err
|
|
}
|
|
err = acmeClient.Challenge.SetDNS01Provider(provider)
|
|
if err != nil {
|
|
log.Fatalf("Failed to setup DNS01 challenge solver: %v", err)
|
|
return err
|
|
}
|
|
|
|
tlsConfig := server.MakeTlsConfig(
|
|
domain,
|
|
certsFile,
|
|
&cache,
|
|
acmeClient,
|
|
globalCtx,
|
|
)
|
|
listener = tls.NewListener(listener, tlsConfig)
|
|
}
|
|
|
|
var waitGroup sync.WaitGroup
|
|
servers := 2
|
|
if acmeDisable {
|
|
servers = 1
|
|
}
|
|
waitGroup.Add(servers)
|
|
|
|
go func() {
|
|
defer waitGroup.Done()
|
|
|
|
log.Infof("Listening on main HTTP server %s", httpAddr)
|
|
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 {
|
|
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
|
|
}
|
|
|
|
func main() {
|
|
app := &cli.App{
|
|
Action: runServer,
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{
|
|
Name: "gitea-url",
|
|
Usage: "The (HTTPS) URL to the serving Gitea instance",
|
|
EnvVars: []string{"GITEA_URL"},
|
|
Required: true,
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "listen-host",
|
|
Usage: "The host to listen on",
|
|
EnvVars: []string{"HOST"},
|
|
Value: "127.0.0.1",
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "listen-port",
|
|
Usage: "The port to listen on",
|
|
EnvVars: []string{"PORT"},
|
|
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{
|
|
Name: "acme-dns-provider",
|
|
Usage: "The provider to use for DNS01 challenge solving",
|
|
EnvVars: []string{"ACME_DNS_PROVIDER"},
|
|
Value: "",
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "pages-domain",
|
|
Usage: "The domain on which the server is reachable",
|
|
EnvVars: []string{"PAGES_DOMAIN"},
|
|
Required: true,
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "certs-file",
|
|
Usage: "File to store certificates in",
|
|
EnvVars: []string{"CERTS_FILE"},
|
|
Value: "",
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "acme-file",
|
|
Usage: "File to store ACME configuration in",
|
|
EnvVars: []string{"ACME_FILE"},
|
|
Value: "",
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "acme-email",
|
|
Usage: "Email to use for an ACME account",
|
|
EnvVars: []string{"ACME_EMAIL"},
|
|
Value: "",
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "acme-server",
|
|
Usage: "CA Directory to use",
|
|
EnvVars: []string{"ACME_SERVER"},
|
|
Value: "https://acme-staging-v02.api.letsencrypt.org/directory",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "acme-disable",
|
|
Usage: "Whether to disable automatic ACME certificates",
|
|
EnvVars: []string{"ACME_DISABLE"},
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "debug",
|
|
Usage: "Whether to enable debug logging",
|
|
EnvVars: []string{"DEBUG_ENABLE"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "default-csp",
|
|
Usage: "The default CSP to include when sending HTTP responses",
|
|
Value: "",
|
|
EnvVars: []string{"DEFAULT_CSP"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "loki-url",
|
|
Usage: "The URL for Loki metric pings",
|
|
Value: "",
|
|
EnvVars: []string{"LOKI_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"},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := app.Run(os.Args); err != nil {
|
|
log.Fatalf("Failed to run app: %s", err)
|
|
}
|
|
}
|