Use ffcli, tidy up usage message

This commit is contained in:
David Stotijn 2022-02-28 12:50:09 +01:00
parent d438f93ee0
commit ca0c085021
No known key found for this signature in database
GPG key ID: B23243A9C47CEE2D
6 changed files with 328 additions and 215 deletions

23
cmd/hetty/config.go Normal file
View file

@ -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.")
}

288
cmd/hetty/hetty.go Normal file
View file

@ -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] [<arg>...]",
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
}

View file

@ -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))
}
}

1
go.mod
View file

@ -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
)

3
go.sum
View file

@ -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=

View file

@ -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"
}