| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377 |
- package main
- import (
- "bufio"
- "context"
- "errors"
- "fmt"
- "log/slog"
- "os"
- "os/exec"
- "strings"
- "time"
- "golang.org/x/term"
- "vocat/internal/auth"
- "vocat/internal/config"
- "vocat/internal/store"
- )
- //envFilePath is the systemd EnvironmentFile that carries VOCAT_ADMIN_PASSWORD.
- // EnsureAdmin reseeds the DB from it on every start, so change-password must
- // rewrite it or the next restart reverts the password.
- const envFilePath = "/etc/vocat/env"
- const systemdUnitPath = "/etc/systemd/system/vocat.service"
- // runMenu is the interactive lifecycle menu: change password, restart the
- // systemd unit, or fully uninstall vocat. It must run as root on the host
- // (needs systemctl + the 0600 env file). Docker deployments do not use it.
- func runMenu(logger *slog.Logger) error {
- if os.Geteuid() != 0 {
- return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)")
- }
- fd := int(os.Stdin.Fd())
- if !term.IsTerminal(fd) {
- return errors.New("vocat menu requires an interactive terminal")
- }
- lang := promptLanguage()
- menu := newMenu(lang)
- reader := bufio.NewReader(os.Stdin)
- for {
- fmt.Println()
- fmt.Println(menu.title())
- for _, opt := range menu.options() {
- fmt.Printf(" %s\n", opt)
- }
- fmt.Print(menu.prompt())
- line, err := reader.ReadString('\n')
- if err != nil {
- return fmt.Errorf("read menu choice: %w", err)
- }
- choice := strings.TrimSpace(line)
- switch choice {
- case "1":
- if err := menuChangePassword(reader, menu, logger); err != nil {
- fmt.Println(menu.errorPrefix(err))
- }
- case "2":
- if err := menuRestart(menu); err != nil {
- fmt.Println(menu.errorPrefix(err))
- }
- case "3":
- if err := menuUninstall(reader, menu); err != nil {
- fmt.Println(menu.errorPrefix(err))
- }
- case "0", "":
- fmt.Println(menu.bye())
- return nil
- default:
- fmt.Println(menu.invalid())
- }
- }
- }
- // promptLanguage asks for 中文 (1) or English (2) once per invocation. The
- // user chose to re-ask every run rather than persist a language preference.
- func promptLanguage() string {
- reader := bufio.NewReader(os.Stdin)
- for {
- fmt.Println("选择语言 / Select language: 1) 中文 2) English")
- fmt.Print("> ")
- line, err := reader.ReadString('\n')
- if err != nil {
- return "zh"
- }
- switch strings.TrimSpace(line) {
- case "1", "":
- return "zh"
- case "2":
- return "en"
- }
- }
- }
- func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) error {
- cfg, err := config.Load()
- if err != nil {
- return fmt.Errorf("%w: %v", errMenuConfig, err)
- }
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- database, err := store.Open(ctx, cfg.DatabasePath)
- if err != nil {
- return fmt.Errorf("%w: %v", errMenuStore, err)
- }
- defer database.Close()
- authService, err := auth.New(database, auth.Options{SessionTTL: cfg.SessionTTL})
- if err != nil {
- return fmt.Errorf("%w: %v", errMenuAuth, err)
- }
- fmt.Print(m.currentPassword())
- currentPw, err := readPasswordMasked()
- if err != nil {
- return err
- }
- fmt.Print(m.newPassword())
- newPw, err := readPasswordMasked()
- if err != nil {
- return err
- }
- fmt.Print(m.confirmPassword())
- confirmPw, err := readPasswordMasked()
- if err != nil {
- return err
- }
- fmt.Println()
- if newPw != confirmPw {
- return errPasswordsDiffer
- }
- if err := authService.ChangePassword(ctx, cfg.AdminUsername, currentPw, newPw); err != nil {
- if errors.Is(err, auth.ErrInvalidCredentials) {
- return errCurrentWrong
- }
- return fmt.Errorf("%w: %v", errMenuAuth, err)
- }
- // Persist the new plaintext to the env file so the next EnsureAdmin (on
- // restart) agrees with the hash we just wrote to the DB. Without this the
- // restart reverts the password to whatever the env file still holds.
- if err := rewriteEnvPassword(newPw); err != nil {
- logger.Error("menu: password changed in DB but env file rewrite failed; restart will revert", "error", err)
- return fmt.Errorf("%w: %v", errMenuEnvWrite, err)
- }
- fmt.Println(m.passwordChanged())
- return nil
- }
- // readPasswordMasked reads a password with echo disabled. term.ReadPassword
- // does not return the trailing newline, so we print one for a clean prompt.
- func readPasswordMasked() (string, error) {
- fd := int(os.Stdin.Fd())
- bytes, err := term.ReadPassword(fd)
- fmt.Println()
- if err != nil {
- return "", fmt.Errorf("read password: %w", err)
- }
- return string(bytes), nil
- }
- // rewriteEnvPassword replaces (or appends) the VOCAT_ADMIN_PASSWORD line in the
- // systemd EnvironmentFile and keeps the file 0600. The replacement is atomic:
- // the temp file lives in the same directory so os.Rename stays on one
- // filesystem.
- func rewriteEnvPassword(newPassword string) error {
- const key = "VOCAT_ADMIN_PASSWORD="
- var lines []string
- if data, err := os.ReadFile(envFilePath); err == nil {
- lines = strings.Split(string(data), "\n")
- } else if !errors.Is(err, os.ErrNotExist) {
- return err
- }
- replaced := false
- for i, line := range lines {
- if strings.HasPrefix(line, key) {
- lines[i] = key + newPassword
- replaced = true
- break
- }
- }
- if !replaced {
- lines = append(lines, key+newPassword)
- }
- content := strings.Join(lines, "\n")
- if !strings.HasSuffix(content, "\n") {
- content += "\n"
- }
- dir := envFilePath[:strings.LastIndex(envFilePath, "/")]
- tmp, err := os.CreateTemp(dir, ".vocat-env-*")
- if err != nil {
- return err
- }
- tmpName := tmp.Name()
- defer os.Remove(tmpName)
- if _, err := tmp.WriteString(content); err != nil {
- _ = tmp.Close()
- return err
- }
- if err := tmp.Chmod(0o600); err != nil {
- _ = tmp.Close()
- return err
- }
- if err := tmp.Close(); err != nil {
- return err
- }
- return os.Rename(tmpName, envFilePath)
- }
- func menuRestart(m *menu) error {
- if _, err := exec.LookPath("systemctl"); err != nil {
- return errNoSystemctl
- }
- cmd := exec.Command("systemctl", "restart", "vocat")
- if out, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out)))
- }
- fmt.Println(m.restarted())
- return nil
- }
- // menuUninstall performs full removal: stop/disable the unit, delete the unit,
- // remove /opt/vocat (binary + data + SQLite DB), remove the env file, reload
- // systemd, and best-effort delete the vocat user.
- func menuUninstall(reader *bufio.Reader, m *menu) error {
- fmt.Println(m.uninstallWarn())
- fmt.Print(m.uninstallConfirm())
- line, err := reader.ReadString('\n')
- if err != nil {
- return fmt.Errorf("read confirmation: %w", err)
- }
- if strings.TrimSpace(line) != "yes" {
- fmt.Println(m.uninstallCancelled())
- return nil
- }
- runIgnore := func(name string, args ...string) {
- _ = exec.Command(name, args...).Run()
- }
- runIgnore("systemctl", "stop", "vocat")
- runIgnore("systemctl", "disable", "vocat")
- _ = os.Remove(systemdUnitPath)
- _ = os.RemoveAll("/opt/vocat")
- _ = os.Remove(envFilePath)
- _ = os.Remove("/etc/vocat") // succeeds only when empty
- runIgnore("systemctl", "daemon-reload")
- runIgnore("userdel", "vocat")
- fmt.Println(m.uninstalled())
- return nil
- }
- // menu-local sentinel errors so callers can map them to localized messages.
- var (
- errCurrentWrong = errors.New("menu: current password is incorrect")
- errPasswordsDiffer = errors.New("menu: passwords do not match")
- errNoSystemctl = errors.New("menu: systemctl not found")
- errRestartFailed = errors.New("menu: restart failed")
- errMenuConfig = errors.New("menu: load configuration")
- errMenuStore = errors.New("menu: open database")
- errMenuAuth = errors.New("menu: auth service")
- errMenuEnvWrite = errors.New("menu: write env file")
- )
- // ---- i18n ----
- type menu struct{ lang string }
- func newMenu(lang string) *menu { return &menu{lang: lang} }
- // msg returns the localized string for a key. Each key carries [zh, en].
- func (m *menu) msg(key string) string {
- const zh, en = 0, 1
- table := map[string][2]string{
- "title": {"vocat 管理菜单", "vocat management menu"},
- "opt_change": {"1) 修改密码", "1) Change password"},
- "opt_restart": {"2) 重启服务", "2) Restart service"},
- "opt_uninstall": {"3) 卸载程序", "3) Uninstall"},
- "opt_exit": {"0) 退出", "0) Exit"},
- "prompt": {"请选择: ", "Select: "},
- "invalid": {"无效选项,请重试。", "Invalid choice, try again."},
- "bye": {"再见。", "Bye."},
- "cur_pw": {"当前密码: ", "Current password: "},
- "new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
- "confirm_pw": {"确认新密码: ", "Confirm new password: "},
- "pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
- "restarted": {"服务已重启。", "Service restarted."},
- "uninstall_warn": {
- "警告: 将删除程序、数据与配置,且不可恢复!",
- "WARNING: removes the program, data and config. Irreversible!",
- },
- "uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
- "uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."},
- "uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
- }
- entry, ok := table[key]
- if !ok {
- return key
- }
- if m.lang == "en" {
- return entry[en]
- }
- return entry[zh]
- }
- func (m *menu) title() string { return m.msg("title") }
- func (m *menu) prompt() string { return m.msg("prompt") }
- func (m *menu) invalid() string { return m.msg("invalid") }
- func (m *menu) bye() string { return m.msg("bye") }
- func (m *menu) currentPassword() string { return m.msg("cur_pw") }
- func (m *menu) newPassword() string { return m.msg("new_pw") }
- func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
- func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
- func (m *menu) restarted() string { return m.msg("restarted") }
- func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") }
- func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
- func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") }
- func (m *menu) uninstalled() string { return m.msg("uninstalled") }
- func (m *menu) options() []string {
- return []string{m.msg("opt_change"), m.msg("opt_restart"), m.msg("opt_uninstall"), m.msg("opt_exit")}
- }
- func (m *menu) errorPrefix(err error) string {
- switch {
- case errors.Is(err, errCurrentWrong):
- if m.lang == "en" {
- return "Current password is incorrect."
- }
- return "当前密码不正确。"
- case errors.Is(err, errPasswordsDiffer):
- if m.lang == "en" {
- return "Passwords do not match."
- }
- return "两次输入的密码不一致。"
- case errors.Is(err, errNoSystemctl):
- if m.lang == "en" {
- return "systemctl not found."
- }
- return "未找到 systemctl。"
- case errors.Is(err, errRestartFailed):
- if m.lang == "en" {
- return "Restart failed."
- }
- return "重启失败。"
- case errors.Is(err, errMenuConfig):
- if m.lang == "en" {
- return "Failed to load configuration."
- }
- return "加载配置失败。"
- case errors.Is(err, errMenuStore):
- if m.lang == "en" {
- return "Failed to open the database."
- }
- return "打开数据库失败。"
- case errors.Is(err, errMenuAuth):
- if m.lang == "en" {
- return "Auth service error."
- }
- return "认证服务错误。"
- case errors.Is(err, errMenuEnvWrite):
- if m.lang == "en" {
- return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + envFilePath + "."
- }
- return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。"
- default:
- if m.lang == "en" {
- return "Error: " + err.Error()
- }
- return "错误: " + err.Error()
- }
- }
|