feat: add production hardening — Ed25519 signing, Portainer Edge, SSH extension (Phase 4)
Image signing: - Ed25519 sign/verify package (pure Go stdlib, zero deps) - genkey and sign CLI subcommands for build system - Optional --pubkey flag for verifying updates on apply - Signature URLs in update metadata (latest.json) Portainer Edge Agent: - cloud-init portainer.go module writes K8s manifest - Auto-deploys Edge Agent when portainer.edge-agent.enabled - Full RBAC (ServiceAccount, ClusterRoleBinding, Deployment) - 5 Portainer tests in portainer_test.go Production tooling: - SSH debug extension builder (hack/build-ssh-extension.sh) - Boot performance benchmark (test/benchmark/bench-boot.sh) - Resource usage benchmark (test/benchmark/bench-resources.sh) - Deployment guide (docs/deployment-guide.md) Test results: 50 update agent tests + 22 cloud-init tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,17 +19,22 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/kubesolo-os/update/pkg/signing"
|
||||
)
|
||||
|
||||
// UpdateMetadata describes an available update from the update server.
|
||||
type UpdateMetadata struct {
|
||||
Version string `json:"version"`
|
||||
VmlinuzURL string `json:"vmlinuz_url"`
|
||||
VmlinuzSHA256 string `json:"vmlinuz_sha256"`
|
||||
InitramfsURL string `json:"initramfs_url"`
|
||||
Version string `json:"version"`
|
||||
VmlinuzURL string `json:"vmlinuz_url"`
|
||||
VmlinuzSHA256 string `json:"vmlinuz_sha256"`
|
||||
VmlinuzSigURL string `json:"vmlinuz_sig_url,omitempty"`
|
||||
InitramfsURL string `json:"initramfs_url"`
|
||||
InitramfsSHA256 string `json:"initramfs_sha256"`
|
||||
ReleaseNotes string `json:"release_notes,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
InitramfsSigURL string `json:"initramfs_sig_url,omitempty"`
|
||||
MetadataSigURL string `json:"metadata_sig_url,omitempty"`
|
||||
ReleaseNotes string `json:"release_notes,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
}
|
||||
|
||||
// StagedImage represents downloaded and verified update files.
|
||||
@@ -41,9 +46,10 @@ type StagedImage struct {
|
||||
|
||||
// Client handles communication with the update server.
|
||||
type Client struct {
|
||||
serverURL string
|
||||
httpClient *http.Client
|
||||
stageDir string
|
||||
serverURL string
|
||||
httpClient *http.Client
|
||||
stageDir string
|
||||
pubKeyPath string // path to Ed25519 public key for signature verification
|
||||
}
|
||||
|
||||
// NewClient creates a new update image client.
|
||||
@@ -57,6 +63,13 @@ func NewClient(serverURL, stageDir string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// SetPublicKeyPath sets the path to the Ed25519 public key used
|
||||
// for verifying update signatures. If set, downloaded images will
|
||||
// be verified against their .sig files from the update server.
|
||||
func (c *Client) SetPublicKeyPath(path string) {
|
||||
c.pubKeyPath = path
|
||||
}
|
||||
|
||||
// CheckForUpdate fetches the latest update metadata from the server.
|
||||
func (c *Client) CheckForUpdate() (*UpdateMetadata, error) {
|
||||
url := c.serverURL + "/latest.json"
|
||||
@@ -103,6 +116,15 @@ func (c *Client) Download(meta *UpdateMetadata) (*StagedImage, error) {
|
||||
return nil, fmt.Errorf("downloading initramfs: %w", err)
|
||||
}
|
||||
|
||||
// Verify signatures if public key is configured
|
||||
if c.pubKeyPath != "" {
|
||||
if err := c.verifySignatures(meta, vmlinuzPath, initramfsPath); err != nil {
|
||||
os.Remove(vmlinuzPath)
|
||||
os.Remove(initramfsPath)
|
||||
return nil, fmt.Errorf("signature verification: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &StagedImage{
|
||||
VmlinuzPath: vmlinuzPath,
|
||||
InitramfsPath: initramfsPath,
|
||||
@@ -159,6 +181,60 @@ func (c *Client) downloadAndVerify(url, dest, expectedSHA256 string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifySignatures downloads .sig files and verifies them against the staged images.
|
||||
func (c *Client) verifySignatures(meta *UpdateMetadata, vmlinuzPath, initramfsPath string) error {
|
||||
verifier, err := signing.NewVerifierFromFile(c.pubKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading public key: %w", err)
|
||||
}
|
||||
|
||||
// Verify vmlinuz signature
|
||||
if meta.VmlinuzSigURL != "" {
|
||||
sigPath := vmlinuzPath + ".sig"
|
||||
if err := c.downloadToFile(meta.VmlinuzSigURL, sigPath); err != nil {
|
||||
return fmt.Errorf("downloading vmlinuz signature: %w", err)
|
||||
}
|
||||
if err := verifier.VerifyFile(vmlinuzPath, sigPath); err != nil {
|
||||
return fmt.Errorf("vmlinuz: %w", err)
|
||||
}
|
||||
slog.Info("vmlinuz signature verified")
|
||||
}
|
||||
|
||||
// Verify initramfs signature
|
||||
if meta.InitramfsSigURL != "" {
|
||||
sigPath := initramfsPath + ".sig"
|
||||
if err := c.downloadToFile(meta.InitramfsSigURL, sigPath); err != nil {
|
||||
return fmt.Errorf("downloading initramfs signature: %w", err)
|
||||
}
|
||||
if err := verifier.VerifyFile(initramfsPath, sigPath); err != nil {
|
||||
return fmt.Errorf("initramfs: %w", err)
|
||||
}
|
||||
slog.Info("initramfs signature verified")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadToFile downloads a URL to a local file (used for signature files).
|
||||
func (c *Client) downloadToFile(url, dest string) error {
|
||||
resp, err := c.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("server returned %d for %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(dest, data, 0o644)
|
||||
}
|
||||
|
||||
// VerifyFile checks the SHA256 checksum of an existing file.
|
||||
func VerifyFile(path, expectedSHA256 string) error {
|
||||
f, err := os.Open(path)
|
||||
|
||||
Reference in New Issue
Block a user