rio/cmd/rio/main.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)
}
}