misaki/cmd/cli/main.go
q 119b6d73c4
Some checks failed
Build & push Docker image / build (push) Has been cancelled
totp 2fac
2026-11-07 11:43:40 -05:00

411 lines
10 KiB
Go

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>")
}
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 <username>")
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 <username> <newpassword>")
}
u := mustGetUser(args[:1], "passwd <username> <newpassword>")
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 <username>")
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 <username>")
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 <username>")
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 <service>", 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 <list|enable|disable> [service]")
}
}
func cmdUsvc(args []string) {
if len(args) < 2 {
fatalf("usage: usvc <list|enable|disable|setlink> <username> [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 <username> <service>", 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 <username> <service> <url>")
}
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 <list|enable|disable|setlink> <username> [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 <username>")
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 <username>")
}
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] <command> [arguments]
Flags:
-db string path to SQLite database (default /data/misaki.db)
User commands:
list [pending|approved|rejected] list users (default: all statuses)
create <user> <pass> create an approved account directly
approve <user> approve a pending application
reject <user> reject an application
reinstate <user> reinstate a rejected user
passwd <user> <newpass> set a user's password (logs them out)
delete <user> permanently delete an account
info <user> show account details and services
reset-link <user> print a one-time password reset link (24 h)
2fa off <user> turn off 2FA (lost phone + lost codes)
admin <user> grant admin panel access
deadmin <user> revoke admin panel access
Global service commands:
svc list list all services and global toggle
svc enable <service> enable a service globally
svc disable <service> disable a service globally
Per-user service commands:
usvc list <user> list service requests for a user
usvc enable <user> <service> grant a service to a user
usvc disable <user> <service> revoke a service from a user
usvc setlink <user> <service> <url> 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 <user> <service> <value>):
streaming mediamtx stream key
radio stations, comma-separated, e.g. fmradio,live (* = all)
files copyparty mounts, comma-separated, e.g. share,fmradio (* = admin/all)
`)
}