package api import ( "encoding/json" "errors" "net/http" "net/url" "os" "os/exec" "path/filepath" "proxmox-router/internal/auth" "regexp" "strconv" "strings" "time" ) var BuildCommit = "unknown" const updateDir = "/var/lib/debtik/update" var branchPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`) 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") && branchPattern.MatchString(c.Branch) && !strings.Contains(c.Branch, "..") && !strings.HasSuffix(c.Branch, "/") } 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, "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 (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 } args := []string{} if c.TLSKeyPin != "" { args = append(args, "-c", "http.sslVerify=false", "-c", "http.pinnedpubkey="+c.TLSKeyPin) } args = append(args, "ls-remote", "--exit-code", "--heads", c.Repository, "refs/heads/"+c.Branch) cmd := exec.CommandContext(r.Context(), "git", args...) cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") data, err := cmd.Output() if err != nil { http.Error(w, "Cannot read repository branch", 502) return } fields := strings.Fields(string(data)) if len(fields) != 2 || fields[1] != "refs/heads/"+c.Branch { http.Error(w, "Unexpected Git response", 502) return } writeJSON(w, 200, map[string]any{"installed_commit": BuildCommit, "available_commit": fields[0], "update_available": !strings.HasPrefix(fields[0], BuildCommit)}) } 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 _, 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, c.Branch, 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}) }