update_handlers.go 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. package api
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "net/http"
  6. "net/url"
  7. "os"
  8. "os/exec"
  9. "path/filepath"
  10. "proxmox-router/internal/auth"
  11. "regexp"
  12. "strconv"
  13. "strings"
  14. "time"
  15. )
  16. var BuildCommit = "unknown"
  17. const updateDir = "/var/lib/debtik/update"
  18. var branchPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
  19. var pinPattern = regexp.MustCompile(`^sha256//[A-Za-z0-9+/]{43}=$`)
  20. type updateConfig struct {
  21. Repository string `json:"repository"`
  22. Branch string `json:"branch"`
  23. TLSKeyPin string `json:"tls_key_pin,omitempty"`
  24. }
  25. func validUpdateConfig(c updateConfig) bool {
  26. u, err := url.Parse(c.Repository)
  27. if err != nil {
  28. return false
  29. }
  30. if u.User != nil {
  31. if u.Scheme != "ssh" || u.User.Username() == "" {
  32. return false
  33. }
  34. if _, hasPassword := u.User.Password(); hasPassword {
  35. return false
  36. }
  37. }
  38. return (c.TLSKeyPin == "" || pinPattern.MatchString(c.TLSKeyPin)) && u.Host != "" && (u.Scheme == "http" || u.Scheme == "https" || u.Scheme == "ssh") && branchPattern.MatchString(c.Branch) && !strings.Contains(c.Branch, "..") && !strings.HasSuffix(c.Branch, "/")
  39. }
  40. func readUpdateConfig() updateConfig {
  41. var c updateConfig
  42. data, err := os.ReadFile(filepath.Join(updateDir, "config.json"))
  43. if err == nil {
  44. _ = json.Unmarshal(data, &c)
  45. }
  46. return c
  47. }
  48. func requireUpdateAdmin(w http.ResponseWriter, r *http.Request) bool {
  49. session := getSessionFromContext(r.Context())
  50. if session == nil || session.Role != auth.RoleFull {
  51. http.Error(w, "Administrator required", http.StatusForbidden)
  52. return false
  53. }
  54. return true
  55. }
  56. func (s *Server) handleUpdateStatus(w http.ResponseWriter, r *http.Request) {
  57. c := readUpdateConfig()
  58. state := "idle"
  59. if data, err := os.ReadFile(filepath.Join(updateDir, "status")); err == nil {
  60. state = strings.TrimSpace(string(data))
  61. }
  62. logData, _ := os.ReadFile(filepath.Join(updateDir, "update.log"))
  63. if len(logData) > 12000 {
  64. logData = logData[len(logData)-12000:]
  65. }
  66. writeJSON(w, http.StatusOK, map[string]any{"repository": c.Repository, "branch": c.Branch, "tls_key_pin": c.TLSKeyPin, "installed_commit": BuildCommit, "state": state, "log": string(logData)})
  67. }
  68. func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
  69. if !requireUpdateAdmin(w, r) {
  70. return
  71. }
  72. var c updateConfig
  73. if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&c); err != nil || !validUpdateConfig(c) {
  74. http.Error(w, "Valid HTTP(S)/SSH repository and branch required", http.StatusBadRequest)
  75. return
  76. }
  77. if err := os.MkdirAll(updateDir, 0700); err != nil {
  78. http.Error(w, err.Error(), 500)
  79. return
  80. }
  81. data, _ := json.Marshal(c)
  82. tmp, err := os.CreateTemp(updateDir, ".config-*")
  83. if err != nil {
  84. http.Error(w, err.Error(), 500)
  85. return
  86. }
  87. defer os.Remove(tmp.Name())
  88. if _, err = tmp.Write(data); err == nil {
  89. err = tmp.Close()
  90. } else {
  91. tmp.Close()
  92. }
  93. if err == nil {
  94. err = os.Rename(tmp.Name(), filepath.Join(updateDir, "config.json"))
  95. }
  96. if err != nil {
  97. http.Error(w, err.Error(), 500)
  98. return
  99. }
  100. writeJSON(w, 200, c)
  101. }
  102. func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
  103. if !requireUpdateAdmin(w, r) {
  104. return
  105. }
  106. c := readUpdateConfig()
  107. if !validUpdateConfig(c) {
  108. http.Error(w, "Configure repository first", 400)
  109. return
  110. }
  111. args := []string{}
  112. if c.TLSKeyPin != "" {
  113. args = append(args, "-c", "http.sslVerify=false", "-c", "http.pinnedpubkey="+c.TLSKeyPin)
  114. }
  115. args = append(args, "ls-remote", "--exit-code", "--heads", c.Repository, "refs/heads/"+c.Branch)
  116. cmd := exec.CommandContext(r.Context(), "git", args...)
  117. cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
  118. data, err := cmd.Output()
  119. if err != nil {
  120. http.Error(w, "Cannot read repository branch", 502)
  121. return
  122. }
  123. fields := strings.Fields(string(data))
  124. if len(fields) != 2 || fields[1] != "refs/heads/"+c.Branch {
  125. http.Error(w, "Unexpected Git response", 502)
  126. return
  127. }
  128. writeJSON(w, 200, map[string]any{"installed_commit": BuildCommit, "available_commit": fields[0], "update_available": BuildCommit != fields[0]})
  129. }
  130. func (s *Server) handleUpdateAction(w http.ResponseWriter, r *http.Request) {
  131. if !requireUpdateAdmin(w, r) {
  132. return
  133. }
  134. action := r.PathValue("action")
  135. if action != "install" && action != "rollback" {
  136. http.Error(w, "Unknown action", 400)
  137. return
  138. }
  139. c := readUpdateConfig()
  140. if action == "install" && !validUpdateConfig(c) {
  141. http.Error(w, "Configure repository first", 400)
  142. return
  143. }
  144. if _, err := os.Stat("/usr/local/libexec/debtik-update"); err != nil {
  145. http.Error(w, "Update helper is not installed", 503)
  146. return
  147. }
  148. if err := os.MkdirAll(updateDir, 0700); err != nil {
  149. http.Error(w, err.Error(), 500)
  150. return
  151. }
  152. args := []string{"--unit=debtik-update-" + action + "-" + strconv.FormatInt(time.Now().UnixNano(), 10), "--collect", "/usr/local/libexec/debtik-update", action}
  153. if action == "install" {
  154. args = append(args, c.Repository, c.Branch, c.TLSKeyPin)
  155. }
  156. cmd := exec.CommandContext(r.Context(), "systemd-run", args...)
  157. if out, err := cmd.CombinedOutput(); err != nil {
  158. if errors.Is(err, exec.ErrNotFound) {
  159. http.Error(w, "systemd-run unavailable", 503)
  160. } else {
  161. http.Error(w, string(out), 409)
  162. }
  163. return
  164. }
  165. writeJSON(w, http.StatusAccepted, map[string]string{"state": "starting", "action": action})
  166. }