package api import ( "context" "encoding/json" "errors" "net/http" "net/url" "os" "os/exec" "path/filepath" release "debtik" "debtik/internal/auth" "regexp" "strconv" "strings" "time" ) var BuildCommit = "unknown" const updateDir = "/var/lib/debtik/update" var pinPattern = regexp.MustCompile(`^sha256//[A-Za-z0-9+/]{43}=$`) type updateConfig struct { Repository string `json:"repository"` Branch string `json:"branch"` TLSKeyPin string `json:"tls_key_pin,omitempty"` } func validUpdateConfig(c updateConfig) bool { u, err := url.Parse(c.Repository) if err != nil { return false } if u.User != nil { if u.Scheme != "ssh" || u.User.Username() == "" { return false } if _, hasPassword := u.User.Password(); hasPassword { return false } } return (c.TLSKeyPin == "" || pinPattern.MatchString(c.TLSKeyPin)) && u.Host != "" && (u.Scheme == "http" || u.Scheme == "https" || u.Scheme == "ssh") } func readUpdateConfig() updateConfig { var c updateConfig data, err := os.ReadFile(filepath.Join(updateDir, "config.json")) if err == nil { _ = json.Unmarshal(data, &c) } return c } func requireUpdateAdmin(w http.ResponseWriter, r *http.Request) bool { session := getSessionFromContext(r.Context()) if session == nil || session.Role != auth.RoleFull { http.Error(w, "Administrator required", http.StatusForbidden) return false } return true } func (s *Server) handleUpdateStatus(w http.ResponseWriter, r *http.Request) { c := readUpdateConfig() state := "idle" if data, err := os.ReadFile(filepath.Join(updateDir, "status")); err == nil { state = strings.TrimSpace(string(data)) } logData, _ := os.ReadFile(filepath.Join(updateDir, "update.log")) if len(logData) > 12000 { logData = logData[len(logData)-12000:] } writeJSON(w, http.StatusOK, map[string]any{"repository": c.Repository, "branch": c.Branch, "tls_key_pin": c.TLSKeyPin, "installed_commit": BuildCommit, "installed_version": release.Version(), "state": state, "log": string(logData)}) } func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { if !requireUpdateAdmin(w, r) { return } var c updateConfig if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&c); err != nil || !validUpdateConfig(c) { http.Error(w, "Valid HTTP(S)/SSH repository and branch required", http.StatusBadRequest) return } if err := os.MkdirAll(updateDir, 0700); err != nil { http.Error(w, err.Error(), 500) return } data, _ := json.Marshal(c) tmp, err := os.CreateTemp(updateDir, ".config-*") if err != nil { http.Error(w, err.Error(), 500) return } defer os.Remove(tmp.Name()) if _, err = tmp.Write(data); err == nil { err = tmp.Close() } else { tmp.Close() } if err == nil { err = os.Rename(tmp.Name(), filepath.Join(updateDir, "config.json")) } if err != nil { http.Error(w, err.Error(), 500) return } writeJSON(w, 200, c) } func availableRelease(ctx context.Context, c updateConfig) (string, string, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() args := []string{} if c.TLSKeyPin != "" { args = append(args, "-c", "http.sslVerify=false", "-c", "http.pinnedpubkey="+c.TLSKeyPin) } args = append(args, "ls-remote", "--tags", c.Repository, "refs/tags/v*") cmd := exec.CommandContext(ctx, "git", args...) cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") data, err := cmd.Output() if err != nil { return "", "", err } return release.LatestTag(string(data)) } func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) { if !requireUpdateAdmin(w, r) { return } c := readUpdateConfig() if !validUpdateConfig(c) { http.Error(w, "Configure repository first", 400) return } version, commit, err := availableRelease(r.Context(), c) if err != nil { http.Error(w, "Cannot find a stable version tag in repository", 502) return } newer, err := release.Newer(version, release.Version()) if err != nil { http.Error(w, err.Error(), 500) return } writeJSON(w, 200, map[string]any{"installed_version": release.Version(), "available_version": version, "installed_commit": BuildCommit, "available_commit": commit, "update_available": newer}) } func (s *Server) handleUpdateAction(w http.ResponseWriter, r *http.Request) { if !requireUpdateAdmin(w, r) { return } action := r.PathValue("action") if action != "install" && action != "rollback" { http.Error(w, "Unknown action", 400) return } c := readUpdateConfig() if action == "install" && !validUpdateConfig(c) { http.Error(w, "Configure repository first", 400) return } if action == "install" { candidate, _, err := availableRelease(r.Context(), c) if err != nil { http.Error(w, "Cannot read release versions", 502) return } newer, err := release.Newer(candidate, release.Version()) if err != nil || !newer { http.Error(w, "Update requires a version greater than the installed version", 409) return } } if _, err := os.Stat("/usr/local/libexec/debtik-update"); err != nil { http.Error(w, "Update helper is not installed", 503) return } if err := os.MkdirAll(updateDir, 0700); err != nil { http.Error(w, err.Error(), 500) return } args := []string{"--unit=debtik-update-" + action + "-" + strconv.FormatInt(time.Now().UnixNano(), 10), "--collect", "/usr/local/libexec/debtik-update", action} if action == "install" { args = append(args, c.Repository, "master", c.TLSKeyPin) } cmd := exec.CommandContext(r.Context(), "systemd-run", args...) if out, err := cmd.CombinedOutput(); err != nil { if errors.Is(err, exec.ErrNotFound) { http.Error(w, "systemd-run unavailable", 503) } else { http.Error(w, string(out), 409) } return } writeJSON(w, http.StatusAccepted, map[string]string{"state": "starting", "action": action}) }