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 }