mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
21657abe38
Build Docker images / Hub (push) Failing after 5m57s
- 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
591 lines
17 KiB
Go
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
|
|
}
|