//go:generate go run git.rootprojects.org/root/go-gitver/v2 package main import ( "compress/flate" "flag" "fmt" "io/ioutil" "log" "math/rand" "net/http" "os" "strings" "time" "git.example.com/example/goserv/assets" "git.example.com/example/goserv/internal/api" "git.example.com/example/goserv/internal/db" "git.rootprojects.org/root/keypairs" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" _ "github.com/joho/godotenv/autoload" ) var ( name = "goserv" version = "0.0.0" date = "0001-01-01T00:00:00Z" commit = "0000000" ) func usage() { fmt.Println(ver()) fmt.Println("") fmt.Println("Use 'help '") fmt.Println(" help") fmt.Println(" init") fmt.Println(" run") } func ver() string { return fmt.Sprintf("%s v%s %s (%s)\n", name, version, commit[:7], date) } var defaultAddr = ":3000" type runOptions struct { listen string trustProxy bool compress bool static string pub string oidcWL string demo bool } var runFlags *flag.FlagSet var runOpts runOptions var initFlags *flag.FlagSet var dbURL string func init() { // chosen by fair dice roll. // guaranteed to be random. rand.Seed(4) // j/k rand.Seed(time.Now().UnixNano()) initFlags = flag.NewFlagSet("init", flag.ExitOnError) var conftodo bool var confdomain string initFlags.BoolVar(&conftodo, "todo", false, "TODO init should copy out nice templated config files") initFlags.StringVar(&confdomain, "base-url", "https://example.com", "TODO the domain to use for templated scripts") runOpts = runOptions{} runFlags = flag.NewFlagSet("run", flag.ExitOnError) runFlags.StringVar( &runOpts.listen, "listen", "", "the address and port on which to listen (default \""+defaultAddr+"\")") runFlags.BoolVar(&runOpts.trustProxy, "trust-proxy", false, "trust X-Forwarded-* headers") runFlags.BoolVar(&runOpts.compress, "compress", true, "enable compression for text,html,js,css,etc") runFlags.StringVar(&runOpts.static, "serve-path", "", "path to serve, falls back to built-in web app") runFlags.StringVar(&runOpts.pub, "public-key", "", "path to public key, or key string - RSA or ECDSA, JWK (JSON) or PEM") runFlags.StringVar(&runOpts.oidcWL, "oidc-whitelist", "", "list of trusted OIDC issuer URLs (ex: Auth0, Google, PocketID) for SSO") runFlags.BoolVar(&runOpts.demo, "demo", false, "demo mode enables unauthenticated 'DELETE /api/public/reset' to reset") runFlags.StringVar(&dbURL, "database-url", "", "database (postgres) connection url (default postgres://postgres:postgres@localhost:5432/postgres)", ) } func main() { args := os.Args[:] if 1 == len(args) { // "run" should be the default args = append(args, "run") } if "help" == args[1] { // top-level help if 2 == len(args) { usage() os.Exit(0) return } // move help to subcommand argument self := args[0] args = append([]string{self}, args[2:]...) args = append(args, "--help") } switch args[1] { case "version": fmt.Println(ver()) os.Exit(0) return case "init": initFlags.Parse(args[2:]) case "run": runFlags.Parse(args[2:]) if "" == runOpts.listen { listen := os.Getenv("LISTEN") port := os.Getenv("PORT") if len(listen) > 0 { runOpts.listen = listen } else if len(port) > 0 { runOpts.listen = "127.0.0.1:" + port } else { runOpts.listen = defaultAddr } } if "" == dbURL { dbURL = os.Getenv("DATABASE_URL") } if "" == dbURL { dbURL = "postgres://postgres:postgres@localhost:5432/postgres" } api.OIDCWhitelist = runOpts.oidcWL if "" == api.OIDCWhitelist { api.OIDCWhitelist = os.Getenv("OIDC_WHITELIST") } if "" == runOpts.pub { runOpts.pub = os.Getenv("PUBLIC_KEY") } serve() default: usage() os.Exit(1) return } } func initDB(connStr string) { // TODO url.Parse if strings.Contains(connStr, "@localhost/") || strings.Contains(connStr, "@localhost:") { connStr += "?sslmode=disable" } else { connStr += "?sslmode=required" } err := db.Init(connStr) if nil != err { log.Println("db connection error", err) //log.Fatal("db connection error", err) return } return } func serve() { initDB(dbURL) r := chi.NewRouter() // A good base middleware stack if runOpts.trustProxy { api.TrustProxy = true r.Use(middleware.RealIP) } if runOpts.compress { r.Use(middleware.Compress(flate.DefaultCompression)) } r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Get("/api/version", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(ver() + "\n")) }) var pub keypairs.PublicKey = nil if "" != runOpts.pub { var err error pub, err = keypairs.ParsePublicKey([]byte(runOpts.pub)) if nil != err { b, err := ioutil.ReadFile(runOpts.pub) if nil != err { // ignore } else { pub, err = keypairs.ParsePublicKey(b) if nil != err { log.Fatal("could not parse public key:", err) } } } } _ = api.Init(pub, r) var staticHandler http.HandlerFunc pubdir := http.FileServer(assets.Assets) if len(runOpts.static) > 0 { // try the user-provided directory first, then fallback to the built-in devFS := http.Dir(runOpts.static) dev := http.FileServer(devFS) staticHandler = func(w http.ResponseWriter, r *http.Request) { if _, err := devFS.Open(r.URL.Path); nil != err { pubdir.ServeHTTP(w, r) return } dev.ServeHTTP(w, r) } } else { staticHandler = func(w http.ResponseWriter, r *http.Request) { pubdir.ServeHTTP(w, r) } } if runOpts.demo { if err := db.CanDropAllTables(dbURL); nil != err { fmt.Fprintf(os.Stderr, err.Error()) os.Exit(1) } fmt.Println("DANGER: running in demo mode with DELETE /api/public/reset enabled") fmt.Fprintf(os.Stderr, "DANGER: running in demo mode with DELETE /api/public/reset enabled\n") r.Delete("/api/public/reset", func(w http.ResponseWriter, r *http.Request) { if err := db.DropAllTables(db.PleaseDoubleCheckTheDatabaseURLDontDropProd(dbURL)); nil != err { w.Write([]byte("error dropping tabels: " + err.Error())) return } api.Reset() initDB(dbURL) w.Write([]byte("re-initialized\n")) }) } r.Get("/*", staticHandler) fmt.Println("") fmt.Println("Listening for http (with reasonable timeouts) on", runOpts.listen) srv := &http.Server{ Addr: runOpts.listen, Handler: r, ReadHeaderTimeout: 2 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 20 * time.Second, MaxHeaderBytes: 1024 * 1024, // 1MiB } if err := srv.ListenAndServe(); nil != err { fmt.Fprintf(os.Stderr, "%s\n", err) time.Sleep(100 * time.Millisecond) os.Exit(1) return } }