// Command basking is a private notebook: pages you can link, nest and search, kept as plain // markdown files. You sign in with Authentik; there is no other way in. package main import ( "context" "errors" "flag" "fmt" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "git.veillax.com/Veillax/basking/internal/config" "git.veillax.com/Veillax/basking/internal/oidc" "git.veillax.com/Veillax/basking/internal/web" ) var version = "dev" const usage = `basking is a private notebook that you sign in to with Authentik. basking serve [-config basking.json] [-listen addr] [-data dir] run the notebook basking check [-config basking.json] test the Authentik settings without starting basking version Settings come from the settings file and can be overridden by environment variables: BASKING_PUBLIC_URL where you open Basking, such as https://notes.example.com BASKING_OIDC_ISSUER the Authentik provider's OpenID configuration issuer BASKING_OIDC_CLIENT_ID the provider's Client ID BASKING_OIDC_CLIENT_SECRET the provider's Client Secret BASKING_ALLOWED optional: only these emails or usernames may sign in (comma separated) BASKING_LISTEN, BASKING_DATA, BASKING_CONFIG ` // exitConfig is the exit status for settings that need fixing by hand, so a service manager does // not keep restarting something that cannot start. const exitConfig = 2 func main() { log.SetFlags(log.LstdFlags) if len(os.Args) < 2 { fmt.Fprint(os.Stderr, usage) os.Exit(2) } var err error switch os.Args[1] { case "serve": err = serve(os.Args[2:]) case "check": err = check(os.Args[2:]) case "version", "-v", "--version": fmt.Println("basking", version) case "help", "-h", "--help": fmt.Print(usage) default: fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usage) os.Exit(2) } if err != nil { fmt.Fprintln(os.Stderr, "basking:", err) var ce configError if errors.As(err, &ce) { os.Exit(exitConfig) } os.Exit(1) } } type configError struct{ error } func configFlag(fs *flag.FlagSet) *string { def := os.Getenv("BASKING_CONFIG") if def == "" { def = "basking.json" } return fs.String("config", def, "path of the settings file") } func serve(args []string) error { fs := flag.NewFlagSet("serve", flag.ExitOnError) path := configFlag(fs) listen := fs.String("listen", "", "override the listen address") data := fs.String("data", "", "override the folder the notes are kept in") fs.Parse(args) if *listen != "" { os.Setenv("BASKING_LISTEN", *listen) } if *data != "" { os.Setenv("BASKING_DATA", *data) } h, err := config.Load(*path) if err != nil { return configError{err} } if !h.Exists() { // writing it now is what makes the generated secret stick if err := h.Save(); err != nil { return fmt.Errorf("could not write %s: %w", *path, err) } } cfg := h.Get() web, err := web.New(h, version) if err != nil { return configError{err} } srv := &http.Server{ Addr: cfg.Listen, Handler: web.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 2 * time.Minute, } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() errc := make(chan error, 1) go func() { errc <- srv.ListenAndServe() }() log.Printf("basking %s: notes are kept in %s; open %s", version, cfg.DataDir, cfg.PublicURL) log.Printf("sign-in goes through %s; the redirect URI to allow there is %s", cfg.OIDC.Issuer, cfg.RedirectURL()) if len(cfg.Allowed) == 0 { log.Print("anyone Authentik lets into this application gets a notebook of their own; set \"allowed\" to limit it") } if !cfg.Secure() && !strings.Contains(cfg.PublicURL, "localhost") && !strings.Contains(cfg.PublicURL, "127.0.0.1") { log.Print("warning: public_url is not https, and session cookies will not be marked Secure") } go func() { // say early if Authentik cannot be reached, instead of at the first sign-in c, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if _, err := web.OIDC().Discover(c); err != nil { log.Printf("warning: %v (sign-in will work once Authentik is reachable)", err) } }() select { case err := <-errc: return err case <-ctx.Done(): } sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return srv.Shutdown(sctx) } func check(args []string) error { fs := flag.NewFlagSet("check", flag.ExitOnError) path := configFlag(fs) fs.Parse(args) h, err := config.Load(*path) if err != nil { return configError{err} } cfg := h.Get() if err := cfg.Check(); err != nil { return configError{err} } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() d, err := oidc.New(cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.ClientSecret, cfg.RedirectURL()).Discover(ctx) if err != nil { return err } fmt.Println("Authentik answered at", cfg.OIDC.Issuer) fmt.Println(" sign-in page:", d.AuthorizationEndpoint) fmt.Println(" token address:", d.TokenEndpoint) fmt.Println("Make sure the provider in Authentik allows this redirect URI exactly:") fmt.Println(" " + cfg.RedirectURL()) if len(cfg.Allowed) == 0 { fmt.Println("Everyone Authentik lets into the application gets their own notebook.") } else { fmt.Println("Only these may sign in:", strings.Join(cfg.Allowed, ", ")) } return nil }