All checks were successful
Build & push Docker image / build (push) Successful in 1m44s
324 lines
14 KiB
Go
324 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"html/template"
|
|
"log"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"misaki/db"
|
|
"misaki/server"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
type fileCfg struct {
|
|
Addr string `toml:"addr"`
|
|
DB string `toml:"db"`
|
|
InternalSecret string `toml:"internal_secret"`
|
|
AuthSecret string `toml:"auth_secret"`
|
|
BaseDomain string `toml:"base_domain"`
|
|
DefaultNote string `toml:"default_note"`
|
|
PublicURL string `toml:"public_url"`
|
|
TOTPKeyFile string `toml:"totp_key_file"`
|
|
SMTP smtpFileCfg `toml:"smtp"`
|
|
OIDC oidcFileCfg `toml:"oidc"`
|
|
Services map[string]svcFileCfg `toml:"services"`
|
|
}
|
|
|
|
type smtpFileCfg struct {
|
|
Host string `toml:"host"`
|
|
User string `toml:"user"`
|
|
Pass string `toml:"pass"`
|
|
From string `toml:"from"`
|
|
NotifyEmail string `toml:"notify_email"`
|
|
TLSName string `toml:"tls_name"`
|
|
}
|
|
|
|
type oidcFileCfg struct {
|
|
Issuer string `toml:"issuer"`
|
|
KeyFile string `toml:"key_file"`
|
|
Clients map[string]oidcClientFileCfg `toml:"clients"`
|
|
}
|
|
|
|
type oidcClientFileCfg struct {
|
|
SecretSHA256 string `toml:"secret_sha256"`
|
|
RedirectURIs []string `toml:"redirect_uris"`
|
|
Service string `toml:"service"`
|
|
RequireEmail bool `toml:"require_email"`
|
|
}
|
|
|
|
type svcFileCfg struct {
|
|
Enabled *bool `toml:"enabled"`
|
|
Desc string `toml:"desc"`
|
|
InfoURL string `toml:"info_url"`
|
|
URL string `toml:"url"`
|
|
Host string `toml:"host"`
|
|
User string `toml:"user"`
|
|
Pass string `toml:"pass"`
|
|
}
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "", "path to TOML config file (optional)")
|
|
addrFlag := flag.String("addr", "", "listen address (default :8080)")
|
|
dbFlag := flag.String("db", "", "sqlite database path (default /data/misaki.db)")
|
|
defaultNoteFlag := flag.String("default-note", "", "default note for new users (default: \"I love misaki!\")")
|
|
|
|
gancioURL := flag.String("gancio-url", "", "Gancio registration page URL")
|
|
akkomaURL := flag.String("akkoma-url", "", "Akkoma registration page URL")
|
|
prosodyURL := flag.String("prosody-url", "", "Prosody registration page URL")
|
|
forgejoURL := flag.String("forgejo-url", "", "Forgejo registration page URL")
|
|
|
|
mumbleHost := flag.String("mumble-host", "", "Mumble server host:port")
|
|
mumblePass := flag.String("mumble-pass", "", "Mumble server password")
|
|
transferHost := flag.String("transfer-host", "", "Transfer.sh host URL")
|
|
transferUser := flag.String("transfer-user", "", "Transfer.sh username")
|
|
transferPass := flag.String("transfer-pass", "", "Transfer.sh password")
|
|
|
|
mailEnabled := flag.String("mail-enabled", "", "globally enable/disable mail (1/0)")
|
|
mailDesc := flag.String("mail-desc", "", "HTML description for Mail")
|
|
wercEnabled := flag.String("werc-enabled", "", "globally enable/disable werc (1/0)")
|
|
wercDesc := flag.String("werc-desc", "", "HTML description for Werc")
|
|
ergoEnabled := flag.String("ergo-enabled", "", "globally enable/disable ergo (1/0)")
|
|
ergoDesc := flag.String("ergo-desc", "", "HTML description for Ergo")
|
|
gancioEnabled := flag.String("gancio-enabled", "", "globally enable/disable gancio (1/0)")
|
|
gancioDesc := flag.String("gancio-desc", "", "HTML description for Gancio")
|
|
akkomaEnabled := flag.String("akkoma-enabled", "", "globally enable/disable akkoma (1/0)")
|
|
akkomaDesc := flag.String("akkoma-desc", "", "HTML description for Akkoma")
|
|
prosodyEnabled := flag.String("prosody-enabled", "", "globally enable/disable prosody (1/0)")
|
|
prosodyDesc := flag.String("prosody-desc", "", "HTML description for Prosody")
|
|
forgejoEnabled := flag.String("forgejo-enabled", "", "globally enable/disable forgejo (1/0)")
|
|
forgejoDesc := flag.String("forgejo-desc", "", "HTML description for Forgejo")
|
|
cercaEnabled := flag.String("cerca-enabled", "", "globally enable/disable cerca (1/0)")
|
|
cercaDesc := flag.String("cerca-desc", "", "HTML description for Cerca")
|
|
mumbleEnabled := flag.String("mumble-enabled", "", "globally enable/disable mumble (1/0)")
|
|
mumbleDesc := flag.String("mumble-desc", "", "HTML description for Mumble")
|
|
transferEnabled := flag.String("transfer-enabled", "", "globally enable/disable transfer (1/0)")
|
|
transferDesc := flag.String("transfer-desc", "", "HTML description for Transfer")
|
|
streamingEnabled := flag.String("streaming-enabled", "", "globally enable/disable streaming (1/0)")
|
|
streamingDesc := flag.String("streaming-desc", "", "HTML description for Streaming")
|
|
radioEnabled := flag.String("radio-enabled", "", "globally enable/disable radio (1/0)")
|
|
radioDesc := flag.String("radio-desc", "", "HTML description for Radio")
|
|
filesEnabled := flag.String("files-enabled", "", "globally enable/disable files (1/0)")
|
|
filesDesc := flag.String("files-desc", "", "HTML description for Files")
|
|
|
|
notesEnabled := flag.String("notes-enabled", "", "globally enable/disable notes (1/0)")
|
|
notesDesc := flag.String("notes-desc", "", "HTML description for HedgeDoc")
|
|
notesURL := flag.String("notes-url", "", "HedgeDoc URL")
|
|
pasteEnabled := flag.String("paste-enabled", "", "globally enable/disable paste (1/0)")
|
|
pasteDesc := flag.String("paste-desc", "", "HTML description for PrivateBin")
|
|
pasteURL := flag.String("paste-url", "", "PrivateBin URL")
|
|
rssEnabled := flag.String("rss-enabled", "", "globally enable/disable rss (1/0)")
|
|
rssDesc := flag.String("rss-desc", "", "HTML description for RSS Bridge")
|
|
rssURL := flag.String("rss-url", "", "RSS Bridge URL")
|
|
|
|
smtpHost := flag.String("smtp-host", "", "SMTP host:port e.g. smtp.example.com:587")
|
|
smtpUser := flag.String("smtp-user", "", "SMTP username")
|
|
smtpPass := flag.String("smtp-pass", "", "SMTP password (or SMTP_PASS env)")
|
|
smtpFrom := flag.String("smtp-from", "", "From address for notification emails")
|
|
notifyTo := flag.String("notify-email", "", "Address to receive notifications")
|
|
smtpTLSName := flag.String("smtp-tls-name", "", "Hostname for TLS cert verification (useful when connecting via internal hostname)")
|
|
|
|
internalSecret := flag.String("internal-secret", "", "bearer token protecting POST /internal/auth (leave empty to disable)")
|
|
authSecret := flag.String("auth-secret", "", "HMAC secret for signing session-check identity (shared with werc; leave empty to disable signing)")
|
|
baseDomain := flag.String("base-domain", "", "base domain for allowed post-login redirects (e.g. example.com)")
|
|
secureCookiesFlag := flag.Bool("secure-cookies", false, "set Secure flag on session cookies (enable when serving over HTTPS)")
|
|
|
|
flag.Parse()
|
|
|
|
var cfg fileCfg
|
|
if path := firstNonEmpty(*configPath, os.Getenv("CONFIG_FILE")); path != "" {
|
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
log.Printf("config file %q not found, using flags/env only", path)
|
|
} else {
|
|
log.Fatalf("load config %q: %v", path, err)
|
|
}
|
|
} else {
|
|
log.Printf("loaded config from %s", path)
|
|
}
|
|
}
|
|
|
|
cfgOr := func(flagVal, envKey, cfgVal string) string {
|
|
return firstNonEmpty(flagVal, os.Getenv(envKey), cfgVal)
|
|
}
|
|
|
|
svc := func(name string) svcFileCfg { return cfg.Services[name] }
|
|
|
|
resolveEnabled := func(flagVal, envKey string, cfgEnabled *bool) string {
|
|
if flagVal != "" {
|
|
return flagVal
|
|
}
|
|
if ev := os.Getenv(envKey); ev != "" {
|
|
return ev
|
|
}
|
|
if cfgEnabled != nil {
|
|
if *cfgEnabled {
|
|
return "1"
|
|
}
|
|
return "0"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
dbPath := firstNonEmpty(*dbFlag, os.Getenv("DB_PATH"), cfg.DB, "/data/misaki.db")
|
|
database, err := db.Open(dbPath)
|
|
if err != nil {
|
|
log.Fatalf("open db: %v", err)
|
|
}
|
|
|
|
type svcSeed struct {
|
|
name string
|
|
flagEnabled string
|
|
envEnabled string
|
|
cfgSvc svcFileCfg
|
|
flagDesc string
|
|
envDesc string
|
|
}
|
|
seeds := []svcSeed{
|
|
{"mail", *mailEnabled, "MAIL_ENABLED", svc("mail"), *mailDesc, "MAIL_DESC"},
|
|
{"werc", *wercEnabled, "WERC_ENABLED", svc("werc"), *wercDesc, "WERC_DESC"},
|
|
{"ergo", *ergoEnabled, "ERGO_ENABLED", svc("ergo"), *ergoDesc, "ERGO_DESC"},
|
|
{"gancio", *gancioEnabled, "GANCIO_ENABLED", svc("gancio"), *gancioDesc, "GANCIO_DESC"},
|
|
{"akkoma", *akkomaEnabled, "AKKOMA_ENABLED", svc("akkoma"), *akkomaDesc, "AKKOMA_DESC"},
|
|
{"pixelfed", "", "PIXELFED_ENABLED", svc("pixelfed"), "", "PIXELFED_DESC"},
|
|
{"prosody", *prosodyEnabled, "PROSODY_ENABLED", svc("prosody"), *prosodyDesc, "PROSODY_DESC"},
|
|
{"forgejo", *forgejoEnabled, "FORGEJO_ENABLED", svc("forgejo"), *forgejoDesc, "FORGEJO_DESC"},
|
|
{"cerca", *cercaEnabled, "CERCA_ENABLED", svc("cerca"), *cercaDesc, "CERCA_DESC"},
|
|
{"mumble", *mumbleEnabled, "MUMBLE_ENABLED", svc("mumble"), *mumbleDesc, "MUMBLE_DESC"},
|
|
{"transfer", *transferEnabled, "TRANSFER_ENABLED", svc("transfer"), *transferDesc, "TRANSFER_DESC"},
|
|
{"streaming", *streamingEnabled, "STREAMING_ENABLED", svc("streaming"), *streamingDesc, "STREAMING_DESC"},
|
|
{"radio", *radioEnabled, "RADIO_ENABLED", svc("radio"), *radioDesc, "RADIO_DESC"},
|
|
{"files", *filesEnabled, "FILES_ENABLED", svc("files"), *filesDesc, "FILES_DESC"},
|
|
{"notes", *notesEnabled, "NOTES_ENABLED", svc("notes"), *notesDesc, "NOTES_DESC"},
|
|
{"paste", *pasteEnabled, "PASTE_ENABLED", svc("paste"), *pasteDesc, "PASTE_DESC"},
|
|
{"rss", *rssEnabled, "RSS_ENABLED", svc("rss"), *rssDesc, "RSS_DESC"},
|
|
}
|
|
|
|
svcDescs := make(map[string]template.HTML)
|
|
for _, s := range seeds {
|
|
switch strings.ToLower(resolveEnabled(s.flagEnabled, s.envEnabled, s.cfgSvc.Enabled)) {
|
|
case "1", "true":
|
|
if err := database.SetServiceEnabled(s.name, true); err != nil {
|
|
log.Printf("enable %s: %v", s.name, err)
|
|
}
|
|
case "0", "false":
|
|
if err := database.SetServiceEnabled(s.name, false); err != nil {
|
|
log.Printf("disable %s: %v", s.name, err)
|
|
}
|
|
}
|
|
if desc := cfgOr(s.flagDesc, s.envDesc, s.cfgSvc.Desc); desc != "" {
|
|
svcDescs[s.name] = template.HTML(desc)
|
|
}
|
|
}
|
|
|
|
infoURLs := make(map[string]string)
|
|
for _, s := range seeds {
|
|
if u := firstNonEmpty(os.Getenv(strings.ToUpper(s.name)+"_INFO_URL"), s.cfgSvc.InfoURL); u != "" {
|
|
infoURLs[s.name] = u
|
|
}
|
|
}
|
|
|
|
svcURLs := server.ServiceURLs{
|
|
GancioURL: cfgOr(*gancioURL, "GANCIO_URL", svc("gancio").URL),
|
|
AkkomaURL: cfgOr(*akkomaURL, "AKKOMA_URL", svc("akkoma").URL),
|
|
ProsodyURL: cfgOr(*prosodyURL, "PROSODY_URL", svc("prosody").URL),
|
|
ForgejoURL: cfgOr(*forgejoURL, "FORGEJO_URL", svc("forgejo").URL),
|
|
MumbleHost: cfgOr(*mumbleHost, "MUMBLE_HOST", svc("mumble").Host),
|
|
MumblePass: cfgOr(*mumblePass, "MUMBLE_PASS", svc("mumble").Pass),
|
|
TransferHost: cfgOr(*transferHost, "TRANSFER_HOST", svc("transfer").Host),
|
|
TransferUser: cfgOr(*transferUser, "TRANSFER_USER", svc("transfer").User),
|
|
TransferPass: cfgOr(*transferPass, "TRANSFER_PASS", svc("transfer").Pass),
|
|
}
|
|
|
|
smtpCfg := server.SMTPConfig{
|
|
Host: cfgOr(*smtpHost, "SMTP_HOST", cfg.SMTP.Host),
|
|
User: cfgOr(*smtpUser, "SMTP_USER", cfg.SMTP.User),
|
|
Pass: cfgOr(*smtpPass, "SMTP_PASS", cfg.SMTP.Pass),
|
|
From: cfgOr(*smtpFrom, "SMTP_FROM", cfg.SMTP.From),
|
|
To: cfgOr(*notifyTo, "NOTIFY_EMAIL", cfg.SMTP.NotifyEmail),
|
|
TLSName: cfgOr(*smtpTLSName, "SMTP_TLS_NAME", cfg.SMTP.TLSName),
|
|
}
|
|
if smtpCfg.Host == "" {
|
|
log.Printf("smtp: not configured — emails will be skipped")
|
|
} else if smtpCfg.To == "" {
|
|
log.Printf("smtp: host=%s from=%s (warning: no notify_email set, admin notifications disabled)", smtpCfg.Host, smtpCfg.From)
|
|
} else {
|
|
log.Printf("smtp: host=%s from=%s to=%s", smtpCfg.Host, smtpCfg.From, smtpCfg.To)
|
|
}
|
|
|
|
go func() {
|
|
for range time.Tick(24 * time.Hour) {
|
|
if err := database.PruneExpiredSessions(); err != nil {
|
|
log.Printf("prune sessions: %v", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
secureCookies := *secureCookiesFlag ||
|
|
strings.EqualFold(os.Getenv("SECURE_COOKIES"), "true") ||
|
|
os.Getenv("SECURE_COOKIES") == "1"
|
|
|
|
addr := firstNonEmpty(*addrFlag, os.Getenv("ADDR"), cfg.Addr, ":8080")
|
|
cookieURLs := map[string]string{
|
|
"transfer": cfgOr(*transferHost, "TRANSFER_HOST", svc("transfer").URL),
|
|
"notes": cfgOr(*notesURL, "NOTES_URL", svc("notes").URL),
|
|
"paste": cfgOr(*pasteURL, "PASTE_URL", svc("paste").URL),
|
|
"rss": cfgOr(*rssURL, "RSS_URL", svc("rss").URL),
|
|
"akkoma": cfgOr(*akkomaURL, "AKKOMA_URL", svc("akkoma").URL),
|
|
"pixelfed": cfgOr("", "PIXELFED_URL", svc("pixelfed").URL),
|
|
}
|
|
|
|
var oidcCfg *server.OIDCConfig
|
|
if cfg.OIDC.Issuer != "" {
|
|
oidcCfg = &server.OIDCConfig{Issuer: cfg.OIDC.Issuer, KeyFile: cfg.OIDC.KeyFile}
|
|
for id, c := range cfg.OIDC.Clients {
|
|
oidcCfg.Clients = append(oidcCfg.Clients, server.OIDCClient{
|
|
ID: id, SecretSHA256: c.SecretSHA256, RedirectURIs: c.RedirectURIs,
|
|
Service: c.Service, RequireEmail: c.RequireEmail,
|
|
})
|
|
}
|
|
log.Printf("oidc: issuer=%s clients=%d", oidcCfg.Issuer, len(oidcCfg.Clients))
|
|
}
|
|
|
|
srv := server.New(database, svcURLs, svcDescs, smtpCfg, server.Config{
|
|
SecureCookies: secureCookies,
|
|
InfoURLs: infoURLs,
|
|
CookieURLs: cookieURLs,
|
|
InternalSecret: firstNonEmpty(*internalSecret, os.Getenv("INTERNAL_SECRET"), cfg.InternalSecret),
|
|
AuthSecret: firstNonEmpty(*authSecret, os.Getenv("AUTH_SECRET"), cfg.AuthSecret),
|
|
BaseDomain: firstNonEmpty(*baseDomain, os.Getenv("BASE_DOMAIN"), cfg.BaseDomain),
|
|
DefaultNote: firstNonEmpty(*defaultNoteFlag, os.Getenv("DEFAULT_NOTE"), cfg.DefaultNote),
|
|
OIDC: oidcCfg,
|
|
TOTPKeyFile: firstNonEmpty(os.Getenv("TOTP_KEY_FILE"), cfg.TOTPKeyFile, filepath.Join(filepath.Dir(dbPath), "totp.key")),
|
|
PublicURL: firstNonEmpty(os.Getenv("PUBLIC_URL"), cfg.PublicURL, cfg.OIDC.Issuer),
|
|
})
|
|
// Docker stops us with SIGTERM: drain, then close the db so SQLite checkpoints and drops the WAL.
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
|
defer stop()
|
|
log.Printf("listening on %s", addr)
|
|
err = srv.ListenAndServe(ctx, addr)
|
|
if cerr := database.Close(); cerr != nil {
|
|
log.Printf("close db: %v", cerr)
|
|
}
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
log.Print("stopped")
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|