Changelog¶
All notable changes to MaxIOFS will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
Added¶
- Documentation site at https://docs.maxiofs.com/: installation, deployment, every console page with screenshots, S3 tools, the
maxiofsCLI, integrations and operations.
Changed¶
- Disabling your own 2FA and regenerating your backup codes ask for your password. A global administrator still disables another user's 2FA without it.
GET /configof the console gives the data directory, storage root and backend, ports, log level and metrics settings only to a global administrator.- Changing a password ends the console sessions of its account, the one that changed it included: the user signs in again with the new password. Access keys are not affected.
- The parts of multipart uploads in progress count against the bucket and tenant quotas: a part is refused with
QuotaExceededonce what is stored and what is in progress reach the quota. Before, only the completion was checked. - The permission picker offers Tenant administrator only for a user of a tenant, and Super administrator only for a user of no tenant.
Fixed¶
- A user of no tenant with the tenant administrator role or permission was taken for an administrator, and could set a global administrator's password. It administers nothing.
- A presigned GET whose query the router and the handler read differently was allowed by its signature alone, without its signer's permissions: any access key could read any object.
- A presigned URL dated ahead of the clock outlasted the 7 days
X-Amz-Expiresallows: one dated more than 15 minutes ahead is refused. - A POST form upload was not held to all of its policy:
eqconditions and conditions on fields other than the bucket, key and content type were ignored, and fields the policy does not name were accepted. They are enforced as in S3, and a field outside the policy is refused. - An identity provider set to Inactive still signed users in: by password for LDAP, by its sign-in URL for OAuth, and for STS federation. It signs nobody in.
- STS federation (
AssumeRoleWithWebIdentity,AssumeRoleWithLDAPIdentityand their JSON endpoints) did not apply the provider's groups to a user that follows them: one in no mapped group still got temporary credentials, with its old role. It is held to them as at a console sign-in. AssumeRoleWithWebIdentitytook a token to the provider named inProviderIdwhatever its status: a provider that is not Active is refused, as on the JSON endpoint.- The last global administrator that can sign in could be deleted or deactivated when an inactive one existed, and deleted through the IAM API in any case, leaving the deployment with nobody to administer it. It is kept.
- A cluster request with a wrong signature wrote the signature expected to the log.
- Webhooks, the HTTP log target and replication reached addresses in the shared address space (
100.64.0.0/10), where some clouds serve their metadata: they are refused as the private ranges are. - A public WRITE grant let an anonymous request remove a version of an object: it deletes the object only, adding a delete marker in a versioned bucket.
- Enabling 2FA on an account that already has it replaced its secret and backup codes: it answers
409, and 2FA is disabled first. - A mistyped code when enabling 2FA answered
500: it answers400. - The console dashboard named buckets the user cannot list.
- Any console user could end the share of an object of a bucket they cannot read.
- A stop waited for the integrity scan and the lifecycle pass in progress to end: both stop with the server.
[1.10.0] - 2026-11-11¶
Added¶
- Auto Sync reads Google Workspace (with a service account key) and Microsoft tenants (named by the Issuer URL) every hour, as LDAP directories and Keycloak realms.
Changed¶
- Integrity scans cover every version of a bucket and objects written in parts, whose composite ETag is recomputed.
ListObjectVersionsmarkers name the last entry returned, andkey-markeralone starts after that key, as in S3.- Writes stop at 99.5% of the disk of the objects or of the metadata, and a full disk answers
507 InsufficientStorage, not500: Pebble and SQLite keep room, deletes still work, and SOSAPI reports only what can be written. maxiofs recoversays every 30 seconds how far it is: objects done of the total, percentage, time left and when it should end. It waits for the disk once per thousand files, not on each object.- A bucket's marker keeps its settings as JSON (owner, versioning, Object Lock and its default retention, policy, lifecycle, CORS, encryption, tags, quota…) and
maxiofs recovergives them back. A bucket of an earlier release gets them at the first statistics pass. - An upload is encrypted as it is received: its temporary file never holds it in clear, and a PUT of a large object is about 25% faster.
/metricsanswers only with the token of Settings → Metrics (metrics.token) as a bearer token, and401while none is set: it named every bucket of every tenant, with its size, to anyone. Prometheus sends it withauthorizationin its scrape config.trust_private_networksisfalseby default. Behind a proxy or WAF, list intrusted_proxiesthe address it reaches MaxIOFS from, or logs, rate limits andaws:SourceIpsee the proxy.
Fixed¶
- Google and Microsoft sign-ins had no groups, so a group mapping refused them; with the directory set they read them from it.
ListObjectVersionsread every version of the bucket for each page: a page now costs what it returns.- Under load nearly every request opened a new database connection, which parses the whole schema: S3 writes are about 5 times faster.
- A byte changed on disk, or a lost sidecar or key, was reported as an error nobody was told of: integrity scans now report it as corruption, with their audit event and alert, and the alert says why.
DeleteObjectsansweredInternalErrorfor what Object Lock protects: it answersAccessDenied, and takesx-amz-bypass-governance-retention.- A delete marker could be given a retention, which hid its object until the date: it answers
405, and one locked before is removed. PutObjectRetentionandPutObjectLegalHoldwere taken on a bucket without Object Lock.- Deletes of versions no longer hold the bucket through their fsync.
SlowDowncounted as a server error: it is the server asking to send less, and is counted with the refusals.- A start removes the temporary files of uploads a crash cut short.
- A stop during the reconciliation after a crash marked the shutdown clean, and it was never finished: the next start runs it again.
- A start decoded every object of every bucket before opening its ports: with millions of objects it took a minute, and now seconds.
- A stop waited for the bucket statistics pass to finish, minutes with millions of objects.
- Behind a trusted proxy, the S3 request log, bucket access logs and the audit of setting and bucket changes named the proxy, not the client; anonymous S3 requests were limited per connection, not per client.
- The request and latency charts of the Performance tab were always at zero, and those of the API tab showed the average since the start: both show what happens at each moment.
- A refused S3 signature was logged whole; the log keeps its access key only.
- Setting the SMTP password logged it.
- With
storage.rooton another disk thandata_dir, the console, the cluster, disk alerts and SOSAPI showed the space of thedata_dirdisk, not that of the objects. maxiofs recoverread the objects only from<data-dir>/objects: it takes--storage-root, asmaxiofs reconciledoes.- A node joining a cluster empties everything the cluster replicates and takes it from the cluster: its own policies, administrator and settings, newer than the cluster's, replaced them on every node.
- Settings and logging targets are the same on every node of a cluster: each node kept its own.
- An IAM policy received from another node is stored with its versions or not at all, and one without its default version is not taken.
- A node that stops while sending its changes no longer makes the other nodes drop them and log
context canceled. - In Docker, stopping the container killed MaxIOFS 2 seconds after the stop signal, before it finished shutting down, and the next start reported an unclean shutdown.
- A part whose body was cut short was stored with what had arrived, without an error.
maxiofs recoverleft every bucket without its Object Lock setting, so no retention could be set or extended on it: a bucket with a locked version gets it back.- A forced bucket delete checked COMPLIANCE retention and legal holds only on a bucket with the Object Lock setting: it checks the objects always.
- The background encryption pass took the bytes of an object whose sidecar was lost for plaintext, encrypted them again and made up a sidecar for them: bytes it cannot identify are left to the integrity scan.
- A plaintext object without its sidecar that the encryption pass encrypted could not be read after.
- A bucket a global administrator created naming a user as its owner gave that user no access to it.
Security¶
- In the console, a bucket owner had more permissions than its own bucket. Its policy grants its own bucket only; existing owner policies are rewritten at start.
- In the console, reading a bucket's settings needs its permission on the bucket, as changing them does.
[1.9.0] - 2026-11-05¶
Added¶
- Parity: Reed-Solomon erasure coding as an alternative to the replication factor, from 3 nodes (HA page,
PUT /api/v1/cluster/ha). - Storage classes: groups of nodes with their own factor or parity (Cluster → Storage classes); lifecycle transitions move objects to them.
- Lifecycle filters by tags and size, and
NewerNoncurrentVersions. - The copy repair spreads copies and shards by the free room of each node (
ha.rebalance_enabled). - The Nodes page shows the coordinator.
- Hadoop and Spark through S3A, verified.
maxiofs recoverresumes an interrupted run.- Sign-out ends the console session on every node.
- SSO: a session follows its provider; a user deleted or disabled there is signed out. OpenID Connect back-channel logout.
- SSO: users made by a group mapping follow the provider's groups.
- Auto Sync reads LDAP directories and Keycloak realms every hour: it creates, deactivates and reactivates users.
- A node joins a cluster with a one-time join code opened on it (Cluster → Join code).
- SSE-C: keys sent by the client, over HTTPS; blocked per bucket by default, as in AWS (
PutBucketEncryptionBlockedEncryptionTypes, Bucket → Security). trust_private_networks(defaulttrue):falsetakesX-Forwarded-ForandX-Forwarded-Protofrom the proxies oftrusted_proxiesonly.
Changed¶
- Test Connection checks each step against the provider.
- ETags are sent between double quotes.
- 4xx answers are counted apart from errors (
maxiofs_s3_refused_total). - Keys and bucket names follow S3's rules.
- The console's object search is
GET /api/v1/buckets/{bucket}/search. - New writes and repair copies go to the least full nodes.
- The
S3 requestlog line gives received bytes, version, ETag and the reason of a 403. POST /api/v1/cluster/nodestakesjoin_codeinstead ofusernameandpassword.
Security¶
- Add Node sent the cluster's keys (CA key, KEK, JWT secret) over unverified TLS; they are now sealed with the join code.
- OAuth signed in users whose email the provider had not verified.
- An OAuth provider could sign in users of another provider or tenant; an email now belongs to one user.
- A tenant administrator could use another tenant's identity providers.
- Making or changing an identity provider answered its secrets in clear.
- Signed chunks and the digests of a write (
Content-MD5, checksums,x-amz-content-sha256) were not checked. - A COMPLIANCE retention could be turned into GOVERNANCE and bypassed.
- A folder downloaded as a ZIP could hold entries outside the folder.
- A write with SSE-C headers was stored with the server's key and read without the client's.
- The parts of a multipart upload in progress were stored unencrypted.
- The debug log of request headers and the signature log gave the STS session token.
- Behind a trusted proxy a client could choose its address: the leftmost
X-Forwarded-Forentry was taken (limits per address,aws:SourceIp, audit). - Built with Go 1.27.3: eleven standard-library advisories of Go 1.26.6 closed (HTTP/2,
Rangeheaders, MIME headers, TLS, x509). - The console's Content-Security-Policy runs no inline script but its own.
- New data, metadata and metrics directories are created
0750, not0755. - The 404 page of a bucket website ran script put in the link's path.
Fixed¶
- Multipart:
x-amz-version-idand errors ofCompleteMultipartUpload, retried completions and aborts,If-Match/If-None-Match, headers and tags kept,EntityTooSmall. - Reads:
partNumber,response-*overrides, ranges no longer sent the object's checksum,x-amz-tagging-count,Expires. - S3 error codes as AWS answers them (
NoSuchBucket,InvalidBucketName,InvalidTag,KeyTooLongError,MetadataTooLarge,InvalidStorageClass,SignatureDoesNotMatch). - SigV4 with a header sent twice or runs of spaces;
aws-chunkedwith anotherContent-Encoding; ACLxsi:type;PutObjectLockConfigurationwithoutxmlns. - Versioning: objects written before it was enabled, or while suspended, are kept as version
null. - Lifecycle rules that deleted or expired the wrong objects; the console no longer replaces rules set through S3.
- Inventory reports gave
NONEas the encryption status of every object. - Cluster: a request forwarded to another node lost its client's address and
aws:SecureTransport. PutObjectwithIf-MatchorIf-None-Matchunder concurrent writes.- A PUT no longer holds its key's lock while receiving data; signed requests are about 10 times faster.
- Websites: the root index, and an S3 host under the website's host.
- Console: keys ending like a route (
x/tags), presigned URLs of keys with special characters, Sync All results. - A delete of
xno longer removes the folder markerx/. - LDAP: imported or synced users could not sign in; groups named by
memberoruniqueMember; Test Connection with many users. - LDAP and OAuth: a provider that does not answer no longer hangs a sign-in or locks accounts (
503after 10–15 s). - The login limit per IP counts failed sign-ins only, and the security settings apply to sign-in.
- Settings were not read from their
MAXIOFS_environment variables (cluster_listen,auth.jwt_secret, …). - Cluster: user changes, two-factor and the recovery bundle state reach every node at once.
- Cluster: reads with nodes down, joins, changes within one second, KEK rotation and the degraded banner.
- Cluster: the copy repair could move too many copies to a node right after another node's moves.
- A stop with the console open no longer hangs.
maxiofs recoverobject counts in versioned buckets.
[1.8.0] - 2026-10-13¶
Changed¶
- Draining a node moves its objects to the other nodes before it leaves service, with any replication factor. It takes no new data, a write it takes is passed to the node with the most free space, and it serves reads while its objects move. Once it holds none it answers no S3 request (
503,/healthincluded) and every node marks it dead; it can then be removed. A drain marked the node dead at once: with a factor of 1 its objects were unreadable. The drained node itself can now be drained from its own console. - A replication factor of 1 keeps one copy of each object: on the node that takes the write, or on the node with the most free space when that node is under storage pressure. Every node holds every bucket and every object's entry, and lists and reads every object. Each bucket was on one node, which took all its writes: a bucket could not hold more than that node's disk. As with RAID 0, the objects of a node that is down are unreadable until it is back; a factor of 2 or 3 keeps copies on other nodes.
- After the update, each node of a cluster with a factor of 1 gives the objects it holds itself as their location, once, at start, then sends the other nodes its buckets and their entries, without their data.
- The replication factor goes back to 1 with several nodes: the copies it does not need are removed.
- Console: the replication factor descriptions say how many copies of each object are kept and where.
- Multipart completion streams parts directly into one encrypted storage write.
- Removed two full-object reads and two full-object writes from multipart completion.
- Multipart completion keeps only one part reader open at a time.
- Bucket and tenant quotas reserve room for each write in flight: concurrent PUTs and multipart completions on a node cannot pass against the same free space.
- Invalid Object Lock headers on PUT are rejected with
InvalidRequest; the object is not stored. - DELETE and batch delete on a missing bucket return 404 (
NoSuchBucketin the S3 API). - A node that comes back after missing writes is caught up at once: the node that accepted them compares the objects modified since the first missed write and sends the deletes the peer missed.
- In a cluster, the coordinator expires objects by lifecycle rules and its deletes reach the other nodes; every node aborts the incomplete multipart uploads started on it. Expiration ran on the node the rule was set on, and its deletes did not reach the others.
- A bucket's
UpdatedAtdates its configuration: object writes and statistics recounts no longer change it. - The secret stored credentials are encrypted with (identity provider secrets, replication destination keys, share link keys) lives in the database, like the KEK. The first start stores
auth.encryption_secret, or else an explicitauth.jwt_secret, or else a generated one; the configuration is not read again. The JWT secret no longer encrypts anything. Migration 21. - In a cluster every node holds the cluster's encryption secret: a node that joins takes it; the coordinator gives it to a node that holds another, which re-encrypts what it stores. The Nodes page marks a node that holds another secret.
- At startup the log names the stored credentials the encryption secret does not decrypt.
- In a cluster, only the coordinator writes inventory reports and runs scheduled replication rules.
- Only a node without data joins a cluster. A node holding a bucket, a tenant, a user besides its first administrator, an access key, a group, an identity provider, an IAM policy or role created on it, a share or a replication rule is refused (
409, naming what it holds) and stays as it was. Its data was mixed into the cluster's, and its global users were deleted. - A copy of a write without a version made before the current one does not replace it on the node that receives it, whichever order the copies arrive in.
- A node that deleted a key does not store a copy of a write made before the delete. After a partition, the catch-up of the node that missed the delete could send the object back, and it stayed on both nodes.
- Add Node no longer fails with
Failed to determine local cluster addresson a node with no route to 8.8.8.8 and nocluster_advertise_address. The address it worked out for itself was sent to the new node and never read. - A node of a cluster listens on the default ports: S3 API 8080, console 8081, cluster 8082. Add Node registers the new node at those; the documentation allowed another cluster port on every node.
- Debian and RPM upgrades no longer change the owner of every file under /var/lib/maxiofs and /var/log/maxiofs, which took long with many objects. A new installation gives the two directories to the maxiofs user; the service writes as that user.
Fixed¶
- Prometheus gets every metric MaxIOFS declares. S3 operations and errors by S3 error code, storage backend operations, object operations and sizes, sign-ins (S3 signatures, presigned URLs, form uploads, console password, 2FA and OAuth), bucket and Object Lock changes, objects under retention, each bucket's usage, events sent to the console, background task runs and the metadata cache were declared and never fed.
- Prometheus: S3 request metrics are labelled by S3 operation instead of path. Each object key was a new series, and Prometheus memory grew with the number of objects. A refused request does not name its bucket.
- Prometheus: refused S3 requests are counted. The metrics middleware ran after authentication.
- Grafana: the dashboard shows every exported metric, by node and bucket. Its Buckets, Objects and Storage panels read metrics that did not exist.
- Prometheus alert SuspiciousHighOperationRate applied
rate()to a gauge; it reads the request counter. - In a cluster, the code of an OAuth sign-in is exchanged on any node: it names the node the provider's redirect reached, which the other nodes ask for it. Behind a load balancer the exchange could reach another node and fail with "invalid or expired code".
- In a cluster, a change to an identity provider takes effect on every node at the next sign-in: each node rebuilds the provider when its stored row changed. A node kept the provider it first loaded until restart, so a new client secret or LDAP server, or a disabled or deleted provider, took effect only on the node where the change was made.
- OAuth sign-in of a user with two-factor authentication asks for the code: the console exchanges the single-use code for the second step's token. The browser was sent to the login page with the user ID, where nothing asked for the code, and the user could not sign in.
- Docs:
docker/README.mdlisted dashboards and alert rules that do not exist. - Docs: CLUSTER.md's firewall example opened ports 8080 and 8081 only to the node subnet and left out 8082, which every node needs from the others; its unavailable-node checks tested port 8080 where health checks use 8082. Removed the cluster replication API, rules, queue and Prometheus metrics it described, which no longer exist. Prometheus metrics are on the S3 port only, not on both. The console health check is
/api/v1/health;/healthon the console port answers the console page. - A tenant's bandwidth cap paces browser form uploads (S3 POST) as it paces a PUT. They were read at full speed.
- Console: the Security page says what is in place. It said the nodes use mutual TLS (each request is signed with HMAC-SHA256 over TLS), that cluster copies are encrypted again per destination (an object encrypted with the cluster key is copied as stored), that idle sessions end after the session timeout (the console signs out after 15 minutes idle; the timeout is the refresh-token lifetime), and listed a self-replication check that no longer exists. Durations show in the console's language.
- S3: PutBucketReplication answers that replication rules are set in the console or with
/api/v1/buckets/{bucket}/replication/rules. It named a cluster replication API that no longer exists. - Console and packages: the Security page, the About page and the Debian package description no longer claim GDPR, SOC 2, HIPAA, ISO 27001 or PCI DSS support. Nothing in MaxIOFS backs those claims.
- Console: the tenant bandwidth hint says what the cap covers: S3 uploads and downloads to the tenant's buckets, on each node; console transfers are not capped. It said every upload and download of the tenant.
- A tenant's storage goes down when an object is overwritten by a smaller one, on the node that writes it and on the nodes that receive the copy. It went down only at the next statistics pass, up to 15 minutes later; until then the tenant was charged the old size and writes that fit were refused.
- With versioning suspended, a write without a version ID over a version kept from before is charged its whole size against the bucket and tenant quotas, as usage counts it. It was charged the difference and admitted past the limit.
- In a cluster any node serves a sign-in: login, refresh, logout, two-factor verification and the start of an OAuth sign-in. They went to the coordinator: no one signed in during an election or on a node cut off from it. Each node counts the failed sign-ins it serves and locks the account on that node; an administrator's unlock reaches every node.
- A change forwarded to the coordinator keeps the user's address and whether they connected over TLS. The coordinator saw every forwarded change come from
192.0.2.1: the audit log recorded that address, and the sign-in limit per address applied to all STS federation sign-ins through the console together. - A sign-in with two-factor authentication is recorded in the audit log.
- The initial sync to a node asks which writes it holds and sends it only the entry of those it has. A sync running while the copies of a raised factor were made sent each copy back to the node it came from.
- A node is marked dead while another component writes to the database: the change waits for it. It failed with
SQLITE_BUSYand the node stayed unavailable until the next check. - A repair of the copies asked for while one runs runs once that one ends. A factor changed during a repair waited for the next anti-entropy cycle, hours later.
- An anonymous GET of an object in a bucket whose ACL grants public read returns the object without a share, unless Block Public Access ignores public ACLs or restricts public buckets. It answered
AccessDenied(Object is not shared) when the object had no share link (#9). - Bucket policies decide again, with the caller's IAM policies as in AWS: a
Denyin either refuses, anAllowin either grants a user of the bucket's tenant. A statement naming"*"grants or denies requests without credentials, over the bucket ACL. The console decides the same way. Since 1.6.0 they were stored and never evaluated. - Console: editing an existing bucket policy loaded the API response envelope instead of the policy; saving it again failed.
- A bucket policy cannot deny its own management:
s3:GetBucketPolicy,s3:PutBucketPolicyands3:DeleteBucketPolicyare decided by the caller's permissions. - PutBucketPolicy (S3 and console) refuses with
MalformedPolicywhat the evaluator would not enforce as written:NotPrincipal,NotAction,NotResource, other principal types and ARNs, actions outside S3, resources outside the bucket, condition keys other thanaws:SourceIpandaws:SecureTransport. Such a policy was stored and read differently from what it said. BlockPublicPolicyrefuses a public bucket policy (403 AccessDenied) andRestrictPublicBucketsvoids what a public policy grants to requests without credentials, as in AWS.RestrictPublicBucketsno longer voids public ACLs;IgnorePublicAclsdoes.BlockPublicAclsrefuses a request carrying an ACL that grants anything to everyone or to every authenticated user, with403 AccessDenied, before storing anything: PutBucketAcl, PutObjectAcl,x-amz-aclon PutObject, CopyObject and CreateMultipartUpload, and the console ACL endpoints. It was stored and not applied. ACLs already set are kept.maxiofs recoverandmaxiofs reconcilerestore each version's Object Lock retention and legal hold. The object's sidecar keeps them, and a metadata update cannot change them there; a version stored earlier gets them at the encryption worker's first pass. They came back unlocked.- A node installed after a cluster setting changed takes the change, and the defaults it sends replace no setting of the other nodes. A default no one changed was dated by the start of the node that stored it: a node that joined later kept its replication factor of 1, sent it to the other nodes, and they took it and removed copies.
- The Docker image keeps its data in the directory
MAXIOFS_DATA_DIRnames, where it is mounted. It always used/data, so data written with the directory mounted elsewhere was lost when the container was recreated. - A success or error dialog of the console given markup shows it laid out, sanitized. The temporary credentials just issued, the policy of a temporary session and other dialogs showed their HTML as text.
- Failed raw replica overwrites restore the previous data and sidecar, including existing versions.
- Raw replica rollback retains recoverable backups when restoration fails.
- Multipart completion waits for encryption to stop on failure or cancellation.
- Destination metadata lookup errors abort multipart completion before overwriting data.
- PUT stores the retention and legal hold from Object Lock headers, or the bucket default retention, in the same metadata write as the object. A failure after the write no longer leaves the object unprotected.
- Bucket metadata read errors fail PUT, DELETE and quota checks instead of treating the bucket as unversioned or without quota.
- PUT and raw replica writes fail when the existing object's metadata cannot be read, instead of overwriting it without a backup.
- Every copy between nodes carries retention and legal hold with the object: replication (raw or not), the initial sync of a new replica, the anti-entropy push and pull, and the stale-node catch-up.
- Metadata-only changes (tags, ACLs, retention, legal hold, user metadata, restore status) reach the other nodes before the request returns, in order. A node that is down or fails gets them queued and delivered in order when it is back; they were sent in the background to healthy nodes only and lost after three attempts.
- The initial sync of a new replica copies every version of every key with its version ID, including delete markers and keys they hide; it copied only the latest version of visible keys, under new version IDs.
- A version copied from another node never replaces a newer latest version, and one already present is not counted twice in bucket and tenant usage.
- Two nodes with a replication factor of 2 keep accepting writes while one of them is down, as a mirror. Writes answered
503until the peer returned. - A tenant quota refusal inside a write answers
403 QuotaExceededinstead of500(S3 PUT, CopyObject, POST upload, console upload, rename and version restore). CopyObject and POST uploads also answer bucket quota and Object Lock errors with their S3 codes. - A multipart upload keeps user metadata apart from its storage fields and state:
x-amz-meta-content-typeno longer replaces the object's Content-Type,x-amz-meta-x-amz-aclno longer applies a canned ACL, and user metadata no longer enters the object's sidecar. An upload created before this release completes with its user metadata; a canned ACL it recorded is not applied, and the object is private. - Object Lock headers on a write need the permission to set them, as in AWS S3:
s3:PutObjectRetentionfor a mode or retain-until date,s3:PutObjectLegalHoldfor a legal hold (S3 PUT, CreateMultipartUpload, CopyObject and console upload).s3:PutObjectalone was enough. The bucket default retention needs neither. - CopyObject applies the Object Lock headers of the request to the copy; they were ignored.
- A console upload to a tenant without a storage limit (
MaxStorageBytes0) is no longer refused as over quota. CreateMultipartUploadappliesx-amz-object-lock-mode,x-amz-object-lock-retain-until-dateandx-amz-object-lock-legal-hold: validated when the upload is created, applied when it completes. They were ignored.- A write that misses HA quorum removes its local version even under the retention or legal hold it set; the client was told the write failed.
- Encryption migration keeps a crash-recoverable backup of each object and restores it when the rewrite or its verification fails.
docs/API.mdlisted console routes that do not exist and missed many that do; its route tables now match the router.- Copies between nodes that re-encrypt the data (objects under a node's own key) carry tags, ACL, restore state and the multipart ETag; they were lost. Bytes that do not match the version's ETag are refused.
- A copy of a version into a bucket whose versioning was suspended since is stored as that version; it replaced the current object. A copied delete marker stays a delete marker; it deleted the current object.
- The initial sync of a new replica copies the current object without a version ID of a suspended bucket whose key also has older versions; it was skipped.
- Changing the tags, ACL, retention or legal hold of a restored object keeps its restore status; it was erased.
- The performance collector is created once per process. Each server construction replaced it while its throughput ticker was reading it, and left another ticker running.
- In a bucket whose versioning is suspended, a write without a version ID over a version kept from before adds its size to bucket and tenant usage; it subtracted the kept version's size. Over a delete marker it counts the key again. The bucket statistics recount includes such an object.
- In a bucket whose versioning is suspended, DELETE without a version ID adds a delete marker and keeps the versions from before, as in AWS S3; only a current object without a version ID is removed. It deleted the current version permanently, a version from before included, and added no marker.
- Creating a bucket whose name has a removal still pending finishes that removal first. The startup sweep deleted the new bucket's directory; it now keeps the directory of a bucket that exists.
- An object ACL belongs to the version it was set on. ACLs were also kept per key, so the next object at the key, by overwrite, new version or after a delete, inherited the previous object's ACL. ACLs kept per key by earlier releases are moved into the object at startup.
- Tenant synchronisation read
created_atandupdated_at, stored as unix seconds, as timestamps and failed for every tenant; tenants did not synchronise between nodes. - Tenant synchronisation no longer copies storage usage and bucket count: each node counts what it stores. A synchronised tenant took the sending node's counters.
- Storage usage changes no longer update the tenant's
updated_at, which orders configuration between nodes: a write on one node made that node's older configuration win. - A tenant's bucket limit counts the tenant's buckets on every node, each once; the stored counter was per node and drifted. In the console a limit of 0 is no limit; it refused every bucket.
- The console shows each tenant's bucket count and storage for the whole cluster. The tenant list computed them from the first 10,000 objects of each bucket on the local node.
- The bucket statistics pass sets each tenant's storage on the node to what its buckets hold.
- In a cluster, buckets reach every node. Creating a bucket, changing its configuration or ACL, and deleting it are sent to the other nodes before the request returns; a node that missed them is sent every bucket before the objects when it is caught up, when it becomes a replica, at every anti-entropy cycle and when a node starts. A bucket existed only on the node that created it: the other nodes refused its objects, so each write was kept on one node, and its configuration applied on one node. A cluster in that state converges after the upgrade.
- A tenant deletion received from another node no longer removes a tenant changed here after the deletion, with its users and access keys. The tenants table keeps change times as unix seconds and they were read as timestamps, so the deletion always won.
- The reconciliation of a node back from a partition read the tenants' change times the same way and failed whenever a tenant existed.
- Stored credentials are decrypted after a restart when neither
auth.jwt_secretnorauth.encryption_secretis configured. They were encrypted with a JWT secret generated anew each start, so a restart made share links, replication destination keys and identity provider secrets unreadable. Those stored before this release have to be entered again; the log names them. - The settings API no longer returns the JWT signing secret; the console showed it under Security.
- In a cluster, shares, replication rules, inventory configurations and inventory reports reach every node. A change is sent to the other nodes before the request returns; a node that missed it is sent every row after the buckets. They existed only on the node where they were created, and worked only through it. Rows created before the upgrade reach every node at the next synchronization.
- The sync of a replication rule's existing objects, and each scheduled run, queues every object of the bucket; it queued the first 10,000.
- Replication of a tenant's bucket reads and lists its objects under the tenant. It looked for a bucket of that name without tenant.
- An inventory report lists every object of the bucket; it listed the first 10,000.
- An inventory report is stored as a client write is: encrypted, counted in bucket and tenant usage and quotas, and sent to the other nodes in a cluster. It was written directly to storage and metadata.
- A completed inventory report keeps the path of its file; the path was not stored.
- A share created for an object whose share expired but was not yet removed takes the new share's ID. The row kept the expired share's ID, so the ID returned did not exist.
- A deletion sent to another node keeps the time it was made. Every node dated the deletions it received with the time of reception, again every 30 seconds, so deletions never expired and each looked newer than any later change.
- An IAM policy, role, attachment or inline policy deleted and created again under the same name is no longer deleted on every node. A bucket created again lost its owner's policy; a bucket permission granted again to the same user was lost.
- An object deleted and written again is no longer removed when a node that missed the new write is caught up; it is sent to that node. A copy written in the second of a deletion is kept.
- Deletions and changes are ordered by time on every node: a copy changed after a deletion is taken, one changed before it is refused. A deletion is dated after the last change of what it removes.
- Deleting a user, a tenant or a group records the deletion of the IAM policies they held; the other nodes sent them back.
- Each node sends another the deletions it has not taken yet, in order; it sent its whole deletion log every 30 seconds.
- Deleting a bucket deletes its shares, replication rules, inventory configurations and permissions, and the policies naming it also when the delete is forced. A share link of the deleted bucket served the object of the same key in a new bucket of the name without authentication, and replication rules sent the new bucket's objects to the former destination.
- A node drops the notification configuration and integrity scans of a bucket it no longer holds. A new bucket of the name sent its events to the former webhooks. Those earlier releases left are dropped at start; the rows they left are dropped when a bucket of the name is created.
- The deletion log of an earlier release holds no deletion times: at the first start its object deletions, and its deletions of IAM entities the node holds again, are dropped.
- The systemd unit of the Debian and RPM packages no longer limits writes to /var/lib/maxiofs and /var/log/maxiofs: a data directory set elsewhere in config.yaml works without editing the unit, which every upgrade replaced. /usr, /boot, /efi and /etc stay read-only.
- A fresh Debian install sets
data_dirto /var/lib/maxiofs, as the RPM does. It kept./data, under /opt/maxiofs, which the service could not write. - A node removed from the cluster is removed on every node. It was removed only on the node where it was removed, and the next node list from any other node added it back. The removal is recorded like a deletion and is final, as a node joins under a new ID every time; the removed node is answered
410 Goneand leaves the cluster. - A node that leaves the cluster is removed from the others. They kept it and kept sending it changes.
- A change to a node (name, region, priority) is no longer reverted by the next node list of another node: the newest copy wins.
- A drain reaches every node; it was taken only on the node where it was made, and the others kept writing to the drained node. A drain the other nodes cannot hold the copies of is refused (
409); it answered success and did nothing. - A node marked dead after being unreachable is back in service when it answers again, and is caught up from its first missed write. It stayed dead for good. A drained node stays dead.
- A cluster created or joined while the server runs replicates object writes and deletes. They were replicated only after a restart.
- A node that boots with the cluster active encrypts its existing plaintext objects; the encryption worker did not start on it.
- A node that joins a cluster drops the nodes of a cluster it was in before, its own former entry included.
- A node away from the cluster for longer than 7 days, offline or cut off and serving clients, no longer brings back what the other nodes deleted meanwhile, and gets those deletions when it is back. A deletion is kept until every member of the cluster has been sent it and no catch-up that replays it is pending; deletions of buckets and of their shares, replication rules and inventory configurations too. A node removed from the cluster no longer holds them back.
- A deletion that arrives by the deletion log removes the node's copy of the entity. It was only recorded; the copy was removed by the entity's own synchronization, which can be turned off.
- A bucket deleted on one side of a partition and written to on the other is kept on every node with the objects written after its deletion. The node that wrote kept it with everything it held, and the other node never took it back.
- Deletions are numbered in a sequence that never goes back. A number freed when the newest deletions were forgotten was given again, and a node already sent up to it skipped the deletion; a deletion received older than 7 days freed its number within the hour. After the upgrade every node is sent the whole log once.
- Two writes of an object made in the same second on different nodes, or a rewrite while a node was away, are ordered by the time each was written. The anti-entropy took either by ETag order and could replace the last write with the one before it on every node. Each copy carries the time its write was made to the nanosecond; S3
LastModifiedis unchanged. Objects written before the upgrade are ordered by second. - A bucket deleted in the second of its last write is deleted on the other nodes; they kept it.
- A delete marker reaches the other nodes with its time. Each node gave it the time it received it.
- A group deletion received from another node keeps the time it was made; a revoked STS session too. Each node dated the revocations it received with the time of reception, again on every cycle, so they were never forgotten.
- In a cluster, every node holds the entry of every object and the factor's nodes hold its data: the node that takes the write and the healthy nodes with the most free space. A node that does not hold the data reads it from one that does, a range alone; HEAD, listings, versions, tags and ACLs are answered from the entry. A write's data went to the first nodes by priority, and a node without it answered
404, listed only what it held, and kept what another node deleted or overwrote. - In a cluster, a delete reaches every node.
- A read whose data no node holding it serves is answered
503withRetry-After(S3ServiceUnavailable), in the console too. - HEAD,
GetObjectAttributesandRestoreObjectwithversionId, the download token and the presigned URL check the object's entry and do not open its data. - In a cluster, the parts, completion and abort of a multipart upload are made on the node the upload was started on, whichever node they reach, and listing a bucket's uploads covers every node. A load balancer that spread them answered
NoSuchUpload. The upload ID names its node; an upload started before the upgrade is made where its requests arrive, as before. - In a cluster, the anti-entropy, the catch-up of a node that was away and the first copy to a new node send each node the entries it lacks and the data of the writes it holds, and compare every pair of healthy nodes. They compared a node with the first nodes by priority only and sent them every object's data. A node that holds a write and lost its data is sent it again.
- In a cluster, the copies of a write follow the factor. A write with fewer copies than the factor, after a node holding one is removed or dead or the factor is raised, is copied to the healthy nodes with the most free space; one with more copies keeps the copy of the node that repairs it and those of the nodes with the most free space, and the other nodes remove theirs. This runs when a node is removed or marked dead, when the factor changes and after every full anti-entropy cycle. Each change of where a write's copies are is numbered, and every node keeps the newest, whichever order the changes, entries and copies arrive in.
- A node under storage pressure that takes a write passes its copy to the node with the most free space among those holding the entry only, before the request returns. It keeps the copy when no node takes it.
- Every node acts on a change of the replication factor, made on it or received from another node; the other nodes are told at once. They took the change within a minute and did nothing: only the node where it was made sent its buckets and repaired its copies.
- A replication factor raised from 1 makes the copies the factor needs, one per object. Every object was copied to every node.
- Objects written before the cluster kept where their data is are given, by the copy repair, the nodes that hold them: the first of those nodes keeps the factor's copies, the others remove theirs, and missing copies are made. They were copied to every node.
- The copy repair started by a change of membership or factor runs once the first copies to new nodes end.
- A slow node, one whose health check takes over a second, is in service and takes data like a healthy one. It was treated as down. A node whose disk is past the threshold is under storage pressure even when slow.
- A node under storage pressure stays in service and only takes no new copy of data. It was treated as down: with a factor of 1 the other nodes answered that its buckets did not exist and left them out of their lists, and with a factor above 1 it missed every entry, delete and change until the pressure ended.
- A node that was dead and answers again is compared on every write, and the anti-entropy sends a node with older locations of a write the newer entry. A node back from the dead kept the locations it had and the copies made again elsewhere.
- A node that leaves the cluster, or is removed, drops the entries of the objects the other nodes hold and keeps those it holds. It kept every entry and listed objects it could not read.
- In a cluster, the capacity Veeam reads (SOSAPI
capacity.xml) for a bucket without a quota of its own, whose tenant has one, subtracts what the tenant uses on every node. It subtracted what the tenant used on the node that answered. The tenant is the bucket's: a user without a tenant was given the disk. - In a cluster, the capacity Veeam reads for a bucket without a quota is the room of the cluster, each byte on factor different nodes; dead nodes do not count. It was the disk of the node that answered.
- A change of the replication factor in the console starts the sync jobs after the request is answered. The jobs were cancelled with the request.
- In a cluster, a bucket is listed once in the console and in S3
ListBuckets. It was listed once per node holding it. - The degraded-cluster message is each node's own and says whether writes are kept on fewer nodes or refused. A node showed the message of the last node that sent it its settings.
- A node that fails to apply a delete from another node answers so, and is caught up from it when it is back; it answered success. A node that keeps the object under a retention or a legal hold answers that it refused the delete, and is not marked unavailable for it.
- A node that fails a replicated write is unavailable from that failure on: the dead-node threshold counts from it. The failure also turned a dead node back to unavailable.
- An initial copy to a new replica that fails to send some objects says how many in its job, on the HA page too, and the replica is caught up from the oldest of them. They were missing on the replica until the daily anti-entropy.
- A node that joins a cluster is sent the buckets and objects at once. It got them from the anti-entropy, 5 to 60 minutes after start-up.
- The catch-up of a node that missed writes, and the anti-entropy, compare every version and delete marker of a key and send those the other node lacks. Only the current version was compared and sent: a node away while a key was written more than once never got the versions before the last.
- A version or delete marker a node deleted is not stored again when a node that missed the delete sends it, or gives it, a copy.
- A copy the anti-entropy sends keeps the object's tags, ACL and ETag, and is sent encrypted when the other node can decrypt it. It lost them.
- Console: the language menu shows its flags on Windows. They were emoji, which Windows renders as two letters in Edge and Chrome. The language list in user preferences shows the language name only.
- Console: the search icon of the bucket, user, access key, tenant, group and audit log lists is shown; the input covered it.
- Console: the login page animation costs a quarter of the CPU without a GPU (remote sessions), from 2.7 cores to 0.6, and the page keeps 60 frames per second. The waves moved by repainting three full-screen gradients each frame and the card and icons blurred the moving background; the waves now move by transform, 20 times a second, along the same path, and nothing blurs it.
- Console, dark theme: the light-blue metric icons (Total Objects, Admin Users) and the drop zone of the bucket page no longer show a light background; the dark shades they used were not defined. Checkboxes, date pickers, select lists and autofilled fields follow the theme.
Removed¶
internal/metricscollector: nothing used it, and it reported invented events. The gaugesmaxiofs_storage_bytes_totalandmaxiofs_storage_objects_total, never fed:maxiofs_bucket_bytes_totalandmaxiofs_bucket_objects_totalcarry each bucket's usage.- The forwarding of a request about a bucket to the node that held it, with its bucket location cache,
GET /api/v1/cluster/cache/statsandPOST /api/v1/cluster/cache/invalidate; the bucket and tenant-usage aggregation across nodes; the internal endpoints/bucket-exists/{name},/bucketsand/tenant/{id}/storage. Every node holds every bucket and every object's entry. - Console: the Replicated Buckets and Local Buckets cards of the cluster overview, and the Buckets column of the nodes page.
- Bucket migration between nodes: the console Migrations page,
POST /api/v1/cluster/buckets/{bucket}/migrate,GET /api/v1/cluster/migrations,GET /api/v1/cluster/migrations/{id},GET /api/v1/cluster/bucketsand the internal/migration/*endpoints. Every node holds every bucket, and the copies of each object go to the nodes with the most free space. - Console: the Node column of the bucket list and the node choice when creating a bucket.
- Console: the Join Existing Cluster form of a standalone node. It sent the cluster token and an address to the endpoint that takes the join package of an existing node, and always failed. A node joins from the console of a node of the cluster: Manage Nodes, Add Node.
- The stale-node reconciler and its endpoints
/api/internal/cluster/state-snapshotand/api/internal/cluster/ha/objects/changed-since. It never ran: no node ever marked itself stale. A node away for long is sent what it missed by keeping deletions until every node has them. - The replica-first S3 read: each read was first sent to another node's cluster port, which serves no S3 request and answered 404, then served by the node that received it.
- Internal endpoints used only by the previous bucket migration:
/api/internal/cluster/objects/{tenant}/{bucket}/{key}(PUT, DELETE, HEAD),/bucket-permissions,/bucket-acl,/bucket-configand/bucket-inventory. - Console: the Make bucket public option when creating a bucket, and the bucket's
is_publicfield. It was stored and never read; a bucket is made public with its ACL. - Console: the Authenticated Read option of the bucket ACL dialog. It granted nothing: requests with credentials are decided by permissions and the bucket policy. A bucket that has it through the S3 API still shows it.
Added¶
- Prometheus alerts: MaxIOFSDown, S3ServerErrors, StorageFailures, DiskAlmostFull, SignInFailureBurst, BackgroundTaskFailing.
- Prometheus tests: every metric family is exported once the server does what it counts; no label carries an object key; a refused request names no bucket; every background task is observed; every S3 route has an operation name. OAuth sign-in tests with and without two-factor authentication, server and console page; a code exchanged on another node; an identity provider changed through the database is rebuilt.
- Console: a bucket policy is edited as rules instead of JSON: allow or deny; everyone or users of the bucket's tenant; actions from the permission catalogue; the whole bucket or objects under a prefix; source addresses and TLS. Common rules are added in one click. A policy set through the S3 API that rules cannot show is shown read-only and can be replaced.
- Console: each card of a bucket's Security & Access tab says what it decides: Block Public Access what it voids, the bucket policy whom it grants or denies, the ACL that it applies only to requests without credentials. The policy dialog says when public policies are blocked.
- Console: the Security & Access tab of a bucket edits its Block Public Access, with the same fields as bucket creation. The ACL card says when Block Public Access ignores the bucket's public ACL; the ACL dialog offers no public ACL while public ACLs are blocked. It could be set only when creating the bucket.
- Tests for anonymous reads of a public-read bucket, with and without a share link and Block Public Access, and for
BlockPublicAclson every request that sets an ACL. - Bucket policy tests: the decision with IAM policies, the tenant boundary, requests without credentials, its own management,
RestrictPublicBuckets,BlockPublicPolicy, conditions on source address and TLS, and what is refused when saved, through S3 and the console. - Object Lock recovery tests: a rebuilt store gets back the retention a write set, an extended retention, a legal hold on and one turned off; the sidecar keeps the state through multipart completion, a metadata update, a raw copy between nodes and the encryption worker's pass.
- Console: the HA page shows the progress of a drain: the objects and bytes moved, and the expected end at the pace so far.
- Drain tests between three complete nodes, factors 1 and 2: every object moved and readable, the drained node out of service for S3 and on every node, later writes on the others; a node being drained takes no copy and passes on the writes it takes.
- Factor-1 tests between three complete nodes: one copy where the write is made, read and deleted from every node; a write moved off a node under storage pressure; an update from buckets kept on one node; the factor lowered to 1.
- Cluster setting tests: a node installed after a change takes it; its defaults replace no change.
- Write-path I/O accounting tests for PUT, overwrites, versioning and multipart uploads.
- Multipart streaming regression tests for cancellation, read failures, rollback and retry.
- Quota tests for concurrent writers, slow tenant checks, writes finishing or refused during a check, and deletes over the limit.
- Object Lock tests for PUT headers, HA replica transfers and quorum rollback of protected versions.
- Rollback tests for raw replica overwrites and failed encryption migration restores.
- HA tests for mirror writes with a node down, the catch-up of a returning node (window, deletes, retries) and quota error codes.
- Tests for suspended DELETE, bucket creation over a pending removal, object ACL ownership and migration, and tenant usage and synchronisation between nodes.
- HA bucket tests between two complete nodes: every bucket change reaches the other node, in both directions; a node that was down is sent its buckets, then their objects; a new replica is sent the buckets first; deletions keep newer data; refusals. Lifecycle expiration on the coordinator only.
- Encryption secret tests: stored once and kept across restarts; adoption re-encrypts every stored credential in one transaction; each component's credentials re-encrypted; a real join between two nodes; the coordinator gives its secret and no other node can; the settings never show the JWT secret.
- Test for PutBucketReplication and GetBucketReplication through the S3 API.
- Bandwidth tests through the S3 API: a tenant's cap paces a PUT, a multipart part, a GET, a range GET and a form upload.
- Quota tests through the S3 API: a bucket or tenant over its quota refuses writes that grow usage and allows deletes, batch deletes and overwrites that shrink an object, then admits a write that fits once usage is under the limit. Tests for the tenant's storage after overwrites and for a suspended write over a kept version.
- Two-factor sign-in tests: a code alone, the token of the first step used as a session, a session token used for the first step, a user disabled between the steps, lockout after the configured number of wrong codes and the limit per address. An unlock reaches the node where the account locked. Tests for the sign-in paths any node serves and for the user's address and TLS on a forwarded change.
Security¶
- A two-factor sign-in needs the password step: login returns a
two_factor_tokenvalid for five minutes, and/api/v1/auth/2fa/verifytakes it with the code. It took a user ID and a code: an account with two-factor authentication was reached without its password, with unlimited attempts, locked or not. A wrong code counts as a failed sign-in, the limit per address applies, and a locked account is refused. The login response no longer carriesuser_id. - Only an access token is a console credential; a token of any other type is refused. The check refused the refresh and download types only.
[1.7.0] - 2026-09-18¶
Added¶
MAXIOFS_BOOTSTRAP_ACCESS_KEYandMAXIOFS_BOOTSTRAP_SECRET_KEYgive the administrator that key pair on a deployment that has none, so a compose stack comes up with credentials it already knows instead of having to call the API for them. Applied once: a deployment that already has a key ignores them, and changing them later does nothing. A pair nothing could sign with — too short, or carrying a slash or a space — stops the server rather than being ignored. Environment only: a credential does not belong in a file that gets committed. (#8,internal/auth/bootstrap_key.go)maxiofs reconcilerestores the metadata entry of any object that is on disk but missing from the index, reading its identity from the sidecar. The server already did this after an unclean shutdown; there was no way to ask for it. (cmd/maxiofs/reconcile.go)maxiofs repair-pointers --drop-orphansremoves version entries that name no bucket. They were reported as failures, which made a successful repair exit non-zero; nothing can reach them and no pointer can be rebuilt from them. (internal/recovery/repair_pointers.go)scripts/s3-battery.sh— an S3 acceptance battery over a running server: keys that collide on a filesystem, versioning under shared prefixes, multipart integrity and metadata round-trips. It creates its own bucket and access key and removes both afterwards.
Changed¶
- Object and version metadata writes release the bucket mutation lock before waiting for WAL persistence, allowing concurrent writes to share Pebble's group commit without weakening acknowledgements. Startup reconciliation uses the configured storage root; offline reconciliation accepts
--storage-root. - Storage layout v2. Objects are stored one file per object under
<bucket>/<aa>/<bb>/<sha256 of the key>, with no directories built from key components and no folder markers on disk. Buckets sit at the storage root; the tenant-qualified path is recorded in.maxiofs-bucketand in every sidecar. The root carries a.maxiofs-layoutmarker and the server refuses to start on a layout it does not read. (internal/storage/filesystem_layout.go) - The listing filters that hid those invented folders are gone with them, along with the bucket-deletion rule that treated a bucket holding only invented folders as empty. A folder-marker key is now an object like any other. (
internal/object/manager.go,internal/metadata/) - The migration removes the folder objects earlier releases invented for parent prefixes, instead of giving each one a file of its own. It runs on an already-migrated installation too, so those entries do not outlive the release that stopped writing them. (
internal/layout/migrate.go) - Replicated writes no longer create an object for the parent prefixes of the key either. The direct write path stopped doing it; the replication path still did, so a replica grew folders the primary did not have. (
internal/object/raw_replication.go) - No object is created for the parent prefixes of an uploaded key. Every upload wrote a folder object per level into the index, which
ListObjectsV2hid butListObjectVersionsreturned, so clients saw folders they never created. Entries from earlier releases are kept out of both listings. (internal/object/manager.go,internal/metadata/pebble_objects.go) - User metadata keys are returned as stored, in lower case.
Header.Setcanonicalised them, sox-amz-meta-proyectocame back asProyecto. (pkg/s3compat/handler.go) - Object keys ending in
.metadataor.metadata-stagingare accepted. They were refused because the key was the file name and would have collided with a sidecar; the file name is a digest now. (internal/object/manager.go) - New setting
storage.abort_incomplete_multipart_days: aborts multipart uploads left incomplete for that long in buckets with noAbortIncompleteMultipartUploadrule of their own. Default 0, which keeps the AWS behaviour of never aborting on its own. (internal/lifecycle/worker.go) RemoveDirectoryretries like single-file removal already did. On Windows a file another process holds open makes a single attempt fail, which is how a deleted bucket's objects were left on disk. (internal/storage/filesystem.go)- Multipart cleanup no longer drops the upload record when the part files could not be removed. Discarding that error left parts on disk that nothing could reach, since every cleanup path — lifecycle rules included — lists uploads from the index. The upload directory is removed once the parts are gone, and parts whose record is already lost are discarded at startup. (
internal/object/manager.go,internal/server/multipart_sweep.go) - Bucket deletion records the removal in the same atomic write that drops the index entry, and clears it once the directory is gone. A removal that fails is finished at the next start instead of leaving the objects on disk with no marker, invisible to the index. The objects go only because someone ordered the bucket gone, never because the index happens not to mention them. (
internal/metadata/pebble_store.go,internal/server/bucket_removal_sweep.go) - Storage layout migration. An existing installation is moved to the new layout on the first start of this release, bucket by bucket, and
maxiofs migrate-layoutruns it by hand with--dry-run. Objects are hard-linked into place and verified before the original is removed, an interrupted run resumes, and it refuses to start when two buckets share a name across tenants or a bucket is named after a tenant. Objects the index names but disk no longer holds are reported, not deleted, and so are directories that still hold data for a bucket the index does not know. (internal/layout/) storage.Backendtakes an object reference (bucket, key, versionID) instead of a joined path string, with dedicated calls for bucket lifecycle and for multipart parts, which live outside any bucket. The on-disk layout is unchanged. (internal/storage/backend.go)- The object sidecar records which object it belongs to (
maxiofs-bucket,maxiofs-key,maxiofs-version), andmaxiofs recoverand reconcile take the identity from there instead of inferring it from the path. Sidecars without those fields still fall back to the path. (internal/storage/filesystem_layout.go,internal/recovery/) - The packaged systemd unit allows 10 minutes for a clean stop instead of the 90 seconds systemd defaults to. A server with a large index was killed mid-shutdown, which costs a WAL replay on the next start. (
debian/maxiofs.service,rpm/maxiofs.service) - Vitest 4 -> 5 for the frontend test suite, with
@vitest/uiand@vitest/coverage-v8on the same major.engines.nodenow excludes Node 25, which the toolchain does not support.
Fixed¶
- Interrupted overwrites are recovered from the oldest retained copy matching the current index entry. Obsolete copies no longer hide newer recoverable backups. Copies without an index entry remain available for operator review without resurrecting deleted objects, and rollback errors prevent startup. (
internal/rollback/,internal/server/interrupted_writes.go) - Object, version and multipart-part commits fsync before returning, before the writer can discard its rollback copy. This closes the window where an acknowledged part survived on disk with stale metadata after a process kill. (
internal/metadata/) - Reconciliation shares object-key locks with normal operations and re-reads sidecars inside the lock. It no longer overwrites a concurrent PUT's metadata and detects equal-size overwrites within the same second. (
internal/recovery/reconcile.go,internal/storage/object_lock.go) - A multipart completion that fails after publishing the assembled object restores the previous one. The hook that puts it back was armed after the assembly, so a storage error raised once the bytes were already in place — ENOSPC on sync, a device error after the rename — returned early, deleted the retained copy and left the new content under the old object's metadata. (
internal/object/manager.go) - Replacing a multipart part no longer destroys the part it replaces when the new one cannot be recorded. The part file was deleted on a failed metadata commit while the committed row still advertised the previous ETag, so ListParts promised bytes that were gone and the upload could only fail at completion. The stored part is retained before the replacement lands and put back if the commit fails. (
internal/object/manager.go) - The sidecar of an object assembled from parts records the multipart ETag. It is the MD5 of the part digests and cannot be recomputed from the assembled bytes, so an entry rebuilt from disk was given a whole-object MD5 the client had never seen. (
internal/object/manager.go,internal/recovery/recovery.go) - A session policy now covers the operations whose resource is not named in the URL. It was evaluated against the request URL alone, so a copy authorized its source with the base user's permissions: a session denied
s3:GetObjecton a bucket could still read it by copying the object somewhere it was allowed to write, and the same held for the source of an UploadPartCopy. The policy travels inside the resolved permission set now, so every authorization decision consults it, including the ones a handler makes about a second resource. A multi-object delete authorizes each key, and a request proxied between nodes carries the session in a signed header the receiving node revalidates. (internal/auth/sts.go,internal/auth/policy_set.go) - A GET concurrent with an overwrite could return one write's metadata with another's bytes. The index was read and the file opened afterwards with nothing holding the two together; a response declared 3 bytes and delivered 14, and the ETag belonged to neither. Reads and writes serialize per key, and a reader keeps the generation it opened while a writer replaces the name — on Windows too, where replacing an open file needs POSIX rename semantics. (
internal/object/manager.go,internal/storage/filesystem.go) - A global administrator can no longer set a quota on another tenant's bucket. The route asked for a generic capability and honoured
?tenantId=, so the bucket's owner never entered the decision. It evaluatesmaxiofs:PutBucketQuotaagainst the bucket's tenant now. Reading a quota stays available to a global administrator — it is the audit view — and a tenant administrator holds both actions for its own buckets. (internal/server/bucket_quota_handlers.go) - A request for a bucket subresource maps to its own action.
GET /bucket?encryptionand a dozen others were authorized asListBucket, so a policy granting exactlys3:GetEncryptionConfigurationwas refused; the Object Lock configuration had no mapping at all. (internal/auth/s3auth.go) - Every setting can be overridden by an environment variable, not just the top-level ones.
MAXIOFS_STORAGE_ROOTand the rest of the nested keys were read asMAXIOFS_STORAGE.ROOT, which is not a name a shell can export, so they were silently ignored. (internal/config/config.go) - The
docker runline in the README mounted the volume at/var/lib/maxiofs, which the image does not use: everything written went to the container's own layer and was lost with it. The image stores at/data. (README.md) - Console login works with
auth.enable_auth: false. Credential validation returned a synthetic "anonymous" user that exists in no table, and the next step of the login read its 2FA status back from the database —user not found, 500. Both the console and the S3 path now answer with the realadminuser. (#7,internal/auth/manager.go) config.example.yamldescribes what the server reads and nothing else: 677 lines down to 101.storage.enable_encryptionandauth.users_fileare gone, since nothing read them — the first was already logging "deprecated and ignored" at startup while the example explained how to use it.storage.encryption_keynow says what it does: it seeds the master key on the first start of an empty deployment and is ignored afterwards, because the key lives in the database, which is what has to be backed up.auth.access_keyremoved: nothing ever read it. Access keys are created once the server is running. (#8)auth.secret_keyrenamed toauth.encryption_secret. It was documented as a default credential and is not one: it encrypts the stored secrets of identity providers, replication targets and share links. The old name is still read, with a warning, because a deployment that set it has data encrypted with that value./debug/pprof/*answers a global administrator instead of refusing everyone. The routes hang off the root router, outside the subrouter that authenticates, so no user ever reached the check and every request was a 401. (internal/server/profiling.go)- The S3 rate limit answers
SlowDownin an XML error document instead of a plain429. A client that hits the limit backs off and retries; a bare 429 is not an S3 error and aborted the transfer.security.ratelimit_api_per_seconddefault raised 100 -> 1000 per access key, and 0 now means no limit rather than falling back to the default. Existing installations keep the value they have. (internal/auth/api_rate_limiter.go) - Administration granted by a policy is recognised as administration.
IsAdminUserread the role alone, so a user holdingmaxiofs:SuperAdminwithout it was refused across the console.maxiofs:TenantAdminis not read there: every holder of the tenant-admin role is given it when policies resolve. (internal/auth/manager.go) - A global administrator no longer changes a tenant's bucket from the console. Twenty-one mutation routes — lifecycle, tagging, CORS, ACL, policy, versioning, object lock, notifications, encryption, public access block, website and inventory — asked only for the
bucket:configurecapability, which says nothing about whose bucket it is, and then honoured?tenantId=. They resolve the bucket's owner and evaluate the S3 action now, the way the read routes already did. A bucket quota is the exception and stays writable: it is the ceiling the operator imposes on the tenant. (internal/server/capability_guard.go,internal/server/console_api.go) - IAM roles are confined to the tenant that owns them.
GetRole,ListRoles,DeleteRole,UpdateAssumeRolePolicyand the policy actions on a role resolved by name alone, so an administrator of one tenant read, changed and deleted another tenant's roles. A deployment-wide role stays readable by every tenant — it can be assumed — and changeable only by a global administrator. Assuming a role across tenants was already refused. (internal/server/iam_aws_api.go) - IAM managed policies belong to a tenant.
iam_policiesgainedtenant_id: a tenant sees and changes its own, reads the deployment-wide ones to attach them, and reaches neither another tenant's nor the built-in documents. Every policy that exists stays deployment-wide, since nothing recorded who created it; the ones to re-own are named in the log at startup. Migration 20. (internal/auth/iam_store.go,internal/server/iam_aws_api.go) - An IAM action on a group resolves the caller's own group.
GroupNamewas looked up among the deployment-wide groups whoever asked, so a tenant could not manage its own groups by name and could attach a policy to a global one. (internal/server/iam_aws_api.go) - Cluster note: upgrade every node. A 1.6.0 node that re-broadcasts a policy writes the row without
tenant_idand turns it deployment-wide. Roles are unaffected. (internal/cluster/iam_sync.go) - Force-deleting a bucket records the removal like an ordinary delete does. It dropped the index entry outright, so a kill before the directory was removed left the objects on disk with nothing to finish the job. (
internal/metadata/pebble_store.go) - The share dialogs read in dark mode. Their bodies are raw HTML, so they carried light-only classes — a white panel and near-black text on a dark page. They use the theme tokens now. (
web/frontend/src/pages/buckets/[bucket]/index.tsx,web/frontend/src/components/ObjectVersionsModal.tsx) - Stop sharing is an item of the actions menu, both for a selected object in the bucket view and in the object detail view, shown only for objects that are actually shared. Revoking a link was only reachable as a secondary button inside the dialog that opens from Share public link, where nobody looks for it. (
web/frontend/src/pages/buckets/[bucket]/index.tsx,web/frontend/src/components/ObjectDetailsView.tsx) - The object actions menu and the bucket actions menu are rendered outside the page's scroll container. Both were positioned inside it, so the menu was clipped by it and could not open over the bar above — leaving actions such as revoking a share unreachable for rows near the top. (
web/frontend/src/components/ui/DropdownPanel.tsx) - Restoring a delete marker is refused when no earlier version is behind it. Removing the marker left no object at all, which read as the restore having deleted it. (
internal/server/object_extra_handlers.go) - Data loss: a folder marker destroyed a same-named object.
PUT key/deleted any file occupying a component of that path, along with its metadata sidecar and the wrapped DEK, then created a directory there. Listings and GET kept reporting the original size and ETag while the body read empty. Non-versioned buckets only. - Data loss on Windows and macOS: two keys differing only in case shared one file.
Reportandreportare distinct S3 keys, but the key was the filename, so the second write replaced the first and a GET returned the other key's bytes under the original'sContent-Length. - Both collisions are gone with layout v2: the filename is a digest of the key, so
2026and2026/,Reportandreport, and a key ending in.metadataare all distinct files. Key length and Windows path limits stop applying too. - Test setup assigned
globalThis.localStorageandglobalThis.sessionStoragedirectly; under Vitest 5 those are getter-only and every suite failed to load. Installed withObject.defineProperty. (web/frontend/src/test/setup.ts) - Setting descriptions, types and categories are refreshed on startup instead of being written only on first boot, so an upgraded install no longer shows the text of the release it was first installed with. Values are untouched. (
internal/settings/manager.go) security.iam_api_enableddescription said Veeam creates its own identities and credentials, and that disabling it stops the advertisement "to Veeam". Veeam signs with the key it is given, andsystem.xmlis served on the object key with no User-Agent check.- A failed overwrite replaced the stored object anyway. The bytes were written before the index entry, so a metadata failure returned an error while a later GET served the new content under the previous ETag and size. The object that was there is restored. (
internal/object/manager.go) - A part could be replaced while the completion was assembling it, producing an object built from bytes its validated ETag never described.
UploadPart, completion and abort now exclude each other per upload. (internal/object/manager.go) - A delete authorized for one key could remove another. The manager rewrote
folderto thefolder/marker after the permission check, so an explicitDenyon the marker was bypassed. Callers resolve the key before authorizing. (internal/object/manager.go,pkg/s3compat/handler.go,pkg/s3compat/batch.go,internal/server/console_api.go) - The encryption worker's cancel could be overwritten by a second start, leaving the first pass with no way to stop it — it outlived the metadata store and wrote to a closed Pebble. Shutdown now cancels as it closes the gate. (
internal/server/encryption_worker.go,internal/server/server.go)
[1.6.0] - 2026-08-26¶
Added¶
- STS temporary S3 credentials — short-lived credentials (
ASIA…key, secret, session token) that project an existing user instead of creating a new identity. Sessions are revocable rows in the auth database, replicated across the cluster and swept hourly. Console UI under Access Keys;security.sts_max_session_duration(default 12 h). (internal/auth/sts.go,internal/server/sts_handlers.go) - Session policies — an optional policy attached at issuance, evaluated as an intersection, so it can only narrow what the user already has. Constructs that cannot be enforced exactly as written (
Principal,Condition, oversized documents) are rejected at issuance rather than ignored. (internal/auth/sts_policy.go) - STS federation for headless clients —
POST /sts/ldap-identityand/sts/web-identitytrade LDAP credentials or an OAuth token for S3 credentials, so scripts and backup jobs need no permanent keys. Disabled by default viasecurity.sts_federation_enabled. (internal/server/sts_federation.go) - AWS STS protocol on
POST /of the S3 port —GetSessionToken,AssumeRole,AssumeRoleWithWebIdentity,AssumeRoleWithLDAPIdentity, soaws sts … --endpoint-url <s3-endpoint>works with no MaxIOFS-specific client code. A temporary credential cannot mint another. (internal/server/sts_aws_api.go) - AWS IAM protocol on the same endpoint — users, access keys, managed policies with versions, inline policies, attachments and roles. Requires permanent credentials and the new
iam:managecapability; gated bysecurity.iam_api_enabled. (internal/server/iam_aws_api.go) - IAM is the authorization model — roles, bucket permissions and attached policies are all IAM policies now, evaluated AWS-style (default deny, explicit
Denywins). An installation's existing permissions are converted once, on the boot that upgrades it; the old capability and bucket-permission tables are no longer read on the request path. Migration 18. (internal/auth/policy_migration.go) - Roles are IAM roles —
admin,user,readonlyandguestare rows iniam_roleswith an optional trust policy deciding who may assume them. A role session carries the role's permissions, resolved live so edits and deletions reach sessions already issued. The separate Role Capabilities screen is gone. (internal/auth/iam_role.go) - Permissions are picked from a catalogue, per bucket — every assignable permission has a group and a readable label and can be granted on individual buckets. Nobody edits a policy document; the document is what the selection is stored as. The only JSON left in the console is a role's optional trust policy. Console page under Users → IAM, in all 9 languages. (
internal/auth/iam_catalog.go,web/frontend/src/pages/iam/) - IAM entities replicate across the cluster on the access-key schedule with an immediate push after each change; deletions travel as tombstones so a lagging node cannot resurrect them. (
internal/cluster/iam_sync.go) - Large downloads and folder ZIPs stream to disk rather than being assembled in the browser tab, so a download is no longer limited by tab memory. They navigate with a two-minute token naming one object or prefix, accepted only on the download routes. (
internal/server/download_token.go) - Transfers are bounded by progress instead of elapsed time — a request is cancelled once its body has moved no bytes for 60 s, so a large object is never cut off merely for taking long. (
internal/transfer) s3:GetBucketObjectLockConfigurationands3:PutBucketObjectLockConfiguration— a bucket's Object Lock settings had no permission of their own, separate from the per-object retention permissions.- Forced password change — an administrator can require a new password at the next sign-in. The API refuses everything but the password form until it is set, and only setting a password lifts it. Migration 19.
- Read-only notice on tenant buckets — a global administrator browsing a tenant's bucket is told why the write controls are disabled.
Security¶
- Anyone could act as any user on the S3 port by replaying three headers from an observed inter-node request — the proxy signature covered neither the forwarded identity nor the request, so the caller chose who to be. It now covers the method, the full URI, the body digest, the identity and a nonce that is remembered for as long as the signature is accepted. (
internal/cluster/proxy.go) - Header-signed S3 requests never checked their timestamp, so a captured signature stayed valid indefinitely. Requests outside a 15-minute window are refused, as on AWS. (
internal/auth/manager.go) - Suspending a user did not revoke their S3 access keys — the console locked them out while their keys kept full permissions. Both signature versions now check the user's and the key's status.
- A node accepted the internal cluster headers from anyone — sending
X-MaxIOFS-Proxied: truewas enough to be treated as an already-routed request, so a write landed on whichever node received it instead of the one owning the bucket. A request that has not proved it comes from a peer has those headers removed before any handler reads them. (internal/server/server.go) - The inter-node signature did not cover the query string, where the internal API carries the bucket, the key and the version — so a signature was valid for a request pointing somewhere else. Signing and verifying now share one definition of what is covered, instead of building the payload separately at each end. (
internal/clusterauth/) - Inter-node requests could be replayed within a 60-second window — the nonce was generated and signed but never recorded. It is now remembered for as long as the signature is valid. (
internal/middleware/cluster_auth.go) - Streamed object replication left the body unauthenticated, so a peer's bytes could be substituted in flight. The sender now signs the digest it already holds and the receiver verifies while writing, without buffering. (
internal/middleware/body_digest.go) - A client-supplied chunk size was passed straight to an allocation — one small
aws-chunkedrequest could panic the process or exhaust its memory. Sizes are bounded and negatives rejected. (pkg/s3compat/aws_chunked.go) - No XML or JSON endpoint limited its request body, so a single authenticated request could drive the node out of memory. All of them now cap it. (
pkg/s3compat/body_limits.go) - A global administrator could write to any tenant's buckets from the console — the guard granted them everything without consulting policies. Access across a tenant boundary is now read-only, matching the S3 API. Deleting or configuring a tenant's bucket is now the tenant administrator's to do. (
internal/server/capability_guard.go) - A presigned URL whose signer could not be resolved was granted full access instead of being denied — the lookup errors were discarded, leaving the request on the unauthenticated path where a valid signature alone stood in for IAM. (
pkg/s3compat/presigned.go) - Veeam's SOSAPI documents (
capacity.xml,system.xml) were readable by any member of the bucket's tenant, regardless of permissions on that bucket. They now requires3:GetObjectlike any other object. (pkg/s3compat/handler.go) - Five endpoints performed no authorization beyond requiring a session —
SelectObjectContent(which also crossed tenants),handleDownloadZip,HandlePresignedPost,handleUpdateBucketOwnerand the replication endpoints, which additionally returned destination credentials in plaintext. All now ask for the permission the operation needs, and the destination secret is write-only. - A tenant administrator could take over the global administrator account —
handleChangePasswordmatched theadminrole without comparing tenants and skipped the current-password requirement for other users, andhandleCreateUservalidated no roles. Granting either administrator role is now a global administrator's decision. - A multipart upload could be operated on through a key the caller held rather than the one it belonged to — the permission check read the URL while every operation afterwards went by
uploadIdalone. All four entry points now assert the upload belongs to the authorized object, answeringNoSuchUploadon a mismatch. - Ten bucket subresources were guarded by an unrelated permission — encryption by CORS, logging/website/inventory by lifecycle, notification and public-access-block by bucket-policy, ownership by ACL. Each has its own catalogued action now, including
s3:PutBucketReplication. - ACLs could overrule the policies for an authenticated caller — every permission check fell through a widening cascade on denial, so an explicit IAM
Denywas overridden and a bucket ever set toauthenticated-read-writehanded DELETE to every credential in the deployment. The cascade is gone; ACLs and public access still decide anonymous requests. (pkg/s3compat/handler.go) - A credential without a tenant reached every tenant's buckets — the boundary was enforced in one direction only, and because an ARN names a bucket by bare name, a grant on the global
backupsmatched every tenant'sbackups. It is required in both directions now; onlymaxiofs:SuperAdmincrosses it, read-only. (pkg/s3compat/tenant_boundary_test.go) - Naming a specific version was not a permission — object subresources checked only the current-object action while reading
?versionId, sos3:GetObjectreached the tags, attributes and retention of any historical version.CopyObjectandUploadPartCopyread the destination's version instead of the source's. - Anyone signed in could set a bucket's default Object Lock retention — the guard compared the caller's tenant against itself, a condition that can never be true. A compliance-mode default of ten years would have locked every subsequent upload permanently, for everyone. (
pkg/s3compat/handler.go) - Overriding governance-mode retention was granted by holding the admin role, so the catalogued
s3:BypassGovernanceRetentiongranted nothing while every administrator could bypass protection regardless of their policies. It is asked of the caller's policies now. - A revoked permission came back on its own in a cluster — removing a bucket grant recorded no tombstone, so the next sync round restored it, and a deleted bucket's
owner-<bucket>policy returned to hand full access to whoever recreated the name. Revocations and bucket deletions record tombstones now. - Turning off access-key synchronisation also stopped authorization from replicating, so revocations never reached the other nodes, which went on granting what had been taken away. The flag is separate now; the interval is still shared.
- A tenant administrator could read and rewrite another tenant's users' permissions,
handleShareObjectpublished objects out of any tenant named in the query string, the cluster bucket inventory listed every tenant's buckets to any caller, andhandleGeneratePresignedURLsigned a URL for any object while gating its cross-tenant override on a role that exists nowhere in the repository.
Fixed¶
- Shutdown closed the stores while background workers were still writing, panicking Pebble. All 23 components are stopped and waited for first. (
internal/bgwork/,internal/server/server.go,internal/server/components.go,internal/metadata/pebble_store.go) - A bucket that ever held a key with
/could not be deleted over S3 — folder markers are hidden from listings but counted as content. They are now removed with the bucket. (internal/metadata/pebble_store.go) - Flat listings returned short pages — hidden entries (folder markers, delete markers, internal files) consumed the
MaxKeysbudget, so a page could come back with fewer objects than existed. (internal/object/manager.go) - S3 jobs that outlive the request were not part of the shutdown wait: realtime replication on PUT and DELETE, and multipart completion. They now run through the server's worker. (
pkg/s3compat/handler.go,pkg/s3compat/multipart.go) max-keys=0was not honoured — it now returns an empty listing withKeyCount0, as on AWS. (pkg/s3compat/handler.go)- An unparseable
Rangeheader was silently ignored and the whole object returned; it now answersInvalidRangewithContent-Range. (pkg/s3compat/handler.go) - Deleting a version that was itself a delete marker reported
x-amz-delete-marker: false. (pkg/s3compat/handler.go) - The cluster TLS listener was replaced while shutdown was reading it, an unsynchronised field write, and its serve goroutine called
logrus.Fatal— exiting the process with no shutdown at all. (internal/server/server.go) - Deleting the last administrator of a tenant left it with no operator. Deleting the tenant itself is the way to remove it. (
internal/server/console_api.go,internal/server/console_idp.go) - Four background goroutines wrote to the stores untracked: bucket quota alerts (on every object write), tenant quota alerts (on every upload), scheduled replication sync, and dead-node catch-up sync. (
internal/bucket/manager_impl.go,internal/auth/manager.go,internal/replication/manager.go,internal/cluster/dead_node_reconciler.go) - Remote log targets (HTTP, syslog) were never flushed on shutdown — buffered entries were discarded. (
internal/server/server.go) - The inter-node object endpoints answered
200 {"success": true}without storing the object on their no-object-manager fallback. Removed; they refuse the transfer. (internal/server/cluster_object_handlers.go) - Storage-pressure events were emitted from an untracked goroutine and could arrive out of order with the health check. (
internal/cluster/health.go) - An alert that fired before the browser connected was never shown — on connect the server reported only that the disk was back to normal, never an alert still active, and it ignored the critical threshold. It now syncs the current state, and active disk alerts go to global administrators only. (
internal/server/sse_notifications.go) - Repeated alerts for one condition crowded out every other notification — each was a new entry in a list of three. A notification now replaces the one it supersedes, matched by condition and by the node, bucket or tenant it concerns. (
web/frontend/src/hooks/useNotifications.ts) - Successful deletions were styled as failures in the audit log — removing a user, a tenant or an access key was painted red with an error icon by a hardcoded event list. Only a failed status is. (
web/frontend/src/pages/audit-logs/index.tsx) - Writes forwarded to the node that owns a bucket escaped the tenant's quota — one of the two proxy paths dropped the content length, so the body arrived chunked and the quota check read the missing length as nothing to account for. A tenant could exceed its limit indefinitely by writing through any other node. (
internal/cluster/proxy.go) - Paging through object versions lost some and returned others twice — versions are ordered newest-first within a key, but resuming compared version ids as text, which is a different order. A client walking the markers saw neither every version nor each one once. (
pkg/s3compat/versioning.go) - Every delete subtracted from the bucket twice — the object manager and the S3 handler each adjusted the size, the object count and the tenant's quota. Removing one version of an object took away two versions' worth and hid the object from the bucket's count while it was still there. (
pkg/s3compat/handler.go) - Deleting a folder destroyed every object under it — the folder marker and its prefix are the same path on disk, and the delete removed the whole directory while leaving the objects in the metadata store. They stayed listed, with their sizes, and every read of them failed. (
internal/storage/filesystem.go) - A failed
CompleteMultipartUploaddestroyed the object already stored under that key — the parts are combined onto the live path, and the error path deleted it. The previous object is now restored. (internal/object/manager.go) - The cluster brought deleted objects back — the repair scrubber treated "the peer does not have it" as "the peer is missing it", with nothing to distinguish a deletion. It now consults the deletion log and removes its own stale copy instead. (
internal/cluster/anti_entropy.go) If-MatchandIf-None-Matchrejected the list form — a header naming several entity tags was compared as one opaque string, so a client offering any of the versions it holds was refused.*and weak validators are understood too, and a failed precondition now answers with the XML error an SDK can map instead of a bare status. (pkg/s3compat/handler.go)- A
Rangeheader the server could not parse was an error — RFC 7233 says an unparseable range is ignored and the whole object returned; a proxy or downloader that sent a range in any other unit got a refusal where AWS delivers the file. A well-formed but unsatisfiable range still answers 416. (pkg/s3compat/handler.go) CompleteMultipartUploadanswered200 OKwith aNoSuchUploaderror inside it — nothing has been written at that point, so it is an ordinary 404. A client checking the status line reported an object as stored that does not exist. (pkg/s3compat/multipart.go)ListMultipartUploadsignoredprefixand never echoedPrefixorDelimiter, so a cleanup tool asking for its own namespace received every upload in the bucket — and aborting those destroys other workloads' transfers. (pkg/s3compat/multipart.go)NextKeyMarkerwas url-encoded twice whenencoding-type=urlwas requested, so a client resuming a version listing asked for a key that does not exist. (pkg/s3compat/versioning.go)- Every miss scanned the whole bucket's versions — a lookup for one object listed and deserialised every version entry in the bucket and filtered afterwards. Backup tools probe with
HEADbefore each write, so a job writing new objects into a large bucket stalled the node. It reads only that key's versions now. (pkg/s3compat/handler.go) DELETE ?tagging&versionId=deleted the version instead of its tags, so a metadata-only call from any SDK destroyed data. (internal/api/handler.go)- Objects whose metadata sidecar was lost could be served as a successful download of unreadable bytes — the check refused only on evidence of a mismatch, so bytes it could not identify passed. It now requires positive proof that the stored bytes are the recorded object. (
internal/object/storage_identity.go) - Concurrent writes to one object could leave its bytes and its metadata describing different things — eight operations that read, modify and write the same object did so without serializing, including tagging, ACLs, retention, legal hold and version deletes. (
internal/object/manager.go,internal/storage/filesystem.go) - Bucket size and object counts drifted apart from what a recount produced — the running counters and the full recount disagreed on whether versions and folder markers count. Size now means every version, which is what occupies disk; the count means the keys you can see. Deleting a version that was not the latest returned nothing to the bucket or the tenant's quota, and deleting the latest with another version underneath returned nothing either. (
internal/metadata/pebble_store.go,internal/object/manager.go) - Tag-filtered search lost one object per page — the same marker mistake as the object listings, in the path that fix did not cover. Where a listing stops and which marker it returns are now one decision. (
internal/metadata/pager.go) - Cluster-wide settings never propagated between nodes — every entry synced with a timestamp of zero and was skipped as stale, so a node could show a replication factor its peers did not have. (
internal/cluster/sqlite_time.go) - A replication factor of 2 confirmed writes that reached no replica, and a factor of 3 refused writes when any one node was down. Both now require a majority of the copies, counting the local one. (
internal/cluster/ha_object_manager.go) - The repair scrubber pushed every object to every healthy node, ignoring the replica set and consuming several times the provisioned disk.
- Syncing a replication rule while it was being deleted could panic the process, because the lock was released by looking it up again rather than by holding it. (
internal/replication/manager.go) - Dialogs did not hold the keyboard — Tab walked out of the modal into the page behind the overlay, where every control was still reachable and could be activated while the user believed they were answering the dialog. Focus is trapped, returned to whatever opened it, and the dialog announces itself to assistive technology. (
web/frontend/src/components/ui/Modal.tsx) - The bulk-delete dialog said one number and deleted another — filtering the view with the search box left the selection intact but narrowed what the action resolved, so "10 selected" quietly deleted 2. It also read in English inside an otherwise translated console, which is the worst place for it: twelve strings across the confirmation, the progress bars and four notices are now translated in all nine languages.
- Group operations reported nothing when they failed — creating, renaming, deleting a group and adding or removing a member all discarded the error, leaving the form open and the user pressing the button again.
- Dates were formatted in US English regardless of the interface language, including relative times like "3 hours ago", and a missing translation key rendered its own name to the user.
- The bucket list re-fetched every bucket's scan history every 30 seconds and discarded the answers — 120 requests a minute on an installation with 60 buckets.
- The login page's progress and welcome messages went nowhere, because it renders outside the layout that hosts them, and an untranslated SSO error code displayed the lookup key instead of the code.
- Deleting a bucket from the console could delete another tenant's — the tenant was resolved by bucket name, and names repeat across tenants. (
web/frontend/src/pages/buckets/index.tsx) - The console object browser showed only the first 1,000 objects with no indication there were more, so search and select-all silently operated on that page alone. (
web/frontend/src/pages/buckets/[bucket]/index.tsx) - Every quota-checked overwrite leaked a file handle, exhausting the limit under a backup job.
- A two-node cluster could never elect a coordinator, so no configuration change could be made at all — a candidate wrote its own vote into the lease table before the election was decided, and each node then defended its hopeful lease against the other. Votes are separate from leases now, with a pre-vote round and a randomised campaign delay. (
internal/cluster/leader.go) - A cluster that loses a node now re-elects among the nodes still alive — the majority was counted over every node ever known, so the death of one of two left the survivor unable to elect anybody, permanently, including to remove the dead node. It is counted over the nodes that answer the round; the returning node rejoins as an ordinary member and syncs. Two nodes that cannot see each other will each lead what they can see, and entity sync reconciles them by
updated_aton reconnect. (internal/cluster/leader.go) - Every immediate cross-node push after a configuration change was cancelled by its own HTTP response —
TriggerSyncran on the request's context inside a goroutine that outlives the request, so changes took up to a minute to propagate instead of a moment. Seven of the nine sync managers were affected. (internal/cluster/sync_trigger.go) - Two writers of the same bucket document lost each other's work —
UpdateBucketMetricslocked andUpdateBucketdid not, so quotas, versioning and Object Lock configuration silently rolled back. They share the mutex now, with the counters owned by the metrics writer and everything else by the configuration writer. - Deleting one version of an object left the main entry describing data that no longer existed — listings advertised objects whose every GET failed, and a hard kill during the repair made it permanent. The delete and the promotion of the next version are one batch now. (
internal/metadata/pebble_objects.go) - An object whose metadata sidecar was lost had its raw ciphertext served as the object with HTTP 200 — nothing signalled a problem, so a backup tool would have restored garbage. Derived metadata now says it is derived, and bytes that cannot be identified are refused. (
internal/storage/filesystem.go) - The storage backend never called
fsync— not on the data file, the sidecar carrying the wrapped DEK, or the directory — while the metadata store has fsynced its WAL every second since 1.5.2, so after a power cut Pebble reported objects whose bytes were still only in the page cache. Cost of the added durability: +16% on a 10 KB object, +23% on 1 MB, +12% on 10 MB. (internal/storage/filesystem.go) - A tenant using versioning was slowly locked out of its own quota — versioned deletes never returned the freed bytes, and tenant storage does not clamp at zero, so it drifted in the direction that denies service until a manual recount.
- The delete paths took no per-key lock, so two concurrent deletes each debited the same size and
deletePermanentlydebited twice for one removal. - An upload that failed mid-write leaked a goroutine —
FilesystemBackend.Putreturned without draining the reader, leaving the encryption goroutine blocked forever holding the object's DEK. One per failed upload, during exactly the disk-full episodes with no headroom to spare. - Empty objects never had their data file deleted — the versioned delete meant to skip delete markers and skipped legitimately empty objects too, orphaning their files permanently.
- A write to a bucket owned by another node could answer
200having stored nothing — a failed forward fell through to local handling, answering from a node that does not hold the data and applying mutations in the wrong place. A failed forward is refused withServiceUnavailablenow. (pkg/s3compat/handler.go) - Total HTTP deadlines on the paths carrying object data were size limits in disguise — the cluster proxy (60 s), console proxy (30 s) and replication client (120 s) each expressed a maximum object size as a maximum duration, and replication failed silently and permanently since every retry died at the same point. Bounded by progress now; two of the three proxy entry points had no bound at all. (
internal/transfer,internal/cluster/proxy.go) - Console requests proxied to the owning node buffered the whole body in memory, so proxying a large object cost as much memory as the object; a failed forward then stored an empty object in its place. Streamed now, answering
502on failure. (internal/server/console_api.go) - The bucket listings did not consult the policies, so a bucket granted purely through IAM was invisible while every object in it was accessible, and a revoked grant left its bucket on screen. Both listings ask the same policy set as every other request;
s3:ListAllMyBucketsis enforced for the first time. (internal/server/console_api.go,pkg/s3compat/handler.go) - A caller holding only an IAM policy could not read an object — a second gate demanded an explicit ACL after the policy check had already approved the request, so
s3:GetObjectreturned 403 for anyone but the owner, andCopyObjectfailed on its source. - A tenant user could no longer reach a bucket outside every tenant — a bucket in the global namespace does not belong to another tenant. It is decided by the policies now; reaching into a tenant from outside stays closed.
- The console refused a global administrator every write, everywhere — the read-only rule was applied to all global administrators rather than only when crossing into a tenant, so creating a bucket in the global namespace answered 403. (
internal/server/capability_guard.go) - Editing a role had no effect on any user with a tenant — the bounded document substituted for tenant administrators discarded every other document the role carried, so attaching or revoking a policy on a role was silently a no-op for them. It also omitted both Object Lock configuration actions.
- Identities without a stored row lost every permission attached to their tenant — role sessions and federated logins carry a tenant without a
usersrow, and one of the two permission views re-read the tenant from that table. (internal/auth/policy_translate.go) - Saving a user's permissions screen made their role's permissions permanent — the screen shows effective permissions and saved what it showed, turning everything inherited into a direct grant that outlived the role. Only what is not already inherited is stored now.
- Transferring a bucket's ownership moved no permission — the owner fields were written and the owner policy was not, so the new owner got nothing and the previous one kept everything.
- Temporary credentials replicated between nodes lost their role identity —
role_arnandpolicy_modewere absent from the sync payload, so a credential narrowed to a role came back wider on every other node. (internal/cluster/sts_session_sync.go) - Presigned URLs signed with temporary credentials ran with no user in the request context, so a valid presigned URL made with an
ASIAkey was denied downstream. (pkg/s3compat/presigned.go) - A rejected credential answered
401on the S3 API where AWS answers403— the authentication middleware's error writer had drifted from the handler's. It now also emits the XML declaration,HostIdandAWSAccessKeyId, and sends no body onHEAD. - STS errors carried an empty
<RequestId>while the response header had the real one. - Three databases were opened with connection parameters that did nothing — the audit log, share store and metrics history used the wrong DSN syntax for
modernc.org/sqlite, which applies none of it without complaint, so they ran in rollback-journal mode with a zero busy timeout and dropped audit entries under load. All three set WAL and a ten-second wait now. - A download token was accepted on four sibling routes when the object key was literally
acl,tags,versionsorlegal-hold. Both download routes are matched by route identity now. - Downloading a specific object version from the console served the current object — in the versions dialog and, identically, on the bucket page.
FilesystemBackend.Listreturned work in progress as objects and discarded the walk's errors, so an unreadable subtree produced a short listing in silence. Its only consumer deletes what it is handed.- Two tenants could exceed a bucket quota at once — the count was read, compared and then incremented. It is enforced in the increment now, and
UpdateTenantno longer writes usage counters back from a stale snapshot. coordinatorExemptPathmatched substrings rather than path segments, so a bucket or user nameddownloadskipped the coordinator gate.- A data race on the cluster TLS configuration and its HTTP client — both were replaced as plain pointers from the initialize and join handlers while every server was serving, so a request in flight during a join could be sent with a half-published config. Both are atomics now.
- Ten
Start()methods re-created the cluster proxy client without synchronisation while other goroutines were reading it; the reassignment bought nothing and is gone.LeaderManager.Stop()panicked if called twice. - The cluster health prober built and leaked an
http.Transportper probe,GetAllLatencyStatsreleased its lock mid-iteration, the bucket-location cache cleanup had no stop path, the integrity scan history was a racy read-modify-write, andtransfer.TransportleftGetBodyunwrapped so a replayed request stopped stamping the progress clock. - A refused vote was invisible in the logs when a cluster could not elect anybody, and a failed election was logged at debug level. A peer that stays down is now reported once, not every five seconds — a node left down over a weekend wrote a hundred thousand identical lines.
- CI ran
go testwith no-timeout, so Go's ten-minute default applied to packages taking about three minutes locally on a runner several times slower. The likeliest reason CI failed while every command passed locally. - The Vite and Vitest configs used
__dirname, which breaks under Vite's forthcoming native config loader. (web/frontend/vite.config.ts,web/frontend/vitest.config.ts) - Removed the replication of
user_capability_overrides, which wrote rows an operator would reasonably believe were in force, andAPIClient.fetchObjectBlob, which had no callers and droppedversionIdwhile its comment advertised reading a specific version.
Changed¶
- The tenant boundary is enforced by the evaluation engine, not by its callers — a tenant is an account, and an S3 ARN cannot name one, so
arn:aws:s3:::backupsnames a bucket of that name in every tenant at once.PolicySet.Allowsnow takes the resource's owning tenant and the old form no longer compiles, so no path can forget it. The owner is a type whose zero value means "nobody resolved this" and is refused, because as a string an unset field and the global namespace were the same empty value. (internal/auth/policy_set.go) - Both APIs authorize per S3 action rather than per capability —
bucket:configureused to cover versioning, lifecycle, CORS, tagging and ACLs together, and several paths accepted sharing a tenant with the bucket as sufficient. (pkg/s3compat/bucket_ops.go,internal/server/capability_guard.go) - S3 authentication failures are no longer all
401 InvalidAccessKeyId— an expired session returns403 ExpiredToken(SDKs refresh and retry), a session-policy denial returns403 AccessDenied(do not retry). - SOSAPI advertises
IAMSTS=truewith both endpoints, served onPOST /of the S3 endpoint so a single URL covers both. Advertised only whilesecurity.iam_api_enabledis on and a public API URL is configured. (pkg/s3compat/sosapi.go) - Presigned URLs are covered by IAM policies — they bypass the S3 auth middleware, so without this a presigned URL would have been a way around an attached policy. (
pkg/s3compat/presigned.go) - S3 Select streams the object instead of holding it — it parsed the whole object into memory and then loaded that into an in-memory database, so one authenticated query could exhaust the node. The loaders now insert rows as they read — the JSON schema grows mid-stream rather than being discovered by buffering — and the query engine is backed by a temporary file. Measured on a 9 MB input: 11 MB at peak, against 217 MB before. (
pkg/s3compat/select.go) - Dependencies updated — Pebble v2 2.1.7, logrus 1.10.2, testify 1.12.1, modernc sqlite 1.57.0, the AWS SDK, jsdom 30 and
@testing-library/jest-dom7. Built with Go 1.26.6, which closes six standard-library advisoriesgovulncheckreported as reachable, including theencoding/xmlrecursion guard and the quadraticnet/urlpath resolution. - A test now fails if a
Serverfield that can be stopped is not released at shutdown, through the registry or by name. (internal/server/lifecycle_coverage_test.go) - The users table is more compact — the status column folded into a colour-coded toggle that now shows the current state rather than the action, 2FA reduced to an icon, and uniform icon-only actions carrying
titleandaria-label. (web/frontend/src/pages/users/index.tsx) docs/CLUSTER.mddescribes what happens when a node fails — the re-election among survivors, the rejoin and sync on return, and the partition trade. Documented alongside a constraint worth knowing first: every node must use the samecluster_listenport.
[1.5.2] - 2026-07-18¶
Note: v1.5.1 was withdrawn shortly after publication and is not available. Its unclean-shutdown reconciliation could remove latest-object index entries for versioned objects after a crash (object data and per-version metadata, including Object Lock retention, were unaffected). If you installed it, upgrade and run
maxiofs repair-pointers --dry-runwith the server stopped.
Fixed¶
- Listing pagination silently lost one object per page boundary —
NextMarkerwas set to the first key of the NEXT page while marker consumption follows S3 semantics (resume strictly AFTER the marker), so any client paging with the returned marker skipped exactly one object per 1,000 (console listings, S3 ListObjects, SOSAPI, search). At backup-target scale this made verification/repair tools (Veeam, Duplicati) see existing files as missing — ~500 invisible objects on a 500K-file listing. Fixed in all three paginated paths (ListObjects,ListObjectsDelimited— including resuming after a common-prefix marker by skipping the whole prefix group — andsearchObjectsByScan); found by an end-to-end torture test (1,090 files, 5 hard kills, worker conversions, KEK rotation, churn — zero losses after the fix), covered by pagination round-trip regression tests at page sizes 1-100. (internal/metadata/pebble_objects.go,internal/metadata/pebble_pagination_test.go)
Changed¶
- Dead-code sweep across backend and frontend — removed code with zero production callers, verified independently with
deadcode(whole-program reachability),staticcheck(U1000) and per-symbolgit grep, plus manual confirmation of the live equivalent for every removal. Highlights: - Parallel implementations that were never wired (each has a live counterpart that stays):
internal/object/lock.go+retention.go(live object lock/retention = theManagerinterface methods inmanager.go),internal/middleware/ratelimit.go(live =authManager.CheckRateLimit+ dynamic settings),internal/cluster/rate_limiter.go(the server constructed it but never called it — including a startup log line that falsely claimed cluster APIs were rate-limited; cluster port protection is TLS + HMAC),internal/cluster/metrics.go,middleware.SignRequest(live signer =cluster/proxy.go), the CSR pairGenerateKeyAndCSR/SignCSR(live cert issuance =GenerateNodeCert), the s3compat cluster-proxy auth shim, the unregisteredprometheusCollector(live/metrics= the manager's own registry), andpkg/encryption's pre-envelopeEncryptionService/KeyManager. - Dead helpers and config variants: unused CORS/logging config constructors, tracing context helpers,
S3AuthHelper's V2/V4 parsing chain (GetS3Action/GetResourceARNdeliberately kept as IAM primitives), unused bucket validation/permission helpers, assorted single functions (getDiskUsage,queryBucketsFromNodeserver duplicate,calculateMultipartHash,httpHeadersFromObject, etc.) and themain.goassignment that fedtrusted_proxiesinto the dead middleware (the live consumergetClientIPreads the config directly). - Deliberately kept:
Stop()lifecycle methods on sync managers, test seams (MigrateTo,NewManagerWithS3Factory,detectMode, s3compat presigned URL generation — it round-trips the presigned validation tests), andbandwidth.Manager.Remove(documented optional housekeeping). - Frontend (knip): removed 8 unused files (
PageHeader,PermissionsEditor,useLockedUsers, 3 stale test mocks,MetricBarChart,MetricPieChart) and 2 unused dependencies (i18next-browser-languagedetector,@tailwindcss/vite).
Added¶
- Pebble durability on hard kill (bounded loss + self-healing boot) — hot-path metadata commits were
NoSync, so a hard kill (power loss, OOM,kill -9) could lose the last seconds of Pebble writes: a just-PUT object kept its data file + sidecar but vanished from listings, and a lost delete tombstone resurrected a ghost entry. Three-layer fix, none of which meaningfully costs throughput: - Periodic WAL fsync (everysec): a background loop in
PebbleStorefsyncs the WAL once per second whenever there are unsynced writes (one fsync/second flat, regardless of write volume — Pebble's group commit makes everything before it durable), bounding hard-kill metadata loss to ≤1s.WALBytesPerSyncsmooths background flushing so the periodic fsync stays cheap. Interval configurable viaPebbleOptions.WALSyncInterval. - Sync on destructive, low-frequency operations: object/version deletes, bucket create/update/delete, multipart complete/abort and raw-KV deletes now commit with
pebble.Sync— a lost delete tombstone would leave a ghost listing entry pointing at a removed file (unservable), and a completed multipart upload is the durability point the client paid for. Hot paths (object puts, parts, metrics) stayNoSyncunder the 1s loop. - Unclean-shutdown reconciliation:
Close()writes aCLEAN_SHUTDOWNsentinel that the next open consumes; when a pre-existing store opens without it, the server reconciles the metadata store against the on-disk object tree in the background while serving traffic (reads already work via the sidecar fallback). Repairs are non-destructive: entries lost in the crash window are rebuilt from their sidecars (original timestamps preserved), but metadata entries and sidecars are never removed merely because an object path is absent. Bucket stats are recalculated after changes. Rebuilding metadata after a genuine storage incident is an explicit, operator-initiated offline operation, never an automatic one. (internal/metadata/pebble_store.go,internal/recovery/reconcile.go,internal/server/unclean_shutdown_reconcile.go) maxiofs repair-pointersoffline recovery tool — rebuilds missing latest-object index entries from the surviving per-version metadata (version:keyspace), for use when a metadata index is damaged and objects stop appearing in listings while their files remain on disk. Strictly additive: it only writes entries that are currently missing, never deletes a key, never modifies a version entry and never touches object files; Object Lock retention and legal hold are preserved because they are copied from the version metadata, and objects whose latest version is a delete marker are correctly left deleted. Supports--dry-run. Run with the server stopped. (internal/recovery/repair_pointers.go,cmd/maxiofs/repair_pointers.go)
[1.5.0] - 2026-07-10¶
Added¶
- Envelope encryption with the KEK in the database (always-on encryption) — server-side encryption no longer depends on a fixed key in
config.yamland is now always active, matching AWS S3's SSE-S3-by-default model. Highlights: - KEK in the DB: new
encryption_keysSQLite table (migration 16) andinternal/kekpackage. On startup the KEK is resolved in order: existing DB value → seeded fromconfig.yamlstorage.encryption_key(existing deployments keep decrypting everything; keep the config file as a key backup) → freshly generated 32-byte key persisted in the DB (deployments started with only--data-dirself-provision, no config needed). The DB is the source of truth; the config value is only ever a seed. - Envelope (per-object DEK): every new object is encrypted with its own random Data Encryption Key (AES-256-GCM streaming); the DEK is wrapped with the current KEK and stored in the on-disk
.metadatasidecar (wrapped-dek,wrapped-dek-iv,kek-version). Because the crypto material lives in the sidecar — not only in the metadata DB — objects remain recoverable from the filesystem alone given a KEK backup, and future KEK rotation only re-wraps the small DEK instead of re-encrypting object data. - Multi-format reader (full backward compatibility): reads branch per object on its stored format — plaintext objects (from key-less deployments) are served as-is, legacy directly-encrypted objects decrypt with KEK version 1 (the former config key), envelope objects unwrap their DEK. Verified by tests covering all three formats plus restart-persistence.
- Writes always encrypt: the
shouldEncryptObject/enable_encryptionconditional and the plaintext write path are gone (single and multipart uploads);storage.enable_encryptionis deprecated and ignored. Folder markers (zero-bytekey/entries) are exempt — this also fixes a goroutine leak where the encryption pipe for a folder marker was never read. - Sidecar protection:
UpdateObjectMetadatapreviously replaced the whole.metadatasidecar with just the caller's keys, silently destroying the encryption entries of an encrypted object (making it undecryptable). It now merges into the existing sidecar and system keys (encrypted,wrapped-dek,original-size, ...) can never be overridden. - The misleading per-bucket "disable encryption" toggle path in bucket creation now consistently records
AES256on every bucket; the console encryption endpoints report encryption as always enabled. (internal/kek/,internal/db/migrations/versions.go,internal/object/manager.go,internal/server/server.go,internal/server/console_api.go) - Background encryption worker — objects stored before encryption became mandatory (deployments that ran without an encryption key) are now converted to envelope encryption automatically in the background. One shared worker walks every bucket/object and acts on each object's state: plaintext → convert (fresh DEK, wrapped with the current KEK); already encrypted → skip. This is the same component that will re-wrap DEKs on future KEK rotations. Safety first, since it rewrites production data: the plaintext is staged to a temp file, the original is only replaced by an atomic rename, the per-key lock serialises against concurrent writers, and every converted object is read back, decrypted and MD5-compared before counting as done (with automatic plaintext restore and concurrent-overwrite detection on mismatch). All stored versions of a key are converted, folder/delete markers and multipart-format ETags are handled. Scheduling is load-aware (backs off while CPU >60% or RAM >85%) with gentle pacing, and progress is checkpointed in Pebble so a restart resumes where it left off; a full pass re-runs daily to catch strays. New Settings → Security block shows live status (running/paused-by-load/done, converted/skipped/failed counters, per-bucket progress bar) with a "Run now" button; endpoints
GET /api/v1/settings/encryption/worker-statusandPOST /api/v1/settings/encryption/worker-run(global admin only). Translated in all 9 UI languages. Verified end-to-end with the real binary: a plaintext object planted on disk was picked up by a triggered pass, converted (sidecar gainedwrapped-dek/kek-version, disk bytes became ciphertext) and downloaded back byte-for-byte intact. (internal/object/encryption_migration.go,internal/server/encryption_worker.go,web/frontend/src/pages/settings/EncryptionWorkerStatus.tsx) maxiofs recover— offline disaster recovery (metadata DB lost, files intact) — new CLI subcommand that rebuilds the Pebble metadata store from the on-disk object tree, run with the server stopped. Buckets are rediscovered from their.maxiofs-bucketmarkers (owning tenant included), objects and versions from each file's.metadatasidecar (original size/ETag, content-type, encryption fields; versions inbucket/.versions/key/versionIDare rebuilt in order with the newest as latest, and bucket metrics/versioning status are recomputed). With--recovery-bundlethe encryption keys are restored into the auth SQLite (so the recovered server's bootstrap loads them instead of generating a fresh KEK) and every envelope object's wrapped DEK is verified to unwrap before being counted as recovered. Safety: always writes into a FRESH directory laid out exactly like the live store (activate with a singlemv), refuses a non-empty output,--dry-runreports without writing, and the report classifies objects (verified/unverified encrypted, legacy, plaintext) with per-file failures. Verified end-to-end with the real binary: seeded objects → deleted the entire Pebble store AND the SQLite database →recoverrebuilt 1 bucket / 2 objects with 2 DEKs verified and 1 KEK restored → the server started with "KEK loaded from database" and served both objects decrypted byte-for-byte. (internal/recovery/,cmd/maxiofs/recover.go)- Encryption key (KEK) rotation — global admins can rotate the encryption key from Settings → Security (or
POST /api/v1/settings/encryption/rotate-kek). Rotation creates a new key version and makes it current; object data is never re-encrypted — the background worker re-wraps each object's small wrapped-DEK to the new version (verified per object: sidecar update is atomic and the unwrapped DEK is compared byte-for-byte), and legacy directly-encrypted objects (pre-envelope) are now fully converted to a current-KEK envelope during the same pass (decrypted with their original key, staged, re-encrypted and read-back-verified, with the original ciphertext as the restore source). Old key versions are deliberately kept forever: they cost nothing, the recovery bundle includes them, and deleting one would orphan any file still referencing it. Safety tie-ins: rotating resets the recovery-bundle-downloaded flag so the console banner reappears prompting a fresh bundle download (the old bundle lacks the new key), and the worker pass starts immediately. In a cluster the new key is created cluster-shared, pushed to all healthy peers at rotation time and reconciled every 60s by the global-config sync (nodes offline during the rotation catch up automatically; raw replication degrades per-node to the legacy path until they do). UI: current version + "Rotate key" button with confirmation in Settings → Security, translated in all 9 UI languages. Verified end-to-end with the real binary: upload (v1) → download bundle → rotate → status flips to v2/not-downloaded → worker re-wrapped the sidecar to v2 with byte-identical object data → download returns the content intact. (internal/kek/store.go,internal/object/encryption_migration.go,internal/server/encryption_recovery_handlers.go,internal/cluster/global_config_sync.go,web/frontend/src/pages/settings/EncryptionRecovery.tsx) - Ciphertext HA replication (cluster-shared KEK) — HA write fanout no longer decrypts on the primary and re-encrypts on each replica. On the first cluster join, the initiating node creates a cluster-shared KEK (next free version, marked current) and ships every cluster-shared key inside the existing join package (same channel as the JWT secret); each joining node adopts them — keeping its own local v1 for its pre-join objects — and a version conflict (same version, different key material) aborts the join with a clear error. From then on every node wraps new objects with the same KEK, so the fanout sends the stored ciphertext + sidecar + Pebble metadata as-is (
X-HA-Rawheaders); the replica stores a byte-identical copy (with full replica bookkeeping: Pebble entry, metrics, implicit folders) and decrypts only on read. Objects not yet eligible (plaintext, legacy direct-encrypted, or wrapped with a node-local KEK) transparently use the previous decrypt/re-encrypt path and converge to raw-replicable as the rotation worker re-wraps them; a replica that cannot decrypt a KEK version declines with 412 and the primary falls back per node. Newcluster_sharedcolumn onencryption_keys(migration 17). Verified end-to-end with a real 2-node cluster: join created/adopted KEK v2 on both nodes, an uploaded object landed on the replica with byte-identical ciphertext and wrapped DEK, and the replica decrypted it locally with its own key store. (internal/kek/,internal/cluster/ha_object_manager.go,internal/cluster/manager.go,internal/server/cluster_handlers.go,internal/server/cluster_object_handlers.go,internal/object/raw_replication.go) - Encryption recovery bundle (Settings → Security) — the KEK now lives in the database, so a lost database without a key backup would mean losing every encrypted object. Global admins can download a recovery bundle: an export of all KEK versions encrypted under an admin passphrase (PBKDF2-SHA256, 310k iterations + AES-256-GCM; the passphrase is never stored). The console shows an informative banner to global admins until the bundle has been downloaded (tracked in
system_settings), linking to the new section in Settings → Security with passphrase + confirmation inputs.kek.DecryptBundle()provides the opening side for the future offlinemaxiofs recovertooling. New endpointsGET /api/v1/settings/encryption/recovery-statusandPOST /api/v1/settings/encryption/recovery-bundle(global admin only). Translated in all 9 UI languages. Verified end-to-end: fresh server → status not-downloaded → short passphrase rejected (400) → bundle downloads with correct headers → status flips → bundle decrypts and its KEK matches the database's byte-for-byte. (internal/kek/bundle.go,internal/server/encryption_recovery_handlers.go,web/frontend/src/pages/settings/EncryptionRecovery.tsx,web/frontend/src/components/layout/RecoveryBundleBanner.tsx) - Per-tenant bandwidth throttling — global admins can cap a tenant's aggregate transfer bandwidth (upload + download combined) so one client can't saturate the link for others. It throttles (slows), never rejects — legitimate bursts are smoothed, not failed. All of a tenant's concurrent transfers share one token-bucket budget (
internal/bandwidth), enforced on S3 object up/downloads and multipart parts (pkg/s3compat). Buckets inherit their tenant's limit (global buckets are exempt); changes apply hot without restart. New tenant fieldmax_bandwidth_bytes_per_sec(0= unlimited) with SQLite migration, tenant API, and a "Max Bandwidth (MB/s)" field in the tenant form (translated in all 9 UI languages). Verified end-to-end: a tenant capped at 2 MB/s transferred 20 MB in ~10 s (vs ~1 s unlimited), the cap updated live, and two concurrent transfers shared the budget. In a cluster the cap is set once and synced to every node via tenant-sync, then enforced per-node. (internal/bandwidth/,internal/auth/,pkg/s3compat/,internal/server/console_api.go,internal/cluster/tenant_sync.go, tenant UI) - Per-bucket storage quota — buckets can now have their own size and object-count limits, independent of (and in addition to) tenant quotas. This closes the gap where only tenants had quotas, so a global bucket (no tenant) — e.g. a dedicated Veeam/backup target — could not be capped. Highlights:
- Model: optional
Quota { maxSizeBytes, maxObjectCount }on bucket metadata (0= unlimited for that dimension), persisted in Pebble. Current usage reuses the already-cached bucketTotalSize/ObjectCount, so no new counter is introduced. - Enforcement: uploads that would exceed the cap are rejected with S3
QuotaExceeded(bothPutObjectandCompleteMultipartUpload, the path Veeam uses) and403on the console upload endpoint. Correct handling of overwrites (net delta, not double-counted), versioned buckets (each version counts), the object-count cap (only on new keys), and HA replica writes (counter updated, enforcement bypassed on replicas). For tenant buckets, both the bucket quota and the tenant quota apply — the most restrictive wins. - Veeam/SOSAPI exposure: the SOSAPI
capacity.xmla client reads per bucket now advertises capacity with precedence per-bucket quota → tenant quota → physical disk, so Veeam (and any SOSAPI client) sees the real bucket limit and its live usage (Capacity/Used/Available), not just the disk or tenant figure. - API + UI:
GET/PUT/DELETE /api/v1/buckets/{bucket}/quota(returns quota + live usage; routed to the bucket's owner node in a cluster). New Quota tab in bucket settings with a usage bar, translated in all 9 UI languages. - Validation: a tenant bucket's size quota may not exceed the space assigned to its tenant (
maxSizeBytes ≤ tenant.MaxStorageBytes), rejected with400. - Alerts: SSE + email notifications (
bucket_quota_warning/bucket_quota_critical, with a matchingbucket_quota_resolved) fire as a bucket approaches its quota, using the existing warning/critical disk thresholds and escalation-only de-duplication — mirroring the tenant quota alert system. - Cluster: quota is enforced/aggregated correctly per bucket without cross-node summing (buckets are placed on an owner node and HA replicas are full mirrors, so the owner's local total is authoritative — summing would double-count).
Changed¶
- Removed the vestigial RBAC stub and its SEC-03 startup warning — the
CheckPermission/CheckBucketPermission/CheckObjectPermissionchain was unreachable dead code from a superseded design: its only entry point (AuthorizeRequest) had no callers, while fine-grained authorization has been enforced since v1.4.0 by the live stack (roles + tenant scoping, the 11-capability role system on both S3 and console, bucket policies with Condition evaluation, and ACLs). The startup warning misled operators into believing permissions were inactive.GetS3Action/GetResourceARNare kept as primitives for the future IAM policy engine. (internal/auth/manager.go,internal/auth/s3auth.go,SECURITY_AUDIT.md) - Removed the broken
make docker-clustertargets —docker-clusteranddocker-cluster-monitoringreferenced aclustercompose profile that never existed, so they silently started a single node while the Makefile and docs promised a 3-node cluster at :8081/:8083/:8085. Beyond being broken, a multi-node cluster inside one Docker host provides no real HA (shared disk/power/kernel) — the targets and their documentation are gone, and CLUSTER.md now carries explicit guidance for containerized deployments (one node per physical host, local volume pinning, port 8082 between hosts). (Makefile,docker/README.md,docs/DEPLOYMENT.md,docs/CLUSTER.md) - Bucket creation no longer shows an encryption opt-out — the "enable encryption" checkbox in the create-bucket dialog was misleading: unchecking it never stored anything in plaintext (server-side encryption is always on). The Encryption tab is now informational only, and the creation request always records
AES256on the bucket. (web/frontend/src/pages/buckets/create.tsx)
Fixed¶
- Console: the notification stream now reconnects automatically — the SSE connection that delivers disk/quota/bucket alerts to the console had no reconnection: a dropped stream (server restart, reverse-proxy idle timeout, network blip) silently ended alert delivery for the rest of the session until a full page reload. The hook now reconnects with exponential backoff (1s → 30s max, reset after a successful connection). Also hardened the four
dangerouslySetInnerHTMLtranslation renders to route through the shared HTML sanitizer (i18n runs withescapeValue: false, so any future user-influenced interpolation in those keys would have reached the DOM unescaped), and throttled the idle-timer'slocalStorageactivity writes to once per second (previously a synchronous write per mousemove). (web/frontend/src/hooks/useNotifications.ts,web/frontend/src/hooks/useIdleTimer.ts,web/frontend/src/lib/modals.tsx,web/frontend/src/components/BucketIntegrityModal.tsx,web/frontend/src/components/ObjectLockConfigModal.tsx) - Windows: a transient sharing violation no longer orphans a deleted object — on Windows, an external scanner (antivirus, search indexer) briefly holding a just-written file without
FILE_SHARE_DELETEmade the physical delete fail with "used by another process"; since the metadata had already been removed, the file stayed behind as an orphan and, worse, HEAD/GET found it again through the sidecar fallback — the object appeared to survive its own deletion. The filesystem backend now retries the file removal briefly (5 attempts with backoff) before giving up. Reproduced consistently by theTestDeleteObjectErrorCasesflake, now 20/20 green. (internal/storage/filesystem.go) - Legacy HA transfers now preserve client checksums — objects replicated through the decrypt/re-encrypt path (plaintext/legacy objects, or a replica missing the cluster KEK) lost their
x-amz-checksum-*value: the replica's metadata had noChecksumAlgorithm/ChecksumValue, soGetObjectAttributesanswered differently depending on which node served it. All legacy senders (write fanout, sync worker, anti-entropy push) and the internal HA GET now forward the checksum headers; the receiver recomputes and validates them, which also catches a corrupted transfer in flight. (internal/cluster/ha_object_manager.go,internal/cluster/ha_sync_worker.go,internal/cluster/anti_entropy.go,internal/server/cluster_object_handlers.go) - Concurrent KEK rotations no longer collide —
Rotate,EnsureClusterKeyandAdoptClusterKeyscomputed the next key version without serialising against each other, so two concurrent calls (e.g. rotation racing the periodic cluster sync) could pick the same version and fail on the UNIQUE constraint with a cryptic error. Multi-step KEK mutations are now serialised by a store-level write lock, andAdoptClusterKeysapplies the whole received key set in a single SQL transaction (a mid-way failure can no longer leave a partial adoption). (internal/kek/store.go) - KEK sync endpoint returns 400 for malformed payloads (409 only for real conflicts) — every adoption failure was answered with 409 Conflict, including malformed key material that is a plain bad request. A new
kek.ErrKeyConflictsentinel marks the genuine same-version-different-material case; the handler maps it to 409 and everything else to 400. (internal/kek/store.go,internal/server/encryption_recovery_handlers.go) - KEK distribution after a rotation survives a dropped admin connection — the post-rotation push to cluster peers ran under the HTTP request's context, so an admin closing the browser mid-request cancelled the distribution (peers kept wrapping with the old version until the next 60s sync). The push now runs under the server context. Also
GlobalConfigSyncManager.Stop()is idempotent (a second call panicked on double channel close). (internal/server/encryption_recovery_handlers.go,internal/cluster/global_config_sync.go) - A write acknowledged as successful could be invisible in listings — when the Pebble metadata save failed after the object file was already stored (disk full, I/O error),
PutObjectonly logged a warning and returned success: the S3 client got a 200 for an object that no listing would ever show (backup software verifies uploads by listing — a silent gap). The write now fails with an error so the client retries; the stored data file is deliberately kept (on a non-versioned overwrite it is the only copy, and sidecar-only files are exactly whatmaxiofs recoverreindexes). The raw HA replica write applies the same contract, so the primary counts the node as unconfirmed and the sync worker retries instead of leaving an invisible replica. (internal/object/manager.go,internal/object/raw_replication.go) - Encryption worker: a stale checkpoint skipped the entire pass — if the worker was interrupted mid-pass and its checkpointed bucket was deleted before the next run, the resume logic waited forever for a bucket that no longer existed and silently skipped every remaining bucket, ending the pass as "done" with nothing converted. A checkpoint pointing at a nonexistent bucket now falls back to a full scan. (
internal/server/encryption_worker.go) maxiofs recover: objects with an unverifiable DEK no longer disappear from the rebuilt index — an envelope object whose wrapped DEK failed to unwrap with the bundle keys (e.g. corrupt sidecar entry) was reported as a failure but EXCLUDED from the rebuilt Pebble store, making bytes that still exist on disk invisible in every listing — unrecoverable in practice. Such objects are now indexed (counted as encrypted-unverified) with the failure kept in the report for the operator. (internal/recovery/recovery.go)maxiofs recover: KEK restore could leave two keys marked current — restoring a bundle into a database that already held encryption keys with its own current marker inserted the bundle's current flag alongside the existing one, leaving twois_current=1rows and making the server's bootstrap pick one non-deterministically. Bundle keys are now always inserted non-current and the marker is applied once, transactionally: the database's existing current key wins (it is more recent than any external bundle); the bundle's current is applied only when the database had none. (internal/recovery/recovery.go)- A quota-rejected overwrite destroyed the existing object — on a non-versioned bucket,
PutObjectstored the new bytes over the final object path BEFORE validating the tenant/bucket quota; when the quota rejected the write, the cleanup deleted that path — which by then held the only copy, so the client got a quota error AND lost the object it was updating (its stale Pebble entry pointed at nothing). The quota check now runs before the store step (the original size is already known from the streaming stage), so a rejection leaves the existing object completely untouched. Regression tests cover both the bucket-quota and tenant-quota paths. (internal/object/manager.go) - Crash window in object overwrites could leave an undecryptable object — the filesystem backend committed an overwrite as two consecutive renames (sidecar first, then data). A crash or failure between them left the NEW sidecar (new DEK) over the OLD bytes — undecryptable under envelope encryption — and a failed data rename deleted the previous object's sidecar entirely. Writes now use a staged-sidecar two-phase commit: the new sidecar is staged at
<object>.metadata-staging, the data is renamed into place (data commit), then the stage is renamed over the final sidecar (metadata commit). A crash at any point is resolved deterministically on the next access: if the stored bytes match the staged etag the metadata commit is completed (roll forward), otherwise the stage is discarded and the old pair remains fully intact (roll back). Per-path striped locks keep concurrent readers from resolving an in-flight stage; staged files are excluded from listings and frommaxiofs recover; object keys ending in.metadata/.metadata-staging(which always collided with sidecar files) are now rejected. (internal/storage/filesystem.go,internal/object/manager.go,internal/recovery/recovery.go) - Replicas and recovered stores no longer falsify
LastModified(perpetual anti-entropy churn on multipart objects) — the Pebble store stampedLastModified = nowon every metadata write, so an HA replica recorded its receive time instead of the primary's modification time, and amaxiofs recoverrebuild stamped recovery time instead of the original sidecar timestamp. Consequences: the anti-entropy scrubber's multipart comparison (size + mtime ±1s; multipart ETags can't be checksum-compared) flagged every replicated multipart object as divergent on every cycle and ping-ponged full re-transfers forever, LWW could prefer a stale copy just because it was written locally later, and lifecycle timers reset after recovery. Pebble now preserves a caller-providedLastModified(stamping only when unset), raw HA transfers already carry the primary's metadata, legacy HA transfers propagate it via a newX-HA-Last-Modifiedheader (set by write fanout, sync worker and anti-entropy push; returned by the HA GET endpoint and honored by both pull paths), and recovery keeps the sidecar's original timestamp. (internal/metadata/pebble_objects.go,internal/object/manager.go,internal/cluster/ha_object_manager.go,internal/cluster/ha_sync_worker.go,internal/cluster/anti_entropy.go,internal/cluster/stale_reconciler.go,internal/server/cluster_object_handlers.go) - HA object replication requests were 404ing against the dedicated cluster port — every HA sender (write fanout, HA sync worker, anti-entropy repair, stale-node reconciler) targeted
/api/internal/ha/..., but since the dedicated inter-node port was introduced those routes have been registered under/api/internal/cluster/ha/.... Result: synchronous write fanout, catch-up sync and anti-entropy repair silently failed with 404 on every call (withfactor=2zero replica confirmations are required, so writes still "succeeded" without ever replicating — matching previously observed ghost sync errors). All 13 sender URLs now use the registered prefix; verified with a real 2-node cluster where fanout previously 404'd and now lands replicas. (internal/cluster/ha_object_manager.go,internal/cluster/ha_sync_worker.go,internal/cluster/anti_entropy.go,internal/cluster/stale_reconciler.go) - S3 API → console redirect now honors
public_console_url(surfaced via GitHub issue #6) — when a browser hits the S3 API, it is redirected to the web console; that redirect hardcoded the internal console port (8081) and only usedpublic_console_urlwhenX-Forwarded-*proxy headers were present. With direct or port-mapped access (Docker39000:8081, NAT) there are no such headers, so the redirect pointed at the inaccessible internal port. The redirect now usespublic_console_urlwhenever it is configured, independent of proxy-header detection; the request-derived fallback applies only when it is unset. (internal/api/handler.go) - Identical
public_api_urlandpublic_console_urlare now rejected at startup (surfaced via GitHub issue #6) — the S3 API and the web console are distinct services; when both public URLs are set to the exact same value, generated links collide (S3 share/presigned URLs, which usepublic_api_url, would point at the console) and a reverse proxy cannot tell S3 traffic from console traffic. Startup validation now fails with a clear message when the two normalized URLs match, requiring a distinct hostname, port, or path prefix for each. (internal/config/config.go) - TLS enabled via config file is now honored (GitHub issue #6) — setting
enable_tls: truewithcert_file/key_filein the configuration file had no effect: the server still started on plain HTTP and logged"TLS disabled - servers will use HTTP". The startup code treated the--tls-cert/--tls-keyCLI flags as the sole switch for TLS and, when they were absent (the common Docker/config-file setup), forcedEnableTLS = false, overriding the value already loaded from the config file. TLS resolution now respects the merged configuration: viper already mergescert_file/key_filewith the correct precedence (CLI flag > env > config file), the CLI flags additionally act as an explicit switch that enables TLS, and in their absence the config-fileenable_tlsvalue is preserved instead of being clobbered. Config-file-driven HTTPS now works. (cmd/maxiofs/main.go) - Object share URLs are now percent-encoded — sharing an object whose key contained spaces,
#,[], or non-ASCII/UTF-8 characters (accents, emoji) produced a broken share URL: the key was interpolated raw, so the#truncated the key at the URL fragment and spaces/emoji made the link invalid, and the shared object failed to resolve. The share URL is now built with an AWS S3-compatible path encoder (s3EncodePath) that preserves/and RFC 3986 unreserved characters while percent-encoding every other byte (space→%20,#→%23,❌→%E2%9D%8C,💸→%F0%9F%92%B8, etc.), so the generated link round-trips back to the exact key and resolves the real object. Applied to all four URL constructions inhandleShareObject(both the existing-share and new-share branches, with and without a configuredPublicAPIURL). Verified end-to-end against the S3 API: uploading an object with such a key and opening its share URL now returns200with the correct content. (internal/server/console_api.go)
[1.4.2] - 2026-06-30¶
Changed¶
- Dependencies updated (Go + frontend) — Go modules bumped to their current minor/patch releases:
aws-sdk-go-v2v1.41.12→v1.42.0,aws-sdk-go-v2/service/s3v1.103.2→v1.104.1,aws-sdk-go-v2/credentialsv1.19.22→v1.19.25,modernc.org/sqlitev1.52.0→v1.53.0, plus transitive indirect bumps. No major-version changes; the Pebble metadata engine (cockroachdb/pebble/v2v2.1.6, withpebblev1.1.5 retained only for legacy v1→v2 migration) is untouched. Frontend npm dependencies were refreshed within their existing semver ranges (package-lock.jsononly;package.jsonunchanged).go build ./...,go mod verify, the Go core test packages (auth/metadata/object), the frontend production build, and the full vitest suite (106 tests) all pass on the updated set. (go.mod,go.sum,web/frontend/package-lock.json)
Security¶
- Timing attack on S3 secret key comparison eliminated —
ValidateCredentialscompared secret keys with the native!=operator, allowing character-by-character timing side-channel attacks. Replaced withcrypto/subtle.ConstantTimeCompare. (internal/auth/manager.go) - Account-status enumeration via different error codes fixed —
ValidateConsoleCredentialsran bcrypt before checkinguser.Status, leaking whether a valid disabled account existed. Active status is now verified before the password check; both paths returnErrInvalidCredentialson failure. (internal/auth/manager.go) - RBAC stub now emits a startup warning and logs all denials —
CheckPermissionwas a silent stub that returnedErrUnauthorizedwithout logging.NewManagernow logs a prominent warning that RBAC enforcement is in stub mode; every denied call is logged with user, action, and resource fields for audit trail visibility. (internal/auth/manager.go) - S3 access secret keys are now encrypted at rest (AES-256-GCM) — access keys were stored and loaded in plaintext from SQLite.
GenerateAccessKeynow encrypts secrets before persisting (enc:prefix + base64url(nonce‖ciphertext));GetAccessKeydecrypts on load. Existing plaintext keys are returned as-is (backward compatibility). Encryption key derived withderiveStorageKey()using PBKDF2-SHA256 over the JWT secret, 310 000 iterations, 32-byte output. (internal/auth/manager.go) - Inter-node proxy loop header now stripped at ingress — the
X-MaxIOFS-Proxiedloop-detection header and all otherX-MaxIOFS-*cluster-internal headers can be spoofed by an external client, allowing request smuggling into the cluster's internal routing.StripInternalClusterHeadersis called on every inbound proxied request inProxyReadandTryProxyReadbefore the request is forwarded. (internal/cluster/proxy.go,internal/cluster/manager.go) - Key derivation upgraded from single-pass SHA-256 to PBKDF2-SHA256 —
DeriveKeyin the encryption package usedsha256.Sum256(append(password, salt...))with the hardcoded salt"maxiofs-salt". Replaced withpbkdf2.Keywith a minimum of 310 000 iterations (NIST SP 800-132 recommendation for AES-256), enforced even whenEncryptionConfig.KeyDerivationRoundsis set lower. (pkg/encryption/encryption.go) - Legacy SHA-256 password function marked as deprecated —
hashPasswordSHA256is retained only for reading legacy records; a// SEC-07: Do NOT use for new passwordscomment prevents accidental reuse. All new passwords go through bcrypt viaSQLiteStore.CreateUser. (internal/auth/manager.go) - Tenant-scoped admins no longer receive cross-tenant SSE notifications — notification filtering treated any user with the
adminrole as global, even whenTenantIDwas set. Global delivery now requiresrole == "admin"and an empty tenant; tenant admins receive only notifications matching their tenant. (internal/server/sse_notifications.go) - Console API request bodies are now capped at 1 MiB where uploads are not expected — mutating Console API requests now pass through
http.MaxBytesReaderby default, independent ofContent-Type, while preserving large object uploads through the console. (internal/server/console_api.go)
Fixed¶
PutObjectquota TOCTOU eliminated with atomic SQL CAS — two concurrent uploads could both passCheckTenantStorageQuotabefore either incremented the counter, allowing tenants to exceed their limit by up to 2× the upload size in the worst case.IncrementTenantStoragenow uses a single conditional UPDATE:SET current_storage_bytes = current_storage_bytes + ? WHERE … current_storage_bytes + ? <= max_storage_bytes. If 0 rows are affected the quota is exceeded andErrStorageQuotaExceededis returned atomically. (internal/auth/tenant.go)GET /api/v1/usersnow returns the real username instead of the internal user ID — the list-users response populatedusernamefromUser.ID, which broke SSO/imported accounts where IDs are generated (user-...) and usernames are emails or provider names. (internal/server/console_api.go)- Global-admin checks no longer depend on role ordering — settings and integrity endpoints now use the shared admin-role check plus
TenantID == ""/TenantID != "", so global admins remain global regardless of role order while tenant admins stay tenant-scoped. (internal/server/console_api.go,internal/server/object_integrity_handler.go) - Console refresh responses and object tags are now unwrapped consistently in the frontend — the API client now accepts both plain and
{ success, data }responses, preventing silent token-refresh failures and empty tag editors when the backend returns wrapped Console API payloads. (web/frontend/src/lib/api.ts) - Group screens and object helper actions now unwrap Console API responses consistently — group list/detail/member endpoints plus object rename and folder-size responses now accept both plain and
{ success, data }payloads, preventing empty group pages/dropdowns and incorrect folder-size results when the backend returns wrapped payloads. (web/frontend/src/lib/api.ts) - Bucket permissions are now scoped by the bucket tenant — permissions now store and filter
bucket_tenant_id, so buckets with the same name in different tenants no longer share, overwrite, list, or revoke each other's grants. Cluster permission sync and bucket migration payloads include the new scope. (internal/auth/permissions.go,internal/server/console_api.go,internal/db/migrations/migration14_bucket_permission_scope.go,internal/cluster/bucket_permission_sync.go,internal/cluster/migration.go) - Global admins can manage object ACL and legal hold on tenant buckets — object ACL and legal-hold handlers now honor the
tenantIdquery parameter for global admins, matching the existing object tags/restore behavior and avoiding accidental lookups against a same-named global bucket. (internal/server/console_api.go) - Versioning metrics race eliminated with per-key shard mutex — two concurrent writers to the same object key both read the same
existingObjBeforeSavesnapshot and applied the same size delta to the bucket counter, permanently corrupting bucket metrics.objectManagernow has amuShards [256]sync.Mutexarray;lockKey(bucket, key)hashes the key to a shard and holds the lock for the entire read-existing / write-metadata / update-metrics sequence, serialising writes to the same key. (internal/object/manager.go) deleteSpecificVersionno longer uses a stale version snapshot — the function readGetObjectVersionsbefore the delete and then computed the "next latest" from that pre-delete snapshot. A concurrentPutObjectbetween the snapshot andPutObjectVersioncould mark an older version as latest, hiding the new write from subsequent GETs. The function now re-queriesGetObjectVersionsafterDeleteObjectVersionto obtain a fresh post-delete list. (internal/object/manager.go)fanoutPutnow pins replication to the exact locally-written version — the HA fanout re-read the object withGetObject(ctx, bucket, key)after the local write, with no version pin. Under concurrent load a newer version could have been written by then and that newer version would be replicated instead.fanoutPutnow accepts aversionID stringand callsGetObject(ctx, bucket, key, versionID)when non-empty, so only the intended version is replicated. (internal/cluster/ha_object_manager.go)- Login rate limiter TOCTOU fixed with atomic
CheckAndRecord—AllowLogin(read lock) andRecordFailedAttempt(write lock) were separate calls: two goroutines could both pass the check before either incremented the counter. AddedCheckAndRecord(ip string) boolthat atomically checks the limit and increments under a single write lock.authManager.CheckRateLimitnow callsCheckAndRecord. (internal/auth/rate_limiter.go,internal/auth/manager.go) - Cleanup goroutines in rate limiters no longer leak on shutdown —
LoginRateLimiter.cleanupLoopandInMemoryRateLimitStore.cleanupRoutinehad no exit condition, leaking goroutines if the limiter was replaced or the server shut down. Both structs now carry astopCh chan struct{}; the goroutinesselecton it alongside the ticker. A newStop()method closes the channel. Additionally,authManager.SetSettingsManagernow callsStop()on the existing limiter before replacing it with a settings-configured one, so the limiter created in the constructor no longer leaks its cleanup goroutine on every startup. (internal/auth/rate_limiter.go,internal/auth/manager.go,internal/middleware/ratelimit.go) - Proxy no longer buffers entire request body in RAM —
ProxyRequestandProxyToNodeAPIURLcalledio.ReadAll(originalReq.Body), attempting to allocate the full request size (up to 10 GB for large PUTs) on the heap. Both functions now passoriginalReq.Bodydirectly as the outgoing body withdefer originalReq.Body.Close(). (internal/cluster/proxy.go) SearchObjectsscan limit reduced from 100 000 to 10 000 when using delimiter — the hardcodedscanLimit = 100000whendelimiter != ""could load 100 k metadata records into RAM on large buckets. The limit is nowmax(maxKeys*10, 10000), giving 10× headroom over the requested page size while capping the worst-case allocation. (internal/object/manager.go)UpdateObjectMetadatareturned 404 for all objects in versioning-enabled buckets — the function always checkedstorage.Existsat the plain (non-versioned) path. Versioned objects have no file there. The metadata store is now queried first; whenmetaObj.VersionID != ""the path is resolved viagetVersionedObjectPath, matching the logic inGetObjectMetadata. (internal/object/manager.go)validateObjectNamenow rejects path-traversal sequences missed by string checks —strings.Contains(key, "../")can be bypassed with sequences likea/b/../../etc/passwd. Validation now splits the key on/and rejects it when any path segment is exactly..(and rejects absolute paths beginning with/), catching traversal regardless of where it appears in the key. (internal/object/manager.go)unsafe-evalremoved from console Content-Security-Policy —ConsoleHeaders()included'unsafe-eval'inscript-src, enablingeval(),Function(), andsetTimeout(string)— the primary escalation vector for XSS payloads in modern SPAs. Vite/React production builds never emiteval(), so the directive is removed with no functional impact. (internal/middleware/s3headers.go)Strict-Transport-Security(HSTS) now set on the web console endpoint —S3Headers()already sent the HSTS header butConsoleHeaders()did not, leaving the web UI vulnerable to HTTPS-stripping on a user's first unprotected request.max-age=31536000; includeSubDomainsis now included in both middleware functions. (internal/middleware/s3headers.go)generateID()in share manager now propagatescrypto/randerrors — the function silently discarded the error fromrand.Read, so on entropy exhaustion it would return the all-zero hex string"000…000", causing silent ID collisions or UNIQUE-constraint failures in the shares table. The function now returns(string, error)andCreateSharepropagates the failure to the caller. (internal/share/manager.go)- Object storage directories created with restrictive permissions (0750) —
os.MkdirAllused mode0755(world-readable/executable) for the storage root and all object subdirectories. On a shared host any local OS user could read object files directly from disk, bypassing S3 access controls, encryption, and audit logging. Changed to0750so only the process owner and its group can access the data. (internal/storage/filesystem.go) ValidateRefreshTokennow rejects inactive/suspended accounts — the access-token validation path (ValidateJWT) was already patched to block deactivated users, but the refresh-token path fetched the user record without checkinguser.Status. A deactivated account could therefore exchange a still-valid refresh token for a fresh access+refresh pair, bypassing the account-deactivation control. The sameuser.Status != UserStatusActiveguard is now applied inValidateRefreshToken. (internal/auth/manager.go)- Bucket inventory could not be turned off from the web console once enabled — the Inventory tab nested the Save button (and the entire configuration body) inside the
{inventoryForm.enabled && (…)}conditional, so un-checking "enabled" hid the only control that could persist the change and left inventory stuck on. The tab now keeps a single, always-visible Save button: un-checking "enabled" and saving turns inventory off (the backend no longer requires a destination bucket whenenabled=false), and the destination is still required only while enabling. Save is enabled only when the form actually differs from the saved configuration, so clicking it with no pending changes can no longer trigger a no-op save error. The Delete button was removed (disabling and saving is how inventory is turned off). Additionally,getBucketInventorynow returnsnullwhen the backend reports no configuration — the no-config response is{ "success": true }with nodatafield, and the previousresponse.data?.data ?? response.data(and a naive unwrap) fell back to that truthy wrapper, making the UI behave as if a configuration existed. (web/frontend/src/pages/buckets/[bucket]/settings.tsx,web/frontend/src/lib/api.ts,internal/server/inventory_handlers.go) - Collapsed sidebar hid every item under the Users submenu — when the sidebar was collapsed, a parent navigation item with children rendered only the parent icon (linking to
/users), so the submenu entries (Groups, Access Keys, Tenants, Identity Providers, Role Capabilities) became unreachable. Collapsed mode now flattens a parent's children into individual top-level icons at the same level as every other item, each keeping its own tooltip, route, and exact active-state highlight, and still honoring the existing permission filtering. (web/frontend/src/components/layout/SidebarNav.tsx)
[1.4.1] - 2026-05-18¶
Added¶
- Event-driven cluster config sync — all mutating handlers now fan out immediately — previously, all sync managers (tenant, user, access key, bucket permission, IDP provider, group mapping) propagated changes to peer nodes only on a periodic timer (default 30 s), so a user created on node A could be rejected by node B for up to 30 seconds. Every sync manager now exposes a
TriggerSync(ctx)method that launches an immediate full sync to all healthy nodes in a background goroutine without blocking the HTTP response. All mutating handlers (create/update/delete) for tenants, bucket permissions, IDP providers, and group mappings callTriggerSyncon success; user, access key, group, and capability handlers already had this wired. The periodic polling loop remains as a reconciliation safety net for nodes that were temporarily unreachable. (internal/cluster/tenant_sync.go,internal/cluster/bucket_permission_sync.go,internal/cluster/idp_provider_sync.go,internal/cluster/group_mapping_sync.go,internal/server/console_api.go,internal/server/console_idp.go) - Multilingual UI — 9 languages now supported — the web console previously supported only English and Spanish. All 19 i18n namespaces (~2,200 string keys per locale) are now translated into French (
fr), German (de), Italian (it), Brazilian Portuguese (pt), Simplified Chinese (zh), Japanese (ja), and Russian (ru). English is bundled in the main JS chunk as the guaranteed fallback; all other languages are loaded on demand as a single Vite chunk per locale (lang-es.js,lang-fr.js, …lang-ru.js) viaimport.meta.glob+manualChunks, so switching language triggers exactly one extra network request and the main bundle is unaffected. TheLanguagetype union,getSavedLanguage()guard,LanguageContext,TopBar,UserPreferences,AppLayout, anduseAuthare all extended to accept all nine locale codes. The TopBar language selector uses anameKeyfield on each entry so the language name is resolved through the active locale's translations rather than a hardcoded fallback chain (the previous chain silently showed "Portuguese" for any unrecognised code). (web/frontend/src/locales/{es,fr,de,it,pt,zh,ja,ru}/,web/frontend/src/i18n.ts,web/frontend/src/contexts/LanguageContext.tsx,web/frontend/src/components/layout/TopBar.tsx,web/frontend/src/components/preferences/UserPreferences.tsx,web/frontend/src/components/layout/AppLayout.tsx,web/frontend/src/hooks/useAuth.ts,web/frontend/src/locales/{en,es,fr,de,it,pt,zh,ja,ru}/layout.json,web/frontend/src/locales/{en,es,fr,de,it,pt,zh,ja,ru}/preferences.json)
Removed¶
- BadgerDB removed from the codebase — Pebble is now the sole metadata engine —
github.com/dgraph-io/badger/v4has been dropped fromgo.modandgo.sumalong with all BadgerDB implementation files (badger.go,badger_objects.go,badger_rawkv.go,badger_multipart.go,badger_test.go,badger_comprehensive_test.go) and the Badger→Pebble migration logic (migration.go,migration_recovery_test.go). All key-builder functions (bucketKey,objectKey,multipartUploadKey, etc.),ErrNotFound,extractObjectKeyFromKey,matchesFilter,matchesTags, andhasPrefixthat were previously defined in the Badger files and reused by the Pebble implementation have been moved verbatim into a new shared fileinternal/metadata/keys.goso all existing Pebble behaviour is unchanged.server.gono longer callsMigrateFromBadgerIfNeededon startup. The consistency test (store_consistency_test.go) now runs only against PebbleStore.internal/metrics/badger_history.gois deliberately preserved — despite the name it does not import BadgerDB and operates via themetadata.RawKVStoreinterface backed by Pebble. Any installation that was still running on BadgerDB metadata (pre-v1.0.0-beta) would need to have migrated first using a v1.x release; upgrading directly from such an installation to this version will start with an empty metadata store. (internal/metadata/keys.go,internal/metadata/pebble_store.go,internal/metadata/store_consistency_test.go,internal/metadata/pebble_test.go,internal/server/server.go,go.mod,go.sum)
Fixed¶
EntityIsNewerThanTombstoneLWW check and tombstone insert are now atomic —applyRemoteTombstonesperformed the LWW freshness check (EntityIsNewerThanTombstone) and the tombstone insert (RecordDeletion) as two separate database operations with no locking in between. A concurrent goroutine deleting the same entity locally could slip in between: the check would see the entity as newer (correct), but by the timeRecordDeletionran, the entity was already gone — leaving the cluster with a missing tombstone and no live entity. Fixed by introducing asqlQuerierinterface (satisfied by both*sql.DBand*sql.Tx) and changing both functions to accept it. Each tombstone inapplyRemoteTombstonesis now processed inside a singleLevelSerializabletransaction, making the check and the insert one indivisible operation. (internal/cluster/deletion_log.go,internal/cluster/stale_reconciler.go)- User profile page leaked all access keys to global admins viewing their own profile — the user detail page (
/users/{id}) calledGET /api/v1/access-keys(the global admin list endpoint, which returns every key from every user) instead ofGET /api/v1/users/{id}/access-keyswhen the viewer was looking at their own profile (isEditingSelf = true). A global admin opening their own profile would see their own keys mixed with keys belonging to every other user in the system. The fix always uses the per-user endpoint regardless of whether the viewer is editing themselves or another user — theuserIdparameter is always available and unambiguous. (web/frontend/src/pages/users/[user]/index.tsx) TestClusterAuth_TimestampSkewupdated to match the ±30 s HMAC replay window — the test cases "4 minutes in past / future (should pass)" expected the old ±5-minute window and failed after H14 tightenedmaxSkewto 30 seconds. The cases now use ±20 seconds, which is within the window and still meaningfully distinct from the "6 minutes (should fail)" boundary cases. (internal/middleware/cluster_auth_test.go)FilesystemBackend.Putnow cleans up orphaned metadata when the data-file rename fails — after the A5 change (metadata renamed before data), a failed data rename left the metadata file at its final path with no corresponding data file.Getwould returnErrObjectNotFoundcorrectly (itos.Stats the data file first), but the orphaned.metadatafile accumulated on disk indefinitely. The failure path now explicitly removes the metadata file before returning the error. (internal/storage/filesystem.go,internal/storage/filesystem_test.go)- Replication credential minimum-length validation now documented in tests —
decryptAndValidateCredentialrejects any encrypted credential that decrypts to fewer than 8 characters, but the test used"secret"(6 chars) as thedestination_secret_key. The test now uses a realistic-length key, confirming that correctly-sized credentials round-trip through AES-256-GCM encryption and the length check without error. (internal/server/server_test.go) - Stale node now retries reconciliation with exponential backoff when no peers are available —
Reconcilewas called once at startup in a fire-and-forget goroutine; if no healthy peers existed at that moment it returnedniland exited, leavingis_stale = truepermanently until the server restarted. The function now returns a typed sentinelErrNoPeerswhen peers are absent (preservingis_staleso writes are still blocked), and the server launch loop retries with exponential backoff starting at 5 s, doubling up to a 5-minute cap, stopping when reconciliation succeeds or the server shuts down. (internal/cluster/stale_reconciler.go,internal/server/server.go) - Stale-node reconciliation mode detection is now race-free —
detectModepreviously readlast_local_write_atin a standalone query whileHAObjectManagercould concurrently set it, creating a TOCTOU window: a node that made writes during a network partition could be classified as offline, causingpushNewerEntitiesto be skipped and those writes to be permanently lost. The detection now uses a newsnapshotAndClearWriteFlaghelper that readsis_staleandlast_local_write_attogether inside a single serializable transaction and immediately clearslast_local_write_at. Any write arriving after the transaction commits sets the flag anew, cleanly starting the next reconciliation window rather than leaking into the current one. (internal/cluster/stale_reconciler.go) - HA quorum formula for factor=2 is now clearly documented — the formula
neededReplicas = ceil(factor/2) - 1produces 0 for factor=2, meaning the write is acknowledged once it succeeds locally without waiting for the peer. This was previously undocumented, causing it to appear as a bug. A detailed comment and quorum table have been added toreplicaTargetsexplaining the intentional design: requiring 1 peer confirmation with only 2 cluster nodes would block all writes whenever the single peer is unreachable, which is a worse trade-off than best-effort replication caught up by the stale-object reconciler. (internal/cluster/ha_object_manager.go) - Dead-node last-survivor check is now atomic —
markDeadIfSafepreviously read the non-dead node count in one query and then performed the status UPDATE in a separate statement, leaving a window where two concurrent callers (e.g., the periodic reconciler and a manual drain) could both observe a safe count and both mark their respective nodes dead, silently dropping the cluster below the configured replication factor. The count query and the UPDATE are now executed inside a singleBEGIN IMMEDIATE / SERIALIZABLESQLite transaction, ensuring no concurrent writer can change the non-dead count between the check and the write. (internal/cluster/dead_node_reconciler.go) - Cluster inter-node HMAC now covers the request body — the HMAC payload for cluster-internal API calls (
/api/internal/...) previously signed only method, path, timestamp, and nonce, leaving the body unprotected. An attacker with access to the intra-cluster network could intercept a signedPUTrequest, substitute an arbitrary body, and forward it to the target node — which would accept the modified payload as authentic. The payload is nowMETHOD\nPATH\nTIMESTAMP\nNONCE\nSHA256(body)on both the signing side (cluster/proxy.go) and the verification side (middleware/cluster_auth.go). Empty-body requests (GET, DELETE, HEAD) use the fixed SHA-256 of the empty string. (internal/cluster/proxy.go,internal/middleware/cluster_auth.go) FilesystemBackend.Putnow writes metadata before data to prevent silent corruption — the rename order was data-first, metadata-second. If the metadata rename failed (disk full, permission error), the data file was already at its final path and subsequent reads would open it successfully then fail on the metadata read — appearing as a corrupt object. The order is now metadata-first: if the metadata rename fails the data is still in the temp file (no visible change); if the data rename then fails the metadata is in place but the data file is absent, soGetreturns a cleanErrObjectNotFoundviaos.Stat. (internal/storage/filesystem.go)- Bucket size not decremented prematurely when physical versioned file delete fails —
deleteSpecificVersionswallowedstorage.Deleteerrors with a Warn log and still decremented the bucket size counter, making the bucket's reported size smaller than actual disk usage. The function now tracks aphysicalDeleteOKflag and passesfreedBytes = 0toDecrementObjectCountwhen the file could not be removed, preserving an accurate bucket size until the integrity scrubber recovers the orphaned file. (internal/object/manager.go) - Orphaned combined file on multipart upload metadata failure — if writing final object metadata (
PutObjectVersion/PutObject) failed afterCompleteMultipartUploadhad already physically combined all parts into the destination file, the combined file was left on disk indefinitely with no metadata record, accumulating unrecoverable storage. The failure path now deletes the orphaned file viastorage.Deletebefore returning the error. (internal/object/manager.go) - Lifecycle delete-marker cleanup now has tests that guard against infinite-loop regression —
processExpiredDeleteMarkersdeletes expired delete markers by callingDeleteObjectwith the marker's specificversionID. Without aversionID, callingDeleteObjecton a versioned bucket creates a new delete marker instead of removing the existing one, which would cause the lifecycle worker to loop indefinitely. Two tests were added: one that asserts the exactversionIDis forwarded toDeleteObject(preventing the unversioned-delete regression), and one that verifies the worker handles a concurrent deletion (the marker is already gone when the worker processes it) gracefully without panicking. (internal/lifecycle/worker_test.go) - HA replica writes no longer enforce tenant quota limits — the primary validates and increments the tenant quota before fanning a write out to replicas. Replicas called the full
PutObjectpath includingCheckTenantStorageQuota, which could reject a replicated write if the replica's local counter was already near the limit (e.g., because the primary incremented it in a prior cycle). Replicas must accept the write unconditionally (the primary already validated it) and only update their own local usage counter so they have an accurate view if they later become primary. A newWithBypassQuotaEnforcementcontext function in the object package is called bycluster.WithHAReplicaContext;PutObjectchecks this flag and skipsCheckTenantStorageQuotawhile still callingIncrementTenantStorage. (internal/object/manager.go,internal/cluster/ha_object_manager.go) computeMultipartETagnow rejects part ETags that are not valid 32-character MD5 hex strings — previously, whenhex.DecodeStringfailed on a part ETag it silently fell back tomd5.Sum([]byte(etag)), producing a different ETag than the S3 specification requires and masking data corruption (e.g., a truncated or misformatted ETag stored during an earlier bug). The fallback is removed; the function now returns an explicit error if any part ETag is not exactly 32 hex characters or cannot be decoded, preserving the integrity of the final object's ETag and making corruption detectable at upload-completion time rather than silently. (internal/object/manager.go)- Bucket metrics after versioned PUT no longer race against concurrent writes —
updateBucketMetricsAfterPutcontained a fallback path for the versioned-bucket branch: whenexistingObjBeforeSavewas nil it re-queriedGetObjectVersionsafter the save to decide whether to increment the object count or only adjust the size. Because the query ran after the save, a concurrentPutObjecton the same key could insert another version between the save and the query, making the total version count appear ≥ 2 even for a first-time upload — the size would be adjusted instead of the object count being incremented, silently undercounting the bucket. The fallback is removed; all callers are expected to supplyexistingObjBeforeSavecaptured before the save (the primary production call site atPutObjectalready did this correctly at line 617). The coverage test for additional versions was updated to fetch the pre-save state from the metadata store before the secondPutObjectcall. (internal/object/manager.go,internal/object/manager_final_coverage_test.go) - Replication credential decryption failures now surface as an error with an explicit log —
getRuleandGetRulecalleddecryptCredentialand returned its result without additional logging; if the key had rotated or the ciphertext was corrupt, the caller received a generic wrapped error with no indication of the root cause. A newdecryptAndValidateCredentialwrapper logs atErrorlevel before returning a typedErrDecryptionFailedsentinel, and additionally validates that an encrypted (enc1:-prefixed) credential decrypts to at least 8 characters — a partial plaintext write during a previous save could otherwise produce a valid but useless short string. Legacy plaintext credentials (no enc1: prefix) are returned as-is to preserve backwards compatibility. (internal/replication/credentials.go,internal/replication/manager.go,internal/replication/worker.go) - Lifecycle worker now paginates object expiration so objects beyond the first page are cleaned up —
processObjectExpirationfetched at most 10,000 objects in a single call and stopped, leaving older objects in buckets with more than 10,000 entries permanently unexpired. The worker now loops with a pagination marker untilIsTruncatedis false, processing 1,000 objects per page. A context-cancellation check at the top of each loop iteration ensures the worker stops promptly on server shutdown. (internal/lifecycle/worker.go) DeleteBucketempty-check and metadata delete are now protected against late object writes — the previous implementation calledisBucketEmptyandDeleteBucketas two separate store calls with no lock in between, so a concurrentPutObjectcould create object metadata after the empty-check passed but before the bucket key was removed. A first fix moved the check intoDeleteBucketIfEmpty, but the active Pebble v2 store still performed a prefix scan and delete as separate Pebble operations, leaving a smaller in-process race. Pebble now serializes bucket deletion against object metadata writes (PutObject,PutObjectVersion, and completed multipart metadata commits) with a per-bucket mutation lock and rejects late writes to buckets deleted during the process lifetime. Added a regression test that verifies a write afterDeleteBucketIfEmptyfails withErrBucketNotFoundand does not leave orphan object metadata. BadgerDB is no longer part of the codebase, so this protection is implemented only in the Pebble-backed metadata store. (internal/metadata/store.go,internal/metadata/pebble_store.go,internal/metadata/pebble_objects.go,internal/metadata/pebble_multipart.go,internal/metadata/pebble_test.go,internal/bucket/manager_impl.go)- Replication queue race — same object could be replicated twice — when the in-memory worker channel was full,
loadPendingItemsreleased the claim back topendingimmediately. The next loader tick would re-claim and re-enqueue the same item, so a worker could process it a second time before the first worker finished. Fixed by keeping the claim inretryingstate instead of releasing it; a newresetStaleClaimedItemssweep runs at the start of each loader tick and automatically recovers any item that has been inretryingfor more than 60 seconds without being picked up by a worker (e.g., because the channel drained faster than the process could proceed).claimQueueItemnow also stampsprocessed_at = CURRENT_TIMESTAMPso the staleness threshold is always well-defined. (internal/replication/manager.go) CreateBucketrollback now also reverts the ACL when storage marker creation fails — when the storage.maxiofs-bucketmarkerPutfailed, the rollback deleted the bucket metadata entry but left the ACL key (acl:bucket:<tenantID>:<name>) in the store, creating an orphaned ACL record that would be silently applied to any future bucket created with the same name. A newDeleteBucketACLmethod onacl.Managerdeletes the ACL key (ignoringErrNotFoundto remain idempotent), and the rollback path now calls it immediately after the metadata rollback so the store is fully clean on failure. (internal/acl/interface.go,internal/acl/manager.go,internal/bucket/manager_impl.go)- Lifecycle prefix filter now applied consistently to all action paths —
processAbortIncompleteMultipartUploadslisted all in-progress multipart uploads for a bucket and aborted any that exceededDaysAfterInitiation, regardless of the rule's prefix filter. This meant lifecycle rules scoped to a prefix (e.g.,logs/) would abort uploads for any key in the bucket. The function now skips uploads whose key does not start with the configured prefix, consistent with howprocessObjectExpiration,processNoncurrentVersionExpiration, andprocessExpiredDeleteMarkersalready apply the filter. (internal/lifecycle/worker.go) CompleteMultipartUploadnow defaults Content-Type toapplication/octet-streamwhen not set — if the initiatingCreateMultipartUploadrequest did not include aContent-Typeheader, the assembled object was stored and returned with an emptyContentTypefield, which caused subsequentGetObjectresponses to omit theContent-Typeheader. The returned object, the encrypted storage metadata, and the unencrypted storage metadata all now fall back toapplication/octet-streamwhen the multipart metadata does not include a content-type, matching the behavior of regularPutObject. (internal/object/manager.go)- Nil S3 client from replication factory no longer causes a panic —
replicateObjectandreplicateDeletecalledw.s3ClientFactory(...)and immediately used the returnedS3Clientinterface value without checking for nil. A factory that returns a nil interface (possible in test overrides or future custom factories) would cause a nil-pointer panic on the first method call. Both functions now check the result and return a descriptive error instead of panicking. (internal/replication/worker.go) - Audit log failures are now surfaced as Warn logs instead of being silently discarded — several audit call sites still discarded
auditManager.LogEventerrors with_ = ..., so a failed audit write (DB full, I/O error, SQLite contention) could be invisible in compliance-sensitive deployments. The bucket, auth, replication-rule, disk-alert, cluster-node-alert, and integrity audit paths now route through non-blocking helpers that log failures atWarnlevel instead of suppressing them. A follow-up scan confirms there are no remaining discardedLogEventcalls ininternal/auth,internal/server, orinternal/bucket. (internal/bucket/manager_impl.go,internal/auth/audit_helpers.go,internal/server/console_api_replication.go,internal/server/cluster_node_alerts.go,internal/server/disk_alerts.go,internal/server/object_integrity_handler.go,internal/server/integrity_scrubber.go) - HA metadata fanout now retries with per-attempt timeout and logs divergence on permanent failure —
fanoutMetadatadispatched a single goroutine per replica with no timeout or retry. If a transient network error hit during the fire-and-forget, the metadata update was silently lost. Each replica now gets up to 3 attempts with a 5-second per-attempt timeout (using a background context so the replica goroutine outlives the original request) and 200 ms / 400 ms inter-attempt delay. If all retries fail, the failure is logged atErrorlevel identifying the replica and op so operators can detect metadata divergence from logs. (internal/cluster/ha_object_manager.go) - Last-survivor protection now counts only healthy nodes —
markDeadIfSafecheckedhealth_status != dead(including UNAVAILABLE and DEGRADED nodes) when deciding whether marking a node dead would drop the cluster below the replication factor. A cluster with many unavailable nodes could have its count inflated abovefactor, allowing the last truly healthy node to be marked dead. The query now countshealth_status = healthy AND id != candidateso only write-capable nodes are counted. (internal/cluster/dead_node_reconciler.go) - Anti-entropy pull now logs when an object is absent on the peer instead of silently returning —
pullObjectFromPeerreturnednilon HTTP 404 with a code comment ("race; skip") but no log output. Operators had no way to see that a divergence had resolved itself remotely. A shortInfolog is now emitted identifying the peer, bucket, and key so the event appears in the scrubber trace. (internal/cluster/anti_entropy.go) - Anti-entropy ETag tie now resolved deterministically instead of being left unresolved indefinitely —
lwwClassifyreturnedactNonewhen two replicas had the sameLastModifiedtimestamp but different ETags, leaving the divergence in the "found but not fixed" state forever (visible in scrub metrics as a perpetual unfixed divergence count). The function now uses lexicographic ETag order as a stable tie-breaker: the node with the smaller ETag defers to its peer (pulls), the node with the larger ETag wins (pushes). Both sides reach the same conclusion independently, so the cluster converges in one scrub cycle. (internal/cluster/anti_entropy.go,internal/cluster/anti_entropy_test.go) - Duplicate dead-node SSE events suppressed when node was already dead —
markDeadIfSafeemitted anode_deadSSE event unconditionally after the UPDATE, even when theAND health_status != deadguard in the SQL meant 0 rows were affected (node was already dead from a concurrent call). The function now checksRowsAffected()and returns early without emitting the event if the UPDATE was a no-op. (internal/cluster/dead_node_reconciler.go) - Storage-pressure SSE callback now runs in a goroutine with panic recovery — the
storagePressureFncallback was called directly inside the health-check goroutine. A blocking callback (e.g., a slow SSE write) would stall the health ticker for all nodes in the cluster; a panicking callback would crash the goroutine entirely. The callback is now dispatched in a dedicated goroutine with adefer recover()that logs the panic at Error level without propagating it. (internal/cluster/health.go) - HASyncWorker now holds the sync-running lock through the DB existence check —
runTriggerreleasedmubefore querying the DB for an existing sync job, creating a window where two concurrentTriggercalls could both see no running job and start duplicate syncs for the same node. The lock is now held through the DB query; if no job is found, the slot is reserved inw.runningbefore the lock is released. If the subsequent DB insert fails, the slot is released. (internal/cluster/ha_sync_worker.go) - Inter-node HMAC replay window reduced from ±5 minutes to ±30 seconds — the cluster proxy and authentication middleware accepted signed requests up to 5 minutes old, giving an attacker on the intra-cluster network a large capture-and-replay window. Inter-node clocks are NTP-synchronised; the window is tightened to ±30 seconds (matching common practice for signed API tokens on private networks). (
internal/cluster/proxy.go,internal/middleware/cluster_auth.go) - Tenant quota not enforced on versioned-bucket uploads —
PutObjectskippedCheckTenantStorageQuotaentirely when versioning was enabled (condition included!versioningEnabled), so tenants could upload unlimited data to versioned buckets. For non-versioned buckets the delta between old and new object sizes was correctly checked; for versioned buckets the new version size must be added in full (all versions are preserved). The!versioningEnabledguard is removed; the function now branches on versioning to compute the correct size increment before the quota check. (internal/object/manager.go) PutObjectTagsconcurrent update race — last write silently discarded —PutObjectTagsread the object record, merged the new tags, and wrote the result back without holding the per-bucket mutation lock. Two concurrent tag updates on the same key could both read the pre-merge state and one set of tags would be lost. All other write paths (PutObject,PutObjectVersion, multipart completion) already holdgetBucketMutationMutexfor their read-modify-write. The mutex is now acquired at the top ofPutObjectTagsas well. (internal/metadata/pebble_objects.go)- OAuth tokens exposed in redirect URL — replaced with server-side one-time code exchange —
handleOAuthCallbackplaced the access token and refresh token directly in the redirect URL query string (?token=…&refresh_token=…). Tokens appeared in server access logs, browser history, proxy logs, and anyRefererheader on the next navigation, violating RFC 6819 §4.2.2. The callback now generates a cryptographically random 32-byte hex one-time code (TTL 60 s, stored in async.Mapon the server) and redirects with only?code=<code>. A new public endpointGET /api/v1/auth/oauth/exchange-codeatomically consumes the code (LoadAndDelete) and returns the token pair in the JSON response body, where it is not logged or cached by intermediaries. The frontendoauth-complete.tsxis updated to call the exchange endpoint instead of reading tokens from the URL. (internal/server/console_idp.go,internal/server/server.go,internal/server/console_api.go,web/frontend/src/pages/auth/oauth-complete.tsx) - Deactivated users bypass JWT validation —
ValidateJWTlooked up the user by username and returned it without checkinguser.Status, so an admin deactivating a user had no immediate effect: the user's access tokens (up to 15-min TTL) and refresh tokens (up to 24-h TTL) remained valid.ValidateCredentialsandValidateConsoleCredentialsalready checkeduser.Status != UserStatusActive. The same check is now applied inValidateJWT, returningErrUserInactiveimmediately and preventing any further use of tokens issued before deactivation. (internal/auth/manager.go) - Data race in replication rule scheduler —
lastSyncmap written from multiple goroutines — the mainruleSchedulergoroutine read and deleted entries fromlastSyncwhile spawned per-rule goroutines wrote to it with no synchronization, constituting a classic Go map race that would trigger a runtime panic under concurrent replication activity. Fixed by recordinglastSync[ruleID] = nowin the main goroutine before spawning the rule goroutine; the goroutine no longer writes to the map, eliminating the shared mutable state entirely without adding a mutex. (internal/replication/manager.go) - Replication queue cleanup silently discarded database errors —
cleanupOldQueueItemsdiscarded the error fromdb.ExecContextwith_. If the DELETE failed (disk full, locked table), the replication queue would grow without bound with no operator-visible indication. The error is now checked and logged atWarnlevel. (internal/replication/manager.go) - JSON marshal errors silently ignored in HA metadata fanout — six
json.Marshalcalls inHAObjectManager(for object metadata, tags, ACL, retention, legal-hold, and restore-status operations) discarded their error with_. A marshal failure would pass a nil byte slice tofanoutMetadata, which would send empty or malformed data to replica nodes, causing silent metadata divergence that would only surface during a subsequent anti-entropy scrub. Each call now checks the error; on failure it logs atWarnlevel with bucket and key fields and skips the replica fanout rather than sending bad data. (internal/cluster/ha_object_manager.go) Stop()panics on double-call in lifecycle worker and deletion-log sync manager — bothWorker.Stop()andDeletionLogSyncManager.Stop()closed thestopChanunconditionally, so a second call (possible if the HTTP server and the context cancel race during shutdown) would panic with "close of closed channel". AddedstopOnce sync.Onceto both structs; bothStop()methods now wrap theclose()insidestopOnce.Do(...). (internal/lifecycle/worker.go,internal/cluster/deletion_log.go)
[1.4.0] - 2026-05-03¶
Added¶
- Role capabilities system — service-level permission matrix stored entirely in the database — previously all users shared the same implicit permission set: any authenticated user with the
userrole could create buckets, delete buckets, upload and delete objects, and manage policies. There was no mechanism to restrict what a specific user or role could do at the service level, making it impossible to create read-only or limited-access users without relying exclusively on per-bucket ACLs. The new capabilities system introduces a two-layer model on top of the existingbucket_permissions: role defaults (what a role allows by default) and per-user overrides (explicit allow/deny set by an admin on top of the role default). Eleven capabilities are defined:bucket:create,bucket:delete,bucket:configure,bucket:manage_policy,object:upload,object:download,object:delete,object:manage_tags,object:manage_versions,console:access,keys:manage_own. Resolution order for every capability check: (1) explicit user override deny → false; (2) explicit user override grant → true; (3) roleadminsafety net → true; (4) role default inrole_capabilitiestable → true/false; (5) default → false. Theadminrole always has all capabilities regardless of the table, so global admins can never accidentally lock themselves out. (internal/auth/capabilities.go,internal/db/migrations/migration13_capabilities.go) role_capabilitiesanduser_capability_overridesdatabase tables — migration 13 creates two new SQLite tables.role_capabilities (role TEXT, capability TEXT, PRIMARY KEY(role, capability))stores the per-role defaults and is seeded at migration time with opinionated defaults:admingets all capabilities;usergets all exceptbucket:manage_policy;readgetsobject:download,console:access,keys:manage_own;readonlygetsobject:download,console:access;guestgetsobject:downloadonly.user_capability_overrides (id TEXT, user_id TEXT, capability TEXT, granted INTEGER, granted_by TEXT, created_at INTEGER, UNIQUE(user_id, capability))stores per-user overrides created by admins. All defaults are database-stored and editable by a global admin at runtime — no hardcoded values in Go code. (internal/db/migrations/migration13_capabilities.go,internal/db/migrations/versions.go)- Five new capability management API endpoints —
GET /api/v1/users/{id}/capabilitiesreturns the full effective capability matrix for a user with per-capability source annotation (roleoroverride); accessible by any admin scoped to the user's tenant.PUT /api/v1/users/{id}/capabilities/{capability}with body{"granted": true|false}sets an explicit per-user override;DELETE /api/v1/users/{id}/capabilities/{capability}removes the override and reverts to the role default. Both write endpoints trigger a cluster user-sync so overrides propagate to all nodes immediately.GET /api/v1/roles/capabilitiesreturns the full role→capabilities map alongsideall_capabilitiesfor UI enumeration; global admin only.PUT /api/v1/roles/{role}/capabilitieswith body{"capabilities": [...]}atomically replaces a role's capability set; global admin only. All endpoints validate capability names against theAllCapabilitiesconstant list and enforce tenant-scoping (tenant admins can only manage users in their own tenant). (internal/server/capability_handlers.go,internal/server/console_api.go) - Capability enforcement in S3 and console API handlers —
HasCapabilityInContextchecks are wired into all relevant handlers so capability violations return the correct HTTP error rather than silently allowing the operation. Console API:handleCreateBucketchecksbucket:create;handleCreateAccessKeycheckskeys:manage_ownwhen a non-admin user creates a key for themselves;handleLoginchecksconsole:accessafter credentials are validated and before 2FA, returning403 Forbiddenfor users who are not permitted to use the console. S3 API:CreateBucketchecksbucket:create;DeleteBucketchecksbucket:delete;PutObjectchecksobject:upload;DeleteObjectchecksobject:delete;GetObjectchecksobject:download(presigned URL access and anonymous public-bucket requests are exempt to preserve existing share workflows);PutBucketVersioningchecksbucket:configure;PutBucketPolicy/DeleteBucketPolicycheckbucket:manage_policy;PutBucketLifecycle,PutBucketCORS,PutBucketTagging,PutBucketEncryptioncheckbucket:configure;PutObjectTagging/DeleteObjectTaggingcheckobject:manage_tags. Capability checks are bypassed whenauthManageris nil (S3 unit tests without auth initialisation). (internal/server/console_api.go,internal/server/capability_handlers.go,pkg/s3compat/handler.go,pkg/s3compat/bucket_ops.go,pkg/s3compat/object_ops.go) - Capability overrides travel with user sync in cluster —
UserDatain the cluster user-sync payload was extended with aCapabilityOverrides []CapabilityOverrideDatafield carrying each override'sCapability,Granted,GrantedBy, andCreatedAt.listLocalUsersloads overrides from the DB per user before building the payload;computeUserChecksumincludes the override signature so a change in overrides triggers a full re-sync. The receiving node'supsertUserperforms a full replace of overrides for the incoming user (DELETE all existing + INSERT from payload) — Last-Writer-Wins semantics consistent with the rest of the user sync. (internal/cluster/user_sync.go,internal/server/cluster_tenant_handlers.go) - "Manage Permissions" button and modal in the user detail page — admins see a new "Manage Permissions" button in the user profile header (alongside Edit and Change Password). Clicking it opens a modal showing the user's full capability matrix as a grid of toggle switches: green = granted, gray = denied. When a capability has an explicit override a labelled badge and a reset button (↺) appear alongside the toggle, allowing the admin to revert to the role default in one click. The modal fetches capabilities on demand (no extra request when simply viewing a profile). Mutations call the PUT/DELETE capability endpoints immediately and refetch on success. (
web/frontend/src/pages/users/[user]/index.tsx,web/frontend/src/lib/api.ts,web/frontend/src/types/index.ts,web/frontend/src/locales/en/users.json,web/frontend/src/locales/es/users.json) - Role Capabilities management page for global admins — new page at
/roles/capabilities(accessible via the sidebar under Users → Role Capabilities, visible to global admins only) renders an interactive matrix table with roles as columns and capabilities as rows. Each cell is a toggle; theadmincolumn is read-only (always all-granted). Unsaved changes highlight the column header and reveal a per-role Save button, allowing the admin to review all pending changes for a role before committing. Changes are saved atomically viaPUT /api/v1/roles/{role}/capabilitiesand take effect immediately for all users of that role. (web/frontend/src/pages/roles/capabilities.tsx,web/frontend/src/components/layout/SidebarNav.tsx,web/frontend/src/components/layout/AppLayout.tsx,web/frontend/src/App.tsx,web/frontend/src/locales/en/users.json,web/frontend/src/locales/es/users.json,web/frontend/src/locales/en/navigation.json,web/frontend/src/locales/es/navigation.json)
Fixed¶
- Release and package builds now strip debug symbols consistently —
build,build-server,build-all, platform-specific builds, Docker images, Debian source builds,.deb, and RPM package paths now use stripped Go linker flags (-s -w),-trimpath, andCGO_ENABLED=0for cleaner, smaller binaries without embedded local source paths or accidental native-library dependencies. The.debtargets also no longer reference the undefinedFLAGS_RELEASEvariable, which previously caused Debian packages to be built without release linker flags. (Makefile,Dockerfile,debian/rules) - Multipart uploads stored all HTTP request headers as object user metadata —
CreateMultipartUploaditerated over every header in the incoming request and stored them all in the upload's metadata map without filtering. WhenCompleteMultipartUploadcommitted the final object, that unfiltered map was used as both the storage metadata and the user metadata (x-amz-meta-*) of the resulting object. Any client using the AWS SDK or CLI would therefore permanently embedAuthorization,User-Agent,Amz-Sdk-Invocation-Id,X-Amz-Date,X-Amz-Content-Sha256, and other internal request headers into the object record, exposing sensitive values (including the full SigV4 authorization token) viaHeadObjectto any caller with read access. Fixed by replacing the raw header loop inCreateMultipartUploadwithextractMetadataFromHeaders— the same helper used byPutObject— which whitelists onlyContent-Type,Content-Disposition,Content-Encoding,Cache-Control,Content-Language, andx-amz-meta-*headers.x-amz-aclis additionally preserved as an internal-only key in the upload metadata (consumed byCompleteMultipartUploadto apply the canned ACL after the object is written) and excluded from user metadata byfilterStorageMetadataKeysso it does not appear inHeadObjectresponses. The bug was present since the multipart upload feature was introduced and is not specific to any single release. (internal/object/manager.go) - Frontend inactivity timeout no longer treats background token refresh as user activity — token refreshes updated
last_activityand dispatched a keep-alive event consumed by the idle timer, allowing an inactive browser tab to stay authenticated indefinitely as long as refresh succeeded. The idle timer now advances only on real browser interaction events, while active users still keep their session alive through normal UI activity. (web/frontend/src/lib/api.ts,web/frontend/src/hooks/useIdleTimer.ts,web/frontend/src/__tests__/useIdleTimer.test.ts) - Tenant-scoped Object Lock and legal-hold console actions now preserve tenant context — several frontend API helpers dropped
tenantIdfor object legal hold, object ACL updates, and Object Lock configuration updates, so global admins managing tenant buckets could hit the wrong bucket scope or receive false failures. Those calls now propagatetenantId, and the Object Lock modal invalidates the tenant-specific bucket query after saving. (web/frontend/src/lib/api.ts,web/frontend/src/pages/buckets/[bucket]/index.tsx,web/frontend/src/pages/buckets/[bucket]/settings.tsx,web/frontend/src/components/ObjectLockConfigModal.tsx) - Bucket replication settings now resolve the correct source bucket context without constraining destinations — the console replication endpoints and frontend helpers did not carry
tenantIdwhen a global admin opened a tenant-scoped bucket, so listing or managing rules could target the global bucket namespace instead of the tenant bucket. The tenant context now applies only to the MaxIOFS source bucket lookup; destination endpoint, bucket, region, and credentials remain fully S3-compatible and can point to MaxIOFS, AWS S3, MinIO, or any object server. The frontend replication helpers also unwrap the console API's standard{success,data}envelope correctly, and the replication rule form now preservesenabled: falseinstead of forcing saved rules back to enabled. (internal/server/console_api_replication.go,internal/server/server_test.go,web/frontend/src/lib/api.ts,web/frontend/src/pages/buckets/[bucket]/settings.tsx) - Frontend object operations now encode reserved characters in console API routes — several object helpers interpolated object keys or tenant IDs directly into URLs, so keys containing reserved URL characters such as
?,#,%, or spaces could be truncated or routed incorrectly from the web console. Console object reads, deletes, renames, tags, shares, version operations, and presigned URL generation now encode path/query components consistently. (web/frontend/src/lib/api.ts) - OAuth completion cookies now work on HTTP console deployments — the OAuth callback always wrote
auth_tokenandrefresh_tokencookies with theSecureattribute, unlike the normal login flow. In HTTP lab or local deployments those cookies were ignored by the browser, leaving OAuth sessions partially initialized. The callback now addsSecureonly when the current page is served over HTTPS. (web/frontend/src/pages/auth/oauth-complete.tsx) - Cluster bucket-permission sync test no longer leaks a background sync loop —
TestBucketPermissionSyncManager_Start/disabled_auto_syncassumed auto-sync was disabled by default, but the replication schema seedsauto_bucket_permission_sync_enabled=true. The test now disables the setting explicitly before callingStart, preventing a background goroutine from continuing after the test database is closed and makinggo test ./internal/clusterreliable again. (internal/cluster/bucket_permission_sync_test.go) - Folder delete confirmation in the web console now matches recursive delete behavior — the bucket browser warned that folder deletion would fail when the folder contained objects, but the frontend already performs recursive deletion before removing the folder marker. The warning now states that all objects inside the folder will be deleted, and the delete modal text is localized through the bucket locale files. (
web/frontend/src/pages/buckets/[bucket]/index.tsx,web/frontend/src/locales/en/buckets.json,web/frontend/src/locales/es/buckets.json) - JSON-typed dynamic settings now reject malformed JSON — settings declared with type
jsonwere accepted without validation in both single-setting updates and bulk updates, allowing invalid JSON to be stored in SQLite and fail later when consumers parsed it. JSON settings now useencoding/jsonvalidation before commit, and bulk updates still roll back atomically if any JSON value is invalid. (internal/settings/manager.go,internal/settings/manager_test.go) - Readiness checks now reflect real object/auth dependencies —
ObjectManager.IsReady()andAuthManager.IsReady()always returnedtrue, so/readycould report the node as ready even after the metadata store or SQLite auth database was closed/unavailable. Object readiness now verifies storage, metadata store readiness, filesystem root availability, and encryption service presence when encryption is enabled; auth readiness now pings SQLite. The API/readyendpoint also includes auth readiness instead of checking only bucket/object managers. (internal/object/manager.go,internal/auth/manager.go,internal/api/handler.go) - Metrics collector no longer reports fabricated network and file-descriptor values — the system collector returned hardcoded open-file-descriptor and network byte counters, and runtime collection events encoded numeric fields with
string(rune(...)), producing unreadable control characters instead of decimal values. The collector now reads process FDs and network counters from gopsutil, serializes runtime event fields as decimal strings, and can be stopped and started again without reusing a closed stop channel. (internal/metrics/collector.go,internal/metrics/collector_test.go) - HA object replication now preserves version IDs for versioned objects and delete markers — HA fanout replays used
PutObject/DeleteObjecton replica nodes without carrying the primary node'sversionId, so each replica generated different version IDs for the same object version or delete marker. That could break version-specific reads, deletes, Object Lock operations, and repair workflows when a request hit a replica. HA PUT now carries the source version ID through an internal header and trusted context, HA DELETE carries either the specific version ID or the delete-marker version ID, and stale/anti-entropy pulls preserve the peer's current version ID as well. (internal/cluster/ha_object_manager.go,internal/server/cluster_object_handlers.go,internal/object/manager.go,internal/cluster/anti_entropy.go,internal/cluster/stale_reconciler.go) - S3 clients can now delete real filesystem folder markers even when they omit the trailing slash — clients such as S3 Browser may issue
DeleteObject/DeleteObjectsforfolderwhile MaxIOFS stores the real directory marker asfolder/. The object manager now resolves that alias only when no exact object exists, so folder metadata is removed consistently, and the filesystem backend detects directories bystatinstead of relying only on a trailing/in the key. (internal/object/manager.go,internal/storage/filesystem.go,internal/object/manager_cleanup_test.go,internal/storage/filesystem_test.go,pkg/s3compat/s3_test.go) - Frontend sessions now keep refreshing past the 15-minute access-token window — the web client scheduled proactive refresh by decoding the JWT payload with plain
atob, which is not safe for base64url-encoded JWT segments and could fail silently. When that happened, active users could still be logged out as soon as the 15-minute access token expired. JWT payload decoding now handles base64url padding/characters correctly, restoring proactive refresh for active console sessions. (web/frontend/src/lib/api.ts,web/frontend/src/__tests__/api-token.test.ts) - Bucket deletion now treats metadata-only objects, versions, and delete markers as real bucket contents —
DeleteBucketpreviously checked only physical storage, so a bucket with object metadata but missing files, or a bucket containing only version/delete-marker metadata, could be deleted as if empty.isBucketEmptynow checks the metadata version index, andForceDeleteBucketremoves nested object/version metadata before physical cleanup. Failed physical bucket creation also rolls back the metadata bucket record. (internal/bucket/manager_impl.go,internal/bucket/delete_bucket_test.go) - S3 object operations now resolve the bucket's actual tenant instead of assuming the request user's tenant — authenticated tenant users accessing global buckets were routed to
tenantID/bucketeven though global buckets intentionally have no tenant prefix.GetObject,PutObject,DeleteObject,HeadObject, andGetObjectAttributesnow resolve the bucket owner from metadata first, preserving bare global bucket paths and avoiding tenant quota checks for writes into non-tenant buckets. (pkg/s3compat/handler.go,pkg/s3compat/object_ops.go,pkg/s3compat/s3_test.go) GetObjectAttributesnow honorsversionId— the handler read theversionIdquery parameter but always returned latest metadata, so clients asking for attributes of an older object version received the current version's ETag, size, and version header. Versioned requests now read the specific version throughGetObject. (pkg/s3compat/object_ops.go,pkg/s3compat/s3_test.go)- S3 Object Lock retention and legal hold operations now honor
versionId—GetObjectRetentionandGetObjectLegalHoldalways read the latest version, andPutObjectLegalHoldignored the requested version entirely. Clients targeting an older object version could read the wrong lock state or accidentally apply legal hold to the latest version. The Object Manager now resolves lock metadata for a specific version when requested, and the S3 handlers propagateversionIdconsistently for retention/legal-hold reads and writes. (internal/object/manager.go,pkg/s3compat/object_ops.go,pkg/s3compat/s3_test.go) - S3 object tagging operations now honor
versionId—GetObjectTagging,PutObjectTagging, andDeleteObjectTaggingalways operated on the latest version even when clients targeted a specific object version. Versioned tagging now reads and mutates the requested version metadata, and HA metadata fanout carries the version ID so replicas apply the same per-version tag update. (internal/object/manager.go,internal/cluster/ha_object_manager.go,internal/server/cluster_object_handlers.go,pkg/s3compat/object_ops.go,pkg/s3compat/s3_test.go) - S3 RestoreObject now honors
versionId— restore requests for a specific object version verified and updated latest metadata instead, so a restore marker could be written to the wrong version. Restore status now resolves the requested version and HA metadata fanout carries the version ID for replica replay. (internal/object/manager.go,internal/cluster/ha_object_manager.go,internal/server/cluster_object_handlers.go,pkg/s3compat/handler.go,pkg/s3compat/s3_test.go) - S3 object ACL operations now honor
versionId— object ACLs were stored only in the legacy object-ACL namespace keyed by bucket/key, soGetObjectACLandPutObjectACLcould not distinguish versions and always affected the effective latest object ACL. Object ACLs now persist into object metadata, versioned ACL requests target the requested version, unversioned requests keep updating the legacy ACL store for compatibility, and HA metadata fanout carries the version ID for replica replay. (internal/object/manager.go,internal/cluster/ha_object_manager.go,internal/server/cluster_object_handlers.go,pkg/s3compat/object_ops.go,pkg/s3compat/s3_test.go) - Multipart upload validation now rejects missing uploads, wrong ETags, and invalid part order before mutating state —
UploadPartwrote the physical part file before verifying that the upload ID existed, leaving orphaned data when metadata rejected the part.ListPartscould return an empty list for a nonexistent upload, andAbortMultipartUploadcould report success for an invalid upload ID.CompleteMultipartUploadalso accepted duplicate/out-of-order parts and ignored client-supplied ETags, allowing completion with the wrong part list. The object manager now verifies upload existence before writing parts, maps missing uploads consistently toNoSuchUpload, validates strict ascending part order and ETag matches, and preserves the upload for retry after failed completion validation. (internal/object/manager.go,pkg/s3compat/multipart.go,internal/object/multipart_validation_test.go) - Console API mutating routes now enforce service-level capabilities consistently — after adding the capability system, many console routes still required only an authenticated user: bucket deletion, object upload/delete, lifecycle, bucket tags, CORS, ACLs, policies, versioning, Object Lock, legal hold, notifications, encryption, public access block, inventory, website hosting, object tag edits, object rename, and version restore. Those routes now use the same capability guard as S3: bucket configuration changes require
bucket:configure, bucket policy changes requirebucket:manage_policy, bucket deletion requiresbucket:delete, object upload/delete require their matching object capabilities, object tag edits requireobject:manage_tags, and version/legal-hold restore operations requireobject:manage_versions. (internal/server/capability_guard.go,internal/server/console_api.go,internal/server/object_extra_handlers.go,internal/server/inventory_handlers.go,internal/server/website_handlers.go) - HA object replication now escapes object keys in internal URLs — HA fanout, initial sync, stale-node pull, and anti-entropy repair built
/api/internal/ha/objects/{key}URLs with raw object keys. Keys containing query or fragment control characters such as?,#, spaces, or literal%could be truncated or routed incorrectly during replication. Internal HA callers now escape each path segment while preserving slash-separated prefixes. (internal/cluster/ha_url.go,internal/cluster/ha_object_manager.go,internal/cluster/ha_sync_worker.go,internal/cluster/stale_reconciler.go,internal/cluster/anti_entropy.go) - S3 versioned object responses now match AWS semantics for delete markers and HEAD —
GET/HEADover a current delete marker returned a plainNoSuchKeywithoutx-amz-delete-markerorx-amz-version-id, explicitversionIdrequests could collapse missing versions intoNoSuchKeyor idempotent delete success, andHEAD Objectomittedx-amz-version-idfor real versions. S3 handlers now return delete-marker headers for current markers,MethodNotAllowedfor explicitly requested delete-marker versions,NoSuchVersionfor missing explicit versions, and version headers onHEADresponses. (pkg/s3compat/handler.go,pkg/s3compat/s3_test.go) - S3 ListObjectsV2
KeyCountnow includes common prefixes — when clients useddelimiter,KeyCountcounted only<Contents>entries and ignored<CommonPrefixes>, which made paginated SDK listings see fewer returned results than the response actually contained.KeyCountnow follows S3 semantics by counting both object entries and common-prefix entries. (pkg/s3compat/handler.go,pkg/s3compat/s3_test.go) - S3
x-amz-taggingand CopyObject tag directives are now honored —PutObjectignored thex-amz-taggingheader, andCopyObjectcreated the destination without copying source tags even though S3 defaultsx-amz-tagging-directivetoCOPY. PUT now persists URL-encoded tag headers, CopyObject copies source tags by default, andx-amz-tagging-directive=REPLACEapplies replacement tags from the request. (pkg/s3compat/handler.go,pkg/s3compat/object_ops.go,pkg/s3compat/s3_test.go) - S3 CopyObject now goes through source read and destination write authorization — copy requests were dispatched before the normal
PutObjectpermission path, sox-amz-copy-sourceoperations could bypass service-level object capabilities and bucket/object ACL checks.CopyObjectnow requires source read permission plus destination write permission, includingobject:downloadandobject:uploadcapabilities for authenticated S3 requests. (pkg/s3compat/object_ops.go,pkg/s3compat/s3_test.go) - S3 multipart copy now validates upload state, source conditionals, and capabilities consistently —
UploadPartCopyfetched the source object before verifying that the multipart upload existed, did not applyx-amz-copy-source-if-*preconditions, and did not enforce the same source download/destination upload capabilities asCopyObject. Multipart copy now returnsNoSuchUploadbefore source reads, honors copy-source preconditions, maps missing explicit source versions toNoSuchVersion, and checks source read plus destination upload permissions. (pkg/s3compat/multipart.go,pkg/s3compat/object_ops.go,pkg/s3compat/s3_test.go) - S3 presigned V4 URLs now enforce signed headers and expiration limits — V4 validation trusted
X-Amz-SignedHeadersbut only canonicalizedhost, so URLs that claimed to sign additional headers did not require those headers and could ignore tampering. Presigned generation also ignoredPresignedURLConfig.Headers, and validation acceptedX-Amz-Expiresvalues beyond the seven-day SigV4 limit. Signed headers are now canonicalized from the request, missing/tampered signed headers fail signature validation, generated URLs include configured signed headers, and invalid expiration windows are rejected. (pkg/s3compat/presigned.go,pkg/s3compat/presigned_test.go) - S3 error mapping and multipart completion error XML are now stricter —
MalformedPOSTRequestandInvalidPolicyDocumentwere emitted by browser POST presigned upload validation but were not mapped in the shared S3 error writer, causing malformed policy/form requests to fall through as 500 responses. Those codes now return400 Bad Request,NoSuchVersionis covered in the error mapper tests, andCompleteMultipartUploadembedded error bodies are XML-encoded instead of string-formatted so error messages cannot produce malformed XML after the initial 200 response. (pkg/s3compat/handler.go,pkg/s3compat/handler_coverage_test.go,pkg/s3compat/multipart.go) - Expired Object Lock retentions can now be cleaned up —
CleanupExpiredRetentionsattempted to clear expired retention by setting it tonil, butSetObjectRetentiontreated anynilupdate as shortening retention even when the retention date was already in the past. Expired retentions are now removable, while active COMPLIANCE/GOVERNANCE retentions still reject shortening or deletion. (internal/object/manager.go,internal/object/manager_low_coverage_test.go) - Filesystem storage path validation now rejects Windows separator collisions without rejecting valid dotted object names — the backend previously rejected any key containing
.., blocking valid S3 object names such asfile..txt, while still accepting backslashes on Windows. Backslash keys can collide with slash-separated keys on disk (a\bvsa/b) and corrupt stored objects. Validation now rejects native Windows separators and drive-qualified paths, but only treats an exact..path segment as traversal. (internal/storage/filesystem.go,internal/storage/filesystem_test.go) - Console access revocation now takes effect for existing access and refresh tokens —
console:accesswas checked during login only, so a token issued before an override deny remained usable until expiry and refresh tokens could mint new sessions. The console JWT middleware and refresh endpoint now re-checkconsole:accesson every authenticated request/refresh. (internal/server/console_api.go,internal/server/console_access_test.go) - Lifecycle cleanup now sees hidden versioned keys whose latest state is a delete marker — noncurrent-version expiration and expired-delete-marker cleanup used
ListObjects, which lists visible objects and can miss keys hidden by a latest delete marker. Those lifecycle paths now scanListAllObjectVersionsfrom metadata and group by key, so noncurrent versions and delete-marker-only keys are eligible for cleanup. (internal/lifecycle/worker.go,internal/lifecycle/worker_test.go) - Metadata tag searches no longer return stale results after
PutObjectoverwrites an object's tags — Pebble created newtag_idxentries when storing an object but did not remove the previous tag indices for the same bucket/key. Replacing an object with a different tag set could therefore makeListObjectsByTagsand tag-filteredSearchObjectsreturn objects that no longer matched the requested tags. Fixed by deleting existing tag indices atomically before storing the overwritten object and creating the replacement indices. BadgerDB references from the original fix were removed because Pebble is now the only metadata engine. (internal/metadata/pebble_objects.go,internal/metadata/store_consistency_test.go) - Version metadata lookups no longer bleed between keys where one key is a colon-prefixed extension of another — version entries are stored under
version:{bucket}:{key}:{versionID}. A key such asa:bshares the prefixversion:bucket:a:used to list versions for keya, soGetObjectVersions(bucket, "a")could include versions belonging toa:b, and adding a new latest version foracould mark versions ofa:bas non-latest. Fixed by filtering version-prefix scans against the stored object key in Pebble. BadgerDB references from the original fix were removed because Pebble is now the only metadata engine. (internal/metadata/pebble_objects.go,internal/metadata/store_consistency_test.go) - Versioned bucket visibility and storage metrics now stay consistent across delete markers, PUT, and multipart completion — creating a delete marker always decremented the bucket object count, even when the latest version was already a delete marker, so repeated DELETE requests could drive visible object counts down incorrectly. A later PUT over a latest delete marker added bytes as an extra hidden version but did not restore the visible object count.
CompleteMultipartUploadalso ignored bucket versioning entirely, writing the completed object to the unversioned path with noVersionIDand calculating quota/metrics as an overwrite instead of a new stored version. Fixed by detecting delete markers explicitly, changing metrics only on real visibility transitions, generating versioned paths/version IDs for multipart completion in versioned buckets, and charging versioned multipart uploads as additional stored versions. (internal/object/manager.go,internal/object/versioning_multipart_metrics_test.go) - CopyObject and UploadPartCopy now parse
x-amz-copy-sourcewithout corrupting keys that contain encoded query characters — the copy-source parser decoded the object key before separating the optional?versionId=...query. A valid source key such asreports/a%3FversionId=v1.txtwas decoded toreports/a?versionId=v1.txtand then incorrectly split as ifversionIdwere a source version query, causing the wrong key to be copied. Fixed by parsing the raw query portion before URL-decoding the key and sharing that parser betweenCopyObjectandUploadPartCopy. (pkg/s3compat/object_ops.go,pkg/s3compat/multipart.go,pkg/s3compat/copy_source_test.go) - S3 Select JSON Lines input no longer silently drops malformed records —
loadJSONLinesstopped reading on the first JSON decode error and returned the rows parsed so far, which could produce successful but incomplete query results for corrupt objects. Fixed by decoding untilio.EOFand returning anInvalidRequestpath on malformed JSON. The handler also now rejects unsupported JSON input types such asDOCUMENTinstead of treating them as JSON Lines. (pkg/s3compat/select.go,pkg/s3compat/select_test.go) - Multipart listing pagination now reports truncation and markers correctly —
ListMultipartUploadsmarkedIsTruncated=trueas soon as it returnedmax-uploadsentries, even when there were no more uploads, and treatedkey-markeras inclusive whenupload-id-markerwas absent, allowing duplicate uploads across pages.ListPartscalculated truncation withlen(parts) > len(filteredParts)+partNumberMarker, incorrectly treating the numeric part marker as an array count; sparse part numbers such as 100, 200, 300 could returnIsTruncated=falsewhile more parts were still available. Fixed both paths by sorting deterministically, applying exclusive marker semantics, and detecting truncation with a real lookahead. (pkg/s3compat/multipart.go,pkg/s3compat/multipart_pagination_test.go) - Inventory XML rendering no longer panics on empty or malformed frequency values —
internalToXMLformattedcfg.FrequencywithfreqLower[:1], which crashes if a stored configuration ever reaches the handler with an empty frequency. That turns a bad record into a handler panic instead of a recoverable response. Fixed by treating an empty frequency as an empty XML field instead of slicing the string blindly. (pkg/s3compat/inventory.go,pkg/s3compat/inventory_test.go) - Presigned URL generation and validation now handle object keys with spaces and reserved characters correctly —
GeneratePresignedURLV2andGeneratePresignedURLV4were emitting rawbucket/objectKeypaths, andValidatePresignedURLV2/V4were signing againstr.URL.Pathdirectly. That breaks presigned access for keys containing spaces,+, or other reserved bytes because the generated URL is not RFC 3986-safe and the reconstructed canonical path no longer matches what the client sends. Fixed by applying the same S3-style path encoding on both generation and validation, preserving/as a path separator while encoding each unsafe byte. Added regression tests for keys likefolder name/a+b c.txt. (pkg/s3compat/presigned.go,pkg/s3compat/presigned_test.go) - Inventory report generation now preserves the current bucket namespace model instead of collapsing tenant-scoped and global buckets together — the inventory generator previously listed source objects using only
config.BucketNameand uploaded the generated report usingdestinationBucket/reportPath, ignoringconfig.TenantIDeven when the bucket was tenant-scoped. That made inventory generation read from or write to the wrong logical bucket namespace in multi-tenant deployments. Fixed by deriving the internal bucket path astenantID/bucketonly whentenantIDis non-empty, while leaving global buckets unscoped as plainbucket. Added regression tests covering both tenant-scoped and global buckets so the existing global-bucket contract stays intact. (internal/inventory/generator.go,internal/inventory/generator_test.go,internal/inventory/worker_test.go) - Inventory report uploads no longer leave partial state when storage and metadata commits diverge —
uploadReportpersisted report metadata before uploading the actual report object. If the storage write failed, the metadata store could still expose an inventory report whose payload did not exist. The write order is now reversed: the report is uploaded to storage first, then metadata is committed. If metadata persistence fails after the object upload, the uploaded object is deleted as compensation so no orphaned report object remains behind. Tests were updated to assert the new commit order and cleanup path. (internal/inventory/generator.go,internal/inventory/generator_test.go) - Replication queue items are now claimed before enqueueing, preventing duplicate remote replication of the same object — the queue loader periodically selected rows with
status='pending'and pushed them into the in-memory worker channel without first marking them as claimed in SQLite. If the loader ticked again before a worker changed the row toin_progress, the same item could be enqueued multiple times, resulting in duplicate PUT/DELETE operations against the destination. Fixed by atomically claiming eligible rows withstatus='retrying'before enqueueing, releasing the claim if the in-memory queue is full, and resetting staleretrying/in_progressrows back topendingon manager start. (internal/replication/manager.go,internal/replication/manager_test.go) - Notification webhook delivery now closes response bodies per attempt and rejects empty DNS resolutions cleanly — the webhook sender deferred
resp.Body.Close()inside the retry loop, so repeated failed attempts held bodies open until the function returned. The SSRF-protected dialer also assumedLookupHostalways returned at least one address, which could panic or mis-handle an edge case where DNS returned an empty set without an error. Fixed by closing each response body immediately after the attempt and treating an empty resolution result as an explicit validation error. (internal/notifications/manager.go) - All user capabilities appeared as unchecked in the Manage Permissions modal regardless of role —
EffectiveCapabilityininternal/auth/capabilities.godeclared the granted field asAllowed boolwith JSON tag"allowed", but the frontend TypeScript interface expected the field name"granted". Every capability in the API response arrived with the wrong key, socap.grantedwas alwaysundefined(falsy) and every toggle rendered in the off/denied state. Fixed by renaming the struct field toGrantedwith JSON tag"granted". This also affected users who had been granted capabilities via role defaults: after upgrade all their capabilities appeared disabled until the fix was applied. (internal/auth/capabilities.go,web/frontend/src/types/index.ts) - Legacy cluster object migration lost tenant context, breaking tenant-scoped bucket replication and deletion — the legacy inter-node object endpoints (
PUT/DELETE /api/internal/cluster/objects/{tenantID}/{bucket}/{key}) extractedtenantIDfrom the route but then calledobjectManager.PutObjectandDeleteObjectwith only the bare bucket name.objectManager.parseBucketPath()interprets a bare bucket as a global bucket, so tenant-scoped objects copied through the legacy migration path were written, verified, or deleted against the wrong logical bucket namespace on the destination node. Fixed by reconstructing the full bucket path astenantID + "/" + bucketbefore calling the object manager. The HA fanout path already used the correct full bucket path and was not affected. (internal/server/cluster_object_handlers.go) - Legacy cluster migration could not handle object keys containing
/— the dedicated cluster router registered the legacy object endpoints as/objects/{tenantID}/{bucket}/{key}, which only captures a single path segment for{key}. Bucket migration and verification build URLs using the full S3 object key, so any object such asdir/a.txtornested/path/file.binproduced a route mismatch on the destination node. Fixed by changing the route pattern to{key:.*}forPUT,DELETE, and the new verificationHEADendpoint, preserving the full S3 key including embedded slashes. (internal/server/server.go) verifyData=truebucket migration failed because the internal object endpoint did not supportHEAD— migration verification sampled objects and issuedHEAD /api/internal/cluster/objects/{tenantID}/{bucket}/{key}expecting a 200 withX-Object-ETag, but the cluster router only exposedPUTandDELETEon that path. As a result, migrations with verification enabled received405 Method Not Allowedduring the verification phase and were marked failed even when the object had actually been copied correctly. Fixed by addinghandleHeadReplicatedObject, which returns object existence plusETag,Content-Type,Content-Length, andLast-Modifiedfor the replicated object. (internal/server/cluster_object_handlers.go,internal/server/server.go,internal/cluster/migration.go)- Custom object metadata was corrupted during legacy bucket migration — the migration sender serialized object metadata into the
X-Object-Metadataheader as JSON, but the receiver stored that JSON blob under a syntheticX-Amz-Meta-Originalheader.objectManager.extractMetadataFromHeaders()only persists realx-amz-meta-*headers, so the destination object ended up with a single incorrect metadata key (original) containing the raw JSON string instead of the original user metadata map. Fixed by parsing the JSON payload on the receiving node and re-emitting each key/value pair as a realx-amz-meta-{key}header before callingPutObject. (internal/server/cluster_object_handlers.go,internal/cluster/migration.go,internal/object/manager.go) - Legacy migration URLs did not escape tenant or bucket path segments — the sender built internal migration URLs using
fmt.Sprintf(.../%s/%s/%s, tenantID, bucket, key)withouturl.PathEscapeontenantIDandbucket. While bucket names are normally DNS-safe, the tenant segment is an internal identifier and the route contract should still be encoded consistently; leaving it raw also made the sender/receiver contract brittle if any future internal naming scheme introduced reserved URL characters. Fixed by path-escaping the tenant and bucket segments when constructing both copy and verification URLs. (internal/cluster/migration.go) GET /api/v1/security/statusreturned synthetic security data instead of the real server state — the endpoint hardcodedactiveSessions: 0,failedLogins24h: 0,audit.enabled: false, and a fakelastRotationtimestamp 30 days in the past, even on installations with audit logging enabled and recent failed logins recorded in the user table. This made the security dashboard actively misleading for operators. Fixed by derivingmfaEnabledandfailedLogins24hfrom the actual user set, reading audit enablement fromconfig.AuditplusauditManager, querying real audit event totals (totalEventsandeventsToday), and marking values that are still not tracked (activeSessions,userPolicies, key rotation history) as untracked instead of fabricating numbers. (internal/server/console_api.go)- Filesystem object writes published metadata before the object file was atomically committed —
FilesystemBackend.Put()wrote{object}.metadatafirst and only then renamed the temporary data file into place. If the process crashed or the final rename failed, the system could be left with visible metadata for an object whose payload file did not exist, creating a false-positive object record and confusing integrity checks or manual debugging. Fixed by preparing the metadata in a temporary file, performing the object rename first, and only then atomically renaming the metadata temp file into its final location. (internal/storage/filesystem.go) - Filesystem metadata writes were non-atomic and could leave partial or orphaned
.metadatafiles —saveMetadata()usedos.WriteFile()directly on the final metadata path. A crash or interrupted write could truncate the metadata file in place, and a failedPut()path could leave partially prepared metadata visible to subsequent reads. Fixed by rewriting metadata persistence to useCreateTemp+ write + close +os.Rename, so both standalone metadata updates and write-path metadata commits become atomic at the filesystem level. Added a regression test that forces a final object rename failure and verifies no stray.metadatafile remains behind. (internal/storage/filesystem.go,internal/storage/filesystem_test.go)
[1.3.0] - 2026-04-21¶
Added¶
- Dedicated cluster inter-node port 8082 — all cluster coordination routes (token validation, CSR signing, node registration, entity sync, JWT secret exchange) now run on a dedicated HTTP server bound to
:8082(configurable viacluster_listen). Previously these routes were multiplexed with the S3 API on port 8080, which caused routing conflicts, prevented independent firewall rules, and meant that any node that had not yet initialized a cluster (noIsClusterEnabled()at startup) could not participate in a join flow because the routes did not exist yet. Separating the concerns gives operators a clear boundary: port 8080 is S3 client traffic, port 8081 is the web console, and port 8082 is internal cluster coordination — never exposed to end users. (internal/server/server.go,internal/config/config.go) - New config option
cluster_listen— controls the bind address for the cluster inter-node server. Default::8082. Set to127.0.0.1:8082to restrict inter-node communication to the loopback interface (single-machine test clusters). (internal/config/config.go,docs/CONFIGURATION.md) - Replication
allow_internal_endpointsconfig option — newreplication.allow_internal_endpointsYAML setting disables SSRF protection for replication destination endpoints, allowing connections to private/internal IP ranges (10.x, 172.16.x, 192.168.x, 127.x). Required when replicating between MaxIOFS instances on the same Kubernetes cluster, Docker network, or LAN where endpoints resolve to private IPs. Whenfalse(default), the existing SSRF-blocking dialer rejects connections to RFC 1918 addresses and the cloud metadata endpoint (169.254.169.254). (internal/config/config.go,internal/replication/types.go,internal/replication/s3client.go,internal/replication/manager.go,internal/server/server.go,config.example.yaml) - Realtime replication mode now functional —
mode=realtimereplication rules are now triggered automatically on every S3PutObjectandDeleteObjectoperation. Previously the realtime mode was accepted in the UI and stored in the database, but no hook existed in the S3 handlers to enqueue objects — replication only worked via manual sync or thescheduledmode timer. AddedQueueRealtimeObject()to the replication manager (filters matching rules bymode=realtimeonly) and async hooks in the S3PutObjectandDeleteObjecthandlers that call it after the response is sent. Thescheduledandbatchmodes are unaffected — the existingruleSchedulercontinues to process those independently. (internal/replication/manager.go,pkg/s3compat/handler.go,internal/api/handler.go,internal/server/server.go) - HA write quorum — writes now block until a quorum of replicas acknowledge — previously
fanoutPutandfanoutDeleteinHAObjectManagerfired replica requests in background goroutines and returned immediately, meaning the client received HTTP 200 after only the local write completed. If the local node died before the background fanout finished, data was silently lost with no indication to the client. Rewritten as a synchronous quorum protocol: the quorum threshold isceil(factor/2)confirmations (the local write counts as 1, soceil(factor/2)-1replica acknowledgements are required). NewManager.ClusterCanAcceptWrites(ctx)pre-checks that enough healthy non-local nodes are available before the local write begins, saving a write-then-rollback cycle when the cluster is already degraded. If quorum is not reached after all replica goroutines complete,collectAndCheckQuorumreturns the newcluster.ErrClusterDegradederror: the S3 handler maps it toHTTP 503withRetry-After: 30onPutObject,DeleteObject, andCompleteMultipartUpload. On a PUT or CompleteMultipart quorum failure the local write is immediately rolled back viaManager.DeleteObjectunder aWithHARollbackContext(ctx)so the rollback itself is not fanned out to replicas. DELETE quorum failures are not rolled back — a delete is a tombstone and the anti-entropy scrubber (see below) will reconcile the missing tombstone on lagging replicas; the client receives 503 and is expected to retry.factor=2special-case: needed replica confirmations = 0, preserving best-effort second-copy semantics. Strict two-copy guarantees requirefactor=3. (internal/cluster/ha_object_manager.go,internal/cluster/manager.go,pkg/s3compat/handler.go,pkg/s3compat/multipart.go; tests ininternal/cluster/ha_quorum_test.go) - HA read fallback with ordered retry across replicas —
GetObjectpreviously selected a single read node viaSelectReadNodeand surfaced the error directly to the client if that node was unavailable or returned 404. In an HA cluster this caused spurious 404s and connection errors even when the requested object existed on other replicas. Replaced withManager.SelectReadNodes, which returns an ordered list of all ready replicas sorted bylatency_msascending →priorityascending →name, then rotated by an atomicreadCounter % Nto preserve round-robin load distribution while still providing a deterministic per-request retry path. NewManager.TryProxyRead(ctx, w, r, node)peeks the replica's HTTP status before committing anything to the response writer:2xx/3xx/non-404 client errors (401/403/412/416) are streamed to the client and short-circuit the retry loop;404closes the response and tries the next candidate (object not yet synced on this replica);5xxor transport failure closes the response, tries the next candidate, and flips the failing node toUnavailableviamarkNodeUnavailable. TheGetObjectS3 handler iterates the candidate list and falls through to a local read on a full miss. Mid-stream failures after a 200 response has been started surface as truncated bodies — those are unavoidable once bytes are committed. The oldSelectReadNodeis kept as a deprecated thin wrapper for other callers. (internal/cluster/manager.go,pkg/s3compat/handler.go; tests ininternal/cluster/ha_read_test.go) - Anti-entropy scrubber — continuous background reconciliation across all HA replicas — added
AntiEntropyScrubberininternal/cluster/anti_entropy.go. One goroutine per node runs a scheduler with a jittered 5–60 minute first-run delay then a configurable cycle interval (ha.scrub_interval_hours, default 24 h). Each cycle iterates all buckets in randomized order (so the full keyspace is covered within one interval rather than alphabetically-biased detection) and processes objects in batches ofha.scrub_batch_size(default 500). Per batch, the scrubber callsPOST /api/internal/ha/checksum-batchon every healthy peer and reconciles divergences using Last-Writer-Wins (LWW): a peer missing the object or with an older ETag receives a push via the existingPUT /api/internal/ha/objects/{key}endpoint underWithHAReplicaContext; a peer with a newer ETag triggers a pull (GET from peer, local PUT under replica context). Multipart objects (ETag contains<md5>-N) skip ETag comparison and rely on existence + size + 1 s mtime tolerance instead of expensive whole-file recompute. Identical timestamps with different ETags are logged for manual triage and not auto-fixed. The scrubber is crash-safe: after every batch a JSON checkpoint(bucket_idx, last_key)is written to Pebble atha:scrub:checkpointso a restart resumes the same cycle from where it left off. Each run is recorded in the newha_scrub_runsSQLite table (last 30 runs, pruned on completion). Throttled toha.scrub_rate_limitobject comparisons per second (default 50) viatime.Sleepbetween objects. Four new live-reloadable global config keys:ha.scrub_enabled,ha.scrub_interval_hours,ha.scrub_rate_limit,ha.scrub_batch_size. Status exposed viaGET /cluster/ha/scrub-status(global admin only): last 10 runs plus in-progress checkpoint snapshot. (internal/cluster/anti_entropy.go,internal/cluster/sync_schema.go,internal/server/cluster_object_handlers.go,internal/server/cluster_ha_handlers.go,internal/server/server.go,internal/server/console_api.go; tests ininternal/cluster/anti_entropy_test.go) - Dead-node redistribution — automatic removal of permanently unavailable nodes from write paths — introduced
HealthStatusDeadas a new terminal health state alongsideunknown / healthy / unavailable. A newunavailable_sinceTIMESTAMP column (applied via idempotentapplyDeadNodeMigration) records the start of a continuous outage;markNodeUnavailableandCheckNodeHealthuseCOALESCE(unavailable_since, ?)so the timestamp survives repeated failed probes and is cleared on the first healthy transition. Once the outage exceedsha.dead_node_threshold_hours(default 24 h, live-reloadable), the newDeadNodeReconcilerininternal/cluster/dead_node_reconciler.goflips the node todeadand callsHASyncWorker.Trigger()to start a catch-up sync on remaining nodes — because every healthy node already holds every bucket in N-way replication, no per-bucket replica reassignment is needed. The reconciler loop runs everyha.redistribution_check_interval_minutes(default 5 min) with a 30 s jittered first pass and reloads the interval on config change; kill switch viaha.redistribution_enabled. Last-survivor protection: if marking a node dead would drop the non-dead node count below the replication factor, the reconciler refuses and writes a human-readable reason intoha.cluster_degraded_reason, which is exposed viaGET /cluster/ha/degraded-stateand broadcast over SSE ascluster_degraded/cluster_degraded_resolvedevents. Admin drain shortcut:POST /cluster/nodes/{id}/drainaccepts an optional{"reason"}body and immediately flips a node to dead, bypassing the threshold timer. The drain endpoint rejects requests targeting the local node (the responding server cannot safely flip itself to dead mid-call) and nodes already in the dead state. The SSE bridge lives ininternal/server/dead_node_events.go, which decouples the cluster package from the server via anEventEmittercallback registered at startup. (internal/cluster/types.go,internal/cluster/schema.go,internal/cluster/sync_schema.go,internal/cluster/manager.go,internal/cluster/health.go,internal/cluster/dead_node_reconciler.go,internal/server/dead_node_events.go,internal/server/cluster_ha_handlers.go,internal/server/server.go,internal/server/console_api.go; tests ininternal/cluster/dead_node_reconciler_test.go) - Storage-pressure health state — graceful degradation under high disk usage — added
HealthStatusStoragePressureas a node-level health state betweenhealthyanddegraded. The existing health checker already readscapacity_totalandcapacity_usedfrom disk on each probe; it now computesusage% = used/total*100and transitions a node tostorage_pressurewhen usage exceedsha.storage_pressure_threshold_percent(default 90 %). The node returns tohealthyonly when usage drops belowha.storage_pressure_release_percent(default 85 %), providing hysteresis that prevents rapid oscillation. A misconfiguration whererelease ≥ thresholdis auto-clamped tothreshold - 5so the hysteresis band is never zero. Nodes instorage_pressureare excluded from write target selection (GetHealthyNodesuses a strict= healthyfilter) but remain eligible for reads (GetReadyReplicaNodesacceptsIN (healthy, storage_pressure)) because they still hold valid data.HealthStatusStoragePressurenever overridesdead,unavailable, ordegraded— the transition only fires for reachable low-latency nodes. Two new live-reloadable global config keys:ha.storage_pressure_threshold_percentandha.storage_pressure_release_percent. SSE eventsnode_storage_pressure(carriesusage_percent+threshold_percent) andnode_storage_pressure_resolvedare emitted as one-shots on state transitions viacluster.StoragePressureEmittercallback and the new SSE bridge ininternal/server/storage_pressure_events.go. (internal/cluster/types.go,internal/cluster/sync_schema.go,internal/cluster/manager.go,internal/cluster/health.go,internal/server/storage_pressure_events.go,internal/server/server.go; tests ininternal/cluster/storage_pressure_test.go) - Stale-node reconciler — safe catch-up when a partitioned or offline node rejoins — added
StaleReconcilerininternal/cluster/stale_reconciler.go. Runs once at node startup whenis_stale = trueis detected incluster_nodes. Detects one of two modes based onlast_local_write_at:ModeOffline(node was unreachable but did not accept writes — only tombstones need immediate propagation) orModePartition(node was isolated but continued serving writes — a full LWW push of locally-newer entities is performed before the stale flag is cleared). InModePartitionthe reconciler builds a localStateSnapshot(entity IDs +updated_attimestamps for tenants, users, access keys, bucket permissions, IDP providers, group mappings) and fetches the corresponding snapshot from each peer viaGET /api/internal/cluster/state-snapshot, then pushes any entity where the local timestamp is strictly greater. Both modes perform bidirectional tombstone sync: local tombstones absent or newer on the peer are pushed via the existingdeletion-log-syncendpoint; remote tombstones absent or newer locally are applied with LWW (EntityIsNewerThanTombstoneprevents overwriting a local entity that was re-created after the deletion). After all entity reconciliation completes, the stale flag is cleared. When the node was previously a ready HA replica (aha_sync_jobsrecord withstatus=doneexists), a further HA object delta sync runs: the reconciler contacts any healthy peer viaGET /api/internal/ha/objects/changed-since(paginated by bucket), then pulls each changed key withGET /api/internal/ha/objects/{key}and writes it locally underWithHAReplicaContext, creating a trackedha_sync_jobsentry for progress visibility. ANewDynamicProxyClientis used so TLS config changes after construction (e.g. a cluster join after the reconciler was created) are picked up automatically on each request. (internal/cluster/stale_reconciler.go,internal/cluster/proxy.go,internal/cluster/manager.go,internal/server/server.go) - Inter-node S3 proxy with HMAC authentication — added a dedicated S3 proxy layer in
internal/cluster/proxy.gothat handles forwarding S3 client requests to the node that owns the requested bucket or object.ProxyClientwas extended withNewDynamicProxyClient(getTLS func() *tls.Config): unlike the originalNewProxyClient(which captured TLS config once at construction), the dynamic variant re-evaluates the getter on each request and transparently rebuilds the underlyinghttp.Clientwhen the TLS config pointer changes — critical for sync managers and the stale reconciler that outlive cluster join. Two new functions handle inter-node S3 forwarding:ProxyToNodeAPIURLderives the target URL fromnode.Endpoint(always an internal IP, avoiding public-cert issues) viabuildInternalS3URL, copies headers from the original request, and replaces the client'sAuthorizationheader with cluster HMAC auth;AddClusterProxyHeaderscomputesHMAC-SHA256(clusterToken, "proxy:{nodeID}:{timestamp}")and addsX-MaxIOFS-Node-Hmac+ forwarded user context (X-MaxIOFS-Forwarded-User,X-MaxIOFS-Forwarded-Tenant,X-MaxIOFS-Forwarded-Roles). The receiving node validates the proxy auth viaValidateClusterProxyAuth, which checks theX-MaxIOFS-Proxied: trueheader, recomputes the HMAC, and enforces a ±5-minute timestamp window to prevent replay attacks. Loop prevention is enforced atProxyClient.ProxyRequest: if the incoming request already carriesX-MaxIOFS-Proxied: true, the proxy immediately returns an error instead of forwarding, preventing infinite A→B→A proxy chains.buildInternalS3URLextracts the IP fromnode.Endpoint(cluster port) and derives the S3 port fromnode.APIURL's explicit port if set (skipping defaults 80/443), falling back to 8080 — ensuring inter-node S3 traffic always travels over internal IPs regardless of the configured public API URL. (internal/cluster/proxy.go,internal/cluster/stale_reconciler.go,internal/cluster/router.go, all six*_sync.gomanagers,internal/server/console_api.go; tests ininternal/cluster/proxy_test.go)
Changed¶
- Frontend dependency toolchain upgraded to current releases — all frontend runtime, test, lint, and build packages were brought up to date, including the major compiler/tooling jumps to Vite 8,
@vitejs/plugin-react6, ESLint 10, and@eslint/js10. The Vite production build now uses Oxc minification to match the new Vite 8 toolchain and avoid relying on the old esbuild minifier path. (web/frontend/package.json,web/frontend/package-lock.json,web/frontend/vite.config.ts,web/frontend/eslint.config.js) - Add Node and Join Cluster forms accept plain IP addresses — previously both cluster setup forms required a full URL (e.g.
https://node2.example.com:8081). Both now accept a plain IP address (192.168.1.10) or IP:port pair (192.168.1.10:9000). The backend resolves the input to a fullscheme://host:portURL automatically, applying default ports: 8081 for Add Node (console contact to reach the remote node's join API) and 8082 for Join Cluster (cluster port of the node being joined). A user-provided port always overrides the default. (internal/server/cluster_handlers.go,web/frontend/src/pages/cluster/Nodes.tsx,web/frontend/src/pages/cluster/Overview.tsx)
Fixed¶
- Cluster sync tables never created — all inter-node synchronization permanently disabled —
server.New()calledcluster.InitSchema(db)to create the cluster membership tables but never calledcluster.InitReplicationSchema(db), which is responsible for creating the sync-tracking tables (cluster_entity_sync,cluster_sync_state, etc.) and thecluster_global_configtable with default auto-sync flags (auto_users_sync_enabled=true,auto_access_keys_sync_enabled=true,auto_tenants_sync_enabled=true). Every sync manager's main loop queriescluster_global_configat startup to determine whether synchronization is enabled; because the table did not exist, the query returned a SQL error that was silently treated as "disabled" and the manager returned immediately on every tick. As a result, access keys, users, tenants, bucket permissions, IDP providers, and group mappings were never propagated between cluster nodes in any production deployment. (internal/server/server.go,internal/cluster/sync_schema.go) - Remote nodes always showed 0 GB disk capacity in the cluster node list —
handleAPIHealthon the cluster inter-node port wrapped its response in theAPIResponseenvelope ({"success":true,"data":{"status":"healthy"}}). The health checker that parses remote node responses readscapacity_totalandcapacity_usedas top-level flat JSON fields; because both fields were nested inside thedataobject they were never resolved and always defaulted to0, making every remote node appear to have no available disk space in the console. Fixed by rewritinghandleAPIHealthto emit a flat JSON object containingstatus,capacity_total, andcapacity_useddirectly froms.systemMetrics.GetDiskUsage(). (internal/server/console_api.go) - Inter-node sync requests rejected with TLS certificate errors — each sync manager (
AccessKeySync,UserSync,TenantSync,BucketPermissionSync,IDPProviderSync,GroupMappingSync) created itsproxyClient(anhttp.Clientwith the cluster TLS configuration) insideserver.New(), before cluster initialization had run at runtime. At that point the cluster CA certificate had not yet been loaded, so the already-built clients had no trusted CA and all subsequent HTTPS inter-node sync requests were rejected with TLS verification errors. Fixed by refreshingm.proxyClient = NewProxyClient(m.clusterManager.GetTLSConfig())at the start of each manager'sStart()method, after cluster TLS has been fully initialized. (internal/cluster/access_key_sync.go,internal/cluster/user_sync.go,internal/cluster/tenant_sync.go,internal/cluster/bucket_permission_sync.go,internal/cluster/idp_provider_sync.go,internal/cluster/group_mapping_sync.go) -
Access keys, users, and tenants unavailable on newly-joined nodes for up to 30 seconds — after
handleAddClusterNoderegistered a new node in the database itshealth_statuswasunknown.GetHealthyNodes(), called by all sync manager loops to obtain the list of peers, filters exclusively forhealth_status='healthy'rows. The new node was therefore excluded from every sync iteration until the health-check goroutine ran (30-second interval), validated the node, and updated its status. During this window any S3 client that connected to the new node receivedInvalidAccessKeyIdfor every request. Fixed by addingkickstartNewNodeSync(): immediately after the join sequence completes a goroutine callsCheckNodeHealthto mark the node healthy, then callstenantSyncMgr.SyncToNode,userSyncMgr.SyncToNode, andaccessKeySyncMgr.SyncToNodein order to push all existing data to the new node without waiting for the periodic loops. (internal/server/cluster_handlers.go,internal/cluster/user_sync.go,internal/cluster/access_key_sync.go,internal/cluster/tenant_sync.go) -
Cluster join permanently broken: primary node sent its console URL instead of S3 API URL to joining nodes —
handleAddClusterNodebuilt thenode_endpointpayload froms.config.PublicConsoleURL(port 8081) instead ofs.config.PublicAPIURL(port 8080). The joining node stored this as the primary's endpoint and used it for all subsequent inter-node calls (/api/internal/cluster/validate-token,/api/internal/cluster/register-node,/api/internal/cluster/sign-csr,/api/internal/cluster/nodes,/api/internal/cluster/jwt-secret). All of these routes are registered exclusively on the S3 API port; sending them to the console port returned 404 on every call, making cluster join always fail in production. Only worked in tests because both ports werelocalhostand tests happened to hit the right server. Fixed by moving all cluster routes to the dedicated cluster port 8082. (internal/server/cluster_handlers.go,internal/server/server.go) - Inter-node TLS verification always failed in real multi-node deployments —
GenerateNodeCertandGenerateKeyAndCSRhardcoded onlylocalhostand127.0.0.1in the certificate Subject Alternative Names. When nodes communicated via real IP addresses or hostnames (e.g.192.168.1.10,node2.internal), TLS verification rejected the connection because the address did not match any SAN in the peer certificate. This silently broke all HTTPS inter-node traffic outside of single-machine test setups. Fixed by adding anendpointURLparameter to both functions; the hostname or IP is extracted from the URL and appended to the SANs alongside the existinglocalhostentries. All call sites inmanager.gonow passm.publicAPIURL. (internal/cluster/tls.go,internal/cluster/manager.go) -
Cluster inter-node communication and direct console access broken after configuring
public_console_url—setupConsoleRoutesextracted the path component frompublic_console_urland mounted all console routes (API and SPA) under that subpath. Because cluster nodes communicate with each other directly by IP and port (not through the public URL), any deployment that setpublic_console_urlto a domain name or load-balancer address caused every inter-node API call to receive 404, effectively breaking cluster formation, node join, and all intra-cluster coordination. Direct operator access to individual nodes via IP:port was equally broken for the same reason.spaHandler.ServeHTTPcompounded the problem by also returning 404 for requests whose path did not start with the configured base path.public_console_urlandpublic_api_urldefine the public-facing URLs used for link generation, OAuth callbacks, and SPA<base>tag injection — they must not control which paths the server listens on; that is the sole responsibility oflistenandconsole_listen. Fixed by always mounting all console routes at/regardless of the configured public URL. ThespaHandlernow strips the base-path prefix only when present (for reverse-proxy deployments that do not strip the prefix) and falls back to serving from root otherwise. (internal/server/server.go,internal/server/embed.go) -
Session always expired after exactly 15 minutes regardless of user activity — the refresh token issued at login was never stored in the browser because
APIClient.loginreadresponse.data.refreshToken(camelCase) while the backend sendsrefresh_token(snake_case). As a result the in-memoryrefreshTokenfield was alwaysundefined, whichTokenManager.setTokenssilently ignored. Every subsequent mechanism that depends on the refresh token — the proactive timer that fires 2 minutes before access-token expiry, the sliding-window background refresh that fires on each API response, and the 401 retry interceptor — found no token and did nothing. The access token (default 15 min) expired, the 401 interceptor had no refresh token to exchange, and the user was logged out. Fixed by readingresponse.data.refresh_token ?? response.data.refreshToken(snake_case first, camelCase as fallback). (web/frontend/src/lib/api.ts) - Inactivity timeout did not account for tab close / page reload — the idle timer lived entirely in memory (
useRef), so closing the browser tab or reloading the page reset the inactivity counter to zero. A user who left the tab open for 10 minutes, closed it, and reopened it an hour later would not be logged out because no timer had been running during the closure. Fixed by persistinglast_activitytolocalStorageon every user-interaction event (mousedown, keydown, scroll, click, etc.). On mount,useAuthProvider.initializeAuthreads this timestamp and, if it is older than the 15-minute inactivity window, immediately clears auth and redirects to the login page before attempting any API call. The timestamp is cleared on explicit logout. (web/frontend/src/hooks/useIdleTimer.ts,web/frontend/src/hooks/useAuth.ts) - Inactivity timeout was set to 30 minutes instead of the documented 15 minutes —
useAuthProviderpassedtimeout: 30 * 60 * 1000touseIdleTimer. Changed to15 * 60 * 1000. The constant is now extracted asIDLE_TIMEOUT_MSand shared between the idle timer and the tab-close check so both use the same value. (web/frontend/src/hooks/useAuth.ts) - Duplicate folders in S3 clients when listing large buckets — S3 clients (S3 Browser, AWS CLI, SDKs) that paginate a bucket with
delimiter=/received the same common prefix (folder name) on multiple pages. Root cause had two parts: (1)ListObjectsinmanager.gosetnextMarkerto the common prefix string (e.g."folder_1000/") when truncating a page that ended on a folder boundary. On the next page the Pebble iterator seeked to that key and, if it existed as a folder marker or if objects inside the folder were encountered, extracted the same common prefix again. (2) When the store's own paginationnextMarkerfell inside a returned common prefix (large folder with >100 000 objects across multiple 100 k-scan chunks), the same folder was emitted again on the next chunk's page. Fixed by introducingadvancePastPrefix(prefix, delimiter)which computes the lexicographically-smallest key that is greater than every key starting withprefix(e.g.advancePastPrefix("folder_1000/", "/") → "folder_1001"). The function is applied in two places: when truncating by prefix count, and when the store'snextMarkerfalls inside any returned common prefix. (internal/object/manager.go) - Pebble
ListObjects/searchObjectsByScanskipped all results when marker key was not in store — the skip-to-marker loop usedobjKeyStr == markerto detect when to start collecting.SeekGEpositions the iterator at the first key ≥ marker; if the marker is a synthesised sentinel (e.g."folder_1001"produced byadvancePastPrefix) that does not exist as a real object key, the first key fromSeekGEis already past the marker, butstartednever becametrueand every subsequent key was discarded. Fixed by promotingstarted = trueimmediately whenobjKeyStr > marker(SeekGE guarantees the key is ≥ marker, so>means the marker was not found and the current key is the correct start), falling through to process the key instead of continuing. The== markerbranch still skips the marker itself for exclusive-lower-bound semantics. Same fix applied tosearchObjectsByScan. (internal/metadata/pebble_objects.go) handleCreateBucketpanicked with nil pointer dereference in server tests — the handler calleds.clusterManager.GetLocalNodeID(r.Context())unconditionally to assign the HA primary node. In the test environments.clusterManageris nil (cluster is not initialised for unit tests), causing a panic. Every other call site inconsole_api.goguards withif s.clusterManager != nil. Added the same guard around the primary-node assignment. (internal/server/console_api.go)- Bucket migration available when HA replication factor > 1 — the
POST /cluster/buckets/{bucket}/migratehandler did not check the current replication factor. In HA mode every node already holds a complete copy of all data, so migrating a bucket to a specific node is meaningless and, ifdelete_sourcewas set, would destroy data that is still serving live read traffic. Added a guard that readsGetReplicationFactorand returnsHTTP 400with a clear message when factor > 1. (internal/server/console_api_migration.go) - Bucket migration
delete_sourceoption was a no-op —executeMigrationinmigration.gocontained a// TODO: Implement source deletioncomment and no code. Settingdelete_source: truesilently succeeded without removing anything from the source node. Implemented by addingbucketManagerForMigration— a minimal interface exposingForceDeleteBucket— tocluster.Manageralongside aSetBucketManagersetter.executeMigrationnow callsForceDeleteBucket(ctx, tenantID, job.BucketName)after all copy, permission, ACL, configuration, and verification steps complete.SetBucketManageris called fromserver.goduring startup alongside the existingSetStoragecall. IfbucketManageris nil whendelete_source: trueis requested, an explicit error is returned instead of silently skipping the deletion. (internal/cluster/migration.go,internal/cluster/manager.go,internal/server/server.go)
Changed¶
- Object Lock / WORM bucket creation UI simplified and Veeam guidance added — the warning banner was reduced from four bullet points to two (the mode-specific bullets were redundant with the mode descriptions below them). A new "No default retention (app-managed)" option is now the first choice in the Retention Mode selector, selected by default, with an explicit note: Use this for Veeam Backup & Replication — Veeam sets its own mode and retention directly on each object. The retention-period inputs (days / years) are hidden when this option is selected since they do not apply to app-managed retention. The COMPLIANCE and GOVERNANCE descriptions were trimmed to one sentence each. Both
enandeslocales updated. (web/frontend/src/pages/buckets/create.tsx,web/frontend/src/locales/en/createBucket.json,web/frontend/src/locales/es/createBucket.json)
[1.2.0] - 2026-04-02¶
Fixed¶
- Background token refresh triggered immediate logout on transient failure — the
handleResponseactivity-based background refresh (fired after every successful API call, debounced to once per minute) calleddoLogout()directly when the refresh endpoint returned 401 or 403. This meant that if the refresh token expired between two user actions, the user was silently logged out the next time any API call completed successfully — even though their current access token was still valid for up to another 14 minutes. The race was particularly common when thesecurity.session_timeoutwas set shorter than intended: both the access token and refresh token would share the same short TTL, expire together, and the next background refresh would immediately terminate the session. Fixed by removingdoLogout()from the background refresh catch path; the 401 error interceptor (which fires only when an actual API request is rejected) remains the sole authoritative logout trigger. Additionally,setTokensno longer clears the in-memory refresh token tonullwhen called without a second argument (preventing a silent accidental clear if a refresh response ever omits therefresh_tokenfield), andgetRefreshToken()now falls back to localStorage if the in-memory copy is missing. (web/frontend/src/lib/api.ts) - COMPLIANCE Object Lock retention did not prevent bucket deletion by global admins — the
handleDeleteBucketconsole API handler blocked non-admin users from deleting Object Lock–enabled buckets but allowed global administrators to delete them regardless — including via the?force=truepath that callsForceDeleteBucket, which bypasses the empty-bucket check entirely. Any user with global admin credentials could destroy WORM-protected data, defeating the guarantee offered to Veeam B&R users and similar backup workloads that rely on COMPLIANCE retention. Fixed by adding a pre-deletion scan (HasActiveComplianceRetention) that iterates all object metadata and versioned object entries for the bucket and returnstrueif any record hasRetention.Mode == "COMPLIANCE"with a futureRetainUntilDate, or hasLegalHold == true. If active immutable data is found, the request is rejected withHTTP 409 Conflictfor all callers — including global admins and force-delete.HasActiveComplianceRetentionwas added to themetadata.Storeinterface, its Pebble implementation, theobject.Managerinterface, andobjectManager. The bucket list UI now disables the delete button for all users on Object Lock buckets (previously only blocked non-global-admins) with an updated tooltip explaining why. TheonErrorhandler in the bucket list no longer offers the force-delete dialog when the server responds with a COMPLIANCE error. (internal/metadata/store.go,internal/metadata/pebble_objects.go,internal/object/manager.go,internal/server/console_api.go,web/frontend/src/pages/buckets/index.tsx) - GOVERNANCE Object Lock bypass ignored when deleting a specific version —
DeleteObjectreads thex-amz-bypass-governance-retention: trueheader and passesbypassGovernance=truetoobjectManager.DeleteObject, but when aversionIdis present the call is dispatched todeleteSpecificVersion, which did not accept the flag. The GOVERNANCE retention check insidedeleteSpecificVersionalways blocked the deletion regardless of the header, so--bypass-governance-retentionhad no effect on versioned deletes. Fixed by threadingbypassGovernance boolthroughdeleteSpecificVersionand applying the same bypass logic thatdeletePermanentlyalready had. (internal/object/manager.go) GET /{bucket}?uploads(ListMultipartUploads) was captured by ListObjects —ListMultipartUploadswas registered on the bucket router afterListObjects(which has no query-parameter constraint). gorilla/mux matches routes in registration order, so everyGET /{bucket}?uploadsrequest was handled byListObjects, which returned aListBucketResultinstead ofListMultipartUploadsResult. The AWS CLI parsed the wrong XML and showed an empty response with noUploadsarray even for in-progress uploads. Fixed by moving theListMultipartUploadsroute registration inside the per-path loop, immediately beforeListObjects, so the more-specific query-constrained route is evaluated first. (internal/api/handler.go)- Panic
pebble: closedon graceful shutdown —BucketAccessLogger.Stop()closed thedonechannel to signal the background goroutine to stop and flush, but returned immediately without waiting for the goroutine to finish. The shutdown sequence then closed the Pebble metadata store while the goroutine was still influshEntries→PutObject→pebbleGet, causing apanic: pebble: closed. Fixed by adding async.WaitGroup(wg.Add(1)before starting the goroutine,defer wg.Done()at the top ofrun).Stop()now callswg.Wait()after closing the channel, blocking until the goroutine has completed its final flush before Pebble is closed. (internal/server/bucket_logging.go) - Inventory report ETag was the content length encoded as hex, not an MD5 checksum — when the inventory generator uploaded a report object it set
ETag: fmt.Sprintf("%x", len(content)), which produces the byte count in hexadecimal (e.g."f5a"for a 3 930-byte CSV). The integrity scanner compares stored ETags against the actual MD5 of the file content, so every inventory report was flagged as corrupt on the next scrub cycle. Fixed by computingmd5.Sum(content)and storing it as a plain hex string viafmt.Sprintf("%x", md5.Sum(content)). ETags are stored without surrounding double-quote characters in the metadata store (the HTTP"quoting is an on-wire convention only); a prior attempt that usedfmt.Sprintf("\"%x\"", ...)stored literal"characters in Pebble, causing the integrity check to always report the object as corrupted even after the MD5 fix. (internal/inventory/generator.go) - Object deletion order left metadata intact after physical file was removed —
deletePermanentlydeleted the physical storage file in step 1 and the metadata in step 2. If step 2 failed (or if the process crashed between the two steps), the object remained visible in metadata but its data was gone; the integrity scanner would then report it as corrupt on the next scan. Swapped the order: metadata is now deleted first, then the physical file. If the physical deletion fails the file becomes an orphan (harmless, cleaned up by the next bucket directory removal or orphan scrub), which is the safe failure direction. (internal/object/manager.go) isBucketEmptysilently deleted orphaned physical files as a side effect — whenDeleteBucketcalledisBucketEmptyto verify the bucket was safe to remove, the helper walked all physical files and deleted any that had no corresponding metadata entry. This caused unexpected"Found orphaned physical file without metadata - deleting"warnings in the logs before the bucket-deleted audit event, making it appear the storage layer was making unilateral deletions outside of any explicit user action. Removed the destructive side effect:isBucketEmptynow simply skips files with no metadata (treating them as absent for the empty-check). TheDeleteBucketRemoveDirectorycall at the end of the deletion sequence already removes the entire directory including any residual orphaned files. (internal/bucket/manager_impl.go)- Console UI allowed deleting Object Lock–enabled buckets — any authenticated user (including non-admins) could delete a bucket that had Object Lock enabled through the console API, bypassing WORM protection. Added a server-side guard in
handleDeleteBucket: if the bucket hasObjectLockEnabled: trueand the caller is not a global administrator the request is rejected with403 Forbidden. The delete button in the bucket list is also disabled in the UI for Object Lock buckets for non-global-admins, with a tooltip explaining why. (internal/server/console_api.go,web/frontend/src/pages/buckets/index.tsx) - Non-admin users could not see buckets they created — when a global user (no tenant) created a bucket through the console API,
handleCreateBucketleftOwnerIDandOwnerTypeempty on the new bucket.FilterBucketsByPermissionsgrants visibility whenbucket.OwnerID == user.ID && bucket.OwnerType == "user", so the bucket was invisible to the creator immediately after creation. Fixed by assigningOwnerID = user.IDandOwnerType = "user"for global non-admin users on bucket creation. (internal/server/console_api.go) - 2FA login failed with HTTP 500 after code was accepted —
GetUserWith2FAscanned thetenant_idcolumn directly into astringvariable.tenant_idisNULLin SQLite for users without a tenant assignment;database/sqldoes not allow scanningNULLinto a plainstringand returns an error, which the handler mapped to a 500 response. Fixed by scanning intosql.NullStringand only assigninguser.TenantIDwhenValidis true. (internal/auth/sqlite.go) -
Vite compiled frontend with absolute
/assets/…import paths —vite.config.tshadbase: '/ui/'hardcoded, so dynamicimport()chunks were compiled with/ui/assets/chunk-xxx.jsabsolute paths. In a deployment where the console is served under a different prefix, the browser fetched those paths from the S3 API port, which returned a MIME-type error and caused a blank page. Changed tobase: './'so all asset paths are relative to the HTML document.embed.goinjects a<base href="…">tag that resolves relative imports to the correct runtime prefix regardless of the configured console path. (web/frontend/vite.config.ts) -
RestoreAlreadyInProgressreturned HTTP 500 instead of 409 — the error code was missing from thewriteErrorstatus-code table, so the server logged it as an internal error and the AWS CLI retried the request 4 times before giving up. AddedRestoreAlreadyInProgressto the 409 Conflict case. (pkg/s3compat/handler.go) NoSuchConfiguration(inventory) returned HTTP 500 instead of 404 — same root cause: the error code was not mapped inwriteError. AddedNoSuchConfigurationto the 404 Not Found case soGetBucketInventoryConfigurationon a missing id returns a clean 404 instead of an internal error. (pkg/s3compat/handler.go)-
RestoreObjectallowed re-restoring an already-restored object — the in-progress check only testedRestoreStatus == "ongoing"but the handler sets status to"restored"(not"ongoing") on success. A second restore call within the expiry window silently succeeded with 200 instead of returning 409. Fixed by also checkingRestoreStatus == "restored" && RestoreExpiresAt.After(now). (pkg/s3compat/handler.go) -
Metadata store not closed on shutdown — all recent writes silently lost on restart —
s.metadataStore.Close()was missing from the serverShutdown()sequence. Pebble usespebble.NoSyncfor all writes (intentional for performance), meaning writes go to the WAL in memory and are only flushed to disk whendb.Close()is called. Without this call, every metadata change since the last background compaction flush — object deletes, version promotions, renames, tag updates, bucket config changes — was silently discarded on process exit. On the next startup Pebble replayed only what had been physically written to disk, reverting the database to an earlier state. This caused symptoms like: deleting a file and seeing it reappear after restart, restoring a version (removing a delete marker) only for the marker to come back after restart, and renamed objects reverting to their original key. Fixed by addings.metadataStore.Close()at the end ofShutdown(), after all workers have stopped (workers may still write metadata during their own shutdown, so order matters). (internal/server/server.go) - Session expiry while actively working in the UI — the access token used a fixed 15-minute window from the moment it was issued, with no sliding behaviour. A user who worked continuously for 15 minutes was silently logged out regardless of their activity, because the token timer was never reset by API calls. Implemented a true sliding-window session: after every successful API response a background token refresh is triggered (debounced to at most once per minute via
lastActivityRefreshAt). Any action that produces an API call — navigation, bucket operations, uploads, settings changes — now resets the 15-minute window from that moment. The existing proactive refresh timer (fires 2 minutes before expiry) is kept as a safety net for idle tabs where no API calls are in flight. (web/frontend/src/lib/api.ts) - Proactive refresh silently abandoned on transient failure — if the proactive background refresh failed due to a network glitch or server hiccup, the
catchblock discarded the error and no retry was scheduled. The token then expired with no further recovery attempt before the 401 interceptor. The proactive refresh now retries every 30 seconds on transient errors (non-401/403 responses) for as long as the current token is still alive. Auth rejections (401/403) are not retried — those mean the refresh token itself is invalid and the next API call's 401 interceptor handles the final logout. (web/frontend/src/lib/api.ts) - Idle timer logged out user during active file upload — the idle timer tracks UI events (mouse, keyboard) to detect inactivity. During a large file upload the user is not interacting with the UI, so after 30 minutes of upload progress the idle timer fired
handleIdleand redirected to login, aborting the transfer. The idle timer now checks anactiveUploadCountcounter before firing: if any upload is in progress the logout is postponed by a full 30-minute interval instead of executing. The counter is incremented whenuploadObjectstarts and decremented infinallyso it is always correct even on error. (web/frontend/src/lib/api.ts,web/frontend/src/hooks/useIdleTimer.ts,web/frontend/src/hooks/useAuth.ts) -
Console UI allowed deleting Object Lock–protected objects — the bucket browser did not check an object's retention or Legal Hold status before calling the delete API. Objects marked as immutable by VEEAM B&R appeared locked in the UI (lock icon + retention countdown) but the delete button remained enabled. On versioned buckets the console API created a delete marker, making the object disappear from the default view while the retained version remained — giving a false impression of successful deletion. Fixed by adding an
isObjectLockedhelper (checks active non-expired retention andlegalHold.status === 'ON'), disabling the Actions dropdown delete button when any selected item is locked, blockinghandleDeleteObjectbefore confirmation with a specific error toast, and filtering locked items out of bulk delete with a skip count shown to the user. (web/frontend/src/pages/buckets/[bucket]/index.tsx,web/frontend/src/locales/en/buckets.json,web/frontend/src/locales/es/buckets.json) -
Stale "account locked" notification shown on fresh installations — notifications were persisted in browser
localStoragewithout any session binding. Opening a new MaxIOFS deployment in a browser that had previously used another instance could show an olduser_lockednotification (e.g. "admin account locked 7 days ago") even though the new server had no lock state at all. Fixed by storing the current auth token alongside the notifications inlocalStorage. On mount, if the token does not match the one stored with the notifications, the stale notifications are discarded and replaced with an empty list for the new session. (web/frontend/src/hooks/useNotifications.ts) -
cleanupEmptyDirectoriesnever cleaned up directories — the function compared a relative object path (bucket/key) against the absoluterootPathfromFilesystemBackend.GetRootPath().strings.HasPrefixalways returned false, so the cleanup loop broke on the first iteration and no empty directory was ever removed after object deletion. Fixed by constructing the full path withfilepath.Join(rootPath, getObjectPath(bucket, key))before entering the loop. (internal/object/manager.go) - Actions dropdown opened downward even when near the bottom of the viewport — the dropdown always rendered below the button, causing it to overflow off-screen when the table was scrolled near the bottom. Fixed by measuring the remaining viewport space on click and flipping to
bottom-fullpositioning when less than 520px remain below the button. (web/frontend/src/pages/buckets/[bucket]/index.tsx) - Actions button enabled with no objects selected — the Actions toolbar button could be clicked with an empty selection, opening a dropdown with no applicable targets. Fixed by adding
selectedObjects.size === 0to the button's disabled condition. (web/frontend/src/pages/buckets/[bucket]/index.tsx) - Inventory settings always showed as disabled — the console API wraps inventory configuration responses in
{ "success": true, "data": { … } }butgetBucketInventoryin the frontend returnedresponse.data(the outer envelope) instead ofresponse.data.data(the actual config). As a resultinventoryConfig.enabledwas alwaysundefined, which the form treated asfalseregardless of the stored value. Fixed by unwrapping the innerdatafield:return response.data?.data ?? response.data. (web/frontend/src/lib/api.ts) MockMetadataStore,MockObjectManager, andmockManagermissingHasActiveComplianceRetention— addingHasActiveComplianceRetentionto themetadata.Storeandobject.Managerinterfaces caused compilation failures in three test packages (internal/inventory,internal/api,internal/object) because their mock structs did not implement the new method. Added stub implementations returningfalse, nilto all three. (internal/inventory/generator_test.go,internal/api/handler_security_test.go,internal/object/lock_test.go)- Bucket encryption not reflected in dashboard —
BucketWithLocationin the cluster aggregator did not have anEncryptionfield, so the encryption config was dropped on every list request and the dashboard always showed 0 encrypted buckets. Fixed by addingEncryption *bucket.EncryptionConfigtoBucketWithLocation, populating it when converting local buckets, and forwarding it inbucketWithLocationToBucket. (internal/cluster/bucket_aggregator.go,internal/server/console_api.go) - Bucket encryption metadata not persisted on creation — when
req.Encryptionwas nil but global encryption was enabled, the bucket was saved without encryption metadata. Fixed by falling back to{type: "AES256"}whens.config.Storage.EnableEncryptionis true. (internal/server/console_api.go) - Non-admin users could not delete their own buckets — the
isOwnercheck used camelCase fields (bucket.ownerType,bucket.ownerId) but the API returns snake_case (owner_type,owner_id), soisOwnerwas always false. Fixed by reading both variants with fallback. (web/frontend/src/pages/buckets/index.tsx) - Regular tenant users had buckets assigned to the tenant instead of themselves —
handleCreateBucketusedownerType = "tenant"for all tenant users including non-admins, preventing them from ever satisfying theisOwnercondition. Non-admin tenant users now getownerType = "user"andownerID = user.ID; tenant admins retain tenant ownership. (internal/server/console_api.go)
Changed¶
- Pebble metadata engine upgraded from v1.1.5 to v2.1.4 — the embedded LSM-tree engine that stores all object and bucket metadata has been updated to the latest stable release. Pebble v1 and v2 use incompatible on-disk formats, so the server performs an automatic one-time migration on first start with this version:
- The existing Pebble v1 database (
metadata/) is opened read-only. - All key-value pairs are streamed into a temporary v2 directory (
metadata_pebblev2/). - The directories are swapped atomically:
metadata/is renamed tometadata_pebblev1_backup_{timestamp}/(preserved as a safety backup), andmetadata_pebblev2/is promoted tometadata/. - A sentinel file
metadata/PEBBLE_FORMAT_V2is written. All subsequent restarts detect it and skip the migration check immediately.
The migration is fully transparent — no manual steps, no downtime window, no data loss. The backup directory can be deleted after verifying the server operates normally. If the server is killed mid-migration, the next start automatically detects the incomplete state and either retries or completes the rename. Upgrading from BadgerDB (pre-v1.0.0-beta) is also covered: the BadgerDB migration path now writes to Pebble v2 directly and stamps the same sentinel, so the v1→v2 check is skipped for those deployments too.
Upgrade path summary: - Pre-v1.0.0-beta (BadgerDB) → first upgrade to v1.0.0-beta, which migrates to Pebble v1, then upgrade to this version, which migrates to Pebble v2. Alternatively, upgrading directly to this version also works if the BadgerDB→Pebble v2 direct path runs first. - v1.0.0-beta (Pebble v1) → upgrade directly to this version; automatic migration runs on first start. - Fresh install → Pebble v2 is created directly, no migration needed.
(internal/metadata/pebble_v1_migration.go, internal/metadata/pebble_store.go, internal/metadata/migration.go, internal/server/server.go, go.mod)
- New config option:
storage.metadata_cache_size_mb— controls the size of the Pebble block cache, which is the primary lever for metadata read performance. When MaxIOFS lists a folder or fetches object info, Pebble first checks this in-RAM cache. A cache hit is essentially instant (~0.1 ms); a miss requires a disk read. The cache is populated on first access and persists for the lifetime of the process.
Default is 256 MB. Recommended values by workload:
| Deployment | Objects / bucket | Recommended |
|---|---|---|
| Small / development | < 5 000 | 256 MB (default) |
| Medium | 5 000–20 000 | 512 MB |
| Veeam B&R / large buckets | 20 000–100 000 | 1 024 MB |
| Very large deployments | 100 000+ | 2 048 MB+ |
Configure in config.yaml:
(internal/config/config.go, internal/metadata/pebble_store.go, internal/server/server.go, config.example.yaml, docs/CONFIGURATION.md)
- Pebble pre-tuned for write-heavy workloads — several internal engine parameters were tuned to improve performance under the write-burst patterns produced by tools like Veeam Backup & Replication (tens of thousands of objects written in rapid succession):
- MemTable size: 64 MB (up from the default 4 MB) — writes are buffered in RAM before being flushed to L0 SSTables. A larger MemTable absorbs longer bursts without triggering a flush, reducing write amplification and keeping the LSM tree cleaner.
- MemTableStopWritesThreshold: 12 — the number of unflushed MemTables before incoming writes are stalled. Raising this gives compaction more time to catch up during sustained write loads without blocking the client.
- L0StopWritesThreshold: 12 — similarly, writes are only stalled when 12 L0 files are pending compaction (default is 4). This prevents false write stalls during backup job peaks.
- Compaction concurrency: 2–4 goroutines — Pebble now runs 2 to 4 background compaction goroutines simultaneously. This keeps the LSM tree compact between backup sessions, directly improving list and point-lookup latency.
- Bloom filters on L1–L6 (10 bits/key) — a probabilistic structure that avoids unnecessary disk reads for point lookups where the key is absent. For example, a Veeam existence check (
HEAD /{bucket}/{object}) on a key that doesn't exist would previously require scanning each LSM level on disk; with bloom filters the answer is ~99% certain in RAM. L0 deliberately has no bloom filter because range scans dominate there. - Block size: 32 KB — each SSTable block read from disk contains 32 KB of contiguous key-value data. Larger blocks are more efficient for sequential range scans (folder listings, prefix iteration) at the cost of slightly more memory per cached block.
These values are fixed in the binary. The only user-tunable parameter is the cache size described above. (internal/metadata/pebble_store.go)
- Actions dropdown in bucket browser is now context-aware — previously the dropdown mixed bucket-level info (S3 URI, ARN, Endpoint URL, Website URL) with object actions in a single list regardless of selection state. Redesigned so that with no objects selected the dropdown shows only bucket information (S3 URI, ARN, Endpoint URL, Website URL) under a "Bucket Info" header, and with one or more objects selected it shows only object actions under an "Object Actions" header. All new labels internationalised in both en and es locales. (web/frontend/src/pages/buckets/[bucket]/index.tsx, web/frontend/src/locales/en/buckets.json, web/frontend/src/locales/es/buckets.json)
- Bucket list search moved inline into table header — the standalone search card above the buckets table was removed. The search input is now embedded directly in the table header row alongside the bucket count, matching the layout used inside the bucket browser. Pagination row height reduced (py-4 → py-2) and page-navigation buttons made more compact. (web/frontend/src/pages/buckets/index.tsx)
- CI/CD GitHub Actions updated to Node.js 24 runtime — all actions in both workflow files (main.yml, docker-publish.yml) updated to versions that declare runs.using: node24: actions/checkout v4→v6, actions/setup-go v5→v6, actions/setup-node v4→v6, actions/setup-python v5→v6, aws-actions/configure-aws-credentials v4→v6, docker/setup-qemu-action v3→v4, docker/setup-buildx-action v3→v4, docker/login-action v3→v4, docker/build-push-action v6→v7, peter-evans/dockerhub-description v4→v5. Eliminates the "Node.js 20 actions are deprecated" warning ahead of GitHub's June 2, 2026 enforcement deadline. (.github/workflows/main.yml, .github/workflows/docker-publish.yml)
Added¶
- Docker multi-arch images published to DockerHub — added
Dockerfilewith multi-stage build (frontend → Go binary → runtime),docker-entrypoint.shfor privilege drop viarunuserand first-runconfig.yamlgeneration, and two CI workflows:docker-publish.yml(triggered on version tags, buildslinux/amd64+linux/arm64, pusheslatest+ versioned tag, updates DockerHub description) and nightly Docker build inmain.yml. Runtime image usesdebian:trixie-slimwithapt-get upgradeon every build to minimise CVE exposure. (Dockerfile,docker-entrypoint.sh,.github/workflows/docker-publish.yml,.github/workflows/main.yml,DOCKERHUB.md) docker-compose.yamluses bind mount by default — data directory mounted as./maxiofs-data:/datasoconfig.yamlis directly accessible on the host for editing without needing a helper container. (docker-compose.yaml)- Replication worker unit tests —
worker_test.gocoversprocessItem(PUT/COPY success, source not found, S3 error, DELETE with replicate-deletes on/off, unknown action, rule not found, rule disabled),handleError(retry path and max-retries → StatusFailed),getRule(found and not found),completeItem(status=completed, bytes_replicated set), andupdateReplicationStatus(insert new record and update existing record). (internal/replication/worker_test.go) - Replication manager sync unit tests —
manager_sync_test.gocoversSyncBucket(success, rule not found, rule disabled, no objects, list error, prefix filter),SyncRule(success, lock contention),processScheduledRules(no rules, real-time mode skipped, overdue scheduled rule triggered, recently-synced rule skipped, stale lastSync entries cleaned up), andcleanup(expired items removed, recent completed items kept, pending items never deleted). (internal/replication/manager_sync_test.go) - Replication credential unit tests —
credentials_test.gocoversencryptCredential/decryptCredentialround-trip, multiple value types, random nonce, empty key passthrough, empty plaintext/stored, legacy plaintext, wrong key error, corrupt base64 error, too-short data error, andderiveCredentialKey(32-byte length, determinism, different inputs → different keys). (internal/replication/credentials_test.go) - SSRF validation unit tests —
s3client_test.gocovers all blocked IP ranges inisBlockedIP(loopback, RFC-1918 class A/B/C, link-local/metadata 169.254.x, IPv6 ULA/link-local, unspecified) andvalidateReplicationEndpoint(empty, valid HTTP/HTTPS, invalid scheme, literal private IPs). (internal/replication/s3client_test.go) - Object adapter unit tests —
adapter_test.gocoversRealObjectAdapter.CopyObject(success and object-not-found),DeleteObject(no-op), andGetObjectMetadata(success and error). (internal/replication/adapter_test.go) cleanupEmptyDirectoriesunit tests —manager_cleanup_test.gocovers single empty directory removal, nested empty directory chain, stopping at a non-empty parent,.maxiofs-folderand.metadatafiles treated as system files, root path never removed, and non-filesystem backend returning early. (internal/object/manager_cleanup_test.go)- Object integrity unit tests —
manager_integrity_test.gocoversVerifyObjectIntegrity(folder marker skipped, metadata not found, delete marker skipped, multipart ETag skipped, OK match, corrupted ETag mismatch) andVerifyBucketIntegrity(empty bucket, all OK, mixed results with counts, default maxKeys, listing error, duration set, multipart objects skipped). (internal/object/manager_integrity_test.go) RestoreObject—POST /{bucket}/{object}?restore— implements the S3 RestoreObject API used by Glacier-aware tools (Veeam, Commvault, NetBackup, AWS CLIrestore-object). Parses<RestoreRequest>XML (Days, GlacierJobParameters/Tier). Since MaxIOFS has no cold storage tier, the restore completes immediately: object metadata is updated withRestoreStatus="restored"andRestoreExpiresAt = now + Days. ReturnsHTTP 409 RestoreAlreadyInProgressif a restore is already active.HeadObjectandGetObjectresponses now include thex-amz-restoreheader (ongoing-request="false", expiry-date="...") when restore metadata is present. New metadata fieldsRestoreStatusandRestoreExpiresAtadded toObjectMetadata. NewSetRestoreStatus()method added toobject.Manager. (internal/api/handler.go,pkg/s3compat/handler.go,internal/object/manager.go,internal/object/adapter.go,internal/metadata/types.go)OwnershipControls—GET/PUT/DELETE /{bucket}?ownershipControls— implements the S3 Bucket Ownership Controls API. AWS SDK v2 (Go, Python, Java, JS) callsGetBucketOwnershipControlsautomatically on bucket creation and ACL operations; without this endpoint the SDK throwsOwnershipControlsNotFoundError. GET returnsBucketOwnerEnforcedas the default when no explicit configuration is set (matches AWS new-bucket behaviour). PUT accepts and validatesRule.ObjectOwnershipvalues (BucketOwnerEnforced,BucketOwnerPreferred,ObjectWriter); rejects unknown values withInvalidArgument. DELETE clears the stored config so a subsequent GET returns 404OwnershipControlsNotFoundError. Configuration persisted in bucket metadata. (internal/api/handler.go,pkg/s3compat/bucket_ops.go,internal/bucket/manager_impl.go,internal/bucket/interface.go,internal/bucket/types.go,internal/metadata/types.go)SelectObjectContent—POST /{bucket}/{object}?select— implements the S3 Select API. Loads the object into an in-memory SQLite database and executes the SQL expression, giving full SQL support (SELECT, WHERE, GROUP BY, ORDER BY, aggregate functions) without a custom parser. Streams results back using the Amazon Event Stream binary protocol (CRC32-framed Records → Stats → End events, 1 000-row batches withhttp.Flusherfor low latency). Supported input formats: CSV (withFileHeaderInfoUSE/NONE/IGNORE and configurableFieldDelimiter) and JSON Lines. Supported output formats: CSV and JSON (per-record JSON objects preserving SELECT column order). Compression is not supported (GZIP/BZIP2 inputs returnInvalidRequest). (pkg/s3compat/select.go,internal/api/handler.go)GetObjectTorrent—GET /{bucket}/{object}?torrent— returns501 NotImplemented. BitTorrent manifests have no practical use in self-hosted object storage; the route is now registered so clients receive a proper error instead of a 404 or silent failure. (pkg/s3compat/handler.go,internal/api/handler.go)Transfer Acceleration(?accelerate) — documented as a no-op.GET /?acceleratereturns an empty<AccelerateConfiguration/>andPUT /?accelerateaccepts and discards any config. Clients that configure acceleration see no difference in behavior, which is correct for self-hosted deployments without CDN edge nodes. No code changes needed — already behaved this way.BucketNotifications—GET/PUT/DELETE /{bucket}?notification— notification configuration was already stored but never delivered. Wired real async webhook delivery: after everyPutObject,DeleteObject,CopyObject, andCompleteMultipartUpload, matching notification targets receive an HTTP POST with the S3 event payload (event name, bucket, key, ETag, size, timestamp). Event filtering by prefix/suffix and event type (wildcards supported, e.g.s3:ObjectCreated:*) is applied before dispatch. SSRF protection blocks delivery to loopback, RFC-1918, and link-local addresses. Failures are logged as warnings without retries to avoid blocking the request path. AddedDELETE /{bucket}?notificationroute for compatibility with Terraform and SDKs (clears all configurations; AWS S3 itself uses empty-body PUT for this). (internal/server/bucket_logging.go,internal/bucket/interface.go,internal/bucket/manager_impl.go,pkg/s3compat/bucket_ops.go,internal/api/handler.go)BucketLogging—GET/PUT /{bucket}?logging— access log configuration was already stored but theBucketAccessLoggerworker was never started or connected to the request path. Wired a response-capturing middleware (s3AccessLoggingMiddleware) into the S3 router after the auth middleware so every S3 request is captured with: bucket, object key, operation name (e.g.REST.GET.OBJECT,REST.PUT.PART), remote IP, authenticated user, request ID, HTTP status, and bytes sent. The worker groups entries by source bucket, looks up the logging target, formats lines in AWS S3 access log format, and writes a single object per bucket per flush. Flush triggers: 100 entries buffered or 5-minute ticker. Buffer is drained on server shutdown. (internal/server/bucket_logging.go,internal/server/server.go)-
BucketInventory—GET/PUT/DELETE /{bucket}?inventory&id=,GET /{bucket}?inventory— implements the full S3 Bucket Inventory Configuration API. Supports multiple named configurations per bucket (identified byid). PUT creates or updates a configuration: validates destination bucket, schedule frequency (Daily/Weekly), format (CSV/JSON), and optional fields; maps S3OptionalFieldsnames (Size,LastModifiedDate,ETag,StorageClass,IsMultipartUploaded,EncryptionStatus,ReplicationStatus,ObjectACL) to internal field constants; mapsIncludedObjectVersions: Alltoversion_id/is_latestfields. GET by id returns the configuration in S3 XML wire format. DELETE by id removes the configuration. GET without id lists all configurations for the bucket asListInventoryConfigurationsResult. The pre-existing inventory execution engine (internal/inventory/) runs the scheduled reports; these new handlers expose its configuration through the standard S3 API so AWS CLI, Terraform, and SDK-based pipelines work without changes. (pkg/s3compat/inventory.go,internal/inventory/manager.go,internal/api/handler.go,pkg/s3compat/handler.go) -
"Show versions" view in bucket browser — added a Show Versions toggle button in the bucket browser table header (visible only when versioning is enabled). When active, the regular object listing is replaced by a flat versions table showing every object version and delete marker across the bucket (or current folder prefix). Each row shows: file name, type (Version / Delete Marker), last modified, size, and a truncated Version ID. Actions per row: restore old versions with a single click (creates a new PUT making the version current), remove delete markers to recover incorrectly deleted files (delete marker is permanently removed, revealing the previous version), and delete specific old versions permanently. New backend endpoints:
GET /buckets/{bucket}/versions?prefix=&maxKeys=(usesmetadataStore.ListAllObjectVersions) andPOST /buckets/{bucket}/objects/{key}/restore(copy-to-same-key pattern for content versions, delete-marker removal for delete markers). (internal/server/object_extra_handlers.go,internal/server/console_api.go,web/frontend/src/pages/buckets/[bucket]/index.tsx,web/frontend/src/lib/api.ts,web/frontend/src/types/index.ts)
Changed¶
- Bucket creation: encryption defaults to enabled when server encryption is active — the encryption toggle in the Create Bucket form always initialised to
falseregardless of the server's global encryption setting. If the server hadenable_encryption: true, the user had to manually enable the toggle or the bucket appeared unencrypted in the UI even though the server applied encryption anyway. The toggle now initialises totrueviauseEffectwhenserverConfig.storage.enableEncryptionistrue, and an informational green banner explains that server-wide AES-256-GCM encryption applies to all objects regardless of this setting. (web/frontend/src/pages/buckets/create.tsx,web/frontend/src/locales/en/createBucket.json,web/frontend/src/locales/es/createBucket.json)
[1.1.0] - 2026-03-25¶
Added¶
- Folder download as ZIP — new endpoint
GET /buckets/{bucket}/download-zip?prefix=folder/streams all objects under a prefix as a ZIP archive directly to the client without buffering on the server. Objects are stored without compression (zip.Store) to minimise CPU usage. Limits enforced before streaming begins: maximum 10 000 objects and 10 GB total size; requests exceeding either limit receive a400 Bad RequestJSON error. Client disconnections are detected via request context so the stream is aborted early. The download is recorded in the audit log with object count, total size, and ZIP filename. (internal/server/download_zip_handler.go,internal/server/console_api.go) - Object rename — new endpoint
POST /buckets/{bucket}/objects/{key}/renamewith body{ "newKey": "..." }renames an object by copying data + metadata + tags to the new key then deleting the original. Blocked for objects under active COMPLIANCE retention or Legal Hold. (internal/server/object_extra_handlers.go,internal/server/console_api.go) - Object tags get/set — new endpoints
GET /buckets/{bucket}/objects/{key}/tagsandPUT /buckets/{bucket}/objects/{key}/tagsexpose object tagging through the console API. (internal/server/object_extra_handlers.go) - Folder size endpoint — new endpoint
GET /buckets/{bucket}/folder-size?prefix=folder/returns the total size (bytes) and object count for all objects under a given prefix, paging through the listing as needed. (internal/server/object_extra_handlers.go) - Actions toolbar in bucket browser — replaced per-row action buttons with a single Actions dropdown in the table toolbar, matching AWS S3 UI conventions. The dropdown appears when one or more items are checked and offers: Copy S3 URI, Copy Object URL, Download, Download as ZIP (folders), Calculate Folder Size, Share, Presigned URL, View Versions, Legal Hold toggle, Rename, Edit Tags, and Delete. Actions that require a single selection or apply only to files/folders are disabled automatically when the selection does not match. (
web/frontend/src/pages/buckets/[bucket]/index.tsx,web/frontend/src/lib/api.ts) - Object detail view — clicking a file name in the bucket browser now opens a full AWS S3-style detail page that replaces the table, showing the object's S3 URI, ARN, Object URL, Key, size, last modified, ETag, content type, storage class, region, and custom metadata. The page has three tabs: Properties (all metadata fields with copy-to-clipboard buttons), Permissions (ACL owner and grants, loaded lazily), and Versions (version history with delete markers, loaded lazily). The breadcrumb tracks the full path (
Buckets > bucket > folder > file) and each segment is a clickable navigation link. The header includes a Download button and an Actions dropdown with the same operations as the table toolbar. (web/frontend/src/components/ObjectDetailsView.tsx,web/frontend/src/pages/buckets/[bucket]/index.tsx) - Bucket browser breadcrumb — replaced the "Back to Buckets" button with an inline breadcrumb (
Buckets > bucket-name > folder > …) above the page title, matching AWS S3's navigation style. The page title dynamically shows the current folder name rather than always showing the bucket name. (web/frontend/src/pages/buckets/[bucket]/index.tsx) tenantIdremoved from bucket browser URL — the bucket detail route no longer embedstenantIdas a URL segment; tenant context is now carried in routerlocation.stateto keep URLs clean and shareable.
Fixed¶
- Inconsistent table row height in bucket browser — folder rows were rendered taller than file rows because the folder name used a native
<button>element whose UA stylesheet defaults add implicit height even withp-0. Replaced with a<span role="button">which has no UA height, matching file rows exactly. (web/frontend/src/pages/buckets/[bucket]/index.tsx) ..(parent directory) row shorter than other rows — the parent directory row had no action buttons so its height was determined by text alone, making it shorter than item rows whose height is set by theh-9action buttons. Addedh-9to the content wrapper div to match item row height. (web/frontend/src/pages/buckets/[bucket]/index.tsx)- Breadcrumb navigation and parent directory row in bucket browser — replaced the "Up to Parent Folder" button with an always-visible breadcrumb path below the bucket header, and added a
..row as the first table entry when inside a subfolder. Both the breadcrumb segments and the..label link back to the correct parent prefix. (web/frontend/src/pages/buckets/[bucket]/index.tsx) - Table header background indistinguishable from row hover —
TableHeaderusedbg-secondaryandTableRowusedhover:bg-secondary, which map to the same CSS variable value in both light and dark modes. Changed header to explicitbg-slate-300 dark:bg-slate-700and row hover tohover:bg-slate-100 dark:hover:bg-slate-700/40. (web/frontend/src/components/ui/Table.tsx) - Action buttons lacked hover feedback — ghost icon buttons in the object table had no visible hover state. Each action button now has a colour-coded hover: download=blue, share=green, versions=blue, presigned URL=purple, legal hold=yellow, delete=red. (
web/frontend/src/pages/buckets/[bucket]/index.tsx) - Delete from object detail view triggered back-navigation before confirmation — the "Delete object" action in
ObjectDetailsViewcalledonBack()immediately afteronDelete(), returning the user to the bucket list while the SweetAlert confirmation modal was still open. If the user cancelled the deletion they would be left on the list without the object having been deleted — with no indication of what happened. Removed the prematureonBack()call; the existingdeleteObjectMutation.onSuccesshandler already closes the detail view viadetailsObjectKeyRefonce the deletion is confirmed. (web/frontend/src/components/ObjectDetailsView.tsx) tenantIdnot forwarded to ACL, versions, versions modal, and presigned URL modal —ObjectDetailsViewmade API calls togetObjectACLandlistObjectVersionswithouttenantId, causing both requests to resolve against the global bucket namespace and return 404 for any tenant bucket. Similarly,ObjectVersionsModalandPresignedURLModalwere rendered in the bucket browser without thetenantIdprop. All four call sites now receivetenantId.APIClient.getObjectACLsignature updated to accept an optionaltenantIdparameter. (web/frontend/src/components/ObjectDetailsView.tsx,web/frontend/src/pages/buckets/[bucket]/index.tsx,web/frontend/src/lib/api.ts)- Breadcrumb folder segments in object detail view all navigated to the bucket root — every intermediate folder in the breadcrumb called
onBack()which closed the detail view at whatevercurrentPrefixwas, instead of navigating to the specific prefix for that segment. For paths deeper than one level (e.g.a/b/c/file.txt) clickingaorbwould take the user toa/b/c/instead ofa/ora/b/. AddedonNavigateToPrefixcallback toObjectViewCallbacks; each breadcrumb segment now computes and sets the correct prefix before callingonBack(). (web/frontend/src/components/ObjectDetailsView.tsx,web/frontend/src/pages/buckets/[bucket]/index.tsx) -
Object detail view tab labels and field labels hard-coded in Spanish — all UI strings in
ObjectDetailsView(tab names, field labels, error messages, tooltips) were written as Spanish string literals rather than usinguseTranslation. All strings replaced witht()calls; corresponding keys added to bothen/buckets.jsonandes/buckets.json. (web/frontend/src/components/ObjectDetailsView.tsx,web/frontend/src/locales/en/buckets.json,web/frontend/src/locales/es/buckets.json) -
Data race in
metrics.collector.running—StartBackgroundCollectionandStopBackgroundCollectionwrote therunning boolfield concurrently without synchronization; detected with-race. Replacedboolwithsync/atomic.Bool;StartBackgroundCollectionusesCompareAndSwapto guarantee only one goroutine runs at a time, anddefer Store(false)as the single write point on exit. (internal/metrics/collector.go) - Data race in
handleTrace— the handler launchedpprof.Trace(w, r)in a background goroutine and returned when the timeout fired, leaving the goroutine still writing to theResponseRecorderwhile the caller was already reading it. Removed the goroutine+select pattern;pprof.Tracenow receives acontext.WithTimeoutand blocks until completion, ensuring the handler does not return whilewis still being written. (internal/server/profiling.go) - Data race in
TestHandleNotificationStreamExtraCases— the test readrr.Codeafter atime.Sleepwhile the SSE handler was still writing to the sameResponseRecorder. Replaced the sleep withcontext.WithTimeoutand adonechannel: the test waits for the handler to finish before reading the result. (internal/server/server_test.go)
Changed¶
- Go dependencies updated to latest — all direct and indirect Go dependencies updated to their latest available versions. Notable updates:
google.golang.org/grpcv1.56.3 → v1.79.3,go.opentelemetry.io/otel/sdkv1.37.0 → v1.42.0,modernc.org/sqlitev1.40.0 → v1.47.0,golang.org/x/crypto,golang.org/x/net,golang.org/x/sys,golang.org/x/textupdated across 6+ minor versions each. Two previously retracted packages resolved:github.com/klauspost/compressv1.18.1 → v1.18.5 andgithub.com/microcosm-cc/bluemondayv1.0.23 → v1.0.27. No vulnerabilities detected viagovulncheckpost-update. (go.mod,go.sum)
Security¶
- Bucket policy
Conditionblocks now evaluated —statementMatchespreviously ignored theConditionfield of every policy statement; conditions were silently skipped, meaning any policy that relied onaws:SourceIp,aws:SecureTransport,s3:prefix, or other condition keys had those restrictions completely unenforced. Implemented a full condition evaluator (conditionMatches) supporting the following operators:StringEquals,StringNotEquals,StringEqualsIgnoreCase,StringNotEqualsIgnoreCase,StringLike/StringNotLike(with AWS*/?wildcards),Bool,IpAddress/NotIpAddress(CIDR ranges vianet.ParseCIDR),ArnLike/ArnEquals/ArnNotEquals/ArnNotLike, andNumericEquals/NumericNotEquals. The condition context is populated from the real HTTP request:aws:SourceIpmaps to the client's IP (net.SplitHostPortofr.RemoteAddr),aws:SecureTransportmaps to whetherr.TLS != nil, and additional keys can be passed via aRequestContextmap. All condition operators within a statement are ANDed together; within each operator all keys are also ANDed. Unknown operators fail closed (deny).PolicyEvaluationRequestgained three new fields:SourceIP string,SecureTransport bool,RequestContext map[string]string. (internal/bucket/policy_evaluation.go,pkg/s3compat/handler.go) - Bucket policy
Principal: nullgranted access to everyone —principalMatchesreturnedtruewhenprincipal == nil, treating a malformed or omitted Principal as a wildcard match. In AWS S3,Principalis a required field in bucket policy statements; a statement with a null/missing Principal is malformed and must not match any request. Any policy loaded from storage with a missingPrincipalfield (e.g. a hand-edited or corrupted policy document) would silently allow any authenticated user through. Fixed by returningfalsefornilprincipal. (internal/bucket/policy_evaluation.go) -
GET /{bucket}/{key}?versionsremoved — non-standard route returning 501 — this route was registered ininternal/api/handler.goand mapped to a stub handler that unconditionally returned HTTP 501. The endpoint does not exist in the AWS S3 API; per-key version listing is accomplished via the standardListObjectVersions(GET /{bucket}?versions&prefix={key}) which is already implemented. The dead route and its stub were removed. (internal/api/handler.go,pkg/s3compat/handler.go) -
InternalErrorresponses leaked internal filesystem paths — every error path that calledh.writeError(w, "InternalError", err.Error(), ...)forwarded the raw Go error string to the XML response body. On Windows, OS errors fromos.WriteFileinclude the full absolute path (e.g.C:\Users\aricardo\Projects\MaxIOFS\data\objects\...). Similarly, storage errors inStorageError.Error()concatenate the OS cause. Fixed by sanitizingInternalErrormessages insidewriteError: the real error is now logged via logrus and the client always receives the generic AWS-compatible message "We encountered an internal error. Please try again." This is consistent with real AWS S3 behavior. (pkg/s3compat/handler.go) - Presigned V2 URL and header-based V2 signatures always rejected — Both the presigned URL validator (
calculateSignatureV2inpresigned.go) and the Authorization-header-based V2 verifier (verifyS3SignatureV2inmanager.go) usedHMAC-SHA256with hex encoding, but AWS S3 Signature Version 2 specifiesHMAC-SHA1with standard base64 encoding (RFC 4648). Any presigned URL generated by the AWS CLI with--signature-version s3or any V2-capable SDK was rejected with "signature does not match". Fixed in both code paths by switching tohmac.New(sha1.New, ...)andbase64.StdEncoding.EncodeToString. (pkg/s3compat/presigned.go,internal/auth/manager.go) NoSuchWebsiteConfigurationreturned HTTP 500 instead of 404 — the error code was not listed inwriteError's HTTP status switch, falling through to the default 500. AWS S3 returns 404 for this error. Added to both the 404 status case and theBucketNameresource field mapping. (pkg/s3compat/handler.go)- Auth error XML responses missing AWS context fields —
InvalidAccessKeyId,SignatureDoesNotMatch, andRequestExpiredreturned only<Resource>(the URL path) in the XML body. AWS S3 includes<AWSAccessKeyId>for credential/signature errors so clients can correlate the failed key, and<ExpiresDate>/<ServerTime>forRequestExpiredto help diagnose clock-skew. Added these fields to theErrorstruct and populated them inwriteErrorby extracting the access key fromAWSAccessKeyIdquery param (V2),X-Amz-Credentialquery param (V4), orAuthorization: AWS key:sigheader, and computing expiration times fromExpires(V2) orX-Amz-Date+X-Amz-Expires(V4). (pkg/s3compat/handler.go) - Presigned URL validation returned wrong HTTP status codes —
HandlePresignedRequestreturnedInvalidRequest(400) for all validation failures. AWS S3 returnsSignatureDoesNotMatch(403),RequestExpired(403), andInvalidAccessKeyId(403) for the respective conditions. Added apresignedValidationErrortype carrying the AWS error code; validators now return structured errors andHandlePresignedRequestmaps them to the correct codes.InvalidAccessKeyIdandSignatureDoesNotMatchwere also incorrectly mapped to HTTP 401 inwriteError; corrected to 403, andRequestExpiredadded as 403. (pkg/s3compat/presigned.go,pkg/s3compat/handler.go) - SigV2
CanonicalizedResourcemissing sub-resources — The StringToSign for AWS Signature Version 2 (both presigned URL validation and Authorization-header validation) used only the raw URL path asCanonicalizedResource. The AWS SigV2 spec requires appending recognized sub-resource query parameters (e.g.?versionId=,?acl,?delete,?uploadId=, etc.) to the resource path, sorted lexicographically. Any SigV2 request that included one of these parameters would fail signature verification. ImplementedcanonicalizedResourceV2(path, query)in bothpresigned.goandmanager.gowith the full list of 21 recognized sub-resources from the AWS documentation. (pkg/s3compat/presigned.go,internal/auth/manager.go) DeleteBucketbypassed Object Lock on versioned buckets —isBucketEmptywalked the physical storage to determine if a bucket could be deleted. Versioned objects are stored atbucket/.versions/key/versionID; these paths do not match the plain metadata key format (obj:{bucket}:{key}), so every versioned physical file was classified as an "orphaned file" and deleted. After the cleanup the current metadata entry was a delete marker (ETag="" Size=0), so the bucket appeared empty and was deleted — silently bypassing COMPLIANCE-mode Object Lock retention on all non-current versions. Fixed by detecting/.versions/paths in the walk, extracting the real key + versionID, and looking them up viaGetObject(ctx, bucket, key, versionID). Files with active content (non-delete-marker) now correctly report the bucket as not empty. (internal/bucket/manager_impl.go)- Console port (8081) missing all security headers — the S3 port (8080) already applied
Strict-Transport-Security,X-Content-Type-Options, andX-XSS-ProtectionviaS3Headers()middleware, but the console API server had no security headers at all. AddedConsoleHeaders()middleware applied to the console router:X-Frame-Options: SAMEORIGIN(clickjacking protection),Content-Security-Policy(XSS mitigation),X-Content-Type-Options: nosniff,X-XSS-Protection: 1; mode=block, andReferrer-Policy: strict-origin-when-cross-origin. (internal/middleware/s3headers.go,internal/server/server.go) - Object Lock COMPLIANCE shortening returned
InternalErrorinstead ofAccessDenied— when a client attempted to shorten a COMPLIANCE-mode retention date, the server returned<Code>InternalError</Code>with the error text as the message. AWS S3 returnsAccessDeniedfor this operation. Fixed by adding an explicit check forErrCannotShortenCompliancebefore the generic error fallback inPutObjectRetention. (pkg/s3compat/object_ops.go)
Added¶
GetObjectAttributesS3 API (GET /{bucket}/{key}?attributes) — returns object metadata (ETag, StorageClass, ObjectSize, Checksum, ObjectParts/TotalPartsCount) without downloading the object body. The caller specifies which attributes are needed via thex-amz-object-attributesheader. Used by AWS CLI v2, Mountpoint S3, and SDK v3 as a lighter alternative to HeadObject. Multipart part count is inferred from the<md5>-NETag suffix. (pkg/s3compat/object_ops.go,internal/api/handler.go)PublicAccessBlockS3 API (GET/PUT/DELETE /{bucket}?publicAccessBlock) — stores and retrieves the four AWS public-access-block flags (BlockPublicAcls,IgnorePublicAcls,BlockPublicPolicy,RestrictPublicBuckets) in bucket metadata via the standard AWS XML format. Configuration was previously settable only through the MaxIOFS console API; it is now also reachable from any S3-compatible client. (internal/bucket/interface.go,internal/bucket/manager_impl.go,pkg/s3compat/bucket_ops.go,internal/api/handler.go)PublicAccessBlockenforcement —checkPublicBucketAccessnow reads the bucket'sPublicAccessBlockconfiguration before evaluating ACL grants. IfIgnorePublicAclsorRestrictPublicBucketsis set, all public access is denied regardless of the bucket or object ACL. Previously the flags were stored but never consulted. (pkg/s3compat/handler.go)- Real
GetBucketLogging/PutBucketLogging— bucket server access logging configuration is now persisted and returned.PutBucketLoggingwith a<LoggingEnabled>element stores the target bucket and prefix; an empty<BucketLoggingStatus/>disables logging. ABucketAccessLoggerbackground goroutine buffers access log entries and delivers them asynchronously to the target bucket in AWS S3 access log format (5-minute flush interval, 100-entry batch trigger). (internal/bucket/interface.go,internal/bucket/manager_impl.go,internal/metadata/types.go,pkg/s3compat/bucket_ops.go,internal/server/bucket_logging.go) PutObjectconditional write (If-None-Match: *) — AWS added this in August 2024; clients like Terraform state backends and distributed lock managers rely on it to guarantee atomic "write only if not exists" semantics. When theIf-None-Match: *header is present and the object already exists, MaxIOFS now returns412 PreconditionFailedinstead of silently overwriting. (pkg/s3compat/handler.go)
Fixed¶
- Global encryption not applied to new objects when no bucket-level rule was set —
shouldEncryptObjectreturnedfalseif the bucket had no explicit SSE rule configured, even when the server-wide encryption key was present andEnableEncryption=true. Only buckets that had previously received aPutBucketEncryptioncall were actually encrypting objects; all other buckets silently stored data unencrypted. Fixed by returningtrueas the default when the encryption service is initialized and no bucket override says otherwise. (internal/object/manager.go) GetBucketEncryption/PutBucketEncryption/DeleteBucketEncryptionwere no-ops — all three handlers ignored the request body and never persisted any configuration.GetBucketEncryptionalways returnedServerSideEncryptionConfigurationNotFoundError. Implemented real persistence following the website-configuration pattern:GetEncryption/SetEncryption/DeleteEncryptionmethods added tobucket.Manager; handlers now parse the standard AWSServerSideEncryptionConfigurationXML on PUT and return it on GET. (internal/bucket/interface.go,internal/bucket/manager_impl.go,pkg/s3compat/bucket_ops.go)- SSE response headers missing on
GetObject,HeadObject, andPutObject— AWS S3 returnsx-amz-server-side-encryption: AES256on all three operations when the object is server-side encrypted. MaxIOFS never set this header. AddedSSEAlgorithm stringfield to theObjectstruct; it is populated from persistedObjectMetadata.SSEAlgorithmon read (with a storage-metadata fallback for objects created before this change), and set to"AES256"on write when encryption is active. All three response-header helpers now emit the header when non-empty. (internal/object/manager.go,internal/object/adapter.go,pkg/s3compat/handler.go)
Fixed¶
- Version update checker couldn't distinguish stable from pre-release —
isNewerVersioninAppLayout.tsxstripped all pre-release suffixes before comparing, sov1.0.0-beta,v1.0.0-rc1, andv1.0.0were all treated as equal and no update notification was shown. Replaced the string-strip approach with a ranked comparison: stable > rc > beta > alpha; within the same rank, the numeric suffix is compared. This correctly identifiesv1.0.0as newer thanv1.0.0-rc1, which is newer thanv1.0.0-beta. (web/frontend/src/components/layout/AppLayout.tsx) - Debian package:
1.0.0-rc1detected as newer than1.0.0by dpkg — dpkg treats-as the separator between upstream and Debian revision, sorc1(letters) sorted after the empty string, making1.0.0-rc1 > 1.0.0from dpkg's perspective. Attempting to upgrade from rc1 to stable produced "downgrade" warnings. Fixed by adding epoch1:to the package version (1:1.0.0), which is always sorted before the epoch field. Future pre-releases will use tilde notation (1.0.1~beta1) which sorts before any release. (debian/changelog,debian/control) - RPM packages: all pre-release and stable builds produced the same NVR —
VERSION_CLEANin the Makefile stripped all suffixes including-beta,-rc1, etc., so a build fromv1.0.0-rc1produced the samemaxiofs-1.0.0-1NVR asv1.0.0. RPM refused to upgrade because the NVR was identical. Fixed by adding a computedRPM_RELEASEvariable: pre-release builds produceRelease: 0.beta1/Release: 0.rc1; stable producesRelease: 1. The spec uses%{!?release: %define release 1}so the Makefile can override via--define "release $(RPM_RELEASE)"at build time. (Makefile,rpm/maxiofs.spec) - CI/CD:
npm cifailing after frontend dependency updates —package-lock.jsonwas out of sync withpackage.jsonafter Vite/plugin updates were made directly topackage.json.npm cirequires an exact lockfile match and aborted withEUSAGE. Regeneratedpackage-lock.jsonwithnpm install; this added 8 missing transitive packages (@testing-library/dom,@types/aria-query,dom-accessibility-api,lz-string,pretty-format,ansi-regex,ansi-styles,react-is). (web/frontend/package-lock.json) - CORS preflight
OPTIONSreturning 405 on bucket and object routes — gorilla/mux returns HTTP 405 Method Not Allowed before running subrouter middleware when a route matches by path but not by method. Since noOPTIONSmethod was registered for/{bucket},/{bucket}/, or/{bucket}/{object}routes, CORS preflight requests received 405 instead of being intercepted bybucketCORSMiddleware. Fixed by registering explicitOPTIONSno-op handlers for all three path patterns;bucketCORSMiddlewareintercepts them and returns 200 with the correct CORS headers. (internal/api/handler.go) - WORM badge not shown in cluster mode — in cluster mode
handleListBucketsusesBucketAggregator.ListAllBuckets()which converts localbucket.Bucketstructs toBucketWithLocation. TheBucketWithLocationstruct lacked anObjectLockfield, so the Object Lock configuration was silently dropped during conversion. The console API'sbucketWithLocationToBucketfunction propagated the missing field to the response, resulting in noobjectLockfield in the bucket list JSON and the WORM badge never rendering. Fixed by addingObjectLock *bucket.ObjectLockConfigtoBucketWithLocation, mapping it from the source bucket inListAllBuckets, and including it in thebucketWithLocationToBucketconversion. (internal/cluster/bucket_aggregator.go,internal/server/console_api.go) - Object Lock retention modal fully in English regardless of UI language —
ObjectLockConfigModalhad nouseTranslationhook; all strings (warning banner, labels, button text, error messages) were hardcoded in English. Applied full i18n viauseTranslation('bucketSettings'); all strings now uset('objectLock.configModal.*')keys. (web/frontend/src/components/ObjectLockConfigModal.tsx)
Added¶
- i18n:
objectLock.configModalkeys added to English and Spanish locales — addedobjectLock.configModal.*translation keys for the Object Lock configuration modal (title, warning messages, current/new retention labels, unit selectors, action buttons, success/error messages) to bothen/bucketSettings.jsonandes/bucketSettings.json. (web/frontend/src/locales/en/bucketSettings.json,web/frontend/src/locales/es/bucketSettings.json) - i18n:
stablestatus key added to About page locales — the About page "Status" badge referencedt('stable')which was missing from both locale files, falling back to the key name. Added"stable": "Stable"(English) and"stable": "Estable"(Spanish). (web/frontend/src/locales/en/about.json,web/frontend/src/locales/es/about.json)
Changed¶
- README.md — complete rewrite with MinIO comparison and modern structure — the previous README was a flat markdown file. Rewrote with: centered header with CI/version/license/Go/S3/Security badges; tagline; honest feature-by-feature comparison table with MinIO (marking missing features clearly: no erasure coding, no S3 Select, no cloud tiering); collapsible
<details>sections for S3 API, Multi-tenancy, Identity & Access, Security, Cluster & HA, and Operations features; single-command Docker quick start;docker-composeand binary install examples; performance table; documentation index; contributing and security sections. (README.md) - About page "New In v1.0.0" section expanded — the section previously listed only 4 UI changes from the last development session. Expanded to 11 items covering the full v1.0.0 scope: complete frontend redesign, folder upload, POST presigned URLs, bucket notifications, lifecycle execution, spec-compliant multipart ETag, Veeam B&R compatibility, Object Lock per-version enforcement, security fixes BUG-25/26/27, throughput metrics fix, and refresh token fix. (
web/frontend/src/pages/about/index.tsx)
[1.0.0] - 2026-03-17¶
Added¶
- Folder upload in bucket browser — the bucket browser now supports uploading entire folder trees via drag-and-drop (using
DataTransfer.webkitGetAsEntry(), works in all browsers without any confirmation dialog) or via "Browse folder" button backed by the File System Access APIshowDirectoryPicker()(Chrome/Edge/Safari 15.2+; Firefox shows an unsupported message). The full relative path is preserved as the S3 key prefix, matching AWS S3 behavior. The upload modal has Files / Folder tabs. (web/frontend/src/pages/buckets/[bucket]/index.tsx,web/frontend/src/locales/en/buckets.json,web/frontend/src/locales/es/buckets.json)
Changed¶
- Button consistency across all pages — all action buttons now use the shared
Buttoncomponent (consistent font, padding, border-radius, icon spacing viagap-2). Previously Settings, Logging Targets, Migrations, Security, Dashboard, and Buckets pages mixed raw<button>elements and manually-styled<Link>tags with theButtoncomponent, causing visual inconsistencies. Also removedmr-2from icon children inside Button elements (redundant with thegap-2base class that was causing double spacing). (web/frontend/src/pages/settings/index.tsx,LoggingTargets.tsx,web/frontend/src/pages/security/index.tsx,web/frontend/src/pages/cluster/Migrations.tsx,web/frontend/src/pages/index.tsx,web/frontend/src/pages/buckets/index.tsx) - Upload modal UX — the upload modal now shows a styled drag-and-drop zone with a branded "Browse files" button; after selecting files the drop zone collapses to a compact bar so the modal no longer grows downward. The selected file list is capped at a scrollable
max-h-48preview area. (web/frontend/src/pages/buckets/[bucket]/index.tsx)
Fixed¶
- Object table header height jumps on row selection — when checking items in the bucket browser, the "Delete selected" button appeared conditionally inside the table header, pushing the layout downward. The header container now has a fixed height (
h-14 shrink-0); the button is rendered conditionally inside the fixed container so the layout remains stable. (web/frontend/src/pages/buckets/[bucket]/index.tsx)
Security¶
- BUG-27 (MEDIUM): Open redirect via
success_action_redirectin POST presigned uploads —HandlePostObjectredirected to any URL supplied in thesuccess_action_redirectform field, includingjavascript:anddata:schemes. Added a scheme whitelist check: only URLs starting withhttp://orhttps://are accepted; all other values return HTTP 400InvalidArgument. (pkg/s3compat/presigned.go) - BUG-25 (CRITICAL): SSRF via webhook notification delivery —
deliverNotificationusedhttp.DefaultClientwith no network restrictions, allowing an attacker with bucket-owner privileges to configure a notification webhook pointing to internal services (e.g.http://169.254.169.254/,http://localhost:8081/). Replaced with a customnotifSSRFBlockingClient()that dials through anet.Dialerwhich rejects loopback, link-local, and RFC-1918 private addresses before any HTTP bytes are sent. The client is injectable viaHandler.notifHTTPClientso tests can bypass the SSRF check using a plain client. (pkg/s3compat/notifications.go,pkg/s3compat/handler.go) - BUG-26 (HIGH): No validation on notification webhook endpoint URL —
PutBucketNotificationinbucket_ops.goaccepted any string as a queue/topic/function ARN value without checking it was a valid HTTP URL. An attacker could store malformed or non-HTTP values that would later cause unhandled errors or unexpected behavior in the delivery goroutine. Added URL scheme validation: onlyhttp://andhttps://endpoints are accepted; others returnInvalidArgument. (pkg/s3compat/bucket_ops.go)
Changed¶
- Log levels: per-request S3 traces moved from Info to Debug — several high-frequency per-request log entries were emitted at Info level, causing excessive noise in production deployments running at the default Info log level. The following were changed to Debug: SigV4/V2 signature parsing steps (
ValidateS3Signature called,Parsed SigV4 signature,Canonical request components,Delegating to SigV4/V2), per-request path resolution traces (GetObject: Using bucketPath,PutObject: Using bucketPath),GetObjectLockConfigurationstep-by-step traces, quota check internals, and the S3 API request log in bothS3RequestLogmiddleware andserver.go. Meaningful operational events (object created/deleted, multipart completed, replication results, lifecycle expiry, Object Lock changes) remain at Info. (internal/middleware/verbose_logging.go,internal/server/server.go,internal/auth/manager.go,pkg/s3compat/handler.go,pkg/s3compat/object_ops.go,pkg/s3compat/multipart.go,internal/object/manager.go,internal/replication/,internal/lifecycle/worker.go)
UI — Complete Frontend Redesign¶
- New floating layout — the application shell now uses a floating design: sidebar and main content area are inset from the browser edge (
p-3 gap-3) with the page background color (slate-200light /slate-900dark) visible as a gap, creating a framed floating appearance. (web/frontend/src/components/layout/AppLayout.tsx) - Collapsible floating sidebar — the sidebar can now be collapsed to icon-only mode (
w-16) or expanded (w-64) via a toggle button at the bottom. State is persisted tolocalStorage. In icon-only mode each item shows its icon with a nativetitletooltip and navigates directly on click. On mobile the sidebar slides in/out as a fixed overlay. New icons:PanelLeftClose/PanelLeftOpenfor the toggle button. (web/frontend/src/components/layout/SidebarNav.tsx) - New light mode theme — white cards on slate background — replaced the previous all-gray light mode (
#7d7d7dbackground,#e5e7ebcards) with a proper contrast scheme:slate-200(#e2e8f0) page background, white (#ffffff) cards and sidebar,slate-900foreground text,slate-600muted text,slate-300borders. This eliminates the "gray on gray" readability problem that affected all pages in light mode. Dark mode is unchanged. (web/frontend/src/index.css) - Design tokens — global replacement of hardcoded gray colors — a mass replacement across all pages and components converted hardcoded Tailwind gray pairs (e.g.
text-gray-500 dark:text-gray-400,bg-white dark:bg-gray-800,border-gray-200 dark:border-gray-700,hover:bg-gray-50 dark:hover:bg-gray-700) to semantic CSS variable tokens (text-muted-foreground,text-foreground,bg-card,border-border,hover:bg-secondary). All pages, modals, and UI components are now fully theme-aware. (web/frontend/src/pages/**/*.tsx,web/frontend/src/components/**/*.tsx) - TopBar redesign — removed hardcoded white/dark background in favor of
bg-transparent(inherits the parent card container), removed the bottom border divider, and changed all action buttons frombg-background(blended with gray page in light mode) tobg-card(clearly visible white/dark surface). Removedstickypositioning (not needed with the new layout). Dropdowns usebg-card border-border shadow-float. (web/frontend/src/components/layout/TopBar.tsx) - Button component — outline/secondary/ghost variants fixed for light mode —
variant="outline"usedbg-backgroundwhich in light mode matched the medium gray page background, making buttons invisible. Changed tobg-cardforoutlineandsecondaryvariants so buttons are always clearly visible as white/dark surfaces.ghostvariant now usestext-foreground hover:bg-secondary. (web/frontend/src/components/ui/Button.tsx) - Page headers standardized — all page titles now use
text-2xl font-bold text-foregroundand all description lines usetext-sm text-muted-foreground mt-1, replacing the inconsistent mix oftext-3xl,text-2xl, missingtext-sm, and missingmt-1across pages. A reusablePageHeadercomponent was created for future use. (web/frontend/src/components/ui/PageHeader.tsx, all pages underweb/frontend/src/pages/) - Table component — compact S3-style rows — reduced cell padding from
px-6 py-4topx-4 py-2and header padding frompx-6 py-3topx-4 py-2, matching the compact single-line row style of AWS S3 console. Row hover changed from a gradient with left border accent to a simplehover:bg-secondary. Header background usesbg-secondary. Row dividers usedivide-border. (web/frontend/src/components/ui/Table.tsx) - Bucket list — inline name cell — removed the
w-9 h-9gradient icon container that forced rows to be tall. The bucket name cell now shows a small inline icon, the bucket name link, and status badges (WORM, scan progress, integrity issues) all on a single line, matching AWS S3 list style. All gradient hover effects onTableRowacross pages replaced withhover:bg-secondary. (web/frontend/src/pages/buckets/index.tsx) - Dashboard page — theme-aware — replaced hardcoded
bg-white dark:bg-gray-800 rounded-xl border border-gray-200cards with theCardcomponent. Page title usestext-foreground, description usestext-muted-foreground. All chart empty states, legend items, and bucket list items use semantic tokens. (web/frontend/src/pages/index.tsx) - About page — transparent logo background in light mode — the logo container had
bg-gradient-to-br from-gray-50 to-gray-100in light mode. Changed to transparent; the dark mode gradient is preserved for logo visibility. (web/frontend/src/pages/about/index.tsx) - Content area padding reduced — the inner content wrapper padding was halved from
p-4 md:p-6 2xl:p-8top-2 md:p-3 2xl:p-4, giving more usable screen space to page content. (web/frontend/src/components/layout/AppLayout.tsx) - i18n: added
collapseSidebarandexpandSidebarkeys — the new sidebar collapse toggle button referenced these translation keys which were missing from both English and Spanish locale files. (web/frontend/src/locales/en/layout.json,web/frontend/src/locales/es/layout.json)
Fixed¶
- Notification webhook tests failing after SSRF client introduction — the new
notifSSRFBlockingClient()correctly blocked connections to127.0.0.1(loopback), which is where test webhook servers listen. Tests injecting webhook endpoints pointed at localhost always received a connection-refused error from the SSRF dial hook. Fixed by adding anotifHTTPClient *http.Clientfield toHandler; production code leaves it nil (falls back to SSRF client), and tests injectplainHTTPClient()(standardhttp.Clientwith no restrictions) to allow localhost webhook delivery. (pkg/s3compat/handler.go,pkg/s3compat/notifications.go,pkg/s3compat/notifications_test.go)
Dependencies¶
- Downgraded Vite from v8 to v7 —
vite@8.0.0was installed bynpm updatebut@tailwindcss/vite@4.xonly declares peer support for^5.2.0 || ^6 || ^7; the build failed withERESOLVE unable to resolve dependency tree. Pinned tovite@^7.3.1. (web/frontend/package.json) - Downgraded
@vitejs/plugin-reactfrom v6 to v4 —@vitejs/plugin-react@6importsvite/internalwhich does not exist in Vite 7, causingERR_PACKAGE_PATH_NOT_EXPORTEDat build time. Downgraded to@vitejs/plugin-react@^4, the last version compatible with Vite 7. (web/frontend/package.json) - Fixed
vite.config.tsfor Vite 7 compatibility — the config used two Vite 8-only APIs:minify: 'oxc'(OXC minifier, Rust-based, Vite 8 only) androlldownOptions(Rolldown bundler API, Vite 8 only). Changed tominify: 'esbuild'androllupOptionsfor Vite 7 compatibility. (web/frontend/vite.config.ts) - Updated
jsdomfrom v28 to v29 — no breaking changes affecting the project; Vitest declaresjsdom: '*'as a peer dependency so any version is accepted. (web/frontend/package.json)
Added¶
- S3: POST presigned URLs (HTML form upload) — browsers can now upload files directly to a bucket via HTML
<form enctype="multipart/form-data">using S3-compatible POST policy signatures. The server validates the policy expiration, HMAC-SHA256 signature (V4) or V2 signature, bucket/key/content-type conditions, starts-with prefix conditions, and content-length-range constraints before storing the object. Supportssuccess_action_redirect(303),success_action_status(200/201/204), andx-amz-meta-*form fields. (pkg/s3compat/presigned.go,internal/api/handler.go) - S3: bucket notifications now stored and dispatched as webhooks —
PutBucketNotificationandGetBucketNotificationwere previously no-ops; the configuration was accepted but discarded. Notification config is now persisted in the bucket metadata. After each mutating object operation (PutObject, DeleteObject, CopyObject, CompleteMultipartUpload), the bucket's notification rules are evaluated: event type is matched against configured patterns (supports trailing wildcards3:ObjectCreated:*), object key is checked against prefix/suffix filters, and matching targets receive an S3-compatible JSON event payload via HTTP POST (non-blocking goroutine). Since MaxIOFS is not AWS, SNS/SQS/Lambda ARN values are treated as webhook HTTP endpoint URLs (same approach as MinIO). (internal/metadata/types.go,internal/bucket/types.go,internal/bucket/interface.go,internal/bucket/manager_impl.go,internal/bucket/adapter.go,pkg/s3compat/bucket_ops.go,pkg/s3compat/notifications.go,pkg/s3compat/handler.go,pkg/s3compat/object_ops.go,pkg/s3compat/multipart.go) - S3: per-bucket CORS rules are now applied to actual requests — bucket CORS configurations stored via
PutBucketCORSwere saved to the metadata store but never enforced on incoming requests. AddedbucketCORSMiddlewareto the S3 API handler that looks up the bucket's CORS rules, matches the requestOriginagainst the configured allowed origins and methods, and sets the appropriateAccess-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers,Access-Control-Expose-Headers, andAccess-Control-Max-Ageheaders. The middleware runs befores3ClientMiddlewareso that browserOPTIONSpreflight requests (which carry no auth headers) are answered correctly without being redirected to the web console. (internal/api/handler.go) - S3: lifecycle
Expiration.Days/Expiration.Daterules now executed — lifecycle rules with anExpirationaction (delete objects older than N days, or older than a fixed date) were parsed and stored but the background lifecycle worker never evaluated them. AddedprocessObjectExpirationto the worker; it lists all objects under the rule prefix, compares each object'sLastModifiedagainst the configured cutoff (eithertime.Now() - Daysor the literalDate), and callsDeleteObjectfor expired objects. On versioned bucketsDeleteObjectwithout aversionIdcreates a delete marker (correct S3 behavior). (internal/lifecycle/worker.go) - S3: lifecycle
AbortIncompleteMultipartUploadrules now executed — lifecycle rules with anAbortIncompleteMultipartUploadaction were stored but never evaluated. AddedprocessAbortIncompleteMultipartUploadsto the worker; it lists all in-progress multipart uploads for the bucket, compares each upload'sInitiatedtimestamp againsttime.Now() - DaysAfterInitiation, and callsAbortMultipartUploadfor stale uploads. (internal/lifecycle/worker.go) - S3: multipart upload ETag now uses the correct
MD5(parts)-Nformat —CompleteMultipartUploadpreviously used only the MD5 of the concatenated part ETags as a hex string. The AWS S3 spec defines the multipart ETag ashex(MD5(raw_binary_MD5_part1 || … || raw_binary_MD5_partN))-N, where each part digest is the 16 raw bytes decoded from the hex ETag. AddedcomputeMultipartETagto compute the spec-compliant value; the resulting ETag is stored in metadata and returned in the response. This enables clients such asaws s3 sync --checksumto correctly verify uploaded multipart objects. (internal/object/manager.go)
Fixed¶
- Veeam:
HEAD /returned 404, causing Veeam to enable multi-bucket mode — Veeam makes aHEAD /request to the S3 endpoint root before any bucket operations to verify it is talking to a valid S3 service. The router only registeredGET /(ListBuckets) but notHEAD /, so Veeam received HTTP 404 with no S3 headers. This caused Veeam to classify the storage as generic S3-compatible and activate multi-bucket mode automatically. Fixed by registeringHEAD /on the same route and returning HTTP 200 with S3-compatible headers. (internal/api/handler.go) - Veeam:
HeadBucketandGetBucketLocationmissingx-amz-bucket-regionheader — Veeam reads this header from both theHEAD /bucketandGET /bucket?locationresponses to determine the bucket region and decide whether to activate multi-bucket mode. Without the header, Veeam cannot confirm same-region access and defaults to enabling multi-bucket mode, which must then be disabled manually. Addedx-amz-bucket-region: us-east-1to both responses, consistent with MinIO behavior. (pkg/s3compat/handler.go) - Veeam / S3:
GetBucketLocationreturned"us-east-1"instead of empty string — the AWS S3 spec requires that buckets in the default region return an emptyLocationConstraint, not the string"us-east-1". Fixed to return"". (pkg/s3compat/handler.go) - Veeam / S3:
PutObjectLockConfigurationnil pointer panic when<Rule>element is absent — if a client sent aPutObjectLockConfigurationrequest without a<Rule>/<DefaultRetention>element (e.g., a bare<ObjectLockEnabled>Enabled</ObjectLockEnabled>body), the handler accessednewConfig.Rule.DefaultRetention.Modedirectly, triggering a nil pointer dereference that was caught by the deferred panic handler and returned HTTP 500. Veeam interpreted this as "default retention is not supported". Added an explicit nil check: ifRuleorDefaultRetentionis absent the handler now returnsMalformedXML400 with a clear message. (pkg/s3compat/handler.go) - S3 Object Lock:
PutObjectLockConfigurationreturned wrong error code when Object Lock is not enabled on bucket — when called on a bucket that was not created with Object Lock enabled, the handler returnedObjectLockConfigurationNotFoundError(the same 404 code used byGetObjectLockConfiguration). The AWS S3 spec requiresInvalidBucketStatewith HTTP 409 Conflict for this case. Fixed to returnInvalidBucketState409; also addedInvalidBucketStateto thewriteErrorswitch so it maps to the correct HTTP status. (pkg/s3compat/handler.go) - Metrics: real-time throughput cards (requests/s, bytes/s, objects/s) always showed zero —
RecordThroughputwas defined but never called anywhere; the three counters (requestCount,bytesProcessed,objectsProcessed) were always 0 soCalculateThroughput()returned zero for all three values every 5-second cycle. Fixed by callingRecordThroughputinsideTracingMiddlewareafter every request: bytes =r.ContentLength(upload body) + response bytes written (tracked by extendingresponseWriterwith abytesWrittencounter); objects = 1 for PutObject / GetObject / DeleteObject / CopyObject / MultipartUpload operations. Latency percentile cards were unaffected. (internal/middleware/tracing.go) - Cluster: duplicate buckets in list when same bucket is replicated across nodes — when a bucket was mirrored/replicated on multiple cluster nodes, both S3 API
ListBucketsand the web console showed one entry per node (e.g. two entries for the same logical bucket). Users could mistake a replica for a duplicate and delete it.BucketAggregator.ListAllBucketsnow deduplicates by(TenantID, Name)after aggregating all nodes; one canonical entry per logical bucket is returned, preferring the local node. The consolehandleListBucketsin cluster mode usesbucketAggregator.ListAllBuckets, so S3 clients and the web console share the same deduplicated list. (internal/cluster/bucket_aggregator.go,internal/server/console_api.go,internal/cluster/bucket_aggregator_test.go) - Cluster / quotas: tenant storage must not double-count replicated buckets — when a bucket is replicated across nodes, the same data exists on multiple nodes; tenant storage usage must be counted once, not per replica. (Phase 1: list dedup done; quota aggregation to exclude replica size is planned.)
- Static website: error document not persisted when set — configuring only the index document did not persist correctly; setting both index and error document appeared to work but the error document field showed empty when revisiting the config. The
errorDocumentfield is now optional; when provided it is persisted. (internal/metadata/types.go,internal/server/website_handlers.go,web/frontend/src/pages/buckets/[bucket]/settings.tsx,web/frontend/src/lib/api.ts) - Static website: 404 when accessing website endpoint for bucket without website config — accessing the website-style URL for a bucket that has no static website hosting configured now returns 403 Access Denied (S3-style XML) instead of 404, so unconfigured website endpoints do not leak bucket existence. (
internal/server/server.go) - Clean URL shares (non-presigned) returning 403 — shared object URLs that do not use presigned query parameters (clean links stored in DB) were failing with 403. Share URLs are now generated without
tenant_idin the path (bucket names are globally unique).GetShareByObjectwhentenantIDis empty (clean URL) searches bybucket_nameandobject_keyonly. (internal/server/console_api.go,internal/share/sqlite.go) - TestAccessKeySyncManager_Start "enabled auto sync" flakiness on CI — setup (schema init, DB inserts) used a context with timeout, causing "context deadline exceeded" on slow CI. Setup now uses
context.Background(); only theStart()call usescontext.WithTimeoutto assert non-blocking behavior. (internal/cluster/access_key_sync_test.go) - Refresh token never saved after 2FA login —
verify2FAinapi.tsdiscarded therefresh_tokenreturned byPOST /auth/2fa/verify; sessions after 2FA expired after 15 minutes.LoginResponse.refreshTokenis now populated and forwarded totokenManager.setTokens(). (web/frontend/src/lib/api.ts) - Refresh token never saved after OAuth/SSO login —
oauth-complete.tsxonly readtokenfrom the URL, ignoringrefresh_token; OAuth/SSO sessions expired after 15 minutes. The page now reads and storesrefresh_token. (web/frontend/src/pages/auth/oauth-complete.tsx) - Audit logs export only fetched the current page —
exportToCSVusedfilteredLogs(capped to current page). Export now loops through all pages via the API. (web/frontend/src/pages/audit-logs/index.tsx) - Audit logs stats cards showed counts for current page only — success/failed counters were derived from the current page. Replaced with queries that use the
totalfield from the API. (web/frontend/src/pages/audit-logs/index.tsx) - CSV export: timestamp column split by comma —
formatTimestampusedtoLocaleString()(e.g."3/5/2026, 2:30:00 PM"), so the comma split the timestamp into two columns. Replaced withYYYY-MM-DD HH:MM:SSformat. (web/frontend/src/pages/audit-logs/index.tsx) - Clean share (URL limpia) 403 — S3 API — unauthenticated GET to
/{bucket}/{object}(clean share link, no presigned params) now resolves the share viaGetShareByObjectwith empty tenant and, when found, serves the object instead of returning 403. Bucket read permission uses anallowedByShareflag so access is granted whenever the share was found (including global buckets whereshareTenantIDis empty; previously the checkshareTenantID != ""wrongly denied those). (pkg/s3compat/handler.go; lookup by bucket+object when tenant empty ininternal/share/sqlite.go.) - Website 403 HostId — when the static website endpoint returns 403 Access Denied (bucket without website config or bucket not found), the response
<HostId>and headerX-Amz-Id-2now use the request host (r.Host) instead of a static value. (internal/server/server.go) - S3:
X-Amz-Id-2header sent as hex instead of base64 — MaxIOFS generated the host ID as a 64-character lowercase hex string (hex.EncodeToString), while AWS S3 and MinIO use base64-encoded binary (48 random bytes → 64-char base64). Some S3 clients validate or log this header; using non-standard encoding could cause parsing issues. Changed tobase64.StdEncoding.EncodeToStringwith 48 bytes of random data. (internal/middleware/s3headers.go,pkg/s3compat/handler.go) - S3: responses missing
Content-Lengthheader —writeXMLResponsecalledw.WriteHeader(statusCode)before the response body was known, causing Go's HTTP layer to fall back to chunked transfer encoding (noContent-Length). AWS S3 and MinIO always sendContent-Length. Fixed by buffering the full XML body first, settingContent-Lengthfrom the buffer size, then writing the header and body. (pkg/s3compat/handler.go) - Object Lock: default retention rule now optional at bucket creation — the console API and frontend previously required a retention mode (GOVERNANCE/COMPLIANCE) and a period (days/years) whenever Object Lock was enabled, making it impossible to create a bucket with Object Lock enabled but no bucket-level default retention. Veeam B&R and other clients that manage retention per-object reject buckets with a pre-configured default retention rule ("unable to use backup immutability: the default retention is not supported"). Fixed: mode and period are now optional — a bucket can be created with Object Lock enabled and no default retention rule. If either mode or period is provided, both must be specified (consistency validation remains). The bucket-level default retention was never required by the AWS S3 spec; it is a convenience feature that is incompatible with clients that set retention per-object. (
internal/server/console_api.go,web/frontend/src/pages/buckets/create.tsx) - Veeam SOSAPI:
capacity.xmlreported 0 bytes when tenant has no storage quota configured — when a tenant user requestedcapacity.xmland the tenant had no quota set (MaxStorageBytes == 0), the handler calculatedtotalCapacity = 0andavailableCapacity = 0, causing Veeam to display the repository as full or with unknown capacity. Fixed: whenMaxStorageBytesis 0 (no quota), the handler now falls back to the actual disk usage (same as global users), reporting the real available disk space. (pkg/s3compat/sosapi.go) - Object Lock:
PutObjectLockConfigurationwith no<Rule>element now clears the bucket-level default retention — AWS S3 allows sending aPutObjectLockConfigurationrequest with only<ObjectLockEnabled>Enabled</ObjectLockEnabled>and no<Rule>element to remove the bucket-level default retention policy without disabling Object Lock or affecting per-object locks. Previously MaxIOFS returned HTTP 400MalformedXMLfor any request missing<Rule>/<DefaultRetention>, preventing administrators from clearing a pre-existing default retention rule on a bucket. Fixed: when<Rule>is absent the handler now clearsObjectLock.Ruleand persists the updated configuration. Existing per-object lock metadata is unaffected. (pkg/s3compat/handler.go) - Veeam: "unable to use backup immutability: the default retention is not supported" — root cause: versioning
Suspendedon Object Lock bucket — Object Lock requires versioning to be permanentlyEnabled; AWS S3 does not allow suspending versioning once Object Lock is active. If the bucket's versioning status wasSuspended(or unset) while Object Lock was enabled,GetBucketVersioningreturnedSuspended, causing Veeam to reject the bucket as incompatible. Fixed in two places: (1)GetBucketVersioningnow overridesSuspended/unset toEnabledwhen Object Lock is active, matching AWS S3 behavior where Object Lock permanently locks versioning on; (2)PutBucketVersioningnow returnsInvalidBucketState(409) when attempting to suspend versioning on an Object Lock bucket. (pkg/s3compat/handler.go) - S3:
HeadBucketmissingx-amz-bucket-object-lock-enabledheader — AWS S3 and MinIO include this header in theHeadBucketresponse when the bucket was created with Object Lock enabled. Veeam B&R uses it to determine whether the bucket supports immutability before proceeding with Object Lock configuration. The handler only calledBucketExists(boolean check) and never exposed the Object Lock status. Changed toGetBucketInfoto retrieve full bucket metadata and setx-amz-bucket-object-lock-enabled: truewhen Object Lock is enabled. Also moved the VeeamHeadBucketdebug log to after all headers are set (previously it logged at the start of the handler before the bucket lookup). (pkg/s3compat/handler.go) - Veeam immutable backup:
HeadObjectwith?versionIdreturned 404 — Veeam B&R verifies objects in an immutable repository by issuingHEAD /{bucket}/{object}?versionId={id}before starting a backup session (Cloud.StartBackupSession).HeadObjectinternally calledGetObjectMetadatawhich ignores theversionIdquery parameter entirely, so it looked up the latest version regardless; when the latest version differed from the requested one the lookup failed and returned 404, causing Veeam to abort with "Failed to preprocess target Error: Not Found". Fixed by detecting theversionIdquery parameter inHeadObjectand delegating toGetObject(which already supports version lookups) when one is provided, closing the reader immediately since HEAD does not return a body. (pkg/s3compat/handler.go) - Veeam immutable backup:
PutObjectRetentionwith?versionIdreturned 404 — after verifying objects, Veeam applies per-object retention by issuingPUT /{bucket}/{object}?retention&versionId={id}. ThePutObjectRetentionhandler calledSetObjectRetentionwhich usedGetObjectMetadata(no version support) to look up the object, returning 404 for any version-specific retention request. This caused the backup session to abort immediately after the object verification phase. Fixed in two layers: (1)SetObjectRetentionin the object manager now accepts an optionalversionID ...stringparameter and usesmetadataStore.GetObjectwith the specific version when provided; (2) thePutObjectRetentionhandler extracts theversionIdquery parameter and forwards it. (pkg/s3compat/object_ops.go,internal/object/manager.go) - Object Lock: enabling Object Lock on a bucket did not automatically enable versioning — AWS S3 requires versioning to be permanently
Enabledon any bucket with Object Lock; enabling Object Lock atomically enables versioning and prevents it from ever being suspended.SetObjectLockConfigonly saved the Object Lock configuration without touching the versioning state, leaving the bucket in an inconsistent state (Object Lock active, versioning unset). Separately,PutBucketVersioningalready blockedSuspendedon Object Lock buckets but the initial versioning activation on bucket creation was missing. Fixed:SetObjectLockConfignow setsVersioning: Enabledin the sameUpdateBucketcall wheneverObjectLockEnabledis true. (internal/bucket/manager_impl.go) - Object Lock: legal hold and retention not enforced on specific object versions — when
SetObjectLegalHoldorSetObjectRetentionwas called with a specificversionId, the updated lock metadata was written to the non-versioned metadata key instead of the per-version key. As a result,DeleteObjectwith aversionIdread from the per-version key and found no legal hold or retention, allowing deletion of locked versions. Root cause:PutObjectVersionstored a lightweightObjectVersionstruct (noLegalHold/Retentionfields) at the versioned key; the fullObjectMetadata(with lock fields) was only at the non-versioned key. Fixed by storing fullObjectMetadataat versioned keys, adding anIsLatestfield, and routingPutObjectwrites to both the versioned and non-versioned keys when the version is the latest. (internal/metadata/types.go,internal/metadata/pebble_objects.go,internal/object/manager.go,internal/object/adapter.go) - Tests:
TestS3Operation_MissingAuthenticationexpected 403 but received 307 redirect — the test sent requests with no S3 headers, soisS3Client()returned false ands3ClientMiddlewareredirected to the console URL before the authentication check ran. Fixed by addingx-amz-content-sha256to test requests so they pass the S3 client check and reach the auth logic that correctly returns 403 AccessDenied. (internal/api/handler_security_test.go) - Tests:
PutObjectLockConfigurationXML missing required S3 namespace — several tests sent<ObjectLockConfiguration>XML without thexmlns="http://s3.amazonaws.com/doc/2006-03-01/"namespace. The Goxml.Unmarshaldecoder enforces namespace matching because the struct declares the full qualified name, so all requests were rejected with "expected element in name space … but have no name space". Added the namespace to all affected test XML payloads. (pkg/s3compat/s3_test.go) - Tests:
TestGetBucketLocationincorrectly checked response body for region string —GetBucketLocationcorrectly returns an empty<LocationConstraint>per the AWS S3 spec (default-region buckets return empty body). The test asserted the body contained "us-east-1", which always failed. Fixed to assert thex-amz-bucket-regionresponse header instead. (pkg/s3compat/s3_test.go) - Tests:
TestValidateShareAccess_TenantBucketmock returned wrong type —mockShareManagerFull.GetShareByObjectreturnedmap[string]interface{}butvalidateShareAccesscasts the result to*share.Share; the type assertion always failed with "share manager returned invalid type". Fixed the mock to return a proper*share.Share. (pkg/s3compat/handler_coverage_test.go)
Pre-1.0 history — 0.2.0-dev through 1.0.0-rc1 (October 2025 – March 2026)¶
Twenty alpha, beta and release-candidate versions preceded the first stable release. They are summarised here rather than listed: none of them were supported, and every one has been superseded by 1.0.0. The full entries remain in git log and in this file's history.
- 0.2.0-dev → 0.2.5-alpha (Oct 2025) — the foundations: an S3-compatible API, the React console, SQLite metadata, the filesystem backend, and the beginnings of multi-tenancy, users and access keys.
- 0.3.x-beta (Oct–Nov 2025) — bucket tagging, a CORS editor, complete bucket policy operations, object versioning with delete markers, and lifecycle rules — each with a visual editor rather than raw XML.
- 0.4.x-beta (Nov 2025) — the audit log: a SQLite store with 20+ event types, filtering and CSV export in the console, and a configurable retention policy.
- 0.5.0-beta (Dec 2025) — bucket replication (realtime, scheduled and batch), the logging infrastructure (console, file, HTTP, syslog), themes, nightly multi-architecture builds, and the frontend test suite.
- 0.6.x-beta (Dec 2025 – Jan 2026) — clustering: node-to-node bucket replication over HMAC, tenant synchronisation, the cluster management UI, bucket-location caching with failover, and Debian packaging.
- 0.7.0-beta (Jan 2026) — the database migration system, AWS-format access keys (
AKIA), bucket inventory reports, cluster bucket migration, RPM packages and the benchmark suite. - 0.8.0-beta (Feb 2026) — object search and filters, bucket policy enforcement, presigned URL validation (SigV4 and SigV2), Object Lock default retention, and the cluster join protocol.
- 0.9.x-beta (Feb 2026) — identity providers (LDAP and OAuth SSO with auto-provisioning and group mapping), inter-node TLS, external syslog targets, tombstone-based deletion sync, and a round of tenant-isolation fixes across the user, access-key and bucket-permission handlers.
- 1.0.0-beta (Mar 2026) — the metadata engine moved from BadgerDB to Pebble, object integrity verification, SMTP notifications, disk and quota alerts, maintenance mode, and the cluster state-snapshot and stale-reconciler mechanisms. Object compression was removed.
- 1.0.0-rc1 (Mar 2026) — a full security audit and its remedies: AES-256-GCM replacing AES-256-CTR for object encryption, the cluster CA private key no longer crossing the network, missing authorization on cluster handlers, SSRF in webhooks, logging targets and replication endpoints, path traversal on Windows, plaintext replication credentials, a predictable HMAC nonce, an infinite proxy loop, OAuth CSRF validation, CORS allowlisting, and a set of frontend XSS and cookie-flag fixes.