menu.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. package main
  2. import (
  3. "bufio"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "os"
  9. "os/exec"
  10. "strings"
  11. "time"
  12. "golang.org/x/term"
  13. "vocat/internal/auth"
  14. "vocat/internal/config"
  15. "vocat/internal/store"
  16. )
  17. //envFilePath is the systemd EnvironmentFile that carries VOCAT_ADMIN_PASSWORD.
  18. // EnsureAdmin reseeds the DB from it on every start, so change-password must
  19. // rewrite it or the next restart reverts the password.
  20. const envFilePath = "/etc/vocat/env"
  21. const systemdUnitPath = "/etc/systemd/system/vocat.service"
  22. // runMenu is the interactive lifecycle menu: change password, restart the
  23. // systemd unit, or fully uninstall vocat. It must run as root on the host
  24. // (needs systemctl + the 0600 env file). Docker deployments do not use it.
  25. func runMenu(logger *slog.Logger) error {
  26. if os.Geteuid() != 0 {
  27. return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)")
  28. }
  29. fd := int(os.Stdin.Fd())
  30. if !term.IsTerminal(fd) {
  31. return errors.New("vocat menu requires an interactive terminal")
  32. }
  33. lang := promptLanguage()
  34. menu := newMenu(lang)
  35. reader := bufio.NewReader(os.Stdin)
  36. for {
  37. fmt.Println()
  38. fmt.Println(menu.title())
  39. for _, opt := range menu.options() {
  40. fmt.Printf(" %s\n", opt)
  41. }
  42. fmt.Print(menu.prompt())
  43. line, err := reader.ReadString('\n')
  44. if err != nil {
  45. return fmt.Errorf("read menu choice: %w", err)
  46. }
  47. choice := strings.TrimSpace(line)
  48. switch choice {
  49. case "1":
  50. if err := menuChangePassword(reader, menu, logger); err != nil {
  51. fmt.Println(menu.errorPrefix(err))
  52. }
  53. case "2":
  54. if err := menuRestart(menu); err != nil {
  55. fmt.Println(menu.errorPrefix(err))
  56. }
  57. case "3":
  58. if err := menuUninstall(reader, menu); err != nil {
  59. fmt.Println(menu.errorPrefix(err))
  60. }
  61. case "0", "":
  62. fmt.Println(menu.bye())
  63. return nil
  64. default:
  65. fmt.Println(menu.invalid())
  66. }
  67. }
  68. }
  69. // promptLanguage asks for 中文 (1) or English (2) once per invocation. The
  70. // user chose to re-ask every run rather than persist a language preference.
  71. func promptLanguage() string {
  72. reader := bufio.NewReader(os.Stdin)
  73. for {
  74. fmt.Println("选择语言 / Select language: 1) 中文 2) English")
  75. fmt.Print("> ")
  76. line, err := reader.ReadString('\n')
  77. if err != nil {
  78. return "zh"
  79. }
  80. switch strings.TrimSpace(line) {
  81. case "1", "":
  82. return "zh"
  83. case "2":
  84. return "en"
  85. }
  86. }
  87. }
  88. func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) error {
  89. cfg, err := config.Load()
  90. if err != nil {
  91. return fmt.Errorf("%w: %v", errMenuConfig, err)
  92. }
  93. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  94. defer cancel()
  95. database, err := store.Open(ctx, cfg.DatabasePath)
  96. if err != nil {
  97. return fmt.Errorf("%w: %v", errMenuStore, err)
  98. }
  99. defer database.Close()
  100. authService, err := auth.New(database, auth.Options{SessionTTL: cfg.SessionTTL})
  101. if err != nil {
  102. return fmt.Errorf("%w: %v", errMenuAuth, err)
  103. }
  104. fmt.Print(m.currentPassword())
  105. currentPw, err := readPasswordMasked()
  106. if err != nil {
  107. return err
  108. }
  109. fmt.Print(m.newPassword())
  110. newPw, err := readPasswordMasked()
  111. if err != nil {
  112. return err
  113. }
  114. fmt.Print(m.confirmPassword())
  115. confirmPw, err := readPasswordMasked()
  116. if err != nil {
  117. return err
  118. }
  119. fmt.Println()
  120. if newPw != confirmPw {
  121. return errPasswordsDiffer
  122. }
  123. if err := authService.ChangePassword(ctx, cfg.AdminUsername, currentPw, newPw); err != nil {
  124. if errors.Is(err, auth.ErrInvalidCredentials) {
  125. return errCurrentWrong
  126. }
  127. return fmt.Errorf("%w: %v", errMenuAuth, err)
  128. }
  129. // Persist the new plaintext to the env file so the next EnsureAdmin (on
  130. // restart) agrees with the hash we just wrote to the DB. Without this the
  131. // restart reverts the password to whatever the env file still holds.
  132. if err := rewriteEnvPassword(newPw); err != nil {
  133. logger.Error("menu: password changed in DB but env file rewrite failed; restart will revert", "error", err)
  134. return fmt.Errorf("%w: %v", errMenuEnvWrite, err)
  135. }
  136. fmt.Println(m.passwordChanged())
  137. return nil
  138. }
  139. // readPasswordMasked reads a password with echo disabled. term.ReadPassword
  140. // does not return the trailing newline, so we print one for a clean prompt.
  141. func readPasswordMasked() (string, error) {
  142. fd := int(os.Stdin.Fd())
  143. bytes, err := term.ReadPassword(fd)
  144. fmt.Println()
  145. if err != nil {
  146. return "", fmt.Errorf("read password: %w", err)
  147. }
  148. return string(bytes), nil
  149. }
  150. // rewriteEnvPassword replaces (or appends) the VOCAT_ADMIN_PASSWORD line in the
  151. // systemd EnvironmentFile and keeps the file 0600. The replacement is atomic:
  152. // the temp file lives in the same directory so os.Rename stays on one
  153. // filesystem.
  154. func rewriteEnvPassword(newPassword string) error {
  155. const key = "VOCAT_ADMIN_PASSWORD="
  156. var lines []string
  157. if data, err := os.ReadFile(envFilePath); err == nil {
  158. lines = strings.Split(string(data), "\n")
  159. } else if !errors.Is(err, os.ErrNotExist) {
  160. return err
  161. }
  162. replaced := false
  163. for i, line := range lines {
  164. if strings.HasPrefix(line, key) {
  165. lines[i] = key + newPassword
  166. replaced = true
  167. break
  168. }
  169. }
  170. if !replaced {
  171. lines = append(lines, key+newPassword)
  172. }
  173. content := strings.Join(lines, "\n")
  174. if !strings.HasSuffix(content, "\n") {
  175. content += "\n"
  176. }
  177. dir := envFilePath[:strings.LastIndex(envFilePath, "/")]
  178. tmp, err := os.CreateTemp(dir, ".vocat-env-*")
  179. if err != nil {
  180. return err
  181. }
  182. tmpName := tmp.Name()
  183. defer os.Remove(tmpName)
  184. if _, err := tmp.WriteString(content); err != nil {
  185. _ = tmp.Close()
  186. return err
  187. }
  188. if err := tmp.Chmod(0o600); err != nil {
  189. _ = tmp.Close()
  190. return err
  191. }
  192. if err := tmp.Close(); err != nil {
  193. return err
  194. }
  195. return os.Rename(tmpName, envFilePath)
  196. }
  197. func menuRestart(m *menu) error {
  198. if _, err := exec.LookPath("systemctl"); err != nil {
  199. return errNoSystemctl
  200. }
  201. cmd := exec.Command("systemctl", "restart", "vocat")
  202. if out, err := cmd.CombinedOutput(); err != nil {
  203. return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out)))
  204. }
  205. fmt.Println(m.restarted())
  206. return nil
  207. }
  208. // menuUninstall performs full removal: stop/disable the unit, delete the unit,
  209. // remove /opt/vocat (binary + data + SQLite DB), remove the env file, reload
  210. // systemd, and best-effort delete the vocat user.
  211. func menuUninstall(reader *bufio.Reader, m *menu) error {
  212. fmt.Println(m.uninstallWarn())
  213. fmt.Print(m.uninstallConfirm())
  214. line, err := reader.ReadString('\n')
  215. if err != nil {
  216. return fmt.Errorf("read confirmation: %w", err)
  217. }
  218. if strings.TrimSpace(line) != "yes" {
  219. fmt.Println(m.uninstallCancelled())
  220. return nil
  221. }
  222. runIgnore := func(name string, args ...string) {
  223. _ = exec.Command(name, args...).Run()
  224. }
  225. runIgnore("systemctl", "stop", "vocat")
  226. runIgnore("systemctl", "disable", "vocat")
  227. _ = os.Remove(systemdUnitPath)
  228. _ = os.RemoveAll("/opt/vocat")
  229. _ = os.Remove(envFilePath)
  230. _ = os.Remove("/etc/vocat") // succeeds only when empty
  231. runIgnore("systemctl", "daemon-reload")
  232. runIgnore("userdel", "vocat")
  233. fmt.Println(m.uninstalled())
  234. return nil
  235. }
  236. // menu-local sentinel errors so callers can map them to localized messages.
  237. var (
  238. errCurrentWrong = errors.New("menu: current password is incorrect")
  239. errPasswordsDiffer = errors.New("menu: passwords do not match")
  240. errNoSystemctl = errors.New("menu: systemctl not found")
  241. errRestartFailed = errors.New("menu: restart failed")
  242. errMenuConfig = errors.New("menu: load configuration")
  243. errMenuStore = errors.New("menu: open database")
  244. errMenuAuth = errors.New("menu: auth service")
  245. errMenuEnvWrite = errors.New("menu: write env file")
  246. )
  247. // ---- i18n ----
  248. type menu struct{ lang string }
  249. func newMenu(lang string) *menu { return &menu{lang: lang} }
  250. // msg returns the localized string for a key. Each key carries [zh, en].
  251. func (m *menu) msg(key string) string {
  252. const zh, en = 0, 1
  253. table := map[string][2]string{
  254. "title": {"vocat 管理菜单", "vocat management menu"},
  255. "opt_change": {"1) 修改密码", "1) Change password"},
  256. "opt_restart": {"2) 重启服务", "2) Restart service"},
  257. "opt_uninstall": {"3) 卸载程序", "3) Uninstall"},
  258. "opt_exit": {"0) 退出", "0) Exit"},
  259. "prompt": {"请选择: ", "Select: "},
  260. "invalid": {"无效选项,请重试。", "Invalid choice, try again."},
  261. "bye": {"再见。", "Bye."},
  262. "cur_pw": {"当前密码: ", "Current password: "},
  263. "new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
  264. "confirm_pw": {"确认新密码: ", "Confirm new password: "},
  265. "pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
  266. "restarted": {"服务已重启。", "Service restarted."},
  267. "uninstall_warn": {
  268. "警告: 将删除程序、数据与配置,且不可恢复!",
  269. "WARNING: removes the program, data and config. Irreversible!",
  270. },
  271. "uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
  272. "uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."},
  273. "uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
  274. }
  275. entry, ok := table[key]
  276. if !ok {
  277. return key
  278. }
  279. if m.lang == "en" {
  280. return entry[en]
  281. }
  282. return entry[zh]
  283. }
  284. func (m *menu) title() string { return m.msg("title") }
  285. func (m *menu) prompt() string { return m.msg("prompt") }
  286. func (m *menu) invalid() string { return m.msg("invalid") }
  287. func (m *menu) bye() string { return m.msg("bye") }
  288. func (m *menu) currentPassword() string { return m.msg("cur_pw") }
  289. func (m *menu) newPassword() string { return m.msg("new_pw") }
  290. func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
  291. func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
  292. func (m *menu) restarted() string { return m.msg("restarted") }
  293. func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") }
  294. func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
  295. func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") }
  296. func (m *menu) uninstalled() string { return m.msg("uninstalled") }
  297. func (m *menu) options() []string {
  298. return []string{m.msg("opt_change"), m.msg("opt_restart"), m.msg("opt_uninstall"), m.msg("opt_exit")}
  299. }
  300. func (m *menu) errorPrefix(err error) string {
  301. switch {
  302. case errors.Is(err, errCurrentWrong):
  303. if m.lang == "en" {
  304. return "Current password is incorrect."
  305. }
  306. return "当前密码不正确。"
  307. case errors.Is(err, errPasswordsDiffer):
  308. if m.lang == "en" {
  309. return "Passwords do not match."
  310. }
  311. return "两次输入的密码不一致。"
  312. case errors.Is(err, errNoSystemctl):
  313. if m.lang == "en" {
  314. return "systemctl not found."
  315. }
  316. return "未找到 systemctl。"
  317. case errors.Is(err, errRestartFailed):
  318. if m.lang == "en" {
  319. return "Restart failed."
  320. }
  321. return "重启失败。"
  322. case errors.Is(err, errMenuConfig):
  323. if m.lang == "en" {
  324. return "Failed to load configuration."
  325. }
  326. return "加载配置失败。"
  327. case errors.Is(err, errMenuStore):
  328. if m.lang == "en" {
  329. return "Failed to open the database."
  330. }
  331. return "打开数据库失败。"
  332. case errors.Is(err, errMenuAuth):
  333. if m.lang == "en" {
  334. return "Auth service error."
  335. }
  336. return "认证服务错误。"
  337. case errors.Is(err, errMenuEnvWrite):
  338. if m.lang == "en" {
  339. return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + envFilePath + "."
  340. }
  341. return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。"
  342. default:
  343. if m.lang == "en" {
  344. return "Error: " + err.Error()
  345. }
  346. return "错误: " + err.Error()
  347. }
  348. }