| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194 |
- 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})
- }
|