Some checks failed
Docker image builds / build (push) Waiting to run
Asset Build Verification / asset_verification (push) Has been cancelled
Docs deploy / build (push) Has been cancelled
Go Mod Tidy Check / go_mod_tidy_check (push) Has been cancelled
Go / go_tests (push) Has been cancelled
Package builds (unstable) / package_builds (push) Has been cancelled
Smoke tests / smoke-test (default-config-macro) (push) Has been cancelled
Smoke tests / smoke-test (docker-registry) (push) Has been cancelled
Smoke tests / smoke-test (double_slash) (push) Has been cancelled
Smoke tests / smoke-test (forced-language) (push) Has been cancelled
Smoke tests / smoke-test (git-clone) (push) Has been cancelled
Smoke tests / smoke-test (git-push) (push) Has been cancelled
Smoke tests / smoke-test (healthcheck) (push) Has been cancelled
Smoke tests / smoke-test (i18n) (push) Has been cancelled
Smoke tests / smoke-test (log-file) (push) Has been cancelled
Smoke tests / smoke-test (nginx) (push) Has been cancelled
Smoke tests / smoke-test (palemoon/amd64) (push) Has been cancelled
Smoke tests / smoke-test (robots_txt) (push) Has been cancelled
Check Spelling / Check Spelling (push) Has been cancelled
SSH CI / ssh (aarch64-16k) (push) Has been cancelled
SSH CI / ssh (aarch64-4k) (push) Has been cancelled
SSH CI / ssh (ppc64le) (push) Has been cancelled
SSH CI / ssh (riscv64) (push) Has been cancelled
zizmor / zizmor latest via PyPI (push) Has been cancelled
220 lines
6.5 KiB
Go
220 lines
6.5 KiB
Go
package lib
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.sad.ovh/sophie/nuke"
|
|
"git.sad.ovh/sophie/nuke/data"
|
|
"git.sad.ovh/sophie/nuke/internal"
|
|
"git.sad.ovh/sophie/nuke/internal/honeypot/naive"
|
|
"git.sad.ovh/sophie/nuke/internal/ogtags"
|
|
"git.sad.ovh/sophie/nuke/lib/challenge"
|
|
"git.sad.ovh/sophie/nuke/lib/config"
|
|
"git.sad.ovh/sophie/nuke/lib/localization"
|
|
"git.sad.ovh/sophie/nuke/lib/policy"
|
|
"git.sad.ovh/sophie/nuke/web"
|
|
"git.sad.ovh/sophie/nuke/xess"
|
|
"github.com/a-h/templ"
|
|
)
|
|
|
|
type Options struct {
|
|
Next http.Handler
|
|
Policy *policy.ParsedConfig
|
|
Target string
|
|
TargetHost string
|
|
TargetSNI string
|
|
TargetInsecureSkipVerify bool
|
|
CookieDynamicDomain bool
|
|
CookieDomain string
|
|
CookieExpiration time.Duration
|
|
CookiePartitioned bool
|
|
BasePrefix string
|
|
WebmasterEmail string
|
|
RedirectDomains []string
|
|
ED25519PrivateKey ed25519.PrivateKey
|
|
HS512Secret []byte
|
|
StripBasePrefix bool
|
|
OpenGraph config.OpenGraph
|
|
ServeRobotsTXT bool
|
|
CookieSecure bool
|
|
CookieSameSite http.SameSite
|
|
Logger *slog.Logger
|
|
LogLevel string
|
|
PublicUrl string
|
|
JWTRestrictionHeader string
|
|
DifficultyInJWT bool
|
|
}
|
|
|
|
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string) (*policy.ParsedConfig, error) {
|
|
var fin io.ReadCloser
|
|
var err error
|
|
|
|
if fname != "" {
|
|
fin, err = os.Open(fname)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
|
|
}
|
|
} else {
|
|
fname = "(data)/botPolicies.yaml"
|
|
fin, err = data.BotPolicies.Open("botPolicies.yaml")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", fname, err)
|
|
}
|
|
}
|
|
|
|
defer func(fin io.ReadCloser) {
|
|
err := fin.Close()
|
|
if err != nil {
|
|
slog.Error("failed to close policy file", "file", fname, "err", err)
|
|
}
|
|
}(fin)
|
|
|
|
nukePolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
|
|
}
|
|
var validationErrs []error
|
|
|
|
for _, b := range nukePolicy.Bots {
|
|
if _, ok := challenge.Get(b.Challenge.Algorithm); !ok {
|
|
validationErrs = append(validationErrs, fmt.Errorf("%w %s", policy.ErrChallengeRuleHasWrongAlgorithm, b.Challenge.Algorithm))
|
|
}
|
|
}
|
|
|
|
if len(validationErrs) != 0 {
|
|
return nil, fmt.Errorf("can't do final validation of Nuke config: %w", errors.Join(validationErrs...))
|
|
}
|
|
|
|
return nukePolicy, err
|
|
}
|
|
|
|
func New(opts Options) (*Server, error) {
|
|
if opts.Logger == nil {
|
|
opts.Logger = slog.With("subsystem", "nuke")
|
|
}
|
|
|
|
if opts.ED25519PrivateKey == nil && opts.HS512Secret == nil {
|
|
opts.Logger.Debug("opts.PrivateKey not set, generating a new one")
|
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lib: can't generate private key: %v", err)
|
|
}
|
|
opts.ED25519PrivateKey = priv
|
|
}
|
|
|
|
nuke.BasePrefix = strings.TrimRight(opts.BasePrefix, "/")
|
|
nuke.PublicUrl = opts.PublicUrl
|
|
|
|
result := &Server{
|
|
next: opts.Next,
|
|
ed25519Priv: opts.ED25519PrivateKey,
|
|
hs512Secret: opts.HS512Secret,
|
|
policy: opts.Policy,
|
|
opts: opts,
|
|
OGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store, ogtags.TargetOptions{
|
|
Host: opts.TargetHost,
|
|
SNI: opts.TargetSNI,
|
|
InsecureSkipVerify: opts.TargetInsecureSkipVerify,
|
|
}),
|
|
store: opts.Policy.Store,
|
|
logger: opts.Logger,
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
xess.Mount(mux)
|
|
|
|
// Helper to add global prefix
|
|
registerWithPrefix := func(pattern string, handler http.Handler, method string) {
|
|
if method != "" {
|
|
method = method + " " // methods must end with a space to register with them
|
|
}
|
|
|
|
// Ensure there's no double slash when concatenating BasePrefix and pattern
|
|
basePrefix := strings.TrimSuffix(nuke.BasePrefix, "/")
|
|
prefix := method + basePrefix
|
|
|
|
// If pattern doesn't start with a slash, add one
|
|
if !strings.HasPrefix(pattern, "/") {
|
|
pattern = "/" + pattern
|
|
}
|
|
|
|
mux.Handle(prefix+pattern, handler)
|
|
}
|
|
|
|
// Ensure there's no double slash when concatenating BasePrefix and StaticPath
|
|
stripPrefix := strings.TrimSuffix(nuke.BasePrefix, "/") + nuke.StaticPath
|
|
registerWithPrefix(nuke.StaticPath, internal.UnchangingCache(internal.NoBrowsing(http.StripPrefix(stripPrefix, http.FileServerFS(web.Static)))), "")
|
|
|
|
if opts.ServeRobotsTXT {
|
|
registerWithPrefix("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFileFS(w, r, web.Static, "static/robots.txt")
|
|
}), "GET")
|
|
registerWithPrefix("/.well-known/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFileFS(w, r, web.Static, "static/robots.txt")
|
|
}), "GET")
|
|
}
|
|
|
|
if opts.Policy.Impressum != nil {
|
|
registerWithPrefix(nuke.APIPrefix+"imprint", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
templ.Handler(
|
|
web.Base(opts.Policy.Impressum.Page.Title, opts.Policy.Impressum.Page, opts.Policy.Impressum, localization.GetLocalizer(r)),
|
|
).ServeHTTP(w, r)
|
|
}), "GET")
|
|
}
|
|
|
|
registerWithPrefix(nuke.APIPrefix+"pass-challenge", http.HandlerFunc(result.PassChallenge), "GET")
|
|
registerWithPrefix(nuke.APIPrefix+"check", http.HandlerFunc(result.maybeReverseProxyHttpStatusOnly), "")
|
|
registerWithPrefix("/", http.HandlerFunc(result.maybeReverseProxyOrPage), "")
|
|
|
|
mazeGen, err := naive.New(result.store, result.logger)
|
|
if err == nil {
|
|
registerWithPrefix(nuke.APIPrefix+"honeypot/{id}/{stage}", mazeGen, http.MethodGet)
|
|
|
|
opts.Policy.Bots = append(
|
|
opts.Policy.Bots,
|
|
policy.Bot{
|
|
Rules: mazeGen.CheckNetwork(),
|
|
Action: config.RuleWeigh,
|
|
Weight: &config.Weight{
|
|
Adjust: 30,
|
|
},
|
|
Name: "honeypot/network",
|
|
},
|
|
policy.Bot{
|
|
Rules: mazeGen.CheckUA(),
|
|
Action: config.RuleWeigh,
|
|
Weight: &config.Weight{
|
|
Adjust: 30,
|
|
},
|
|
Name: "honeypot/user-agent",
|
|
},
|
|
)
|
|
} else {
|
|
result.logger.Error("can't init honeypot subsystem", "err", err)
|
|
}
|
|
|
|
//goland:noinspection GoBoolExpressions
|
|
if nuke.Version == "devel" {
|
|
// make-challenge is only used in tests. Only enable while version is devel
|
|
registerWithPrefix(nuke.APIPrefix+"make-challenge", http.HandlerFunc(result.MakeChallenge), "POST")
|
|
}
|
|
|
|
for _, implKind := range challenge.Methods() {
|
|
impl, _ := challenge.Get(implKind)
|
|
impl.Setup(mux)
|
|
}
|
|
|
|
result.mux = mux
|
|
|
|
return result, nil
|
|
}
|