Pages are sealed with AES-256-GCM. Each person's key is derived (HKDF) from a master key in the settings file plus their Authentik issuer and subject. Plain files from earlier versions are read and re-saved encrypted. Mako refuses to open a notebook it cannot decrypt rather than overwrite it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
221 lines
7.2 KiB
Go
221 lines
7.2 KiB
Go
// Package config holds Mako'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"
|
|
|
|
"git.veillax.com/Veillax/mako/internal/vault"
|
|
)
|
|
|
|
// OIDC is the OAuth client Authentik gives you for Mako.
|
|
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 Mako 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"`
|
|
|
|
// Key encrypts the pages on disk (each person's key is made from it and who they are). It is made
|
|
// on first run. Unlike Secret it must never change or be lost: without it the pages cannot be read.
|
|
Key string `json:"key,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)
|
|
}
|
|
if c.Key == "" {
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
c.Key = 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 Mako 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 Mako can sign anyone in, in words a person can act on.
|
|
func (c Config) Check() error {
|
|
if len(c.Key) < vault.MinKey {
|
|
return fmt.Errorf("the encryption key (key, or MAKO_KEY) must be at least %d characters; leave it out to have one made, or make one with: openssl rand -base64 32", vault.MinKey)
|
|
}
|
|
var missing []string
|
|
if c.PublicURL == "" {
|
|
missing = append(missing, " public_url (MAKO_PUBLIC_URL) where you open Mako, such as https://notes.example.com")
|
|
}
|
|
if c.OIDC.Issuer == "" {
|
|
missing = append(missing, " oidc.issuer (MAKO_OIDC_ISSUER) from Authentik, such as https://auth.example.com/application/o/mako/")
|
|
}
|
|
if c.OIDC.ClientID == "" {
|
|
missing = append(missing, " oidc.client_id (MAKO_OIDC_CLIENT_ID) the Client ID of the Authentik provider")
|
|
}
|
|
if c.OIDC.ClientSecret == "" {
|
|
missing = append(missing, " oidc.client_secret (MAKO_OIDC_CLIENT_SECRET) the Client Secret of the Authentik provider")
|
|
}
|
|
if len(missing) > 0 {
|
|
return fmt.Errorf("Mako 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
|
|
made bool // the file is missing, or lacked a secret or key that was just made
|
|
}
|
|
|
|
// 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.made = err != nil || h.file.Secret == "" || (h.file.Key == "" && !keyInEnv())
|
|
keyless := h.file.Key == "" && keyInEnv() // a key in the environment is used instead, so none is made
|
|
h.file.Defaults()
|
|
if keyless {
|
|
h.file.Key = ""
|
|
}
|
|
return h, nil
|
|
}
|
|
|
|
func keyInEnv() bool { return strings.TrimSpace(os.Getenv("MAKO_KEY")) != "" }
|
|
|
|
// NeedsSave says whether the file should be written now: it is missing, or a secret or key was
|
|
// made that is only in memory. Writing it is what makes them stick.
|
|
func (h *Holder) NeedsSave() bool { return h.made }
|
|
|
|
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("MAKO_LISTEN", &c.Listen)
|
|
env("MAKO_DATA", &c.DataDir)
|
|
env("MAKO_PUBLIC_URL", &c.PublicURL)
|
|
env("MAKO_OIDC_ISSUER", &c.OIDC.Issuer)
|
|
env("MAKO_OIDC_CLIENT_ID", &c.OIDC.ClientID)
|
|
env("MAKO_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
|
|
env("MAKO_KEY", &c.Key)
|
|
if v := os.Getenv("MAKO_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)
|
|
}
|