411 lines
10 KiB
Go
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)
|
|
`)
|
|
}
|