// Package config holds Nurse's settings: where to listen, where notes live and how to reach // Authentik. The notes themselves are not in here. package config import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "net/url" "os" "path/filepath" "strings" "sync" ) // OIDC is the OAuth client Authentik gives you for Nurse. type OIDC struct { Issuer string `json:"issuer"` ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` } type Config struct { Listen string `json:"listen"` DataDir string `json:"data_dir"` // PublicURL is the address you open Nurse at, such as https://notes.example.com. The sign-in // redirect is PublicURL + /auth/callback. PublicURL string `json:"public_url"` OIDC OIDC `json:"oidc"` // Allowed limits who may sign in, by email, username or Authentik id. Empty means anyone Authentik // lets into the application, each with a notebook of their own. Allowed []string `json:"allowed,omitempty"` // Secret signs session cookies, so a restart does not sign you out. It is made on first run. Secret string `json:"secret,omitempty"` } // Defaults fills in what is missing. func (c *Config) Defaults() { if c.Listen == "" { c.Listen = "127.0.0.1:8490" } if c.DataDir == "" { c.DataDir = "data" } if c.Secret == "" { b := make([]byte, 32) rand.Read(b) c.Secret = base64.RawURLEncoding.EncodeToString(b) } c.PublicURL = strings.TrimRight(strings.TrimSpace(c.PublicURL), "/") c.OIDC.Issuer = strings.TrimSpace(c.OIDC.Issuer) c.OIDC.ClientID = strings.TrimSpace(c.OIDC.ClientID) c.OIDC.ClientSecret = strings.TrimSpace(c.OIDC.ClientSecret) } // RedirectURL is the address to enter in Authentik as the redirect URI. func (c Config) RedirectURL() string { return c.PublicURL + "/auth/callback" } // Secure says whether Nurse is reached over HTTPS, which decides whether cookies are Secure. func (c Config) Secure() bool { return strings.HasPrefix(c.PublicURL, "https://") } // IsLocal reports whether a URL points at this machine, where plain http is fine. func IsLocal(u *url.URL) bool { h := u.Hostname() return h == "localhost" || h == "127.0.0.1" || h == "::1" || strings.HasSuffix(h, ".localhost") } func checkURL(name, raw string) error { u, err := url.Parse(raw) if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { return fmt.Errorf("%s must be a full address such as https://notes.example.com (got %q)", name, raw) } if u.Scheme == "http" && !IsLocal(u) { return fmt.Errorf("%s must use https unless it points at this machine (got %q)", name, raw) } return nil } // Check says what is still missing before Nurse can sign anyone in, in words a person can act on. func (c Config) Check() error { var missing []string if c.PublicURL == "" { missing = append(missing, " public_url (NURSE_PUBLIC_URL) where you open Nurse, such as https://notes.example.com") } if c.OIDC.Issuer == "" { missing = append(missing, " oidc.issuer (NURSE_OIDC_ISSUER) from Authentik, such as https://auth.example.com/application/o/nurse/") } if c.OIDC.ClientID == "" { missing = append(missing, " oidc.client_id (NURSE_OIDC_CLIENT_ID) the Client ID of the Authentik provider") } if c.OIDC.ClientSecret == "" { missing = append(missing, " oidc.client_secret (NURSE_OIDC_CLIENT_SECRET) the Client Secret of the Authentik provider") } if len(missing) > 0 { return fmt.Errorf("Nurse signs in with Authentik, and these settings are still empty:\n%s\n\nIn Authentik, make an OAuth2/OpenID provider with the redirect URI %s/auth/callback.\nSee the README for the steps", strings.Join(missing, "\n"), orPlaceholder(c.PublicURL)) } if err := checkURL("public_url", c.PublicURL); err != nil { return err } if u, _ := url.Parse(c.PublicURL); u.Path != "" { return fmt.Errorf("public_url must not have a path (got %q)", c.PublicURL) } return checkURL("oidc.issuer", c.OIDC.Issuer) } func orPlaceholder(s string) string { if s == "" { return "https://your-address" } return s } // Holder keeps the settings file. Environment variables override what is in the file, but are // never written back to it. type Holder struct { path string mu sync.RWMutex file Config } // Load reads the file. A missing file gives the defaults (and is created by the first Update). func Load(path string) (*Holder, error) { h := &Holder{path: path} b, err := os.ReadFile(path) switch { case errors.Is(err, os.ErrNotExist): case err != nil: return nil, err default: if err := json.Unmarshal(b, &h.file); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } } h.file.Defaults() return h, nil } func (h *Holder) Path() string { return h.path } // Exists says whether the file is on disk yet. func (h *Holder) Exists() bool { _, err := os.Stat(h.path); return err == nil } // Get returns the settings with the environment applied. func (h *Holder) Get() Config { h.mu.RLock() c := h.file c.Allowed = append([]string{}, c.Allowed...) h.mu.RUnlock() env := func(name string, to *string) { if v := strings.TrimSpace(os.Getenv(name)); v != "" { *to = v } } env("NURSE_LISTEN", &c.Listen) env("NURSE_DATA", &c.DataDir) env("NURSE_PUBLIC_URL", &c.PublicURL) env("NURSE_OIDC_ISSUER", &c.OIDC.Issuer) env("NURSE_OIDC_CLIENT_ID", &c.OIDC.ClientID) env("NURSE_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret) if v := os.Getenv("NURSE_ALLOWED"); strings.TrimSpace(v) != "" { c.Allowed = nil for _, a := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) { if a = strings.TrimSpace(a); a != "" { c.Allowed = append(c.Allowed, a) } } } c.Defaults() return c } // Save writes the file as it is now, which is what makes the generated secret stick. func (h *Holder) Save() error { h.mu.Lock() defer h.mu.Unlock() b, err := json.MarshalIndent(h.file, "", " ") if err != nil { return err } if dir := filepath.Dir(h.path); dir != "." { os.MkdirAll(dir, 0o755) } tmp := h.path + ".tmp" if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { return err } return os.Rename(tmp, h.path) }