package main import ( "flag" "fmt" "log" "os" "strings" "text/tabwriter" "time" "misaki/db" "github.com/BurntSushi/toml" "golang.org/x/crypto/bcrypt" ) const bcryptCost = 12 var database *db.DB func main() { dbPath := flag.String("db", "/data/misaki.db", "path to SQLite database") flag.Usage = usage flag.Parse() args := flag.Args() if len(args) == 0 { usage() os.Exit(1) } var err error database, err = db.Open(*dbPath) if err != nil { log.Fatalf("open db: %v", err) } switch args[0] { case "list": cmdList(args[1:]) case "create": cmdCreate(args[1:]) case "approve": cmdSetStatus(args[1:], "approved") case "reject": cmdSetStatus(args[1:], "rejected") case "reinstate": cmdSetStatus(args[1:], "approved") case "passwd": cmdPasswd(args[1:]) case "delete": cmdDelete(args[1:]) case "info": cmdInfo(args[1:]) case "admin": cmdSetAdmin(args[1:], true) case "deadmin": cmdSetAdmin(args[1:], false) case "svc": cmdSvc(args[1:]) case "usvc": cmdUsvc(args[1:]) case "reset-link": cmdResetLink(args[1:]) case "2fa": cmd2FA(args[1:]) default: fatalf("unknown command: %s\nRun with no arguments to see usage.", args[0]) } } func cmdList(args []string) { statuses := []string{"pending", "approved", "rejected"} if len(args) > 0 { statuses = []string{args[0]} } w := tw() fmt.Fprintln(w, "USERNAME\tSTATUS\tADMIN\tAPPLIED") for _, status := range statuses { users, err := database.ListUsers(status) if err != nil { log.Fatalf("list users: %v", err) } for _, u := range users { admin := "" if u.IsAdmin { admin = "yes" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", u.Username, u.Status, admin, u.CreatedAt.Format("2006-01-02")) } } w.Flush() } func cmdCreate(args []string) { if len(args) < 2 { fatalf("usage: create ") } username, password := args[0], args[1] hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { log.Fatalf("bcrypt: %v", err) } id, err := database.CreateUser(username, string(hash), "") if err != nil { log.Fatalf("create user: %v", err) } if err := database.SetUserStatus(id, "approved"); err != nil { log.Fatalf("approve: %v", err) } fmt.Printf("created and approved user %q (id %d)\n", username, id) } func cmdSetStatus(args []string, status string) { u := mustGetUser(args, "approve|reject|reinstate ") if err := database.SetUserStatus(u.ID, status); err != nil { log.Fatalf("set status: %v", err) } fmt.Printf("%s: status → %s\n", u.Username, status) } func cmdPasswd(args []string) { if len(args) < 2 { fatalf("usage: passwd ") } u := mustGetUser(args[:1], "passwd ") hash, err := bcrypt.GenerateFromPassword([]byte(args[1]), bcryptCost) if err != nil { log.Fatalf("bcrypt: %v", err) } if err := database.SetUserPassword(u.ID, string(hash)); err != nil { log.Fatalf("set password: %v", err) } if err := database.DeleteUserSessions(u.ID, ""); err != nil { log.Fatalf("revoke sessions: %v", err) } if err := database.DeleteResetTokens(u.ID); err != nil { log.Fatalf("revoke reset links: %v", err) } fmt.Printf("password updated for %s, sessions logged out\n", u.Username) } func cmdDelete(args []string) { u := mustGetUser(args, "delete ") fmt.Printf("delete user %q and all their data? [y/N] ", u.Username) var confirm string fmt.Scanln(&confirm) if strings.ToLower(confirm) != "y" { fmt.Println("aborted") return } if err := database.DeleteUser(u.ID); err != nil { log.Fatalf("delete: %v", err) } fmt.Printf("deleted %s\n", u.Username) } func cmdInfo(args []string) { u := mustGetUser(args, "info ") admin := "no" if u.IsAdmin { admin = "yes" } email, _ := database.GetUserEmail(u.ID) sealed, _, _ := database.GetTOTP(u.ID) codes, _ := database.CountRecoveryCodes(u.ID) twofa := "off" if sealed != "" { twofa = fmt.Sprintf("on (%d recovery codes left)", codes) } fmt.Printf("Username: %s\nID: %d (OIDC sub)\nStatus: %s\nAdmin: %s\nEmail: %s\n2FA: %s\nApplied: %s\n", u.Username, u.ID, u.Status, admin, email, twofa, u.CreatedAt.Format("2006-01-02 15:04")) svcs, err := database.ListUserServices(u.ID) if err != nil { log.Fatalf("list services: %v", err) } if len(svcs) == 0 { fmt.Println("\nNo service requests.") return } fmt.Println() w := tw() fmt.Fprintln(w, "SERVICE\tAPPLIED\tGRANTED\tDATA") for _, s := range svcs { fmt.Fprintf(w, "%s\t%v\t%v\t%s\n", s.Service, yn(s.Applied), yn(s.Granted), s.Data) } w.Flush() } func cmdSetAdmin(args []string, admin bool) { u := mustGetUser(args, "admin|deadmin ") if err := database.SetUserAdmin(u.ID, admin); err != nil { log.Fatalf("set admin: %v", err) } verb := "granted" if !admin { verb = "revoked" } fmt.Printf("admin access %s for %s\n", verb, u.Username) } func cmdSvc(args []string) { sub := "list" if len(args) > 0 { sub = args[0] } switch sub { case "list": svcs, err := database.ListServices() if err != nil { log.Fatalf("list services: %v", err) } w := tw() fmt.Fprintln(w, "SERVICE\tLABEL\tGLOBAL") for _, s := range svcs { fmt.Fprintf(w, "%s\t%s\t%s\n", s.Name, s.Label, yn(s.Enabled)) } w.Flush() case "enable", "disable": if len(args) < 2 { fatalf("usage: svc %s ", sub) } if err := database.SetServiceEnabled(args[1], sub == "enable"); err != nil { log.Fatalf("%s service: %v", sub, err) } fmt.Printf("service %s: global → %s\n", args[1], sub+"d") default: fatalf("usage: svc [service]") } } func cmdUsvc(args []string) { if len(args) < 2 { fatalf("usage: usvc [service] [value]") } sub, username := args[0], args[1] u := mustLookup(username) switch sub { case "list": svcs, err := database.ListUserServices(u.ID) if err != nil { log.Fatalf("list user services: %v", err) } w := tw() fmt.Fprintln(w, "SERVICE\tAPPLIED\tGRANTED\tDATA") for _, s := range svcs { fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Service, yn(s.Applied), yn(s.Granted), s.Data) } w.Flush() case "enable", "disable": if len(args) < 3 { fatalf("usage: usvc %s ", sub) } service := args[2] err := database.SetUserServiceEnabled(u.ID, service, sub == "enable") if err != nil { log.Fatalf("usvc %s: %v", sub, err) } fmt.Printf("usvc %s: %s/%s ok\n", sub, username, service) case "setlink": if len(args) < 4 { fatalf("usage: usvc setlink ") } service, link := args[2], args[3] if err := database.SetUserServiceData(u.ID, service, link); err != nil { log.Fatalf("usvc setlink: %v", err) } fmt.Printf("usvc setlink: %s/%s → %s\n", username, service, link) default: fatalf("usage: usvc [service] [value]") } } func publicURL() string { if v := os.Getenv("PUBLIC_URL"); v != "" { return strings.TrimSuffix(v, "/") } var cfg struct { PublicURL string `toml:"public_url"` OIDC struct { Issuer string `toml:"issuer"` } `toml:"oidc"` } if path := os.Getenv("CONFIG_FILE"); path != "" { if _, err := toml.DecodeFile(path, &cfg); err != nil { log.Fatalf("load config %q: %v", path, err) } } u := cfg.PublicURL if u == "" { u = cfg.OIDC.Issuer } if u == "" { fatalf("no public_url (or oidc issuer) in CONFIG_FILE and no PUBLIC_URL set") } return strings.TrimSuffix(u, "/") } func cmdResetLink(args []string) { u := mustGetUser(args, "reset-link ") if u.Status != "approved" { fatalf("%s is not approved", u.Username) } tok, err := database.CreateResetToken(u.ID, 24*time.Hour) if err != nil { log.Fatalf("reset token: %v", err) } fmt.Printf("one-time reset link for %s (24 h):\n%s/reset?t=%s\n", u.Username, publicURL(), tok) } func cmd2FA(args []string) { if len(args) < 2 || args[0] != "off" { fatalf("usage: 2fa off ") } u := mustLookup(args[1]) if err := database.DisableTOTP(u.ID); err != nil { log.Fatalf("disable 2fa: %v", err) } fmt.Printf("2FA off for %s\n", u.Username) } func mustGetUser(args []string, hint string) *db.User { if len(args) == 0 { fatalf("usage: %s", hint) } return mustLookup(args[0]) } func mustLookup(username string) *db.User { u, err := database.GetUserByUsername(username) if err != nil { log.Fatalf("user %q not found", username) } return u } func yn(b bool) string { if b { return "yes" } return "no" } func tw() *tabwriter.Writer { return tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) } func fatalf(format string, a ...any) { fmt.Fprintf(os.Stderr, "error: "+format+"\n", a...) os.Exit(1) } func usage() { fmt.Fprint(os.Stderr, `misaki-cli — Misaki account management Usage: misaki-cli [-db PATH] [arguments] Flags: -db string path to SQLite database (default /data/misaki.db) User commands: list [pending|approved|rejected] list users (default: all statuses) create create an approved account directly approve approve a pending application reject reject an application reinstate reinstate a rejected user passwd set a user's password (logs them out) delete permanently delete an account info show account details and services reset-link print a one-time password reset link (24 h) 2fa off turn off 2FA (lost phone + lost codes) admin grant admin panel access deadmin revoke admin panel access Global service commands: svc list list all services and global toggle svc enable enable a service globally svc disable disable a service globally Per-user service commands: usvc list list service requests for a user usvc enable grant a service to a user usvc disable revoke a service from a user usvc setlink set admin-provided link for a service Services: werc, transfer, akkoma, ergo, gancio, mail, cerca, prosody, mumble, forgejo, streaming, radio, files, notes, paste, rss Data field (usvc setlink ): streaming mediamtx stream key radio stations, comma-separated, e.g. fmradio,live (* = all) files copyparty mounts, comma-separated, e.g. share,fmradio (* = admin/all) `) }