Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3040254
fix(web-app): start folder-size scans and key copy on plain HTTP
Vonng Nov 9, 2026
3e5cda6
fix(web-app): make the share-link expiry independent of Luxon's locale
Vonng Nov 9, 2026
4e2c426
fix(web-app): retry busy bucket-usage scans instead of failing them
Vonng Nov 9, 2026
f1edc61
fix(api): reject malformed multipart uploads instead of panicking
Vonng Nov 9, 2026
ec89725
fix(usage): trust Console's CAs and endpoint policy in the usage worker
Vonng Nov 9, 2026
543d3d2
fix(usage): charge the scan budget for listing time, not queue wait
Vonng Nov 9, 2026
49ad0df
fix(usage): requeue a scan interrupted by worker shutdown
Vonng Nov 9, 2026
89ba052
fix(usage): forget a deleted bucket's totals when it is removed
Vonng Nov 9, 2026
8610671
fix(api): never authorize bucket usage from a canceled probe
Vonng Nov 9, 2026
e62cdba
fix(usage): record the exhausted scan budget on every duration exit
Vonng Nov 9, 2026
d7e587e
fix(usage): release scans interrupted during a database write
Vonng Nov 9, 2026
2ee525a
fix(usage): keep unpersisted bucket removals for the next flush
Vonng Nov 9, 2026
4a3a537
web: rebuild embedded GUI assets
Vonng Nov 9, 2026
2aae2c1
Prepare SILO Console 2.7.0 release metadata
Vonng Nov 9, 2026
082b237
fix(usage): persist pending bucket changes when the worker stops
Vonng Nov 9, 2026
bc17e12
fix(web-app): restore the task-count badge position
Vonng Nov 9, 2026
a9da731
fix(mds): keep protocol-field buttons on the text's end in RTL
Vonng Nov 9, 2026
4e91ae5
Update 2.7.0 release metadata and embedded assets
Vonng Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
31 changes: 30 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
- Include system CA roots and a writable `/tmp` in scratch container images
for HTTPS requests and large multipart uploads, while retaining the non-root
runtime and explicit private-CA configuration (PR #84).

- Upgrade the Swagger generator to v0.36.7 and runtime/server-middleware to
v0.33.4. Regenerate array context validators so zero elements do not stop
validation of later elements. Preserve the customized server and configuration
Expand All @@ -36,6 +35,36 @@
settings. Keep the maintained SILO, PostgreSQL, browser, artifact and
reproducibility checks. Exercise observer renewal with production lease
defaults and synchronization against the database deadline.
- Start folder-size calculations and decryption-key copying when Console is
opened over plain HTTP, where browsers omit `crypto.randomUUID` and the async
Clipboard API. Previously both buttons silently did nothing.
- Let `console usage-worker` trust the same roots as `console server`: it
accepts `--certs-dir`, trusts `certs/CAs` and Console's public certificate,
and applies `CONSOLE_MINIO_SERVER_TLS_SKIP_VERIFY` to the SILO endpoint only.
The worker only reads that directory; it neither creates it nor loads
`private.key`.
- Charge `CONSOLE_USAGE_SCAN_TIMEOUT` for listing time summed across scan
slices, not for time a bucket spends queued behind others. Many large buckets
no longer all end `limited` together. Buckets left limited by the earlier
accounting resume with a full budget on their next retry; stop older usage
workers before starting 2.7.0 workers so the budget is accumulated
consistently.
- Return scans interrupted by worker shutdown, including during a database
write, to the queue with their checkpoint instead of showing them as failed.
- Forget a deleted bucket's saved totals on `s3:BucketRemoved`, including after
a force delete that emits no object events, so a bucket re-created under the
same name never shows them. Deletions that cannot be persisted at once are
retried.
- Without the usage database, retry a busy scanner (HTTP 429) with backoff for
about 30 seconds before reporting a bucket as failed.
- Reject malformed or truncated multipart uploads with an error instead of a
request-handler panic.
- Persist bucket changes received by the usage worker's observer when the
worker stops, so a deletion received during reconciliation is not lost.
- Keep the reveal button and unit selectors of password, URL, e-mail and number
fields beside the text in Arabic, and restore the task-count badge position.
- Never treat a canceled bucket-usage authorization probe as authorized, and
build the share-link expiry from Luxon's locale-independent ISO output.

## Release v2.6.0

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@

## SILO Console 2.7.0

This source prepares **2.7.0**: ten UI languages and Arabic RTL, independently
cached language assets, container HTTPS and temporary-file fixes, and updated
Go dependencies and API documentation. Release artifacts are prepared as a
GitHub draft; [v2.6.0](https://github.com/pgsty/silo-console/releases/tag/v2.6.0)
remains the latest published stable release until that draft is published.
This source is **2.7.0**: ten UI languages and Arabic RTL, independently cached
language assets, container HTTPS and temporary-file fixes, usage-worker TLS and
scheduling fixes, folder sizes over plain HTTP, and updated Go dependencies and
API documentation. See [GitHub Releases](https://github.com/pgsty/silo-console/releases)
for publication status.

[English](README.md) · [简体中文](README_ZH.md)

Expand Down
6 changes: 3 additions & 3 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ SILO 对象存储的 Web 管理控制台,由 Pigsty 社区维护。前端使

## SILO Console 2.7.0

当前源码准备 **2.7.0**:十种语言与阿拉伯语 RTL、独立缓存的语言资源、容器 HTTPS 与临时文件修复,以及 Go 依赖和 API 文档更新。
制品通过标签流水线准备为 GitHub Draft Release;草稿公开发布前,最新正式版仍为
[v2.6.0](https://github.com/pgsty/silo-console/releases/tag/v2.6.0)。
本源码为 **2.7.0**:十种语言与阿拉伯语 RTL、独立缓存的语言资源、容器 HTTPS 与临时文件修复、用量后台任务的 TLS 与调度修复、
普通 HTTP 下的目录大小计算,以及 Go 依赖和 API 文档更新。
发布状态见 [GitHub Releases](https://github.com/pgsty/silo-console/releases)。

**2.7.0** 使用 **Go 1.27.3**、**silo-pkg v3.16.0** 与 **mcli 20261109 正式版**。
MC 的 `RELEASE.2026-11-09T00-00-00Z` 是日历标签,Go 用伪版本
Expand Down
4 changes: 3 additions & 1 deletion api/bucket_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ func authorizeBucketUsage(ctx context.Context, client usage.ListingClient, bucke
if open {
return first.Err
}
return nil
// minio-go closes the channel without an error when ctx is canceled;
// that must not read as an authorized, empty listing.
return ctx.Err()
}
}

Expand Down
39 changes: 39 additions & 0 deletions api/bucket_usage_authorize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Pigsty
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.

package api

import (
"context"
"testing"

"github.com/minio/minio-go/v7"
)

// closedListing models minio-go on a canceled context: the channel closes
// without an error object.
type closedListing struct{}

func (closedListing) ListObjects(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo {
objects := make(chan minio.ObjectInfo)
close(objects)
return objects
}

func TestAuthorizeBucketUsageDoesNotAuthorizeACanceledProbe(t *testing.T) {
if err := authorizeBucketUsage(context.Background(), closedListing{}, "empty", false); err != nil {
t.Fatalf("an empty, readable bucket must be authorized: %v", err)
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
// Either select branch may win; both must refuse.
for range 100 {
if err := authorizeBucketUsage(canceled, closedListing{}, "bucket", true); err == nil {
t.Fatal("a canceled probe was treated as authorized")
}
}
}
7 changes: 7 additions & 0 deletions api/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ var (
insecureMinIOTransport *http.Transport
)

// MinIOEndpointTransport exposes the SILO endpoint policy of
// PrepareSTSClientTransport, without the per-request client address, to
// processes that act on their own behalf, such as the usage worker.
func MinIOEndpointTransport() http.RoundTripper {
return minioEndpointTransport()
}

// minioEndpointTransport returns GlobalTransport unless the operator explicitly
// enabled the SILO endpoint compatibility switch for an HTTPS endpoint.
func minioEndpointTransport() http.RoundTripper {
Expand Down
6 changes: 6 additions & 0 deletions api/user_objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -1014,9 +1014,15 @@ func uploadFiles(ctx context.Context, client MinioClient, params objectApi.PostB

for {
p, err := mr.NextPart()
// Only an unwrapped io.EOF marks the closing boundary. A truncated
// body yields a wrapped EOF and malformed headers other errors; none
// of them return a part.
if err == io.EOF {
break
}
if err != nil {
return err
}

size, err := strconv.ParseInt(p.FormName(), 10, 64)
if err != nil {
Expand Down
63 changes: 63 additions & 0 deletions api/user_objects_upload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) 2026 Pigsty
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.

package api

import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

objectApi "github.com/minio/console/api/operations/object"
"github.com/minio/minio-go/v7"
)

func TestUploadFilesRejectsMalformedMultipartWithoutPanic(t *testing.T) {
var uploaded []string
minioPutObjectMock = func(_ context.Context, bucket, object string, reader io.Reader, size int64, _ minio.PutObjectOptions) (minio.UploadInfo, error) {
data, err := io.ReadAll(reader)
if err != nil {
return minio.UploadInfo{}, err
}
uploaded = append(uploaded, bucket+"/"+object+"="+string(data))
return minio.UploadInfo{Size: size}, nil
}
t.Cleanup(func() { minioPutObjectMock = nil })

params := func(body string) objectApi.PostBucketsBucketNameObjectsUploadParams {
r := httptest.NewRequest(http.MethodPost, "/api/v1/buckets/b/objects/upload", strings.NewReader(body))
r.Header.Set("Content-Type", "multipart/form-data; boundary=X")
prefix := "/dir/a.txt"
return objectApi.PostBucketsBucketNameObjectsUploadParams{HTTPRequest: r, BucketName: "b", Prefix: &prefix}
}
const part = "--X\r\nContent-Disposition: form-data; name=\"5\"; filename=\"a.txt\"\r\n\r\nhello\r\n"

if err := uploadFiles(context.Background(), minioClientMock{}, params(part+"--X--\r\n")); err != nil {
t.Fatalf("valid upload: %v", err)
}
if len(uploaded) != 1 || uploaded[0] != "b/dir/a.txt=hello" {
t.Fatalf("valid upload stored %q", uploaded)
}

for name, body := range map[string]string{
// Each of these used to return a nil part and panic.
"malformed part header": "--X\r\nnot a header line\r\n\r\nhello\r\n--X--\r\n",
"truncated after boundary": part + "--X",
"empty body": "",
"no boundary": "garbage",
} {
t.Run(name, func(t *testing.T) {
uploaded = nil
if err := uploadFiles(context.Background(), minioClientMock{}, params(body)); err == nil {
t.Fatalf("expected an error, stored %q", uploaded)
}
})
}
}
36 changes: 35 additions & 1 deletion cmd/console/usage_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,58 @@ package main

import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"

"github.com/minio/cli"
"github.com/minio/console/api"
"github.com/minio/console/pkg/certs"
"github.com/minio/console/pkg/usage"
)

var usageWorkerCmd = cli.Command{
Name: "usage-worker",
Usage: "Observe storage changes and refresh durable bucket-usage metadata",
Action: func(_ *cli.Context) error {
Flags: []cli.Flag{
cli.StringFlag{
Name: "certs-dir",
Value: certs.GlobalCertsCADir.Get(),
Usage: "path to certs directory; certificates in its CAs directory are trusted for the storage endpoint",
},
},
Action: func(cliCtx *cli.Context) error {
config, err := usage.LoadConfig(true)
if err != nil {
return err
}
if err = loadUsageWorkerTLS(cliCtx, &config); err != nil {
return err
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
return usage.RunWorker(ctx, config)
},
}

// loadUsageWorkerTLS gives the worker the server's outbound trust: system
// roots, certs/CAs and Console's public certificate, with the endpoint-scoped
// CONSOLE_MINIO_SERVER_TLS_SKIP_VERIFY policy applied to the SILO endpoint.
// The worker serves no TLS, so it neither creates the certs directory nor
// loads a private key; a read-only or key-protected certs directory works.
func loadUsageWorkerTLS(ctx *cli.Context, config *usage.Config) error {
dir, _, err := certs.ResolveConfigDirFromCtx(ctx, "certs-dir", certs.DefaultCertsDir.Get)
if err != nil {
return err
}
certs.GlobalCertsDir = dir
certs.GlobalCertsCADir = &certs.ConfigDir{Path: filepath.Join(dir.Get(), certs.CertsCADir)}
if api.GlobalRootCAs, err = certs.GetTrustedRootCAs(); err != nil {
return fmt.Errorf("unable to load trusted certificates at %s: failed with %w", dir.Get(), err)
}
api.ApplyGlobalRootCAs()
config.Transport = api.MinIOEndpointTransport()
return nil
}
111 changes: 111 additions & 0 deletions cmd/console/usage_worker_tls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 Pigsty
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.

package main

import (
"context"
"crypto/x509"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/minio/console/api"
"github.com/minio/console/pkg/certs"
"github.com/minio/console/pkg/usage"
)

func storageServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Buckets></Buckets></ListAllMyBucketsResult>`))
}))
t.Cleanup(server.Close)
return server
}

// listBuckets makes one request with the worker's S3 client, built exactly as
// RunWorker builds it from the loaded configuration.
func listBuckets(t *testing.T, endpoint string, args ...string) error {
t.Helper()
// A pooled connection keeps the trust it was verified with.
api.GlobalTransport.CloseIdleConnections()
config := usage.Config{Endpoint: endpoint, AccessKey: "worker", SecretKey: "worker-secret"}
if err := loadUsageWorkerTLS(serverCLIContext(t, args...), &config); err != nil {
t.Fatalf("loadUsageWorkerTLS: %v", err)
}
client, err := config.NewS3Client()
if err != nil {
t.Fatalf("NewS3Client: %v", err)
}
_, err = client.ListBuckets(context.Background())
return err
}

func isUnknownAuthority(err error) bool {
var unknownAuthority x509.UnknownAuthorityError
return errors.As(err, &unknownAuthority)
}

func TestUsageWorkerTrustsConsoleCAsAndEndpointPolicy(t *testing.T) {
preserveStartupTLSGlobals(t)
// Tests dial their own servers; a proxy would turn TLS into CONNECT.
api.GlobalTransport.Proxy = nil
silo, other := storageServer(t), storageServer(t)

t.Run("CAs directory", func(t *testing.T) {
certsDir := t.TempDir()
writeCertificatePEM(t, filepath.Join(certsDir, certs.CertsCADir, "silo.crt"), silo.Certificate())
if err := listBuckets(t, silo.URL, "--certs-dir", certsDir); err != nil {
t.Fatalf("worker did not trust the configured CA: %v", err)
}
})

t.Run("without the CA", func(t *testing.T) {
if err := listBuckets(t, silo.URL, "--certs-dir", t.TempDir()); !isUnknownAuthority(err) {
t.Fatalf("expected x509.UnknownAuthorityError, got %v", err)
}
})

t.Run("missing certs directory is not created", func(t *testing.T) {
certsDir := filepath.Join(t.TempDir(), "absent")
if err := listBuckets(t, other.URL, "--certs-dir", certsDir); !isUnknownAuthority(err) {
t.Fatalf("expected x509.UnknownAuthorityError, got %v", err)
}
if _, err := os.Stat(certsDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the worker created its certs directory: %v", err)
}
})

t.Run("an unreadable server key does not block the worker", func(t *testing.T) {
certsDir := t.TempDir()
writeCertificatePEM(t, filepath.Join(certsDir, certs.CertsCADir, "silo.crt"), silo.Certificate())
writeCertificatePEM(t, filepath.Join(certsDir, certs.PublicCertFile), other.Certificate())
key := filepath.Join(certsDir, certs.PrivateKeyFile)
if err := os.WriteFile(key, []byte("not readable"), 0o000); err != nil {
t.Fatal(err)
}
if err := listBuckets(t, silo.URL, "--certs-dir", certsDir); err != nil {
t.Fatalf("worker failed with a key it does not need: %v", err)
}
})

t.Run("skip-verify exempts only the configured endpoint", func(t *testing.T) {
t.Setenv(api.ConsoleMinIOServer, silo.URL)
t.Setenv(api.ConsoleMinIOServerTLSSkipVerify, "on")
if err := listBuckets(t, silo.URL, "--certs-dir", t.TempDir()); err != nil {
t.Fatalf("skip-verify was not applied to the SILO endpoint: %v", err)
}
if err := listBuckets(t, other.URL, "--certs-dir", t.TempDir()); !isUnknownAuthority(err) {
t.Fatalf("skip-verify leaked to another origin: %v", err)
}
})
}
Loading
Loading