diff --git a/README.md b/README.md index 2a1e54f..30babc8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ An AI-native distributed data plane that supports high performance RPC, KV Cache [![Linux Only](https://img.shields.io/badge/Linux-Only-2ea44f)](#runtime-requirements) [![Python](https://img.shields.io/badge/Python-%3E%3D3.10-3776AB)](#runtime-requirements) [![Rust](https://img.shields.io/badge/Rust-1.93.0-000000)](./fluxon_rs/rust-toolchain.toml) -[![Latest](https://img.shields.io/badge/Latest-v0.2.3-f28500)](./fluxon_release) +[![Latest](https://img.shields.io/badge/Latest-v0.2.4-f28500)](./fluxon_release) [![Interfaces](https://img.shields.io/badge/Interfaces-KV%2FRPC%20%7C%20MQ%20%7C%20FS-1f6feb)](#interface-capabilities)
@@ -201,7 +201,7 @@ The distribution installs the `fluxon_py` import package. Service-plane runtimes ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode kv \ --etcd-client-port 12379 \ --master-p2p-port 31000 \ @@ -234,7 +234,7 @@ Related interface docs: ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode mq \ --etcd-client-port 37379 \ --kv-master-port 34200 \ @@ -265,7 +265,7 @@ Related interface docs: ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode fs \ --etcd-client-port 36379 \ --kv-master-port 34100 \ diff --git a/README_CN.md b/README_CN.md index 58b82a8..e3ac218 100644 --- a/README_CN.md +++ b/README_CN.md @@ -9,7 +9,7 @@ [![Linux Only](https://img.shields.io/badge/Linux-Only-2ea44f)](#运行要求) [![Python](https://img.shields.io/badge/Python-%3E%3D3.10-3776AB)](#运行要求) [![Rust](https://img.shields.io/badge/Rust-1.93.0-000000)](./fluxon_rs/rust-toolchain.toml) -[![Latest](https://img.shields.io/badge/Latest-v0.2.3-f28500)](./fluxon_release) +[![Latest](https://img.shields.io/badge/Latest-v0.2.4-f28500)](./fluxon_release) [![Interfaces](https://img.shields.io/badge/Interfaces-KV%2FRPC%20%7C%20MQ%20%7C%20FS-1f6feb)](#接口能力)
@@ -198,7 +198,7 @@ python3 -m pip install fluxon-py ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode kv \ --etcd-client-port 12379 \ --master-p2p-port 31000 \ @@ -231,7 +231,7 @@ del demo:hello ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode mq \ --etcd-client-port 37379 \ --kv-master-port 34200 \ @@ -262,7 +262,7 @@ exit ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode fs \ --etcd-client-port 36379 \ --kv-master-port 34100 \ diff --git a/examples/fluxon_quick_start/README.md b/examples/fluxon_quick_start/README.md index 352c4ad..20b569b 100644 --- a/examples/fluxon_quick_start/README.md +++ b/examples/fluxon_quick_start/README.md @@ -11,7 +11,7 @@ It does not replace the formal service-plane, KV, MQ, or FS interface docs. - unified quick-start entrypoint - `build_image.py` - quick-start image build entrypoint -- `fluxon_quick_start:0.2.3` +- `fluxon_quick_start:0.2.4` - quick-start Docker image ## Runtime Modes @@ -86,7 +86,7 @@ Python environment can already import both `fluxon_py` and `fluxon_pyo3`. ```bash docker run --rm -it --network host \ - fluxon_quick_start:0.2.3 \ + fluxon_quick_start:0.2.4 \ --mode kv \ --etcd-client-port 12379 \ --master-p2p-port 31000 \ @@ -119,7 +119,7 @@ del demo:hello ```bash docker run --rm -it --network host \ - fluxon_quick_start:0.2.3 \ + fluxon_quick_start:0.2.4 \ --mode mq \ --etcd-client-port 37379 \ --kv-master-port 34200 \ @@ -155,7 +155,7 @@ The background consumer keeps printing received messages. ```bash docker run --rm -it --network host \ - fluxon_quick_start:0.2.3 \ + fluxon_quick_start:0.2.4 \ --mode fs \ --etcd-client-port 36379 \ --kv-master-port 34100 \ diff --git a/examples/fluxon_quick_start/build_image.py b/examples/fluxon_quick_start/build_image.py index 4a6762b..7f34a2e 100644 --- a/examples/fluxon_quick_start/build_image.py +++ b/examples/fluxon_quick_start/build_image.py @@ -22,7 +22,7 @@ SCRIPTS_DIR = REPO_ROOT / "setup_and_pack" DOCKERFILE_PATH = SCRIPT_DIR / "Dockerfile" IMAGE_NAME = "fluxon_quick_start" -IMAGE_TAG = "0.2.3" +IMAGE_TAG = "0.2.4" # Binaries to copy from ext_images into quick_start bin/. EXT_BINARIES = ("etcd/etcd", "etcd/etcdctl", "greptime/greptime") diff --git "a/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" "b/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" index 55f1acc..107d45e 100644 --- "a/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" +++ "b/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" @@ -207,7 +207,7 @@ docker run -d --name fluxon-s3 ` --mount "type=bind,src=C:\fluxon-s3\data,dst=/data" ` --mount "type=bind,src=C:\fluxon-s3\state,dst=/state" ` --entrypoint python3 ` - "hanbaoaaa/fluxon_quick_start:0.2.3" ` + "hanbaoaaa/fluxon_quick_start:0.2.4" ` -c " from fluxon_py.quick_start import serve_s3_single_node diff --git a/fluxon_py/__init__.py b/fluxon_py/__init__.py index 391acee..03ce0b0 100644 --- a/fluxon_py/__init__.py +++ b/fluxon_py/__init__.py @@ -58,7 +58,7 @@ from typing import Any -__version__ = "0.2.3" +__version__ = "0.2.4" __all__ = [ # Core API "KvClient", diff --git a/fluxon_py/tests/fluxon_fs_rclone_e2e.py b/fluxon_py/tests/fluxon_fs_rclone_e2e.py index a84dae9..1214fab 100644 --- a/fluxon_py/tests/fluxon_fs_rclone_e2e.py +++ b/fluxon_py/tests/fluxon_fs_rclone_e2e.py @@ -28,6 +28,18 @@ RCLONE_COMMAND_TIMEOUT_SECS = 120 RCLONE_COMPLEX_COPY_TIMEOUT_SECS = 600 RCLONE_LIST_READY_TIMEOUT_SECS = 180.0 +# rclone v1.60.1 defaults to a 200 MiB upload cutoff. Keep this test +# independent of that default so the fixture always exercises multipart. +RCLONE_MULTIPART_UPLOAD_CUTOFF = "5Mi" +RCLONE_MULTIPART_CHUNK_SIZE = "5Mi" +RCLONE_MULTIPART_CHUNK_SIZE_BYTES = 5 * 1024 * 1024 +RCLONE_MULTIPART_FIXTURE_SIZE_BYTES = 12 * 1024 * 1024 +RCLONE_MULTIPART_COPY_FLAGS = ( + "--s3-upload-cutoff", + RCLONE_MULTIPART_UPLOAD_CUTOFF, + "--s3-chunk-size", + RCLONE_MULTIPART_CHUNK_SIZE, +) COMPLEX_GROUP_COUNT = 8 COMPLEX_FILES_PER_GROUP = 50 COMPLEX_EXPECTED_FILE_COUNT = 405 @@ -239,7 +251,8 @@ def _build_complex_fixture_files() -> dict[str, bytes]: "configs/dev/app.yaml": b"environment: dev\n", "configs/prod/app.yaml": b"environment: prod\n", "blobs/small.bin": bytes(range(64)), - "blobs/medium-8m.bin": bytes(range(256)) * (8 * 1024 * 1024 // 256), + "blobs/multipart-12m.bin": bytes(range(256)) + * (RCLONE_MULTIPART_FIXTURE_SIZE_BYTES // 256), } ) assert len(files) == COMPLEX_EXPECTED_FILE_COUNT @@ -338,6 +351,7 @@ def run_e2e(*, image_ref: str) -> None: work_root=work_root, args=[ "copy", + *RCLONE_MULTIPART_COPY_FLAGS, f"{RCLONE_CONTAINER_WORKDIR}/complex-source", complex_remote_root, ], diff --git a/fluxon_release/release_notes/v0.2.4.md b/fluxon_release/release_notes/v0.2.4.md new file mode 100644 index 0000000..5fbb21f --- /dev/null +++ b/fluxon_release/release_notes/v0.2.4.md @@ -0,0 +1,70 @@ +# Fluxon v0.2.4 + +`v0.2.4` is a focused FluxonFS S3 compatibility and first-run security +release. It fixes multipart completion for the pinned rclone client, makes the +bootstrap credential transition explicit, and enforces private access-database +permissions on Unix. + +## Highlights + +- Accepts the XML-escaped quoted ETags sent by official rclone `v1.60.1` when + completing a multipart upload. +- Extends the rclone end-to-end test to force a real three-part upload using a + 12 MiB object with a 5 MiB upload cutoff and chunk size. +- Requires a fresh `admin/admin` installation to enter **Change Credentials** + before bucket or S3 access, and allows the initial username and password to + be changed together. +- Creates and repairs `access.db`, `access.db-wal`, and `access.db-shm` with + mode `0600` on Unix. + +## S3 Multipart Compatibility + +rclone `v1.60.1` serializes completed part ETags as quoted XML text. On the +wire, the quotes can appear as `"`, while the uploaded-part metadata stores +the unquoted hash. FluxonFS now decodes XML entities before normalizing and +validating each ETag, preserving strict part identity checks without rejecting +the valid rclone request. + +The regression coverage uses the actual rclone XML shape, including the S3 XML +namespace and `ETag` before `PartNumber`. The Docker end-to-end path pins +`rclone/rclone:1.60.1` and verifies multipart upload, completion, and object +readback. + +## Initial Credentials and Access Database + +Fresh state bootstrapped with `admin/admin` is marked as requiring an initial +credential change. The Web UI redirects the first login to the credential form, +and S3 authentication remains unavailable until that transition completes. +The renamed administrator and completion state persist across restart. Existing +installations that have already replaced the default credentials are not forced +through the flow again. + +On Unix, FluxonFS creates the SQLite access database with mode `0600` and also +repairs permissions on an existing database and its WAL/shared-memory sidecar +files. + +## Version and SDK Contract + +- Public Python package, Rust workspace, and Quick Start version: `0.2.4`. +- Closed communication SDK version: `0.2.1`. +- Closed SDK required open-surface contract version: `0.2.1`. + +The SDK and open-surface contract versions remain independent from the public +release version. + +## Release Artifacts + +- PyPI: `fluxon-py==0.2.4`. +- Docker Hub: `hanbaoaaa/fluxon_quick_start:0.2.4`. +- GitHub Release: `fluxon_release.tar.gz`. +- GitHub Release: `fluxon_quick_start_0.2.4_docker_image.tar.gz`. + +The Docker publication does not update `latest`. + +## Runtime Requirements + +- Linux only; Python `>=3.10`. +- The Quick Start still requires the configured external middleware when + `start_middleware=False`. +- Dynamic bucket creation remains outside the Quick Start contract; exported + bucket names are configured before startup. diff --git a/fluxon_rs/Cargo.lock b/fluxon_rs/Cargo.lock index 876f37f..6962082 100644 --- a/fluxon_rs/Cargo.lock +++ b/fluxon_rs/Cargo.lock @@ -1031,7 +1031,7 @@ dependencies = [ [[package]] name = "fluxon_cli" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "askama", @@ -1056,7 +1056,7 @@ dependencies = [ [[package]] name = "fluxon_commu" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "fluxon_commu_closed_sdk_consumer" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "bitcode", @@ -1094,7 +1094,7 @@ dependencies = [ [[package]] name = "fluxon_commu_contract" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1117,7 +1117,7 @@ dependencies = [ [[package]] name = "fluxon_framework" -version = "0.2.2" +version = "0.2.4" dependencies = [ "async-trait", "fluxon_framework_compiled", @@ -1133,7 +1133,7 @@ dependencies = [ [[package]] name = "fluxon_framework_compiled" -version = "0.2.2" +version = "0.2.4" dependencies = [ "async-trait", "crossbeam-queue", @@ -1151,7 +1151,7 @@ dependencies = [ [[package]] name = "fluxon_fs" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1190,7 +1190,7 @@ dependencies = [ [[package]] name = "fluxon_fs_core" -version = "0.2.2" +version = "0.2.4" dependencies = [ "base64 0.21.7", "hex", @@ -1205,7 +1205,7 @@ dependencies = [ [[package]] name = "fluxon_fs_s3_gateway" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "askama", @@ -1225,6 +1225,7 @@ dependencies = [ "hyper 0.14.32", "parking_lot", "postcard", + "quick-xml", "reqwest", "rusqlite", "serde", @@ -1243,7 +1244,7 @@ dependencies = [ [[package]] name = "fluxon_kv" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-stream", @@ -1303,7 +1304,7 @@ dependencies = [ [[package]] name = "fluxon_mq" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1327,7 +1328,7 @@ dependencies = [ [[package]] name = "fluxon_observability" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1348,7 +1349,7 @@ dependencies = [ [[package]] name = "fluxon_ops" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "askama", @@ -1378,7 +1379,7 @@ dependencies = [ [[package]] name = "fluxon_proxy" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "bitcode", @@ -1392,14 +1393,14 @@ dependencies = [ [[package]] name = "fluxon_proxy_proto" -version = "0.2.2" +version = "0.2.4" dependencies = [ "bitcode", ] [[package]] name = "fluxon_pyo3" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1435,7 +1436,7 @@ dependencies = [ [[package]] name = "fluxon_util" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "chrono", @@ -2741,7 +2742,7 @@ dependencies = [ [[package]] name = "limit_thirdparty" -version = "0.2.2" +version = "0.2.4" dependencies = [ "tokio", ] diff --git a/fluxon_rs/Cargo.toml b/fluxon_rs/Cargo.toml index 5fbb1eb..af96e78 100644 --- a/fluxon_rs/Cargo.toml +++ b/fluxon_rs/Cargo.toml @@ -22,7 +22,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.2.3" +version = "0.2.4" edition = "2024" license = "" authors = ["teleai_infra"] diff --git a/fluxon_rs/fluxon_cli/Cargo.toml b/fluxon_rs/fluxon_cli/Cargo.toml index 668faa8..cb1d279 100644 --- a/fluxon_rs/fluxon_cli/Cargo.toml +++ b/fluxon_rs/fluxon_cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_cli" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_commu/Cargo.toml b/fluxon_rs/fluxon_commu/Cargo.toml index af67440..8bf2968 100644 --- a/fluxon_rs/fluxon_commu/Cargo.toml +++ b/fluxon_rs/fluxon_commu/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_commu" -version = "0.2.3" +version = "0.2.4" edition = "2024" build = "build.rs" diff --git a/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml b/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml index 437ab27..d673e27 100644 --- a/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml +++ b/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_commu_closed_sdk_consumer" -version = "0.2.3" +version = "0.2.4" edition = "2024" build = "build.rs" diff --git a/fluxon_rs/fluxon_framework/Cargo.toml b/fluxon_rs/fluxon_framework/Cargo.toml index d579a24..1a5b919 100644 --- a/fluxon_rs/fluxon_framework/Cargo.toml +++ b/fluxon_rs/fluxon_framework/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_framework" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_framework_compiled/Cargo.toml b/fluxon_rs/fluxon_framework_compiled/Cargo.toml index 715bd11..e81f1e7 100644 --- a/fluxon_rs/fluxon_framework_compiled/Cargo.toml +++ b/fluxon_rs/fluxon_framework_compiled/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_framework_compiled" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml b/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml index d2bbb34..55100d0 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml +++ b/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_fs_s3_gateway" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] @@ -22,6 +22,7 @@ hmac = "0.12" hex = "0.4" chrono = { workspace = true } uuid = { workspace = true } +quick-xml = "0.26" base64 = "0.21" urlencoding = "2" parking_lot = { workspace = true } diff --git a/fluxon_rs/fluxon_fs_s3_gateway/src/lib.rs b/fluxon_rs/fluxon_fs_s3_gateway/src/lib.rs index 99d0a7b..64b81a3 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/src/lib.rs +++ b/fluxon_rs/fluxon_fs_s3_gateway/src/lib.rs @@ -32,10 +32,17 @@ use futures::StreamExt; use futures::future::BoxFuture; use hmac::{Hmac, Mac as _}; use parking_lot::{Mutex, RwLock}; -use rusqlite::{Connection, params}; +use quick_xml::escape::unescape as xml_unescape; +use rusqlite::{Connection, OptionalExtension as _, params}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, VecDeque}; +#[cfg(unix)] +use std::fs::OpenOptions; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path as FsPath, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tower::ServiceExt as _; use uuid::Uuid; @@ -174,6 +181,16 @@ pub struct GatewayAccessConfig { pub transfer_state_store: Option, } +const ACCESS_META_INITIAL_CREDENTIALS_REQUIRED: &str = "initial_credentials_change_required"; +const ACCESS_META_VALUE_TRUE: &str = "true"; +const ACCESS_META_VALUE_FALSE: &str = "false"; + +fn is_default_bootstrap_credentials(model: &FluxonFsAccessModel) -> bool { + model.users.len() == 1 + && model.users[0].username == "admin" + && model.users[0].password == "admin" +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct FsMountRegistryRecord { pub external_instance_key: String, @@ -464,6 +481,7 @@ pub struct GatewayState { transfer_state_store: Option>, transfer_reconcile_handle: Option>, permission_list: Arc>>, + initial_credentials_change_required: Arc, fs_cache: Arc, s3_cfg: FluxonFsS3GatewayConfig, backend: Arc, @@ -719,7 +737,7 @@ impl GatewayState { transfer_history_query: Option, ) -> Result { let access_db = Arc::new(Mutex::new(open_access_db(&access.access_db_path)?)); - let permission_list = { + let (permission_list, initial_credentials_change_required) = { let mut conn = access_db.lock(); load_or_bootstrap_permission_list_from_db( &mut conn, @@ -759,6 +777,9 @@ impl GatewayState { transfer_state_store, transfer_reconcile_handle, permission_list: Arc::new(RwLock::new(permission_list)), + initial_credentials_change_required: Arc::new(AtomicBool::new( + initial_credentials_change_required, + )), fs_cache, s3_cfg, backend, @@ -1035,6 +1056,11 @@ impl GatewayState { self.access_db_path.as_str() } + fn initial_credentials_change_required(&self) -> bool { + self.initial_credentials_change_required + .load(Ordering::Acquire) + } + pub(crate) fn load_effective_fs_exports( &self, ) -> Result, String> { @@ -1570,6 +1596,53 @@ impl GatewayState { Ok(()) } + fn complete_initial_credentials_change( + &self, + current_username: &str, + current_password: &str, + new_username: String, + new_password: String, + ) -> Result<(), String> { + let mut conn = self.access_db.lock(); + if !load_initial_credentials_change_required_from_db(&conn)? { + return Err("initial credentials have already been changed".to_string()); + } + + let mut permission_list = load_permission_list_from_db(&conn)?; + if new_username != current_username + && permission_list + .iter() + .any(|account| account.username == new_username) + { + return Err(format!("username already exists: {}", new_username)); + } + let account = permission_list + .iter_mut() + .find(|account| account.username == current_username) + .ok_or_else(|| { + format!( + "bootstrap account no longer exists in access db: {}", + current_username + ) + })?; + if account.password != current_password { + return Err("bootstrap credentials changed; authenticate again".to_string()); + } + account.username = new_username; + account.password = new_password; + + let normalized_permission_list = + persist_permission_list_to_db_with_initial_credentials_state( + &mut conn, + &permission_list, + false, + )?; + *self.permission_list.write() = normalized_permission_list; + self.initial_credentials_change_required + .store(false, Ordering::Release); + Ok(()) + } + fn create_ui_transfer_task( &self, owner_username: String, @@ -1792,19 +1865,36 @@ fn open_access_db(path: &str) -> Result { if path.trim().is_empty() { return Err("access_db_path must be non-empty".to_string()); } - let db_path = std::path::Path::new(path); - if let Some(parent_dir) = db_path.parent() { - if !parent_dir.as_os_str().is_empty() { - std::fs::create_dir_all(parent_dir).map_err(|e| { - format!( - "create access db parent dir failed: path={} parent={} err={}", - path, - parent_dir.display(), - e - ) - })?; + let is_in_memory = path == ":memory:"; + let db_path = FsPath::new(path); + if !is_in_memory { + if let Some(parent_dir) = db_path.parent() { + if !parent_dir.as_os_str().is_empty() { + std::fs::create_dir_all(parent_dir).map_err(|e| { + format!( + "create access db parent dir failed: path={} parent={} err={}", + path, + parent_dir.display(), + e + ) + })?; + } } } + #[cfg(unix)] + if !is_in_memory { + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .open(db_path) + .map_err(|e| format!("create access db with mode 0600 failed: {}", e))?; + drop(file); + } + if !is_in_memory { + restrict_access_db_permissions(db_path)?; + } let conn = Connection::open(path).map_err(|e| format!("open access db failed: {}", e))?; conn.execute_batch( r#" @@ -1816,6 +1906,10 @@ CREATE TABLE IF NOT EXISTS access_users ( password TEXT NOT NULL, can_manage_users INTEGER NOT NULL CHECK (can_manage_users IN (0, 1)) ); +CREATE TABLE IF NOT EXISTS access_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS access_scope ( scope_id INTEGER PRIMARY KEY AUTOINCREMENT, export_name TEXT NOT NULL, @@ -1862,6 +1956,9 @@ CREATE TABLE IF NOT EXISTS fs_export_overlay_upsert ( ) .map_err(|e| format!("initialize access db schema failed: {}", e))?; ensure_fs_export_registry_export_json_column(&conn)?; + if !is_in_memory { + restrict_access_db_permissions(db_path)?; + } Ok(conn) } @@ -1908,6 +2005,59 @@ fn access_db_user_count(conn: &Connection) -> Result { .map_err(|e| format!("query access user count failed: {}", e)) } +fn persist_initial_credentials_change_required_to_db( + conn: &Connection, + required: bool, +) -> Result<(), String> { + let value = if required { + ACCESS_META_VALUE_TRUE + } else { + ACCESS_META_VALUE_FALSE + }; + conn.execute( + "INSERT INTO access_meta(key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + params![ACCESS_META_INITIAL_CREDENTIALS_REQUIRED, value], + ) + .map_err(|e| { + format!( + "persist access metadata {} failed: {}", + ACCESS_META_INITIAL_CREDENTIALS_REQUIRED, e + ) + })?; + Ok(()) +} + +fn load_initial_credentials_change_required_from_db(conn: &Connection) -> Result { + let value = conn + .query_row( + "SELECT value FROM access_meta WHERE key = ?1", + params![ACCESS_META_INITIAL_CREDENTIALS_REQUIRED], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| { + format!( + "load access metadata {} failed: {}", + ACCESS_META_INITIAL_CREDENTIALS_REQUIRED, e + ) + })?; + match value.as_deref() { + Some(ACCESS_META_VALUE_TRUE) => Ok(true), + Some(ACCESS_META_VALUE_FALSE) => Ok(false), + Some(value) => Err(format!( + "invalid access metadata {} value: {}", + ACCESS_META_INITIAL_CREDENTIALS_REQUIRED, value + )), + None => { + let model = load_access_model_from_db(conn)?; + let required = is_default_bootstrap_credentials(&model); + persist_initial_credentials_change_required_to_db(conn, required)?; + Ok(required) + } + } +} + fn load_access_model_from_db(conn: &Connection) -> Result { let mut users: Vec = Vec::new(); let mut user_stmt = conn @@ -1997,16 +2147,34 @@ fn load_or_bootstrap_permission_list_from_db( conn: &mut Connection, bootstrap_access_model: &AccessModel, _fs_cache: &FluxonFsGlobalConfig, -) -> Result, String> { +) -> Result<(Vec, bool), String> { if access_db_user_count(conn)? != 0 { - return load_permission_list_from_db(conn); - } - persist_access_model_to_db(conn, bootstrap_access_model) + let permission_list = load_permission_list_from_db(conn)?; + let initial_credentials_change_required = + load_initial_credentials_change_required_from_db(conn)?; + return Ok((permission_list, initial_credentials_change_required)); + } + let initial_credentials_change_required = + is_default_bootstrap_credentials(bootstrap_access_model); + let permission_list = persist_access_model_to_db_with_initial_credentials_state( + conn, + bootstrap_access_model, + Some(initial_credentials_change_required), + )?; + Ok((permission_list, initial_credentials_change_required)) } fn persist_access_model_to_db( conn: &mut Connection, model: &AccessModel, +) -> Result, String> { + persist_access_model_to_db_with_initial_credentials_state(conn, model, None) +} + +fn persist_access_model_to_db_with_initial_credentials_state( + conn: &mut Connection, + model: &AccessModel, + initial_credentials_change_required: Option, ) -> Result, String> { let permission_list = s3_permission_list_from_access_model(model)?; let normalized_model = access_model_from_s3_permission_list(&permission_list)?; @@ -2069,6 +2237,9 @@ fn persist_access_model_to_db( } } + if let Some(required) = initial_credentials_change_required { + persist_initial_credentials_change_required_to_db(&tx, required)?; + } tx.commit() .map_err(|e| format!("commit state db transaction failed: {}", e))?; Ok(normalized_permission_list) @@ -2082,6 +2253,19 @@ fn persist_permission_list_to_db( persist_access_model_to_db(conn, &model) } +fn persist_permission_list_to_db_with_initial_credentials_state( + conn: &mut Connection, + permission_list: &[FluxonFsS3PermissionAccount], + initial_credentials_change_required: bool, +) -> Result, String> { + let model = access_model_from_s3_permission_list(permission_list)?; + persist_access_model_to_db_with_initial_credentials_state( + conn, + &model, + Some(initial_credentials_change_required), + ) +} + fn persist_fs_mount_registry_record_to_db( conn: &Connection, record: &FsMountRegistryRecord, @@ -2661,6 +2845,43 @@ fn normalize_external_base_path(path: &str) -> String { format!("/{}", no_trailing) } +#[cfg(unix)] +fn restrict_access_db_file_permissions(path: &FsPath) -> Result<(), String> { + let metadata = match std::fs::metadata(path) { + Ok(value) => value, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => { + return Err(format!( + "stat access db file permissions failed: path={} err={}", + path.display(), + err + )); + } + }; + let mut permissions = metadata.permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions).map_err(|err| { + format!( + "set access db file permissions failed: path={} mode=0600 err={}", + path.display(), + err + ) + }) +} + +#[cfg(not(unix))] +fn restrict_access_db_file_permissions(_path: &FsPath) -> Result<(), String> { + Ok(()) +} + +fn restrict_access_db_permissions(path: &FsPath) -> Result<(), String> { + restrict_access_db_file_permissions(path)?; + let wal_path = PathBuf::from(format!("{}-wal", path.display())); + restrict_access_db_file_permissions(wal_path.as_path())?; + let shm_path = PathBuf::from(format!("{}-shm", path.display())); + restrict_access_db_file_permissions(shm_path.as_path()) +} + #[derive(Debug, Clone)] struct AuthAccount { username: String, @@ -4877,7 +5098,12 @@ fn extract_xml_tag_text(xml: &str, tag: &str) -> Result { detail: format!("missing closing tag: {}", tag), }); }; - Ok(rest[..b].trim().to_string()) + let raw = rest[..b].trim(); + xml_unescape(raw) + .map(|value| value.into_owned()) + .map_err(|err| S3Error::InvalidRequest { + detail: format!("invalid XML entity in {}: {}", tag, err), + }) } async fn multipart_complete( @@ -5120,6 +5346,12 @@ fn auth_verify_sigv4(st: &GatewayState, headers: &HeaderMap) -> Result"abc"", "ETag").unwrap(), + "\"abc\"" + ); + assert_eq!( + super::extract_xml_tag_text(""abc"", "ETag").unwrap(), + "\"abc\"" + ); + assert_eq!( + super::extract_xml_tag_text(""abc"", "ETag").unwrap(), + "\"abc\"" + ); + assert_eq!( + super::extract_xml_tag_text("\"abc\"", "ETag").unwrap(), + "\"abc\"" + ); + } + + #[test] + fn test_parse_complete_multipart_body_accepts_rclone_quoted_etag_entities() { + let parts = super::parse_complete_multipart_body( + br#" + "etag-one"1 + "etag-two"2 + "#, + ) + .unwrap(); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0].part_number, 1); + assert_eq!(parts[0].etag, "etag-one"); + assert_eq!(parts[1].part_number, 2); + assert_eq!(parts[1].etag, "etag-two"); + } + fn test_gateway_access_config() -> GatewayAccessConfig { shared_test_tikv_access_config() } @@ -7419,6 +7728,45 @@ max-background-jobs = {TEST_TIKV_RAFTDB_MAX_BACKGROUND_JOBS}\n" ) } + fn test_state_with_bootstrap_access_model( + backend: Arc, + buckets: &[&str], + access_db_path: String, + bootstrap_access_model: FluxonFsAccessModel, + ) -> Arc { + let mut exports = BTreeMap::new(); + for bucket in buckets { + exports.insert((*bucket).to_string(), test_export(bucket)); + } + Arc::new( + GatewayState::new( + "test_cluster".to_string(), + String::new(), + GatewayAccessConfig { + access_db_path, + bootstrap_access_model, + transfer_state_store: None, + }, + Arc::new(FluxonFsGlobalConfig { + stale_window_ms: 0, + write_session_target_inflight_bytes: + FS_CACHE_DEFAULT_WRITE_SESSION_TARGET_INFLIGHT_BYTES_V1, + rules: Vec::new(), + exports, + }), + FluxonFsS3GatewayConfig { + get_object_inflight_pieces: 4, + kv_miss_policy: FluxonFsS3KvMissPolicy::RemoteRead, + }, + backend, + Arc::new(TestFsMasterAdminBackend), + None, + None, + ) + .unwrap(), + ) + } + fn test_state_with_permission_list( backend: Arc, buckets: &[&str], @@ -10539,6 +10887,231 @@ max-background-jobs = {TEST_TIKV_RAFTDB_MAX_BACKGROUND_JOBS}\n" headers } + fn sigv4_auth_headers(access_key: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_str(&format!( + "AWS4-HMAC-SHA256 Credential={}/20260808/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=00", + access_key + )) + .unwrap(), + ); + headers.insert("x-amz-date", HeaderValue::from_static("20260808T000000Z")); + headers + } + + #[tokio::test] + async fn test_initial_credentials_require_setup_and_persist_renamed_account() { + let root = test_tikv_work_root().join(format!( + "fluxon_fs_s3_gateway_initial_credentials_pid{}_{}", + process::id(), + Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let db_path = root.join("access.db"); + let db_path_text = db_path.to_string_lossy().into_owned(); + let bootstrap_model = FluxonFsAccessModel { + users: vec![FluxonFsAccessUser { + username: "admin".to_string(), + password: "admin".to_string(), + can_manage_users: true, + }], + scope_access: Vec::new(), + }; + let st = test_state_with_bootstrap_access_model( + Arc::new(ObjectBackend::default()), + &["demo"], + db_path_text.clone(), + bootstrap_model.clone(), + ); + + assert!(st.initial_credentials_change_required()); + let err = super::auth_verify_sigv4(&st, &sigv4_auth_headers("admin")).unwrap_err(); + assert!( + matches!(err, S3Error::AccessDenied { detail } if detail.contains("change the initial admin credentials")) + ); + let mut malformed_proxy_headers = HeaderMap::new(); + malformed_proxy_headers.insert(super::HDR_ORIGINAL_URI, HeaderValue::from_static("/")); + let malformed_redirect = + super::ui_initial_credentials_redirect(&malformed_proxy_headers, &st); + assert_eq!( + malformed_redirect + .headers() + .get(axum::http::header::LOCATION) + .unwrap(), + "/ui/account/password/" + ); + + let admin_auth = basic_auth_headers("admin", "admin") + .get(axum::http::header::AUTHORIZATION) + .unwrap() + .clone(); + let redirect = super::build_router(st.clone()) + .oneshot( + Request::builder() + .uri("/ui/") + .header(axum::http::header::AUTHORIZATION, admin_auth.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(redirect.status(), StatusCode::SEE_OTHER); + assert_eq!( + redirect + .headers() + .get(axum::http::header::LOCATION) + .unwrap(), + "/ui/account/password/" + ); + + let setup_page = super::build_router(st.clone()) + .oneshot( + Request::builder() + .uri("/ui/account/password/") + .header(axum::http::header::AUTHORIZATION, admin_auth.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(setup_page.status(), StatusCode::OK); + let setup_body = hyper::body::to_bytes(setup_page.into_body()).await.unwrap(); + let setup_html = String::from_utf8_lossy(&setup_body); + assert!(setup_html.contains("Change Credentials")); + assert!(setup_html.contains("name=\"new_username\"")); + + let setup_response = super::build_router(st.clone()) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/ui/account/password/") + .header(axum::http::header::AUTHORIZATION, admin_auth.clone()) + .header( + axum::http::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(Body::from( + "new_username=operator&new_password=s3-secret&confirm_new_password=s3-secret", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(setup_response.status(), StatusCode::SEE_OTHER); + assert_eq!( + setup_response + .headers() + .get(axum::http::header::LOCATION) + .unwrap(), + "../../" + ); + + assert!(!st.initial_credentials_change_required()); + assert!(super::find_account_by_username(&st, "admin").is_none()); + let account = super::find_account_by_username(&st, "operator").unwrap(); + assert_eq!(account.password, "s3-secret"); + assert!(account.can_manage_users); + assert!(super::auth_verify_sigv4(&st, &sigv4_auth_headers("operator")).is_ok()); + + let old_credentials = super::build_router(st.clone()) + .oneshot( + Request::builder() + .uri("/ui/") + .header(axum::http::header::AUTHORIZATION, admin_auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(old_credentials.status(), StatusCode::UNAUTHORIZED); + + let new_auth = basic_auth_headers("operator", "s3-secret") + .get(axum::http::header::AUTHORIZATION) + .unwrap() + .clone(); + let buckets_page = super::build_router(st.clone()) + .oneshot( + Request::builder() + .uri("/ui/") + .header(axum::http::header::AUTHORIZATION, new_auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(buckets_page.status(), StatusCode::OK); + + let password_page = super::build_router(st.clone()) + .oneshot( + Request::builder() + .uri("/ui/account/password/") + .header( + axum::http::header::AUTHORIZATION, + basic_auth_headers("operator", "s3-secret") + .get(axum::http::header::AUTHORIZATION) + .unwrap() + .clone(), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(password_page.status(), StatusCode::OK); + let password_page_body = hyper::body::to_bytes(password_page.into_body()) + .await + .unwrap(); + let password_page_html = String::from_utf8_lossy(&password_page_body); + assert!(password_page_html.contains("Change Password")); + assert!(!password_page_html.contains("name=\"new_username\"")); + + let password_update = super::build_router(st.clone()) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/ui/account/password/") + .header( + axum::http::header::AUTHORIZATION, + basic_auth_headers("operator", "s3-secret") + .get(axum::http::header::AUTHORIZATION) + .unwrap() + .clone(), + ) + .header( + axum::http::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(Body::from( + "current_password=s3-secret&new_password=next-secret&confirm_new_password=next-secret", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(password_update.status(), StatusCode::SEE_OTHER); + assert_eq!( + super::find_account_by_username(&st, "operator") + .unwrap() + .password, + "next-secret" + ); + + drop(st); + let restarted = test_state_with_bootstrap_access_model( + Arc::new(ObjectBackend::default()), + &["demo"], + db_path_text, + bootstrap_model, + ); + assert!(!restarted.initial_credentials_change_required()); + assert!(super::find_account_by_username(&restarted, "admin").is_none()); + assert!(super::find_account_by_username(&restarted, "operator").is_some()); + drop(restarted); + std::fs::remove_dir_all(&root).unwrap(); + } + #[test] fn test_ui_require_identity_admin_can_view_as() { let backend = Arc::new(ObjectBackend::default()); @@ -11364,7 +11937,7 @@ max-background-jobs = {TEST_TIKV_RAFTDB_MAX_BACKGROUND_JOBS}\n" backend.reset_write_metrics(); let complete_xml = format!( - "1\"{}\"2\"{}\"", + "1"{}"2"{}"", etag1, etag2 ); let complete_resp = super::multipart_complete( diff --git a/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr.rs b/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr.rs index 8fbe639..646c60a 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr.rs +++ b/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr.rs @@ -78,15 +78,64 @@ fn ui_normalize_as_user(as_user: Option) -> Option { Some(t.to_string()) } +fn ui_initial_credentials_redirect(headers: &HeaderMap, st: &GatewayState) -> Response { + let proxy_href = headers + .get(HDR_ORIGINAL_URI) + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + let path = value.split_once('?').map(|(path, _)| path).unwrap_or(value); + path.find("/ui/") + .or_else(|| path.strip_suffix("/ui").map(|_| path.len() - "/ui".len())) + .map(|index| { + format!( + "{}/ui/account/password/", + path[..index].trim_end_matches('/') + ) + }) + }); + let href = proxy_href.unwrap_or_else(|| { + format!( + "{}/ui/account/password/", + st.external_base_path.trim_end_matches('/') + ) + }); + let mut resp = Response::new(boxed(Body::empty())); + *resp.status_mut() = StatusCode::SEE_OTHER; + let location = + HeaderValue::from_str(&href).unwrap_or_else(|_| HeaderValue::from_static("/ui/account/password/")); + resp.headers_mut().insert(header::LOCATION, location); + resp +} + fn ui_require_identity( headers: &HeaderMap, st: &GatewayState, as_user: Option, +) -> Result { + ui_require_identity_inner(headers, st, as_user, false) +} + +fn ui_require_identity_for_credentials( + headers: &HeaderMap, + st: &GatewayState, + as_user: Option, +) -> Result { + ui_require_identity_inner(headers, st, as_user, true) +} + +fn ui_require_identity_inner( + headers: &HeaderMap, + st: &GatewayState, + as_user: Option, + allow_initial_credentials_change: bool, ) -> Result { let viewer = match ui_basic_auth_account(headers, st) { Some(v) => v, None => return Err(ui_basic_auth_required()), }; + if st.initial_credentials_change_required() && !allow_initial_credentials_change { + return Err(ui_initial_credentials_redirect(headers, st)); + } let as_user = ui_normalize_as_user(as_user); let Some(as_username) = as_user.clone() else { diff --git a/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr_render_handlers.rs b/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr_render_handlers.rs index 2ee1e67..2c8c8f1 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr_render_handlers.rs +++ b/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr_render_handlers.rs @@ -147,6 +147,8 @@ struct UiAdminHomeMainTemplate { struct UiAccountPasswordMainTemplate { show_error: bool, error_msg: String, + initial_credentials_change_required: bool, + viewer_username: String, } #[derive(Template)] @@ -1921,7 +1923,8 @@ async fn ui_admin_fs_master_export_remove( #[derive(serde::Deserialize)] struct UiAccountPasswordForm { - current_password: String, + current_password: Option, + new_username: Option, new_password: String, confirm_new_password: String, } @@ -1931,6 +1934,12 @@ fn ui_render_account_password_page( identity: &UiIdentity, error_msg: Option<&str>, ) -> String { + let initial_credentials_change_required = st.initial_credentials_change_required(); + let page_title = if initial_credentials_change_required { + "Change Credentials" + } else { + "Change Password" + }; let crumbs = render_template(&UiCrumbsTemplate { crumbs: vec![ UiCrumbView { @@ -1942,7 +1951,7 @@ fn ui_render_account_password_page( UiCrumbView { href: String::new(), has_href: false, - label: "Change Password".to_string(), + label: page_title.to_string(), current: true, }, ], @@ -1961,6 +1970,8 @@ fn ui_render_account_password_page( let main = render_template(&UiAccountPasswordMainTemplate { show_error: error_msg.is_some(), error_msg: error_msg.unwrap_or("").to_string(), + initial_credentials_change_required, + viewer_username: identity.viewer_username().to_string(), }); let home_href = ui_href_with_as("../../", identity.as_user.as_deref()); @@ -1968,16 +1979,20 @@ fn ui_render_account_password_page( let transfers_href = ui_href_with_as("../../transfers/", identity.as_user.as_deref()); let account_password_href = ui_href_with_as("./", identity.as_user.as_deref()); let admin_manage_href = ui_href_with_as("../../admin/", identity.as_user.as_deref()); - let user_actions_html = ui_user_actions_html(identity, &account_password_href, &admin_manage_href, "./"); + let user_actions_html = if initial_credentials_change_required { + String::new() + } else { + ui_user_actions_html(identity, &account_password_href, &admin_manage_href, "./") + }; ui_page_html( - "Change Password", + page_title, &home_href, &buckets_href, &transfers_href, "buckets", &crumbs, - "Change Password", + page_title, Some(&subtitle), None, &user_actions_html, @@ -1990,52 +2005,119 @@ async fn ui_account_password_page( headers: HeaderMap, axum::extract::Query(q): axum::extract::Query, ) -> Response { - let identity = match ui_require_identity(&headers, &st, q.as_user) { + let identity = match ui_require_identity_for_credentials(&headers, &st, q.as_user) { Ok(v) => v, Err(resp) => return resp, }; Html(ui_render_account_password_page(&st, &identity, None)).into_response() } +fn ui_account_password_error_response( + st: &GatewayState, + identity: &UiIdentity, + status: StatusCode, + message: &str, +) -> Response { + let html = ui_render_account_password_page(st, identity, Some(message)); + let mut resp = Html(html).into_response(); + *resp.status_mut() = status; + resp +} + async fn ui_account_password_save( State(st): State>, headers: HeaderMap, axum::extract::Query(q): axum::extract::Query, Form(f): Form, ) -> Response { - let identity = match ui_require_identity(&headers, &st, q.as_user.clone()) { + let identity = match ui_require_identity_for_credentials(&headers, &st, q.as_user.clone()) { Ok(v) => v, Err(resp) => return resp, }; + let initial_credentials_change_required = st.initial_credentials_change_required(); - if identity.viewer.password != f.current_password { - let html = ui_render_account_password_page(&st, &identity, Some("current password mismatch")); - let mut resp = Html(html).into_response(); - *resp.status_mut() = StatusCode::FORBIDDEN; - return resp; + if !initial_credentials_change_required + && f.current_password.as_deref() != Some(identity.viewer.password.as_str()) + { + return ui_account_password_error_response( + &st, + &identity, + StatusCode::FORBIDDEN, + "current password mismatch", + ); } - let new_password = f.new_password.trim().to_string(); - if new_password.is_empty() { - let html = ui_render_account_password_page(&st, &identity, Some("new_password must be non-empty")); - let mut resp = Html(html).into_response(); - *resp.status_mut() = StatusCode::BAD_REQUEST; - return resp; - } - if f.new_password != new_password { - let html = ui_render_account_password_page( + let new_password = match ui_validate_password_no_whitespace(&f.new_password, "new_password") { + Ok(value) => value, + Err(err) => { + let status = err.status(); + let message = err.message(); + return ui_account_password_error_response( + &st, + &identity, + status, + message.as_str(), + ); + } + }; + if f.confirm_new_password != new_password { + return ui_account_password_error_response( &st, &identity, - Some("new_password must not have leading/trailing whitespace"), + StatusCode::BAD_REQUEST, + "confirm_new_password mismatch", ); - let mut resp = Html(html).into_response(); - *resp.status_mut() = StatusCode::BAD_REQUEST; - return resp; } - if f.confirm_new_password != new_password { - let html = ui_render_account_password_page(&st, &identity, Some("confirm_new_password mismatch")); - let mut resp = Html(html).into_response(); - *resp.status_mut() = StatusCode::BAD_REQUEST; + + if initial_credentials_change_required { + let new_username = + match ui_validate_username_for_basic_auth(f.new_username.as_deref().unwrap_or("")) { + Ok(value) => value, + Err(err) => { + let status = err.status(); + let message = err.message(); + return ui_account_password_error_response( + &st, + &identity, + status, + message.as_str(), + ); + } + }; + if new_username == "admin" { + return ui_account_password_error_response( + &st, + &identity, + StatusCode::BAD_REQUEST, + "new_username must differ from the bootstrap username admin", + ); + } + if new_password == "admin" { + return ui_account_password_error_response( + &st, + &identity, + StatusCode::BAD_REQUEST, + "new_password must differ from the bootstrap password admin", + ); + } + if let Err(err) = st.complete_initial_credentials_change( + identity.viewer.username.as_str(), + identity.viewer.password.as_str(), + new_username, + new_password, + ) { + return ui_account_password_error_response( + &st, + &identity, + StatusCode::CONFLICT, + err.as_str(), + ); + } + + let mut resp = Response::new(boxed(Body::empty())); + *resp.status_mut() = StatusCode::SEE_OTHER; + resp.headers_mut() + .insert(header::LOCATION, HeaderValue::from_static("../../")); return resp; } diff --git a/fluxon_rs/fluxon_fs_s3_gateway/templates/ui/account_password_main.html b/fluxon_rs/fluxon_fs_s3_gateway/templates/ui/account_password_main.html index bf938c1..308cb42 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/templates/ui/account_password_main.html +++ b/fluxon_rs/fluxon_fs_s3_gateway/templates/ui/account_password_main.html @@ -1,25 +1,53 @@ {% if show_error %}
{{ error_msg }}
{% endif %} +{% if initial_credentials_change_required %}
- Change password for the viewer account. Password changes apply to both UI - Basic Auth and S3 SigV4 secret_key. + Replace the bootstrap admin / admin credentials before using FluxonFS. + The new username and password apply to both UI Basic Auth and S3 SigV4. +
+
+
+ + +
+
+ + +
+
+ + +
+
+ Both values must differ from admin. Values must be non-empty and have + no leading or trailing whitespace; the username must not contain :. +
+
+ +
+
+{% else %} +
+ Change password for the {{ viewer_username }} account. Password changes + apply to both UI Basic Auth and S3 SigV4 secret_key.
- +
- +
- +
Rules: non-empty; no leading/trailing whitespace.
+{% endif %} diff --git a/fluxon_rs/fluxon_kv/Cargo.toml b/fluxon_rs/fluxon_kv/Cargo.toml index c49b502..c0ab6fc 100644 --- a/fluxon_rs/fluxon_kv/Cargo.toml +++ b/fluxon_rs/fluxon_kv/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_kv" -version = "0.2.3" +version = "0.2.4" edition = "2024" [features] diff --git a/fluxon_rs/fluxon_mq/Cargo.toml b/fluxon_rs/fluxon_mq/Cargo.toml index 69dd113..5601af5 100644 --- a/fluxon_rs/fluxon_mq/Cargo.toml +++ b/fluxon_rs/fluxon_mq/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_mq" -version = "0.2.3" +version = "0.2.4" edition = "2021" [lib] diff --git a/fluxon_rs/fluxon_observability/Cargo.toml b/fluxon_rs/fluxon_observability/Cargo.toml index 00dbf2c..b266510 100644 --- a/fluxon_rs/fluxon_observability/Cargo.toml +++ b/fluxon_rs/fluxon_observability/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_observability" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_ops/Cargo.toml b/fluxon_rs/fluxon_ops/Cargo.toml index c1637db..9d73326 100644 --- a/fluxon_rs/fluxon_ops/Cargo.toml +++ b/fluxon_rs/fluxon_ops/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_ops" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_pyo3/Cargo.toml b/fluxon_rs/fluxon_pyo3/Cargo.toml index 4d0438a..b074911 100644 --- a/fluxon_rs/fluxon_pyo3/Cargo.toml +++ b/fluxon_rs/fluxon_pyo3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_pyo3" -version = "0.2.3" +version = "0.2.4" edition = "2024" [lib] diff --git a/fluxon_rs/fluxon_util/Cargo.toml b/fluxon_rs/fluxon_util/Cargo.toml index 305f50c..b330fe3 100644 --- a/fluxon_rs/fluxon_util/Cargo.toml +++ b/fluxon_rs/fluxon_util/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_util" -version = "0.2.3" +version = "0.2.4" edition = "2024" authors = ["Your Name "] description = "Utility crate with macros and helper functions" diff --git a/fluxon_rs/limit_thirdparty/Cargo.toml b/fluxon_rs/limit_thirdparty/Cargo.toml index b946566..a061438 100644 --- a/fluxon_rs/limit_thirdparty/Cargo.toml +++ b/fluxon_rs/limit_thirdparty/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "limit_thirdparty" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/setup.py b/fluxon_rs/setup.py index b87a0c5..7479c6f 100644 --- a/fluxon_rs/setup.py +++ b/fluxon_rs/setup.py @@ -36,7 +36,7 @@ def find_libs(): setup( name="fluxon_pyo3", - version="0.2.3", + version="0.2.4", description="for export fluxonkv core to python layer", long_description=open("README.md").read() if os.path.exists("README.md") else "", long_description_content_type="text/markdown", diff --git a/fluxon_test_stack/tests/test_top_attention_fs_s3_rclone_contract.py b/fluxon_test_stack/tests/test_top_attention_fs_s3_rclone_contract.py index b6eaaf7..5a5883d 100644 --- a/fluxon_test_stack/tests/test_top_attention_fs_s3_rclone_contract.py +++ b/fluxon_test_stack/tests/test_top_attention_fs_s3_rclone_contract.py @@ -129,14 +129,30 @@ def test_s3_harness_config_has_no_transfer_state_store(self) -> None: }, ) - def test_complex_fixture_stays_below_one_s3_list_page(self) -> None: + def test_complex_fixture_stays_below_one_s3_list_page_and_forces_multipart(self) -> None: files = _E2E._build_complex_fixture_files() self.assertEqual(len(files), 405) self.assertLess(len(files), 1000) self.assertEqual(sum(relpath.startswith("fanout/") for relpath in files), 400) self.assertIn("deep/l1/l2/l3/l4/l5/l6/l7/l8/final.bin", files) - self.assertEqual(len(files["blobs/medium-8m.bin"]), 8 * 1024 * 1024) + multipart_size = len(files["blobs/multipart-12m.bin"]) + self.assertEqual(multipart_size, _E2E.RCLONE_MULTIPART_FIXTURE_SIZE_BYTES) + self.assertGreater(multipart_size, _E2E.RCLONE_MULTIPART_CHUNK_SIZE_BYTES) + self.assertEqual( + (multipart_size + _E2E.RCLONE_MULTIPART_CHUNK_SIZE_BYTES - 1) + // _E2E.RCLONE_MULTIPART_CHUNK_SIZE_BYTES, + 3, + ) + self.assertEqual( + _E2E.RCLONE_MULTIPART_COPY_FLAGS, + ( + "--s3-upload-cutoff", + "5Mi", + "--s3-chunk-size", + "5Mi", + ), + ) self.assertTrue(all(" " not in relpath for relpath in files)) def test_main_runs_direct_e2e_with_pinned_image(self) -> None: diff --git a/setup_and_pack/utils/docker_build_runtime_utils.py b/setup_and_pack/utils/docker_build_runtime_utils.py index c40b23a..aa7a657 100644 --- a/setup_and_pack/utils/docker_build_runtime_utils.py +++ b/setup_and_pack/utils/docker_build_runtime_utils.py @@ -550,7 +550,7 @@ def build_docker_run_cmd( """Build a `docker run` command (without executing it). Args: - image: Image name (with tag), e.g. "fluxon_quick_start:0.2.3". + image: Image name (with tag), e.g. "fluxon_quick_start:0.2.4". name: Container name (`--name`). remove: Auto-remove on exit (`--rm`). detach: Run detached (`-d`).