Files
Beszel/internal/hub/app_update.go
T
Tomas Dvorak 21657abe38
Build Docker images / Hub (push) Failing after 5m57s
feat(site): enhance monitoring, domain, and system tracking
- Improve domain lookup by adding CNAME and SRV record support
- Enhance domain status logic to include expiry and DNS resolution verification
- Update monitoring API to perform synchronous initial checks for immediate status updates
- Refactor site UI:
    - Add tag filtering to domains and monitors tables
    - Improve calendar view with better visual indicators for today and events
    - Update monitor detail view with improved status badges and pending states
    - Simplify home page layout by removing redundant card wrappers
- Update localization files for numerous languages to support new UI elements
- Add `cleanEndpointsConfig` to hub to safely reuse Docker network settings during container updates
2026-05-02 15:38:41 +02:00

591 lines
17 KiB
Go

package hub
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/henrygd/beszel"
"github.com/pocketbase/pocketbase/core"
"github.com/spf13/cobra"
)
const (
appUpdateImage = "ghcr.io/dvorinka/beszel:latest"
appUpdateRepository = "dvorinka/beszel"
dockerSocketPath = "/var/run/docker.sock"
)
var updateContainerIDPattern = regexp.MustCompile(`[0-9a-f]{64}`)
var appUpdateHTTPClient = &http.Client{Timeout: 20 * time.Second}
type updateCacheState struct {
mu sync.Mutex
checked time.Time
info UpdateInfo
}
var appUpdateCache updateCacheState
type updateApplyState struct {
mu sync.Mutex
running bool
}
var appUpdateApply updateApplyState
// UpdateInfo holds information about the latest GHCR image check.
type UpdateInfo struct {
Version string `json:"v,omitempty"`
Url string `json:"url,omitempty"`
CurrentVersion string `json:"currentVersion"`
Image string `json:"image"`
CurrentImageID string `json:"currentImageId,omitempty"`
CurrentDigest string `json:"currentDigest,omitempty"`
LatestDigest string `json:"latestDigest,omitempty"`
UpdateAvailable bool `json:"updateAvailable"`
CanApply bool `json:"canApply"`
Status string `json:"status"`
Message string `json:"message"`
LastCheck string `json:"lastCheck"`
}
type applyUpdateResponse struct {
Started bool `json:"started"`
Message string `json:"message"`
}
type dockerAPI struct {
client *http.Client
}
type dockerContainerInspect struct {
ID string `json:"Id"`
Name string `json:"Name"`
Image string `json:"Image"`
Config map[string]any `json:"Config"`
HostConfig map[string]any `json:"HostConfig"`
NetworkSettings dockerNetworkSettings `json:"NetworkSettings"`
}
type dockerNetworkSettings struct {
Networks map[string]map[string]any `json:"Networks"`
}
type dockerImageInspect struct {
ID string `json:"Id"`
RepoDigests []string `json:"RepoDigests"`
}
type dockerCreateResponse struct {
ID string `json:"Id"`
Warnings []string `json:"Warnings"`
}
func (h *Hub) getUpdate(e *core.RequestEvent) error {
info := getCachedUpdateInfo(false)
return e.JSON(http.StatusOK, info)
}
func (h *Hub) applyUpdate(e *core.RequestEvent) error {
if !beginAppUpdate() {
return e.BadRequestError("An app update is already running.", nil)
}
helperStarted := false
defer func() {
if !helperStarted {
finishAppUpdate()
}
}()
info := getCachedUpdateInfo(true)
if !info.CanApply {
return e.BadRequestError(info.Message, nil)
}
if !info.UpdateAvailable {
return e.BadRequestError("Beszel is already using the latest image digest.", nil)
}
docker, err := newDockerAPI()
if err != nil {
return e.BadRequestError(err.Error(), nil)
}
container, err := docker.inspectContainer(currentContainerID(docker))
if err != nil {
return e.BadRequestError("Current Beszel container was not found through Docker.", err)
}
if err := docker.pullImage(appUpdateImage); err != nil {
return e.InternalServerError("Failed to pull latest Beszel image.", err)
}
if err := docker.startUpdateHelper(container.ID, appUpdateImage); err != nil {
return e.InternalServerError("Failed to start update helper.", err)
}
helperStarted = true
time.AfterFunc(5*time.Minute, finishAppUpdate)
appUpdateCache.mu.Lock()
appUpdateCache.info.Version = "latest"
appUpdateCache.info.Status = "updating"
appUpdateCache.info.Message = "Update helper started. Beszel will restart after the new container is ready."
appUpdateCache.mu.Unlock()
return e.JSON(http.StatusOK, applyUpdateResponse{
Started: true,
Message: "Update helper started. Beszel will restart after the new container is ready.",
})
}
func getCachedUpdateInfo(force bool) UpdateInfo {
appUpdateCache.mu.Lock()
if !force && time.Since(appUpdateCache.checked) < 30*time.Minute && appUpdateCache.info.CurrentVersion != "" {
info := appUpdateCache.info
appUpdateCache.mu.Unlock()
return info
}
appUpdateCache.mu.Unlock()
info := checkUpdateInfo()
appUpdateCache.mu.Lock()
appUpdateCache.checked = time.Now()
appUpdateCache.info = info
appUpdateCache.mu.Unlock()
return info
}
func checkUpdateInfo() UpdateInfo {
now := time.Now().UTC().Format(time.RFC3339)
info := UpdateInfo{
Url: "https://github.com/dvorinka/Beszel/pkgs/container/beszel",
CurrentVersion: beszel.Version,
Image: appUpdateImage,
Status: "checking",
LastCheck: now,
}
latestDigest, err := fetchGHCRDigest(context.Background(), appUpdateHTTPClient, appUpdateRepository, "latest")
if err != nil {
info.Status = "check-failed"
info.Message = "Could not read latest image digest from GHCR: " + err.Error()
return info
}
info.LatestDigest = latestDigest
docker, err := newDockerAPI()
if err != nil {
info.Status = "docker-unavailable"
info.Message = "Automatic updates need the Docker socket mounted at /var/run/docker.sock."
return info
}
containerID := currentContainerID(docker)
container, err := docker.inspectContainer(containerID)
if err != nil {
info.Status = "container-unavailable"
info.Message = "Docker is available, but the running Beszel container could not be inspected."
return info
}
info.CurrentImageID = container.Image
info.CanApply = true
image, err := docker.inspectImage(container.Image)
if err == nil {
info.CurrentDigest = findRepoDigest(image.RepoDigests, appUpdateRepository)
}
switch {
case info.CurrentDigest == "":
info.Version = "latest"
info.Status = "unknown"
info.UpdateAvailable = true
info.Message = "Running image digest is unknown. Update can pull and recreate from latest."
case digestValue(info.CurrentDigest) != digestValue(info.LatestDigest):
info.Version = "latest"
info.Status = "update-available"
info.UpdateAvailable = true
info.Message = "New Beszel image is available."
default:
info.Status = "up-to-date"
info.Message = "Beszel is already using the latest image digest."
}
return info
}
func beginAppUpdate() bool {
appUpdateApply.mu.Lock()
defer appUpdateApply.mu.Unlock()
if appUpdateApply.running {
return false
}
appUpdateApply.running = true
return true
}
func finishAppUpdate() {
appUpdateApply.mu.Lock()
appUpdateApply.running = false
appUpdateApply.mu.Unlock()
}
func fetchGHCRDigest(ctx context.Context, client *http.Client, repository, tag string) (string, error) {
manifestURL := fmt.Sprintf("https://ghcr.io/v2/%s/manifests/%s", repository, tag)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", strings.Join([]string{
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
}, ", "))
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
token, err := fetchRegistryToken(ctx, client, resp.Header.Get("WWW-Authenticate"))
if err != nil {
return "", err
}
req, err = http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
}
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return "", fmt.Errorf("registry returned %s: %s", resp.Status, strings.TrimSpace(string(raw)))
}
digest := resp.Header.Get("Docker-Content-Digest")
if digest == "" {
return "", errors.New("registry response did not include Docker-Content-Digest")
}
return digest, nil
}
func fetchRegistryToken(ctx context.Context, client *http.Client, challenge string) (string, error) {
params := parseBearerChallenge(challenge)
realm := params["realm"]
if realm == "" {
return "", errors.New("registry auth challenge missing realm")
}
tokenURL, err := url.Parse(realm)
if err != nil {
return "", err
}
query := tokenURL.Query()
for _, key := range []string{"service", "scope"} {
if params[key] != "" {
query.Set(key, params[key])
}
}
tokenURL.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL.String(), nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("token service returned %s: %s", resp.Status, strings.TrimSpace(string(raw)))
}
var data struct {
Token string `json:"token"`
}
if err := json.Unmarshal(raw, &data); err != nil {
return "", err
}
if data.Token == "" {
return "", errors.New("token service returned empty token")
}
return data.Token, nil
}
func parseBearerChallenge(challenge string) map[string]string {
out := make(map[string]string)
challenge = strings.TrimSpace(strings.TrimPrefix(challenge, "Bearer"))
for _, part := range strings.Split(challenge, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
out[key] = strings.Trim(value, `"`)
}
return out
}
func newDockerAPI() (*dockerAPI, error) {
if _, err := os.Stat(dockerSocketPath); err != nil {
return nil, err
}
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", dockerSocketPath)
},
}
return &dockerAPI{
client: &http.Client{
Timeout: 10 * time.Minute,
Transport: transport,
},
}, nil
}
func (d *dockerAPI) do(method, path string, body any, out any) error {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, "http://docker"+path, reader)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := d.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("Docker API %s %s returned %s: %s", method, path, resp.Status, strings.TrimSpace(string(raw)))
}
if out != nil && len(raw) > 0 {
return json.Unmarshal(raw, out)
}
return nil
}
func (d *dockerAPI) inspectContainer(id string) (*dockerContainerInspect, error) {
if strings.TrimSpace(id) == "" {
return nil, errors.New("empty container id")
}
var inspect dockerContainerInspect
err := d.do(http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, &inspect)
return &inspect, err
}
func (d *dockerAPI) inspectImage(id string) (*dockerImageInspect, error) {
var inspect dockerImageInspect
err := d.do(http.MethodGet, "/images/"+url.PathEscape(id)+"/json", nil, &inspect)
return &inspect, err
}
func (d *dockerAPI) pullImage(image string) error {
name, tag, _ := strings.Cut(image, ":")
if tag == "" {
tag = "latest"
}
path := "/images/create?fromImage=" + url.QueryEscape(name) + "&tag=" + url.QueryEscape(tag)
return d.do(http.MethodPost, path, nil, nil)
}
func (d *dockerAPI) startUpdateHelper(targetID, image string) error {
name := "beszel-update-" + shortID(targetID) + "-" + fmt.Sprint(time.Now().Unix())
createBody := map[string]any{
"Image": image,
"Cmd": []string{
"container-update-helper",
"--target", targetID,
"--image", image,
},
"HostConfig": map[string]any{
"AutoRemove": true,
"Binds": []string{dockerSocketPath + ":" + dockerSocketPath},
},
}
var created dockerCreateResponse
if err := d.do(http.MethodPost, "/containers/create?name="+url.QueryEscape(name), createBody, &created); err != nil {
return err
}
return d.do(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, nil)
}
func currentContainerID(d *dockerAPI) string {
if hostname, err := os.Hostname(); err == nil && hostname != "" {
if container, err := d.inspectContainer(hostname); err == nil {
return container.ID
}
}
raw, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return ""
}
return updateContainerIDPattern.FindString(string(raw))
}
func findRepoDigest(repoDigests []string, repository string) string {
for _, digest := range repoDigests {
if strings.Contains(digest, repository+"@sha256:") {
return digest
}
}
return ""
}
func digestValue(digest string) string {
if _, value, ok := strings.Cut(digest, "@"); ok {
return value
}
return digest
}
func shortID(id string) string {
if len(id) > 12 {
return id[:12]
}
return id
}
// NewContainerUpdateHelperCmd creates a helper command that runs outside the current container.
func NewContainerUpdateHelperCmd() *cobra.Command {
var targetID string
var image string
cmd := &cobra.Command{
Use: "container-update-helper",
Short: "Replace the running Beszel container with a newer image",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
if targetID == "" || image == "" {
return errors.New("target and image are required")
}
docker, err := newDockerAPI()
if err != nil {
return err
}
return docker.replaceContainer(targetID, image)
},
}
cmd.Flags().StringVar(&targetID, "target", "", "target container id")
cmd.Flags().StringVar(&image, "image", appUpdateImage, "replacement image")
return cmd
}
func (d *dockerAPI) replaceContainer(targetID, image string) error {
current, err := d.inspectContainer(targetID)
if err != nil {
return err
}
originalName := strings.TrimPrefix(current.Name, "/")
if originalName == "" {
originalName = "beszel"
}
stamp := fmt.Sprint(time.Now().Unix())
oldName := originalName + "-old-" + stamp
newName := originalName + "-new-" + stamp
config := cloneMap(current.Config)
hostConfig := cloneMap(current.HostConfig)
config["Image"] = image
delete(hostConfig, "AutoRemove")
createBody := cloneMap(config)
createBody["HostConfig"] = hostConfig
createBody["NetworkingConfig"] = map[string]any{"EndpointsConfig": cleanEndpointsConfig(current.NetworkSettings.Networks)}
var created dockerCreateResponse
if err := d.do(http.MethodPost, "/containers/create?name="+url.QueryEscape(newName), createBody, &created); err != nil {
return err
}
cleanupNew := true
defer func() {
if cleanupNew {
_ = d.do(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=true", nil, nil)
}
}()
if err := d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/stop?t=10", nil, nil); err != nil {
return err
}
if err := d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/rename?name="+url.QueryEscape(oldName), nil, nil); err != nil {
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/start", nil, nil)
return err
}
if err := d.do(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/rename?name="+url.QueryEscape(originalName), nil, nil); err != nil {
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/rename?name="+url.QueryEscape(originalName), nil, nil)
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/start", nil, nil)
return err
}
if err := d.do(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, nil); err != nil {
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/rename?name="+url.QueryEscape(newName), nil, nil)
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/rename?name="+url.QueryEscape(originalName), nil, nil)
_ = d.do(http.MethodPost, "/containers/"+url.PathEscape(current.ID)+"/start", nil, nil)
return err
}
cleanupNew = false
_ = d.do(http.MethodDelete, "/containers/"+url.PathEscape(current.ID)+"?force=true", nil, nil)
return nil
}
func cloneMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
// cleanEndpointsConfig strips runtime-populated fields from Docker network settings
// so they can be safely reused in a container create request.
func cleanEndpointsConfig(networks map[string]map[string]any) map[string]any {
if networks == nil {
return nil
}
out := make(map[string]any, len(networks))
for netName, cfg := range networks {
cleaned := make(map[string]any, len(cfg))
for k, v := range cfg {
switch k {
case "NetworkID", "EndpointID", "Gateway", "IPAddress", "IPPrefixLen",
"IPv6Gateway", "GlobalIPv6Address", "GlobalIPv6PrefixLen", "MacAddress":
continue
default:
cleaned[k] = v
}
}
out[netName] = cleaned
}
return out
}