Explorar o código

Release 1.1.0 with version-gated Git updates

Proxmox Router hai 4 días
pai
achega
c7dd18339f

+ 1 - 1
README.md

@@ -31,7 +31,7 @@ Run `help` within the CLI for supported commands. The API is under `/api/` on th
 
 ## Git updates
 
-In **Updates**, enter an HTTP(S) or SSH Git repository and a branch, then save, check the available commit and install. The updater clones into `/var/lib/debtik/update`, builds the server and CLI first, backs up the installed binaries, restarts the service and checks that it remains active. If installation fails, it restores the previous binaries. **Restore previous version** is available in the same menu. Logs and the current state are shown there. Only full administrators can change the source or run update actions. Each appliance updates independently; for paired AeroTUN peers, update one side and verify its connection before updating the other.
+In **Updates**, enter an HTTP(S) or SSH Git repository, then save and check for releases. Releases are stable Git tags named `vMAJOR.MINOR.PATCH` (for example `v1.1.0`). The largest numeric version is selected; installation is allowed only when it is strictly greater than the installed version. Equal versions, older versions, malformed tags and prereleases are not installed. The selected tag must match the repository’s `VERSION` file. The updater clones into `/var/lib/debtik/update`, builds the server and CLI first, backs up the installed binaries, restarts the service and checks that it remains active. If installation fails, it restores the previous binaries. **Restore previous version** is available in the same menu. Logs and the current state are shown there. Only full administrators can change the source or run update actions. New releases require bumping `VERSION`, committing the change, creating its matching `vX.Y.Z` tag and pushing both the commit and tag. Recovery rollback remains an explicit separate action. Each appliance updates independently; for paired AeroTUN peers, update one side and verify its connection before updating the other.
 
 `install.sh` must have been run from a Git checkout at least once to install the updater helper. Git access from the appliance to the selected repository and the required Go toolchain are needed. Git credentials belong in the appliance's Git/SSH configuration; repository URLs containing embedded credentials are rejected. For a private Git server whose CA is not trusted by Debian, enter its verified `sha256//...` TLS public key pin in Updates. Rotate the pin when that server changes its TLS key.
 

+ 1 - 0
VERSION

@@ -0,0 +1 @@
+1.1.0

+ 2 - 0
install.sh

@@ -30,6 +30,7 @@ install -d -m 0700 /var/lib/debtik/update
 if [[ $commit =~ ^[a-f0-9]{40}$ ]]; then printf '%s\n' "$commit" > /var/lib/debtik/update/installed_commit; fi
 install -d -m 0755 /usr/local/libexec
 install -m 0750 scripts/debtik-update /usr/local/libexec/debtik-update
+install -m 0750 scripts/release-version.py /usr/local/libexec/debtik-release-version
 install -d -m 0755 /etc/network/interfaces.d
 for name in proxmox-router debtik-cli; do
   if [[ -f /usr/local/bin/$name ]]; then cp -p "/usr/local/bin/$name" "/usr/local/bin/$name.previous"; fi
@@ -42,4 +43,5 @@ systemctl daemon-reload
 systemctl enable proxmox-router.service
 systemctl restart proxmox-router.service
 systemctl is-active --quiet proxmox-router.service
+install -m 0600 VERSION /var/lib/debtik/update/installed_version
 echo 'DebTik installed: http://<host>/ and debtik-cli'

+ 39 - 17
internal/api/update_handlers.go

@@ -1,6 +1,7 @@
 package api
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
 	"net/http"
@@ -8,6 +9,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	release "proxmox-router"
 	"proxmox-router/internal/auth"
 	"regexp"
 	"strconv"
@@ -19,7 +21,6 @@ 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 {
@@ -41,7 +42,7 @@ func validUpdateConfig(c updateConfig) bool {
 			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, "/")
+	return (c.TLSKeyPin == "" || pinPattern.MatchString(c.TLSKeyPin)) && u.Host != "" && (u.Scheme == "http" || u.Scheme == "https" || u.Scheme == "ssh")
 }
 func readUpdateConfig() updateConfig {
 	var c updateConfig
@@ -69,7 +70,7 @@ func (s *Server) handleUpdateStatus(w http.ResponseWriter, r *http.Request) {
 	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)})
+	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) {
@@ -105,6 +106,22 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
 	}
 	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
@@ -114,24 +131,17 @@ func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
 		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()
+	version, commit, err := availableRelease(r.Context(), c)
 	if err != nil {
-		http.Error(w, "Cannot read repository branch", 502)
+		http.Error(w, "Cannot find a stable version tag in repository", 502)
 		return
 	}
-	fields := strings.Fields(string(data))
-	if len(fields) != 2 || fields[1] != "refs/heads/"+c.Branch {
-		http.Error(w, "Unexpected Git response", 502)
+	newer, err := release.Newer(version, release.Version())
+	if err != nil {
+		http.Error(w, err.Error(), 500)
 		return
 	}
-	writeJSON(w, 200, map[string]any{"installed_commit": BuildCommit, "available_commit": fields[0], "update_available": !strings.HasPrefix(fields[0], BuildCommit)})
+	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) {
@@ -147,6 +157,18 @@ func (s *Server) handleUpdateAction(w http.ResponseWriter, r *http.Request) {
 		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
@@ -157,7 +179,7 @@ func (s *Server) handleUpdateAction(w http.ResponseWriter, r *http.Request) {
 	}
 	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)
+		args = append(args, c.Repository, "master", c.TLSKeyPin)
 	}
 	cmd := exec.CommandContext(r.Context(), "systemd-run", args...)
 	if out, err := cmd.CombinedOutput(); err != nil {

+ 0 - 2
internal/api/update_handlers_test.go

@@ -18,8 +18,6 @@ func TestUpdateConfigValidationAndPrivileges(t *testing.T) {
 		{"ssh://git@example.com/team/router.git", "release/1", true},
 		{"file:///etc/passwd", "main", false},
 		{"https://user:password@example.com/router.git", "main", false},
-		{"https://example.com/router.git", "../bad", false},
-		{"https://example.com/router.git", "-option", false},
 	} {
 		if got := validUpdateConfig(updateConfig{Repository: tc.repo, Branch: tc.branch}); got != tc.valid {
 			t.Fatalf("%s %s: got %v", tc.repo, tc.branch, got)

+ 8 - 6
internal/web/static/app.js

@@ -17245,9 +17245,8 @@ async function loadUpdateStatus(populate = true) {
     if (!res.ok) throw new Error(await responseError(res));
     const data = await res.json();
     if (populate && document.activeElement?.id !== 'update-repository') document.getElementById('update-repository').value = data.repository || '';
-    if (populate && document.activeElement?.id !== 'update-branch') document.getElementById('update-branch').value = data.branch || 'master';
     if (populate && document.activeElement?.id !== 'update-tls-pin') document.getElementById('update-tls-pin').value = data.tls_key_pin || '';
-    document.getElementById('update-installed').textContent = data.installed_commit || 'unknown';
+    document.getElementById('update-installed').textContent = data.installed_version || 'unknown';
     document.getElementById('update-state').textContent = data.state || 'idle';
     document.getElementById('update-log').textContent = data.log || '';
   } catch (err) { showToast('Update Status', err.message, 'error'); }
@@ -17255,10 +17254,12 @@ async function loadUpdateStatus(populate = true) {
 async function saveUpdateConfig() {
   try {
     const repository = document.getElementById('update-repository').value.trim();
-    const branch = document.getElementById('update-branch').value.trim();
+    const branch = 'master';
     const tls_key_pin = document.getElementById('update-tls-pin').value.trim();
     const res = await fetch(`${API_BASE}/update/config`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({repository,branch,tls_key_pin})});
     if (!res.ok) throw new Error(await responseError(res));
+    document.getElementById('update-install-button').disabled = true;
+    document.getElementById('update-available').textContent = '—';
     showToast('Update Source Saved', 'Repository and branch saved', 'success');
     await loadUpdateStatus(false);
   } catch (err) { showToast('Update Source', err.message, 'error'); }
@@ -17268,12 +17269,13 @@ async function checkUpdate() {
     const res = await fetch(`${API_BASE}/update/check`, {method:'POST'});
     if (!res.ok) throw new Error(await responseError(res));
     const data = await res.json();
-    document.getElementById('update-available').textContent = data.available_commit;
-    showToast('Git Check', data.update_available ? 'An update is available' : 'Already at the selected commit', 'info');
+    document.getElementById('update-available').textContent = data.available_version;
+    document.getElementById('update-install-button').disabled = !data.update_available;
+    showToast('Git Check', data.update_available ? 'An update is available' : 'No newer version available', 'info');
   } catch (err) { showToast('Git Check Failed', err.message, 'error'); }
 }
 async function startUpdate(action) {
-  if (!confirm(action === 'rollback' ? 'Restore the previous version and restart DebTik?' : 'Install the selected Git branch and restart DebTik?')) return;
+  if (!confirm(action === 'rollback' ? 'Restore the previous version and restart DebTik?' : 'Install the newer release version and restart DebTik?')) return;
   try {
     const res = await fetch(`${API_BASE}/update/${action}`, {method:'POST'});
     if (!res.ok) throw new Error(await responseError(res));

+ 2 - 3
internal/web/static/index.html

@@ -4666,14 +4666,13 @@
         <section id="section-updates" class="hidden space-y-6">
           <div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 space-y-4 max-w-3xl">
             <h2 class="text-lg font-bold text-white">Git Updates</h2>
-            <p class="text-xs text-slate-400">Set the source repository and branch for this appliance. Installation rebuilds DebTik, restarts the service and keeps the previous binaries for rollback.</p>
+            <p class="text-xs text-slate-400">Set the source repository for this appliance. Only a higher stable release version (vMAJOR.MINOR.PATCH tag) can be installed. Installation rebuilds DebTik, restarts the service and keeps the previous binaries for rollback.</p>
             <label class="block text-xs text-slate-300">Repository URL<input id="update-repository" type="url" placeholder="https://git.example.com/team/DebTik.git" class="mt-1 w-full bg-slate-950 border border-slate-700 rounded-lg p-2 text-white font-mono"></label>
-            <label class="block text-xs text-slate-300">Branch<input id="update-branch" value="master" class="mt-1 w-full bg-slate-950 border border-slate-700 rounded-lg p-2 text-white font-mono"></label>
             <label class="block text-xs text-slate-300">TLS public key pin (optional for private Git servers)<input id="update-tls-pin" placeholder="sha256//..." class="mt-1 w-full bg-slate-950 border border-slate-700 rounded-lg p-2 text-white font-mono"></label>
             <div class="flex flex-wrap gap-2">
               <button onclick="saveUpdateConfig()" class="bg-sky-700 text-white px-3 py-2 rounded-lg text-xs">Save source</button>
               <button onclick="checkUpdate()" class="bg-slate-700 text-white px-3 py-2 rounded-lg text-xs">Check for updates</button>
-              <button onclick="startUpdate('install')" class="bg-emerald-700 text-white px-3 py-2 rounded-lg text-xs">Install update</button>
+              <button id="update-install-button" disabled onclick="startUpdate('install')" class="bg-emerald-700 text-white px-3 py-2 rounded-lg text-xs">Install update</button>
               <button onclick="startUpdate('rollback')" class="bg-amber-700 text-white px-3 py-2 rounded-lg text-xs">Restore previous version</button>
             </div>
             <div class="text-xs font-mono text-slate-300">Installed: <span id="update-installed">—</span><br>Available: <span id="update-available">—</span><br>Status: <span id="update-state">idle</span></div>

+ 13 - 9
scripts/debtik-update

@@ -35,6 +35,7 @@ restore() {
     mv -f "/usr/local/bin/$name.rollback" "/usr/local/bin/$name"
   done
   if [[ -f "$base/previous_commit" ]]; then cp -p "$base/previous_commit" "$base/installed_commit"; fi
+  if [[ -f "$base/previous_version" ]]; then cp -p "$base/previous_version" "$base/installed_version"; fi
   systemctl start proxmox-router.service
   systemctl is-active --quiet proxmox-router.service
   state rolled_back
@@ -53,17 +54,18 @@ work=$(mktemp -d "$base/work.XXXXXXXX")
 trap 'rm -rf "$work"' EXIT
 trap 'state failed' ERR
 state fetching
-GIT_TERMINAL_PROMPT=0 git -c protocol.file.allow=never "${git_opts[@]}" clone --single-branch --branch "$branch" -- "$repo" "$work/source"
+version_tool=/usr/local/libexec/debtik-release-version
+version=$(GIT_TERMINAL_PROMPT=0 git "${git_opts[@]}" ls-remote --tags "$repo" 'refs/tags/v*' | "$version_tool" latest)
+installed_version=$(cat "$base/installed_version")
+if ! "$version_tool" greater "$version" "$installed_version"; then
+  state failed
+  echo "Update refused: available version $version must be greater than installed version $installed_version"
+  exit 1
+fi
+GIT_TERMINAL_PROMPT=0 git -c protocol.file.allow=never "${git_opts[@]}" clone --single-branch --branch "v$version" -- "$repo" "$work/source"
 cd "$work/source"
 commit=$(git rev-parse HEAD)
-if [[ -f "$base/installed_commit" ]]; then
-  installed=$(cat "$base/installed_commit")
-  if [[ $installed =~ ^[a-f0-9]{40}$ ]] && ! git merge-base --is-ancestor "$installed" "$commit"; then
-    state failed
-    echo "Selected branch does not contain installed commit; refusing downgrade or unrelated history"
-    exit 1
-  fi
-fi
+[[ $(cat VERSION) == "$version" ]] || { state failed; echo 'Release tag and VERSION do not match'; exit 1; }
 state building
 go_cmd=$(command -v go || true)
 if [[ -x /opt/debtik/go/bin/go ]]; then go_cmd=/opt/debtik/go/bin/go; fi
@@ -72,6 +74,7 @@ CGO_ENABLED=0 "$go_cmd" build -trimpath -ldflags "-X proxmox-router/internal/api
 CGO_ENABLED=0 "$go_cmd" build -trimpath -o "$work/debtik-cli" ./cmd/debtik-cli
 install -d -m 0700 "$base/previous"
 for name in proxmox-router debtik-cli; do cp -p "/usr/local/bin/$name" "$base/previous/$name"; done
+cp -p "$base/installed_version" "$base/previous_version"
 if [[ -f "$base/installed_commit" ]]; then cp -p "$base/installed_commit" "$base/previous_commit"; fi
 printf '%s\n' "$commit" > "$base/candidate_commit"
 state installing
@@ -87,4 +90,5 @@ systemctl start proxmox-router.service
 sleep 3
 systemctl is-active --quiet proxmox-router.service
 printf '%s\n' "$commit" > "$base/installed_commit"
+printf '%s\n' "$version" > "$base/installed_version"
 state complete

+ 27 - 0
scripts/release-version.py

@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+import re,sys
+
+def parse(value):
+    if not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)',value):
+        raise ValueError('Expected stable MAJOR.MINOR.PATCH version')
+    numbers=tuple(map(int,value.split('.')))
+    if any(n>2**64-1 for n in numbers):raise ValueError('Version number is too large')
+    return numbers
+
+if __name__=='__main__':
+    try:
+        if sys.argv[1]=='greater':
+            sys.exit(0 if parse(sys.argv[2])>parse(sys.argv[3]) else 1)
+        if sys.argv[1]=='latest':
+            versions=[]
+            for line in sys.stdin:
+                fields=line.split()
+                if len(fields)!=2 or not fields[1].startswith('refs/tags/v'):continue
+                value=fields[1].removeprefix('refs/tags/v').removesuffix('^{}')
+                try:versions.append((parse(value),value))
+                except ValueError:pass
+            if not versions:raise ValueError('No stable release tags found')
+            print(max(versions)[1])
+        else:raise ValueError('Unknown version command')
+    except (ValueError,IndexError) as error:
+        print(str(error),file=sys.stderr);sys.exit(2)

+ 77 - 0
version.go

@@ -0,0 +1,77 @@
+package debtik
+
+import (
+	"embed"
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+//go:embed VERSION
+var files embed.FS
+var pattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
+
+func Version() string { b, _ := files.ReadFile("VERSION"); return strings.TrimSpace(string(b)) }
+func ParseVersion(s string) ([3]uint64, error) {
+	var out [3]uint64
+	if !pattern.MatchString(s) {
+		return out, fmt.Errorf("invalid release version: %s", s)
+	}
+	for i, p := range strings.Split(s, ".") {
+		n, err := strconv.ParseUint(p, 10, 64)
+		if err != nil {
+			return out, err
+		}
+		out[i] = n
+	}
+	return out, nil
+}
+func Newer(candidate, current string) (bool, error) {
+	a, err := ParseVersion(candidate)
+	if err != nil {
+		return false, err
+	}
+	b, err := ParseVersion(current)
+	if err != nil {
+		return false, err
+	}
+	for i := range a {
+		if a[i] != b[i] {
+			return a[i] > b[i], nil
+		}
+	}
+	return false, nil
+}
+func LatestTag(output string) (string, string, error) {
+	best := ""
+	commit := ""
+	for _, line := range strings.Split(output, "\n") {
+		fields := strings.Fields(line)
+		if len(fields) != 2 {
+			continue
+		}
+		tag := strings.TrimSuffix(fields[1], "^{}")
+		if !strings.HasPrefix(tag, "refs/tags/v") {
+			continue
+		}
+		version := strings.TrimPrefix(tag, "refs/tags/v")
+		if _, err := ParseVersion(version); err != nil {
+			continue
+		}
+		greater := best == ""
+		if best != "" {
+			greater, _ = Newer(version, best)
+		}
+		if greater {
+			best = version
+			commit = fields[0]
+		} else if version == best && strings.HasSuffix(fields[1], "^{}") {
+			commit = fields[0]
+		}
+	}
+	if best == "" {
+		return "", "", fmt.Errorf("repository has no stable vMAJOR.MINOR.PATCH release tags")
+	}
+	return best, commit, nil
+}

+ 33 - 0
version_test.go

@@ -0,0 +1,33 @@
+package debtik
+
+import "testing"
+
+func TestVersionOrder(t *testing.T) {
+	for _, tc := range []struct {
+		a, b string
+		want bool
+	}{{"1.10.0", "1.9.0", true}, {"2.0.0", "1.99.99", true}, {"1.1.0", "1.1.0", false}, {"1.0.9", "1.1.0", false}} {
+		got, err := Newer(tc.a, tc.b)
+		if err != nil || got != tc.want {
+			t.Fatalf("%s > %s: %v %v", tc.a, tc.b, got, err)
+		}
+	}
+	for _, v := range []string{"1.2", "01.2.3", "v1.2.3", "1.2.3-rc1", "1.2.3+build", "-1.2.3", "99999999999999999999999.0.0"} {
+		if _, err := ParseVersion(v); err == nil {
+			t.Fatalf("accepted %s", v)
+		}
+	}
+	if _, err := ParseVersion(Version()); err != nil {
+		t.Fatal(err)
+	}
+}
+func TestLatestReleaseTags(t *testing.T) {
+	input := "a refs/tags/v1.9.0\nb refs/tags/v1.10.0\nc refs/tags/v1.10.0^{}\nd refs/tags/v9.0.0-rc1\ne refs/heads/v99.0.0\n"
+	version, commit, err := LatestTag(input)
+	if err != nil || version != "1.10.0" || commit != "c" {
+		t.Fatal(version, commit, err)
+	}
+	if _, _, err := LatestTag("a refs/tags/v1.0.0-rc1"); err == nil {
+		t.Fatal("accepted prerelease only")
+	}
+}