diff --git a/cmd/hetty/config.go b/cmd/hetty/config.go new file mode 100644 index 0000000..6acecfc --- /dev/null +++ b/cmd/hetty/config.go @@ -0,0 +1,23 @@ +package main + +import ( + "flag" + + "go.uber.org/zap" +) + +// Config represents the global configuration shared amongst all commands. +type Config struct { + verbose bool + jsonLogs bool + logger *zap.Logger +} + +// RegisterFlags registers the flag fields into the provided flag.FlagSet. This +// helper function allows subcommands to register the root flags into their +// flagsets, creating "global" flags that can be passed after any subcommand at +// the commandline. +func (cfg *Config) RegisterFlags(fs *flag.FlagSet) { + fs.BoolVar(&cfg.verbose, "verbose", false, "Enable verbose logging.") + fs.BoolVar(&cfg.jsonLogs, "json", false, "Encode logs as JSON, instead of pretty/human readable output.") +} diff --git a/cmd/hetty/hetty.go b/cmd/hetty/hetty.go new file mode 100644 index 0000000..0bed2a3 --- /dev/null +++ b/cmd/hetty/hetty.go @@ -0,0 +1,288 @@ +package main + +import ( + "context" + "crypto/tls" + "embed" + "errors" + "flag" + "fmt" + "io/fs" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "strings" + + "github.com/chromedp/chromedp" + badgerdb "github.com/dgraph-io/badger/v3" + "github.com/gorilla/mux" + "github.com/mitchellh/go-homedir" + "github.com/peterbourgon/ff/v3/ffcli" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/dstotijn/hetty/pkg/api" + "github.com/dstotijn/hetty/pkg/chrome" + "github.com/dstotijn/hetty/pkg/db/badger" + "github.com/dstotijn/hetty/pkg/proj" + "github.com/dstotijn/hetty/pkg/proxy" + "github.com/dstotijn/hetty/pkg/reqlog" + "github.com/dstotijn/hetty/pkg/scope" + "github.com/dstotijn/hetty/pkg/sender" +) + +var version = "0.0.0" + +//go:embed admin +//go:embed admin/_next/static +//go:embed admin/_next/static/chunks/pages/*.js +//go:embed admin/_next/static/*/*.js +var adminContent embed.FS + +var hettyUsage = ` +Usage: + hetty [flags] + +Options: + --cert Path to root CA certificate. Creates file if it doesn't exist. (Default: "~/.hetty/hetty_cert.pem") + --key Path to root CA private key. Creates file if it doesn't exist. (Default: "~/.hetty/hetty_key.pem") + --db Database directory path. (Default: "~/.hetty/db") + --addr TCP address for HTTP server to listen on, in the form \"host:port\". (Default: ":8080") + --chrome Launch Chrome with proxy settings applied and certificate errors ignored. (Default: false) + --verbose Enable verbose logging. + --json Encode logs as JSON, instead of pretty/human readable output. + --version, -v Output version. + --help, -h Output this usage text. + +Visit https://hetty.xyz to learn more about Hetty. +` + +type HettyCommand struct { + config *Config + + cert string + key string + db string + addr string + chrome bool + version bool +} + +func NewHettyCommand() (*ffcli.Command, *Config) { + cmd := HettyCommand{ + config: &Config{}, + } + + fs := flag.NewFlagSet("hetty", flag.ExitOnError) + + fs.StringVar(&cmd.cert, "cert", "~/.hetty/hetty_cert.pem", + "Path to root CA certificate. Creates a new certificate if file doesn't exist.") + fs.StringVar(&cmd.key, "key", "~/.hetty/hetty_key.pem", + "Path to root CA private key. Creates a new private key if file doesn't exist.") + fs.StringVar(&cmd.db, "db", "~/.hetty/db", "Database directory path.") + fs.StringVar(&cmd.addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\".") + fs.BoolVar(&cmd.chrome, "chrome", false, "Launch Chrome with proxy settings applied and certificate errors ignored.") + fs.BoolVar(&cmd.version, "version", false, "Output version.") + fs.BoolVar(&cmd.version, "v", false, "Output version.") + + cmd.config.RegisterFlags(fs) + + return &ffcli.Command{ + Name: "hetty", + ShortUsage: "hetty [-v] [subcommand] [flags] [...]", + FlagSet: fs, + Exec: cmd.Exec, + UsageFunc: func(*ffcli.Command) string { + return hettyUsage + }, + }, cmd.config +} + +func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error { + ctx, stop := signal.NotifyContext(ctx, os.Interrupt) + defer stop() + + if cmd.version { + fmt.Fprint(os.Stdout, version+"\n") + return nil + } + + mainLogger := cmd.config.logger.Named("main") + + listenHost, listenPort, err := net.SplitHostPort(cmd.addr) + if err != nil { + mainLogger.Fatal("Failed to parse listening address.", zap.Error(err)) + } + + url := fmt.Sprintf("http://%v:%v", listenHost, listenPort) + if listenHost == "" || listenHost == "0.0.0.0" || listenHost == "127.0.0.1" || listenHost == "::1" { + url = fmt.Sprintf("http://localhost:%v", listenPort) + } + + // Expand `~` in filepaths. + caCertFile, err := homedir.Expand(cmd.cert) + if err != nil { + cmd.config.logger.Fatal("Failed to parse CA certificate filepath.", zap.Error(err)) + } + + caKeyFile, err := homedir.Expand(cmd.key) + if err != nil { + cmd.config.logger.Fatal("Failed to parse CA private key filepath.", zap.Error(err)) + } + + dbPath, err := homedir.Expand(cmd.db) + if err != nil { + cmd.config.logger.Fatal("Failed to parse database path.", zap.Error(err)) + } + + // Load existing CA certificate and key from disk, or generate and write + // to disk if no files exist yet. + caCert, caKey, err := proxy.LoadOrCreateCA(caKeyFile, caCertFile) + if err != nil { + cmd.config.logger.Fatal("Failed to load or create CA key pair.", zap.Error(err)) + } + + // BadgerDB logs some verbose entries with `INFO` level, so unless + // we're running in debug mode, bump the minimal level to `WARN`. + dbLogger := cmd.config.logger.Named("badgerdb").WithOptions(zap.IncreaseLevel(zapcore.WarnLevel)) + + dbSugaredLogger := dbLogger.Sugar() + + badger, err := badger.OpenDatabase( + badgerdb.DefaultOptions(dbPath).WithLogger(badger.NewLogger(dbSugaredLogger)), + ) + if err != nil { + cmd.config.logger.Fatal("Failed to open database.", zap.Error(err)) + } + defer badger.Close() + + scope := &scope.Scope{} + + reqLogService := reqlog.NewService(reqlog.Config{ + Scope: scope, + Repository: badger, + Logger: cmd.config.logger.Named("reqlog").Sugar(), + }) + + senderService := sender.NewService(sender.Config{ + Repository: badger, + ReqLogService: reqLogService, + }) + + projService, err := proj.NewService(proj.Config{ + Repository: badger, + ReqLogService: reqLogService, + SenderService: senderService, + Scope: scope, + }) + if err != nil { + cmd.config.logger.Fatal("Failed to create new projects service.", zap.Error(err)) + } + + proxy, err := proxy.NewProxy(proxy.Config{ + CACert: caCert, + CAKey: caKey, + Logger: cmd.config.logger.Named("proxy").Sugar(), + }) + if err != nil { + cmd.config.logger.Fatal("Failed to create new proxy.", zap.Error(err)) + } + + proxy.UseRequestModifier(reqLogService.RequestModifier) + proxy.UseResponseModifier(reqLogService.ResponseModifier) + + fsSub, err := fs.Sub(adminContent, "admin") + if err != nil { + cmd.config.logger.Fatal("Failed to construct file system subtree from admin dir.", zap.Error(err)) + } + + adminHandler := http.FileServer(http.FS(fsSub)) + router := mux.NewRouter().SkipClean(true) + adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool { + hostname, _ := os.Hostname() + host, _, _ := net.SplitHostPort(req.Host) + + // Serve local admin routes when either: + // - The `Host` is well-known, e.g. `hetty.proxy`, `localhost:[port]` + // or the listen addr `[host]:[port]`. + // - The request is not for TLS proxying (e.g. no `CONNECT`) and not + // for proxying an external URL. E.g. Request-Line (RFC 7230, Section 3.1.1) + // has no scheme. + return strings.EqualFold(host, hostname) || + req.Host == "hetty.proxy" || + req.Host == fmt.Sprintf("%v:%v", "localhost", listenPort) || + req.Host == fmt.Sprintf("%v:%v", listenHost, listenPort) || + req.Method != http.MethodConnect && !strings.HasPrefix(req.RequestURI, "http://") + }).Subrouter().StrictSlash(true) + + // GraphQL server. + gqlEndpoint := "/api/graphql/" + adminRouter.Path(gqlEndpoint).Handler(api.HTTPHandler(&api.Resolver{ + ProjectService: projService, + RequestLogService: reqLogService, + SenderService: senderService, + }, gqlEndpoint)) + + // Admin interface. + adminRouter.PathPrefix("").Handler(adminHandler) + + // Fallback (default) is the Proxy handler. + router.PathPrefix("").Handler(proxy) + + httpServer := &http.Server{ + Addr: cmd.addr, + Handler: router, + TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, // Disable HTTP/2 + ErrorLog: zap.NewStdLog(cmd.config.logger.Named("http")), + } + + go func() { + mainLogger.Info(fmt.Sprintf("Hetty (v%v) is running on %v ...", version, cmd.addr)) + mainLogger.Info(fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(32), "Get started at "+url)) + + err := httpServer.ListenAndServe() + if err != http.ErrServerClosed { + mainLogger.Fatal("HTTP server closed unexpected.", zap.Error(err)) + } + }() + + if cmd.chrome { + ctx, cancel := chrome.NewExecAllocator(ctx, chrome.Config{ + ProxyServer: url, + ProxyBypassHosts: []string{url}, + }) + defer cancel() + + taskCtx, cancel := chromedp.NewContext(ctx) + defer cancel() + + err = chromedp.Run(taskCtx, chromedp.Navigate(url)) + + switch { + case errors.Is(err, exec.ErrNotFound): + mainLogger.Info("Chrome executable not found.") + case err != nil: + mainLogger.Error(fmt.Sprintf("Failed to navigate to %v.", url), zap.Error(err)) + default: + mainLogger.Info("Launched Chrome.") + } + } + + // Wait for interrupt signal. + <-ctx.Done() + // Restore signal, allowing "force quit". + stop() + + mainLogger.Info("Shutting down HTTP server. Press Ctrl+C to force quit.") + + // Note: We expect httpServer.Handler to handle timeouts, thus, we don't + // need a context value with deadline here. + err = httpServer.Shutdown(context.Background()) + if err != nil { + return fmt.Errorf("failed to shutdown HTTP server: %w", err) + } + + return nil +} diff --git a/cmd/hetty/main.go b/cmd/hetty/main.go index 6016e8e..107ecca 100644 --- a/cmd/hetty/main.go +++ b/cmd/hetty/main.go @@ -2,232 +2,30 @@ package main import ( "context" - "crypto/tls" - "embed" - "errors" - "flag" - "fmt" - "io/fs" llog "log" - "net" - "net/http" "os" - "os/exec" - "strings" - "github.com/chromedp/chromedp" - badgerdb "github.com/dgraph-io/badger/v3" - "github.com/gorilla/mux" - "github.com/mitchellh/go-homedir" "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "github.com/dstotijn/hetty/pkg/api" - "github.com/dstotijn/hetty/pkg/chrome" - "github.com/dstotijn/hetty/pkg/db/badger" "github.com/dstotijn/hetty/pkg/log" - "github.com/dstotijn/hetty/pkg/proj" - "github.com/dstotijn/hetty/pkg/proxy" - "github.com/dstotijn/hetty/pkg/reqlog" - "github.com/dstotijn/hetty/pkg/scope" - "github.com/dstotijn/hetty/pkg/sender" ) -var version = "0.0.0" - -// Flag variables. -var ( - caCertFile string - caKeyFile string - dbPath string - addr string - launchChrome bool - debug bool - noPrettyLogs bool -) - -//go:embed admin -//go:embed admin/_next/static -//go:embed admin/_next/static/chunks/pages/*.js -//go:embed admin/_next/static/*/*.js -var adminContent embed.FS - func main() { - ctx := context.Background() + hettyCmd, cfg := NewHettyCommand() - flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem", - "CA certificate filepath. Creates a new CA certificate if file doesn't exist") - flag.StringVar(&caKeyFile, "key", "~/.hetty/hetty_key.pem", - "CA private key filepath. Creates a new CA private key if file doesn't exist") - flag.StringVar(&dbPath, "db", "~/.hetty/db", "Database directory path") - flag.StringVar(&addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\"") - flag.BoolVar(&launchChrome, "chrome", false, "Launch Chrome with proxy settings and certificate errors ignored") - flag.BoolVar(&debug, "debug", false, "Enable debug mode") - flag.BoolVar(&noPrettyLogs, "disable-pretty-logs", false, "Disable human readable console logs and encode with JSON.") - flag.Parse() + if err := hettyCmd.Parse(os.Args[1:]); err != nil { + llog.Fatalf("Failed to parse command line arguments: %v", err) + } - logger, err := log.NewZapLogger(debug, !noPrettyLogs) + logger, err := log.NewZapLogger(cfg.verbose, cfg.jsonLogs) if err != nil { llog.Fatal(err) } defer logger.Sync() - mainLogger := logger.Named("main") + cfg.logger = logger - listenHost, listenPort, err := net.SplitHostPort(addr) - if err != nil { - mainLogger.Fatal("Failed to parse listening address.", zap.Error(err)) - } - - url := fmt.Sprintf("http://%v:%v", listenHost, listenPort) - if listenHost == "" || listenHost == "0.0.0.0" || listenHost == "127.0.0.1" || listenHost == "::1" { - url = fmt.Sprintf("http://localhost:%v", listenPort) - } - - // Expand `~` in filepaths. - caCertFile, err := homedir.Expand(caCertFile) - if err != nil { - logger.Fatal("Failed to parse CA certificate filepath.", zap.Error(err)) - } - - caKeyFile, err := homedir.Expand(caKeyFile) - if err != nil { - logger.Fatal("Failed to parse CA private key filepath.", zap.Error(err)) - } - - dbPath, err := homedir.Expand(dbPath) - if err != nil { - logger.Fatal("Failed to parse database path.", zap.Error(err)) - } - - // Load existing CA certificate and key from disk, or generate and write - // to disk if no files exist yet. - caCert, caKey, err := proxy.LoadOrCreateCA(caKeyFile, caCertFile) - if err != nil { - logger.Fatal("Failed to load or create CA key pair.", zap.Error(err)) - } - - // BadgerDB logs some verbose entries with `INFO` level, so unless - // we're running in debug mode, bump the minimal level to `WARN`. - dbLogger := logger.Named("badgerdb").WithOptions(zap.IncreaseLevel(zapcore.WarnLevel)) - - dbSugaredLogger := dbLogger.Sugar() - - badger, err := badger.OpenDatabase( - badgerdb.DefaultOptions(dbPath).WithLogger(badger.NewLogger(dbSugaredLogger)), - ) - if err != nil { - logger.Fatal("Failed to open database.", zap.Error(err)) - } - defer badger.Close() - - scope := &scope.Scope{} - - reqLogService := reqlog.NewService(reqlog.Config{ - Scope: scope, - Repository: badger, - Logger: logger.Named("reqlog").Sugar(), - }) - - senderService := sender.NewService(sender.Config{ - Repository: badger, - ReqLogService: reqLogService, - }) - - projService, err := proj.NewService(proj.Config{ - Repository: badger, - ReqLogService: reqLogService, - SenderService: senderService, - Scope: scope, - }) - if err != nil { - logger.Fatal("Failed to create new projects service.", zap.Error(err)) - } - - proxy, err := proxy.NewProxy(proxy.Config{ - CACert: caCert, - CAKey: caKey, - Logger: logger.Named("proxy").Sugar(), - }) - if err != nil { - logger.Fatal("Failed to create new proxy.", zap.Error(err)) - } - - proxy.UseRequestModifier(reqLogService.RequestModifier) - proxy.UseResponseModifier(reqLogService.ResponseModifier) - - fsSub, err := fs.Sub(adminContent, "admin") - if err != nil { - logger.Fatal("Failed to construct file system subtree from admin dir.", zap.Error(err)) - } - - adminHandler := http.FileServer(http.FS(fsSub)) - router := mux.NewRouter().SkipClean(true) - adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool { - hostname, _ := os.Hostname() - host, _, _ := net.SplitHostPort(req.Host) - - // Serve local admin routes when either: - // - The `Host` is well-known, e.g. `hetty.proxy`, `localhost:[port]` - // or the listen addr `[host]:[port]`. - // - The request is not for TLS proxying (e.g. no `CONNECT`) and not - // for proxying an external URL. E.g. Request-Line (RFC 7230, Section 3.1.1) - // has no scheme. - return strings.EqualFold(host, hostname) || - req.Host == "hetty.proxy" || - req.Host == fmt.Sprintf("%v:%v", "localhost", listenPort) || - req.Host == fmt.Sprintf("%v:%v", listenHost, listenPort) || - req.Method != http.MethodConnect && !strings.HasPrefix(req.RequestURI, "http://") - }).Subrouter().StrictSlash(true) - - // GraphQL server. - gqlEndpoint := "/api/graphql/" - adminRouter.Path(gqlEndpoint).Handler(api.HTTPHandler(&api.Resolver{ - ProjectService: projService, - RequestLogService: reqLogService, - SenderService: senderService, - }, gqlEndpoint)) - - // Admin interface. - adminRouter.PathPrefix("").Handler(adminHandler) - - // Fallback (default) is the Proxy handler. - router.PathPrefix("").Handler(proxy) - - httpServer := &http.Server{ - Addr: addr, - Handler: router, - TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, // Disable HTTP/2 - ErrorLog: zap.NewStdLog(logger.Named("http")), - } - - mainLogger.Info(fmt.Sprintf("Hetty (v%v) is running on %v ...", version, addr)) - mainLogger.Info(fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(32), "Get started at "+url)) - - if launchChrome { - ctx, cancel := chrome.NewExecAllocator(ctx, chrome.Config{ - ProxyServer: url, - ProxyBypassHosts: []string{url}, - }) - defer cancel() - - taskCtx, cancel := chromedp.NewContext(ctx) - defer cancel() - - err = chromedp.Run(taskCtx, chromedp.Navigate(url)) - - switch { - case errors.Is(err, exec.ErrNotFound): - mainLogger.Info("Chrome executable not found.") - case err != nil: - mainLogger.Error(fmt.Sprintf("Failed to navigate to %v.", url), zap.Error(err)) - default: - mainLogger.Info("Launched Chrome.") - } - } - - err = httpServer.ListenAndServe() - if err != nil && errors.Is(err, http.ErrServerClosed) { - mainLogger.Fatal("HTTP server closed unexpected.", zap.Error(err)) + if err := hettyCmd.Run(context.Background()); err != nil { + logger.Fatal("Unexpected error running command.", zap.Error(err)) } } diff --git a/go.mod b/go.mod index a340b75..3b79a2d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/matryer/moq v0.2.5 github.com/mitchellh/go-homedir v1.1.0 github.com/oklog/ulid v1.3.1 + github.com/peterbourgon/ff/v3 v3.1.2 github.com/vektah/gqlparser/v2 v2.2.0 go.uber.org/zap v1.21.0 ) diff --git a/go.sum b/go.sum index fb8bdca..e33c924 100644 --- a/go.sum +++ b/go.sum @@ -114,6 +114,9 @@ github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFSt github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5 h1:1SoBaSPudixRecmlHXb/GxmaD3fLMtHIDN13QujwQuc= github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= +github.com/peterbourgon/ff/v3 v3.1.2 h1:0GNhbRhO9yHA4CC27ymskOsuRpmX0YQxwxM9UPiP6JM= +github.com/peterbourgon/ff/v3 v3.1.2/go.mod h1:XNJLY8EIl6MjMVjBS4F0+G0LYoAqs0DTa4rmHHukKDE= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/pkg/log/log.go b/pkg/log/log.go index b428a92..9d578c8 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -14,13 +14,13 @@ type Logger interface { Errorw(msg string, v ...interface{}) } -func NewZapLogger(debug, pretty bool) (*zap.Logger, error) { +func NewZapLogger(verbose, jsonLogs bool) (*zap.Logger, error) { var config zap.Config - if debug { + if verbose { config = zap.Config{ Level: zap.NewAtomicLevelAt(zap.DebugLevel), - Development: debug, + Development: true, Encoding: "json", EncoderConfig: zapcore.EncoderConfig{ TimeKey: "ts", @@ -43,7 +43,7 @@ func NewZapLogger(debug, pretty bool) (*zap.Logger, error) { config = zap.NewProductionConfig() } - if pretty { + if !jsonLogs { config.Encoding = "console" config.EncoderConfig = zapcore.EncoderConfig{ TimeKey: "T", @@ -67,7 +67,7 @@ func NewZapLogger(debug, pretty bool) (*zap.Logger, error) { EncodeCaller: zapcore.ShortCallerEncoder, } - if debug { + if verbose { config.EncoderConfig.CallerKey = "C" config.EncoderConfig.StacktraceKey = "S" }