Compare commits
32 Commits
9ec5797919
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ff4797041 | |||
| 50fb5aa387 | |||
| cc161bf362 | |||
| 2a0e77a754 | |||
| eb0e435a5a | |||
| 7633007a08 | |||
| de0d869c9f | |||
| fdd068feee | |||
| 66b7677d2c | |||
| 4d90ead816 | |||
| b37a51ed1d | |||
| 0462a7b62e | |||
| 52660570c1 | |||
| 35f61313e0 | |||
| c470cfb576 | |||
| d96955deee | |||
| 85181f0be6 | |||
| d5ca7a8be1 | |||
| 476dc79e42 | |||
| bb6590fc5e | |||
| 899db3421b | |||
| caf01d6ada | |||
| bb366cb4cd | |||
| a2745ff2ee | |||
| 28cb656d94 | |||
| 3c44152fc6 | |||
| 397515edce | |||
| 980fced7e4 | |||
| bae5009ec4 | |||
| 233780617f | |||
| fd8fb21517 | |||
| c6cbe822e1 |
@@ -11,7 +11,3 @@ htmlcov
|
|||||||
logs
|
logs
|
||||||
data
|
data
|
||||||
tmp
|
tmp
|
||||||
tests
|
|
||||||
myfsio_core/target
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -27,11 +27,8 @@ dist/
|
|||||||
.eggs/
|
.eggs/
|
||||||
|
|
||||||
# Rust / maturin build artifacts
|
# Rust / maturin build artifacts
|
||||||
python/myfsio_core/target/
|
myfsio_core/target/
|
||||||
python/myfsio_core/Cargo.lock
|
myfsio_core/Cargo.lock
|
||||||
|
|
||||||
# Rust engine build artifacts
|
|
||||||
rust/myfsio-engine/target/
|
|
||||||
|
|
||||||
# Local runtime artifacts
|
# Local runtime artifacts
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
FROM python:3.14.3-slim AS builder
|
FROM python:3.14.3-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends build-essential curl \
|
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||||
@@ -12,34 +12,23 @@ RUN apt-get update \
|
|||||||
|
|
||||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
RUN pip install --no-cache-dir maturin
|
|
||||||
|
|
||||||
COPY myfsio_core ./myfsio_core
|
|
||||||
RUN cd myfsio_core \
|
|
||||||
&& maturin build --release --out /wheels
|
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.14.3-slim
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY --from=builder /wheels/*.whl /tmp/
|
COPY . .
|
||||||
RUN pip install --no-cache-dir /tmp/*.whl && rm /tmp/*.whl
|
|
||||||
|
|
||||||
COPY app ./app
|
RUN pip install --no-cache-dir maturin \
|
||||||
COPY templates ./templates
|
&& cd myfsio_core \
|
||||||
COPY static ./static
|
&& maturin build --release \
|
||||||
COPY run.py ./
|
&& pip install target/wheels/*.whl \
|
||||||
COPY docker-entrypoint.sh ./
|
&& cd .. \
|
||||||
|
&& rm -rf myfsio_core/target \
|
||||||
|
&& pip uninstall -y maturin \
|
||||||
|
&& rustup self uninstall -y
|
||||||
|
|
||||||
RUN chmod +x docker-entrypoint.sh \
|
RUN chmod +x docker-entrypoint.sh
|
||||||
&& mkdir -p /app/data \
|
|
||||||
|
RUN mkdir -p /app/data \
|
||||||
&& useradd -m -u 1000 myfsio \
|
&& useradd -m -u 1000 myfsio \
|
||||||
&& chown -R myfsio:myfsio /app
|
&& chown -R myfsio:myfsio /app
|
||||||
|
|
||||||
390
README.md
390
README.md
@@ -1,212 +1,250 @@
|
|||||||
# MyFSIO
|
# MyFSIO
|
||||||
|
|
||||||
MyFSIO is an S3-compatible object storage server with a Rust runtime and a filesystem-backed storage engine. The active server lives under `rust/myfsio-engine` and serves both the S3 API and the built-in web UI from a single process.
|
A lightweight, S3-compatible object storage system built with Flask. MyFSIO implements core AWS S3 REST API operations with filesystem-backed storage, making it ideal for local development, testing, and self-hosted storage scenarios.
|
||||||
|
|
||||||
The `python/` implementation is deprecated as of 2026-04-21. It remains in the repository for migration reference and legacy tests, but new development and supported runtime usage should target the Rust server.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- S3-compatible REST API with Signature Version 4 authentication
|
**Core Storage**
|
||||||
- Browser UI for buckets, objects, IAM users, policies, replication, metrics, and site administration
|
- S3-compatible REST API with AWS Signature Version 4 authentication
|
||||||
- Filesystem-backed storage rooted at `data/`
|
- Bucket and object CRUD operations
|
||||||
- Bucket versioning, multipart uploads, presigned URLs, CORS, object and bucket tagging
|
- Object versioning with version history
|
||||||
- Server-side encryption and built-in KMS support
|
- Multipart uploads for large files
|
||||||
- Optional background services for lifecycle, garbage collection, integrity scanning, operation metrics, and system metrics history
|
- Presigned URLs (1 second to 7 days validity)
|
||||||
- Replication, site sync, and static website hosting support
|
|
||||||
|
|
||||||
## Runtime Model
|
**Security & Access Control**
|
||||||
|
- IAM users with access key management and rotation
|
||||||
|
- Bucket policies (AWS Policy Version 2012-10-17)
|
||||||
|
- Server-side encryption (SSE-S3 and SSE-KMS)
|
||||||
|
- Built-in Key Management Service (KMS)
|
||||||
|
- Rate limiting per endpoint
|
||||||
|
|
||||||
MyFSIO now runs as one Rust process:
|
**Advanced Features**
|
||||||
|
- Cross-bucket replication to remote S3-compatible endpoints
|
||||||
|
- Hot-reload for bucket policies (no restart required)
|
||||||
|
- CORS configuration per bucket
|
||||||
|
|
||||||
- API listener on `HOST` + `PORT` (default `127.0.0.1:5000`)
|
**Management UI**
|
||||||
- UI listener on `HOST` + `UI_PORT` (default `127.0.0.1:5100`)
|
- Web console for bucket and object management
|
||||||
- Shared state for storage, IAM, policies, sessions, metrics, and background workers
|
- IAM dashboard for user administration
|
||||||
|
- Inline JSON policy editor with presets
|
||||||
|
- Object browser with folder navigation and bulk operations
|
||||||
|
- Dark mode support
|
||||||
|
|
||||||
If you want API-only mode, set `UI_ENABLED=false`. There is no separate "UI-only" runtime anymore.
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------+ +------------------+
|
||||||
|
| API Server | | UI Server |
|
||||||
|
| (port 5000) | | (port 5100) |
|
||||||
|
| | | |
|
||||||
|
| - S3 REST API |<------->| - Web Console |
|
||||||
|
| - SigV4 Auth | | - IAM Dashboard |
|
||||||
|
| - Presign URLs | | - Bucket Editor |
|
||||||
|
+--------+---------+ +------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+------------------+ +------------------+
|
||||||
|
| Object Storage | | System Metadata |
|
||||||
|
| (filesystem) | | (.myfsio.sys/) |
|
||||||
|
| | | |
|
||||||
|
| data/<bucket>/ | | - IAM config |
|
||||||
|
| <objects> | | - Bucket policies|
|
||||||
|
| | | - Encryption keys|
|
||||||
|
+------------------+ +------------------+
|
||||||
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
From the repository root:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd rust/myfsio-engine
|
# Clone and setup
|
||||||
cargo run -p myfsio-server --
|
git clone https://gitea.jzwsite.com/kqjy/MyFSIO
|
||||||
|
cd s3
|
||||||
|
python -m venv .venv
|
||||||
|
|
||||||
|
# Activate virtual environment
|
||||||
|
# Windows PowerShell:
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
# Windows CMD:
|
||||||
|
.venv\Scripts\activate.bat
|
||||||
|
# Linux/macOS:
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Start both servers
|
||||||
|
python run.py
|
||||||
|
|
||||||
|
# Or start individually
|
||||||
|
python run.py --mode api # API only (port 5000)
|
||||||
|
python run.py --mode ui # UI only (port 5100)
|
||||||
```
|
```
|
||||||
|
|
||||||
Useful URLs:
|
**Credentials:** Generated automatically on first run and printed to the console. If missed, check the IAM config file at `<STORAGE_ROOT>/.myfsio.sys/config/iam.json`.
|
||||||
|
|
||||||
- UI: `http://127.0.0.1:5100/ui`
|
- **Web Console:** http://127.0.0.1:5100/ui
|
||||||
- API: `http://127.0.0.1:5000/`
|
- **API Endpoint:** http://127.0.0.1:5000
|
||||||
- Health: `http://127.0.0.1:5000/myfsio/health`
|
|
||||||
|
|
||||||
On first boot, MyFSIO creates `data/.myfsio.sys/config/iam.json` and prints the generated admin access key and secret key to the console.
|
|
||||||
|
|
||||||
### Common CLI commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Show resolved configuration
|
|
||||||
cargo run -p myfsio-server -- --show-config
|
|
||||||
|
|
||||||
# Validate configuration and exit non-zero on critical issues
|
|
||||||
cargo run -p myfsio-server -- --check-config
|
|
||||||
|
|
||||||
# Reset admin credentials
|
|
||||||
cargo run -p myfsio-server -- --reset-cred
|
|
||||||
|
|
||||||
# API only
|
|
||||||
UI_ENABLED=false cargo run -p myfsio-server --
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building a Binary
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd rust/myfsio-engine
|
|
||||||
cargo build --release -p myfsio-server
|
|
||||||
```
|
|
||||||
|
|
||||||
Binary locations:
|
|
||||||
|
|
||||||
- Linux/macOS: `rust/myfsio-engine/target/release/myfsio-server`
|
|
||||||
- Windows: `rust/myfsio-engine/target/release/myfsio-server.exe`
|
|
||||||
|
|
||||||
Run the built binary directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./target/release/myfsio-server
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The server reads environment variables from the process environment and also loads, when present:
|
|
||||||
|
|
||||||
- `/opt/myfsio/myfsio.env`
|
|
||||||
- `.env`
|
|
||||||
- `myfsio.env`
|
|
||||||
|
|
||||||
Core settings:
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| --- | --- | --- |
|
|----------|---------|-------------|
|
||||||
| `HOST` | `127.0.0.1` | Bind address for API and UI listeners |
|
| `STORAGE_ROOT` | `./data` | Filesystem root for bucket storage |
|
||||||
| `PORT` | `5000` | API port |
|
| `IAM_CONFIG` | `.myfsio.sys/config/iam.json` | IAM user and policy store |
|
||||||
| `UI_PORT` | `5100` | UI port |
|
| `BUCKET_POLICY_PATH` | `.myfsio.sys/config/bucket_policies.json` | Bucket policy store |
|
||||||
| `UI_ENABLED` | `true` | Disable to run API-only |
|
| `API_BASE_URL` | `http://127.0.0.1:5000` | API endpoint for UI calls |
|
||||||
| `STORAGE_ROOT` | `./data` | Root directory for buckets and system metadata |
|
| `MAX_UPLOAD_SIZE` | `1073741824` | Maximum upload size in bytes (1 GB) |
|
||||||
| `IAM_CONFIG` | `<STORAGE_ROOT>/.myfsio.sys/config/iam.json` | IAM config path |
|
| `MULTIPART_MIN_PART_SIZE` | `5242880` | Minimum multipart part size (5 MB) |
|
||||||
| `API_BASE_URL` | unset | Public API base used by the UI and presigned URL generation |
|
| `UI_PAGE_SIZE` | `100` | Default page size for listings |
|
||||||
| `AWS_REGION` | `us-east-1` | Region used in SigV4 scope |
|
| `SECRET_KEY` | `dev-secret-key` | Flask session secret |
|
||||||
| `SIGV4_TIMESTAMP_TOLERANCE_SECONDS` | `900` | Allowed request time skew |
|
| `AWS_REGION` | `us-east-1` | Region for SigV4 signing |
|
||||||
| `PRESIGNED_URL_MIN_EXPIRY_SECONDS` | `1` | Minimum presigned URL expiry |
|
| `AWS_SERVICE` | `s3` | Service name for SigV4 signing |
|
||||||
| `PRESIGNED_URL_MAX_EXPIRY_SECONDS` | `604800` | Maximum presigned URL expiry |
|
| `ENCRYPTION_ENABLED` | `false` | Enable server-side encryption |
|
||||||
| `SECRET_KEY` | loaded from `.myfsio.sys/config/.secret` if present | Session signing key and IAM-at-rest encryption key |
|
| `KMS_ENABLED` | `false` | Enable Key Management Service |
|
||||||
| `ADMIN_ACCESS_KEY` | unset | Optional first-run or reset access key |
|
| `LOG_LEVEL` | `INFO` | Logging verbosity |
|
||||||
| `ADMIN_SECRET_KEY` | unset | Optional first-run or reset secret key |
|
| `SIGV4_TIMESTAMP_TOLERANCE_SECONDS` | `900` | Max time skew for SigV4 requests |
|
||||||
|
| `PRESIGNED_URL_MAX_EXPIRY_SECONDS` | `604800` | Max presigned URL expiry (7 days) |
|
||||||
Feature toggles:
|
| `REPLICATION_CONNECT_TIMEOUT_SECONDS` | `5` | Replication connection timeout |
|
||||||
|
| `SITE_SYNC_ENABLED` | `false` | Enable bi-directional site sync |
|
||||||
| Variable | Default |
|
| `OBJECT_TAG_LIMIT` | `50` | Maximum tags per object |
|
||||||
| --- | --- |
|
|
||||||
| `ENCRYPTION_ENABLED` | `false` |
|
|
||||||
| `KMS_ENABLED` | `false` |
|
|
||||||
| `GC_ENABLED` | `false` |
|
|
||||||
| `INTEGRITY_ENABLED` | `false` |
|
|
||||||
| `LIFECYCLE_ENABLED` | `false` |
|
|
||||||
| `METRICS_HISTORY_ENABLED` | `false` |
|
|
||||||
| `OPERATION_METRICS_ENABLED` | `false` |
|
|
||||||
| `WEBSITE_HOSTING_ENABLED` | `false` |
|
|
||||||
| `SITE_SYNC_ENABLED` | `false` |
|
|
||||||
|
|
||||||
Metrics and replication tuning:
|
|
||||||
|
|
||||||
| Variable | Default |
|
|
||||||
| --- | --- |
|
|
||||||
| `OPERATION_METRICS_INTERVAL_MINUTES` | `5` |
|
|
||||||
| `OPERATION_METRICS_RETENTION_HOURS` | `24` |
|
|
||||||
| `METRICS_HISTORY_INTERVAL_MINUTES` | `5` |
|
|
||||||
| `METRICS_HISTORY_RETENTION_HOURS` | `24` |
|
|
||||||
| `REPLICATION_CONNECT_TIMEOUT_SECONDS` | `5` |
|
|
||||||
| `REPLICATION_READ_TIMEOUT_SECONDS` | `30` |
|
|
||||||
| `REPLICATION_MAX_RETRIES` | `2` |
|
|
||||||
| `REPLICATION_STREAMING_THRESHOLD_BYTES` | `10485760` |
|
|
||||||
| `REPLICATION_MAX_FAILURES_PER_BUCKET` | `50` |
|
|
||||||
| `SITE_SYNC_INTERVAL_SECONDS` | `60` |
|
|
||||||
| `SITE_SYNC_BATCH_SIZE` | `100` |
|
|
||||||
| `SITE_SYNC_CONNECT_TIMEOUT_SECONDS` | `10` |
|
|
||||||
| `SITE_SYNC_READ_TIMEOUT_SECONDS` | `120` |
|
|
||||||
| `SITE_SYNC_MAX_RETRIES` | `2` |
|
|
||||||
| `SITE_SYNC_CLOCK_SKEW_TOLERANCE_SECONDS` | `1.0` |
|
|
||||||
|
|
||||||
UI asset overrides:
|
|
||||||
|
|
||||||
| Variable | Default |
|
|
||||||
| --- | --- |
|
|
||||||
| `TEMPLATES_DIR` | built-in crate templates directory |
|
|
||||||
| `STATIC_DIR` | built-in crate static directory |
|
|
||||||
|
|
||||||
See [docs.md](./docs.md) for the full Rust-side operations guide.
|
|
||||||
|
|
||||||
## Data Layout
|
## Data Layout
|
||||||
|
|
||||||
```text
|
|
||||||
data/
|
|
||||||
<bucket>/
|
|
||||||
.myfsio.sys/
|
|
||||||
config/
|
|
||||||
iam.json
|
|
||||||
bucket_policies.json
|
|
||||||
connections.json
|
|
||||||
operation_metrics.json
|
|
||||||
metrics_history.json
|
|
||||||
buckets/<bucket>/
|
|
||||||
meta/
|
|
||||||
versions/
|
|
||||||
multipart/
|
|
||||||
keys/
|
|
||||||
```
|
```
|
||||||
|
data/
|
||||||
|
├── <bucket>/ # User buckets with objects
|
||||||
|
└── .myfsio.sys/ # System metadata
|
||||||
|
├── config/
|
||||||
|
│ ├── iam.json # IAM users and policies
|
||||||
|
│ ├── bucket_policies.json # Bucket policies
|
||||||
|
│ ├── replication_rules.json
|
||||||
|
│ └── connections.json # Remote S3 connections
|
||||||
|
├── buckets/<bucket>/
|
||||||
|
│ ├── meta/ # Object metadata (.meta.json)
|
||||||
|
│ ├── versions/ # Archived object versions
|
||||||
|
│ └── .bucket.json # Bucket config (versioning, CORS)
|
||||||
|
├── multipart/ # Active multipart uploads
|
||||||
|
└── keys/ # Encryption keys (SSE-S3/KMS)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
All endpoints require AWS Signature Version 4 authentication unless using presigned URLs or public bucket policies.
|
||||||
|
|
||||||
|
### Bucket Operations
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/` | List all buckets |
|
||||||
|
| `PUT` | `/<bucket>` | Create bucket |
|
||||||
|
| `DELETE` | `/<bucket>` | Delete bucket (must be empty) |
|
||||||
|
| `HEAD` | `/<bucket>` | Check bucket exists |
|
||||||
|
|
||||||
|
### Object Operations
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/<bucket>` | List objects (supports `list-type=2`) |
|
||||||
|
| `PUT` | `/<bucket>/<key>` | Upload object |
|
||||||
|
| `GET` | `/<bucket>/<key>` | Download object |
|
||||||
|
| `DELETE` | `/<bucket>/<key>` | Delete object |
|
||||||
|
| `HEAD` | `/<bucket>/<key>` | Get object metadata |
|
||||||
|
| `POST` | `/<bucket>/<key>?uploads` | Initiate multipart upload |
|
||||||
|
| `PUT` | `/<bucket>/<key>?partNumber=N&uploadId=X` | Upload part |
|
||||||
|
| `POST` | `/<bucket>/<key>?uploadId=X` | Complete multipart upload |
|
||||||
|
| `DELETE` | `/<bucket>/<key>?uploadId=X` | Abort multipart upload |
|
||||||
|
|
||||||
|
### Bucket Policies (S3-compatible)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/<bucket>?policy` | Get bucket policy |
|
||||||
|
| `PUT` | `/<bucket>?policy` | Set bucket policy |
|
||||||
|
| `DELETE` | `/<bucket>?policy` | Delete bucket policy |
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/<bucket>/<key>?versionId=X` | Get specific version |
|
||||||
|
| `DELETE` | `/<bucket>/<key>?versionId=X` | Delete specific version |
|
||||||
|
| `GET` | `/<bucket>?versions` | List object versions |
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/myfsio/health` | Health check endpoint |
|
||||||
|
|
||||||
|
## IAM & Access Control
|
||||||
|
|
||||||
|
### Users and Access Keys
|
||||||
|
|
||||||
|
On first run, MyFSIO creates a default admin user (`localadmin`/`localadmin`). Use the IAM dashboard to:
|
||||||
|
|
||||||
|
- Create and delete users
|
||||||
|
- Generate and rotate access keys
|
||||||
|
- Attach inline policies to users
|
||||||
|
- Control IAM management permissions
|
||||||
|
|
||||||
|
### Bucket Policies
|
||||||
|
|
||||||
|
Bucket policies follow AWS policy grammar (Version `2012-10-17`) with support for:
|
||||||
|
|
||||||
|
- Principal-based access (`*` for anonymous, specific users)
|
||||||
|
- Action-based permissions (`s3:GetObject`, `s3:PutObject`, etc.)
|
||||||
|
- Resource patterns (`arn:aws:s3:::bucket/*`)
|
||||||
|
- Condition keys
|
||||||
|
|
||||||
|
**Policy Presets:**
|
||||||
|
- **Public:** Grants anonymous read access (`s3:GetObject`, `s3:ListBucket`)
|
||||||
|
- **Private:** Removes bucket policy (IAM-only access)
|
||||||
|
- **Custom:** Manual policy editing with draft preservation
|
||||||
|
|
||||||
|
Policies hot-reload when the JSON file changes.
|
||||||
|
|
||||||
|
## Server-Side Encryption
|
||||||
|
|
||||||
|
MyFSIO supports two encryption modes:
|
||||||
|
|
||||||
|
- **SSE-S3:** Server-managed keys with automatic key rotation
|
||||||
|
- **SSE-KMS:** Customer-managed keys via built-in KMS
|
||||||
|
|
||||||
|
Enable encryption with:
|
||||||
|
```bash
|
||||||
|
ENCRYPTION_ENABLED=true python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cross-Bucket Replication
|
||||||
|
|
||||||
|
Replicate objects to remote S3-compatible endpoints:
|
||||||
|
|
||||||
|
1. Configure remote connections in the UI
|
||||||
|
2. Create replication rules specifying source/destination
|
||||||
|
3. Objects are automatically replicated on upload
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Build the Rust image from the `rust/` directory:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t myfsio ./rust
|
docker build -t myfsio .
|
||||||
docker run --rm -p 5000:5000 -p 5100:5100 -v "${PWD}/data:/app/data" myfsio
|
docker run -p 5000:5000 -p 5100:5100 -v ./data:/app/data myfsio
|
||||||
```
|
```
|
||||||
|
|
||||||
If the instance sits behind a reverse proxy, set `API_BASE_URL` to the public S3 endpoint.
|
|
||||||
|
|
||||||
## Linux Installation
|
|
||||||
|
|
||||||
The repository includes `scripts/install.sh` for systemd-style Linux installs. Build the Rust binary first, then pass it to the installer:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd rust/myfsio-engine
|
|
||||||
cargo build --release -p myfsio-server
|
|
||||||
|
|
||||||
cd ../..
|
|
||||||
sudo ./scripts/install.sh --binary ./rust/myfsio-engine/target/release/myfsio-server
|
|
||||||
```
|
|
||||||
|
|
||||||
The installer copies the binary into `/opt/myfsio/myfsio`, writes `/opt/myfsio/myfsio.env`, and can register a `myfsio.service` unit.
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run the Rust test suite from the workspace:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd rust/myfsio-engine
|
# Run all tests
|
||||||
cargo test
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/test_api.py -v
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest tests/ --cov=app --cov-report=html
|
||||||
```
|
```
|
||||||
|
|
||||||
## Health Check
|
## References
|
||||||
|
|
||||||
`GET /myfsio/health` returns:
|
- [Amazon S3 Documentation](https://docs.aws.amazon.com/s3/)
|
||||||
|
- [AWS Signature Version 4](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html)
|
||||||
```json
|
- [S3 Bucket Policy Examples](https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies.html)
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"version": "0.5.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `version` field comes from the Rust crate version in `rust/myfsio-engine/crates/myfsio-server/Cargo.toml`.
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html as html_module
|
||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -183,7 +184,6 @@ def create_app(
|
|||||||
object_cache_max_size=app.config.get("OBJECT_CACHE_MAX_SIZE", 100),
|
object_cache_max_size=app.config.get("OBJECT_CACHE_MAX_SIZE", 100),
|
||||||
bucket_config_cache_ttl=app.config.get("BUCKET_CONFIG_CACHE_TTL_SECONDS", 30.0),
|
bucket_config_cache_ttl=app.config.get("BUCKET_CONFIG_CACHE_TTL_SECONDS", 30.0),
|
||||||
object_key_max_length_bytes=app.config.get("OBJECT_KEY_MAX_LENGTH_BYTES", 1024),
|
object_key_max_length_bytes=app.config.get("OBJECT_KEY_MAX_LENGTH_BYTES", 1024),
|
||||||
meta_read_cache_max=app.config.get("META_READ_CACHE_MAX", 2048),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if app.config.get("WARM_CACHE_ON_STARTUP", True) and not app.config.get("TESTING"):
|
if app.config.get("WARM_CACHE_ON_STARTUP", True) and not app.config.get("TESTING"):
|
||||||
@@ -293,7 +293,6 @@ def create_app(
|
|||||||
multipart_max_age_days=app.config.get("GC_MULTIPART_MAX_AGE_DAYS", 7),
|
multipart_max_age_days=app.config.get("GC_MULTIPART_MAX_AGE_DAYS", 7),
|
||||||
lock_file_max_age_hours=app.config.get("GC_LOCK_FILE_MAX_AGE_HOURS", 1.0),
|
lock_file_max_age_hours=app.config.get("GC_LOCK_FILE_MAX_AGE_HOURS", 1.0),
|
||||||
dry_run=app.config.get("GC_DRY_RUN", False),
|
dry_run=app.config.get("GC_DRY_RUN", False),
|
||||||
io_throttle_ms=app.config.get("GC_IO_THROTTLE_MS", 10),
|
|
||||||
)
|
)
|
||||||
gc_collector.start()
|
gc_collector.start()
|
||||||
|
|
||||||
@@ -305,7 +304,6 @@ def create_app(
|
|||||||
batch_size=app.config.get("INTEGRITY_BATCH_SIZE", 1000),
|
batch_size=app.config.get("INTEGRITY_BATCH_SIZE", 1000),
|
||||||
auto_heal=app.config.get("INTEGRITY_AUTO_HEAL", False),
|
auto_heal=app.config.get("INTEGRITY_AUTO_HEAL", False),
|
||||||
dry_run=app.config.get("INTEGRITY_DRY_RUN", False),
|
dry_run=app.config.get("INTEGRITY_DRY_RUN", False),
|
||||||
io_throttle_ms=app.config.get("INTEGRITY_IO_THROTTLE_MS", 10),
|
|
||||||
)
|
)
|
||||||
integrity_checker.start()
|
integrity_checker.start()
|
||||||
|
|
||||||
@@ -719,10 +717,9 @@ def _configure_logging(app: Flask) -> None:
|
|||||||
return _website_error_response(status_code, "Not Found")
|
return _website_error_response(status_code, "Not Found")
|
||||||
|
|
||||||
def _website_error_response(status_code, message):
|
def _website_error_response(status_code, message):
|
||||||
if status_code == 404:
|
safe_msg = html_module.escape(str(message))
|
||||||
body = "<h1>404 page not found</h1>"
|
safe_code = html_module.escape(str(status_code))
|
||||||
else:
|
body = f"<html><head><title>{safe_code} {safe_msg}</title></head><body><h1>{safe_code} {safe_msg}</h1></body></html>"
|
||||||
body = f"{status_code} {message}"
|
|
||||||
return Response(body, status=status_code, mimetype="text/html")
|
return Response(body, status=status_code, mimetype="text/html")
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
@@ -907,11 +907,15 @@ def gc_run_now():
|
|||||||
if not gc:
|
if not gc:
|
||||||
return _json_error("InvalidRequest", "GC is not enabled", 400)
|
return _json_error("InvalidRequest", "GC is not enabled", 400)
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
started = gc.run_async(dry_run=payload.get("dry_run"))
|
original_dry_run = gc.dry_run
|
||||||
|
if "dry_run" in payload:
|
||||||
|
gc.dry_run = bool(payload["dry_run"])
|
||||||
|
try:
|
||||||
|
result = gc.run_now()
|
||||||
|
finally:
|
||||||
|
gc.dry_run = original_dry_run
|
||||||
logger.info("GC manual run by %s", principal.access_key)
|
logger.info("GC manual run by %s", principal.access_key)
|
||||||
if not started:
|
return jsonify(result.to_dict())
|
||||||
return _json_error("Conflict", "GC is already in progress", 409)
|
|
||||||
return jsonify({"status": "started"})
|
|
||||||
|
|
||||||
|
|
||||||
@admin_api_bp.route("/gc/history", methods=["GET"])
|
@admin_api_bp.route("/gc/history", methods=["GET"])
|
||||||
@@ -957,14 +961,12 @@ def integrity_run_now():
|
|||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
override_dry_run = payload.get("dry_run")
|
override_dry_run = payload.get("dry_run")
|
||||||
override_auto_heal = payload.get("auto_heal")
|
override_auto_heal = payload.get("auto_heal")
|
||||||
started = checker.run_async(
|
result = checker.run_now(
|
||||||
auto_heal=override_auto_heal if override_auto_heal is not None else None,
|
auto_heal=override_auto_heal if override_auto_heal is not None else None,
|
||||||
dry_run=override_dry_run if override_dry_run is not None else None,
|
dry_run=override_dry_run if override_dry_run is not None else None,
|
||||||
)
|
)
|
||||||
logger.info("Integrity manual run by %s", principal.access_key)
|
logger.info("Integrity manual run by %s", principal.access_key)
|
||||||
if not started:
|
return jsonify(result.to_dict())
|
||||||
return _json_error("Conflict", "A scan is already in progress", 409)
|
|
||||||
return jsonify({"status": "started"})
|
|
||||||
|
|
||||||
|
|
||||||
@admin_api_bp.route("/integrity/history", methods=["GET"])
|
@admin_api_bp.route("/integrity/history", methods=["GET"])
|
||||||
@@ -136,7 +136,6 @@ class AppConfig:
|
|||||||
site_sync_clock_skew_tolerance_seconds: float
|
site_sync_clock_skew_tolerance_seconds: float
|
||||||
object_key_max_length_bytes: int
|
object_key_max_length_bytes: int
|
||||||
object_cache_max_size: int
|
object_cache_max_size: int
|
||||||
meta_read_cache_max: int
|
|
||||||
bucket_config_cache_ttl_seconds: float
|
bucket_config_cache_ttl_seconds: float
|
||||||
object_tag_limit: int
|
object_tag_limit: int
|
||||||
encryption_chunk_size_bytes: int
|
encryption_chunk_size_bytes: int
|
||||||
@@ -158,13 +157,11 @@ class AppConfig:
|
|||||||
gc_multipart_max_age_days: int
|
gc_multipart_max_age_days: int
|
||||||
gc_lock_file_max_age_hours: float
|
gc_lock_file_max_age_hours: float
|
||||||
gc_dry_run: bool
|
gc_dry_run: bool
|
||||||
gc_io_throttle_ms: int
|
|
||||||
integrity_enabled: bool
|
integrity_enabled: bool
|
||||||
integrity_interval_hours: float
|
integrity_interval_hours: float
|
||||||
integrity_batch_size: int
|
integrity_batch_size: int
|
||||||
integrity_auto_heal: bool
|
integrity_auto_heal: bool
|
||||||
integrity_dry_run: bool
|
integrity_dry_run: bool
|
||||||
integrity_io_throttle_ms: int
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig":
|
def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig":
|
||||||
@@ -316,7 +313,6 @@ class AppConfig:
|
|||||||
site_sync_clock_skew_tolerance_seconds = float(_get("SITE_SYNC_CLOCK_SKEW_TOLERANCE_SECONDS", 1.0))
|
site_sync_clock_skew_tolerance_seconds = float(_get("SITE_SYNC_CLOCK_SKEW_TOLERANCE_SECONDS", 1.0))
|
||||||
object_key_max_length_bytes = int(_get("OBJECT_KEY_MAX_LENGTH_BYTES", 1024))
|
object_key_max_length_bytes = int(_get("OBJECT_KEY_MAX_LENGTH_BYTES", 1024))
|
||||||
object_cache_max_size = int(_get("OBJECT_CACHE_MAX_SIZE", 100))
|
object_cache_max_size = int(_get("OBJECT_CACHE_MAX_SIZE", 100))
|
||||||
meta_read_cache_max = int(_get("META_READ_CACHE_MAX", 2048))
|
|
||||||
bucket_config_cache_ttl_seconds = float(_get("BUCKET_CONFIG_CACHE_TTL_SECONDS", 30.0))
|
bucket_config_cache_ttl_seconds = float(_get("BUCKET_CONFIG_CACHE_TTL_SECONDS", 30.0))
|
||||||
object_tag_limit = int(_get("OBJECT_TAG_LIMIT", 50))
|
object_tag_limit = int(_get("OBJECT_TAG_LIMIT", 50))
|
||||||
encryption_chunk_size_bytes = int(_get("ENCRYPTION_CHUNK_SIZE_BYTES", 64 * 1024))
|
encryption_chunk_size_bytes = int(_get("ENCRYPTION_CHUNK_SIZE_BYTES", 64 * 1024))
|
||||||
@@ -342,13 +338,11 @@ class AppConfig:
|
|||||||
gc_multipart_max_age_days = int(_get("GC_MULTIPART_MAX_AGE_DAYS", 7))
|
gc_multipart_max_age_days = int(_get("GC_MULTIPART_MAX_AGE_DAYS", 7))
|
||||||
gc_lock_file_max_age_hours = float(_get("GC_LOCK_FILE_MAX_AGE_HOURS", 1.0))
|
gc_lock_file_max_age_hours = float(_get("GC_LOCK_FILE_MAX_AGE_HOURS", 1.0))
|
||||||
gc_dry_run = str(_get("GC_DRY_RUN", "0")).lower() in {"1", "true", "yes", "on"}
|
gc_dry_run = str(_get("GC_DRY_RUN", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
gc_io_throttle_ms = int(_get("GC_IO_THROTTLE_MS", 10))
|
|
||||||
integrity_enabled = str(_get("INTEGRITY_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
integrity_enabled = str(_get("INTEGRITY_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
integrity_interval_hours = float(_get("INTEGRITY_INTERVAL_HOURS", 24.0))
|
integrity_interval_hours = float(_get("INTEGRITY_INTERVAL_HOURS", 24.0))
|
||||||
integrity_batch_size = int(_get("INTEGRITY_BATCH_SIZE", 1000))
|
integrity_batch_size = int(_get("INTEGRITY_BATCH_SIZE", 1000))
|
||||||
integrity_auto_heal = str(_get("INTEGRITY_AUTO_HEAL", "0")).lower() in {"1", "true", "yes", "on"}
|
integrity_auto_heal = str(_get("INTEGRITY_AUTO_HEAL", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
integrity_dry_run = str(_get("INTEGRITY_DRY_RUN", "0")).lower() in {"1", "true", "yes", "on"}
|
integrity_dry_run = str(_get("INTEGRITY_DRY_RUN", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
integrity_io_throttle_ms = int(_get("INTEGRITY_IO_THROTTLE_MS", 10))
|
|
||||||
|
|
||||||
return cls(storage_root=storage_root,
|
return cls(storage_root=storage_root,
|
||||||
max_upload_size=max_upload_size,
|
max_upload_size=max_upload_size,
|
||||||
@@ -423,7 +417,6 @@ class AppConfig:
|
|||||||
site_sync_clock_skew_tolerance_seconds=site_sync_clock_skew_tolerance_seconds,
|
site_sync_clock_skew_tolerance_seconds=site_sync_clock_skew_tolerance_seconds,
|
||||||
object_key_max_length_bytes=object_key_max_length_bytes,
|
object_key_max_length_bytes=object_key_max_length_bytes,
|
||||||
object_cache_max_size=object_cache_max_size,
|
object_cache_max_size=object_cache_max_size,
|
||||||
meta_read_cache_max=meta_read_cache_max,
|
|
||||||
bucket_config_cache_ttl_seconds=bucket_config_cache_ttl_seconds,
|
bucket_config_cache_ttl_seconds=bucket_config_cache_ttl_seconds,
|
||||||
object_tag_limit=object_tag_limit,
|
object_tag_limit=object_tag_limit,
|
||||||
encryption_chunk_size_bytes=encryption_chunk_size_bytes,
|
encryption_chunk_size_bytes=encryption_chunk_size_bytes,
|
||||||
@@ -445,13 +438,11 @@ class AppConfig:
|
|||||||
gc_multipart_max_age_days=gc_multipart_max_age_days,
|
gc_multipart_max_age_days=gc_multipart_max_age_days,
|
||||||
gc_lock_file_max_age_hours=gc_lock_file_max_age_hours,
|
gc_lock_file_max_age_hours=gc_lock_file_max_age_hours,
|
||||||
gc_dry_run=gc_dry_run,
|
gc_dry_run=gc_dry_run,
|
||||||
gc_io_throttle_ms=gc_io_throttle_ms,
|
|
||||||
integrity_enabled=integrity_enabled,
|
integrity_enabled=integrity_enabled,
|
||||||
integrity_interval_hours=integrity_interval_hours,
|
integrity_interval_hours=integrity_interval_hours,
|
||||||
integrity_batch_size=integrity_batch_size,
|
integrity_batch_size=integrity_batch_size,
|
||||||
integrity_auto_heal=integrity_auto_heal,
|
integrity_auto_heal=integrity_auto_heal,
|
||||||
integrity_dry_run=integrity_dry_run,
|
integrity_dry_run=integrity_dry_run)
|
||||||
integrity_io_throttle_ms=integrity_io_throttle_ms)
|
|
||||||
|
|
||||||
def validate_and_report(self) -> list[str]:
|
def validate_and_report(self) -> list[str]:
|
||||||
"""Validate configuration and return a list of warnings/issues.
|
"""Validate configuration and return a list of warnings/issues.
|
||||||
@@ -651,7 +642,6 @@ class AppConfig:
|
|||||||
"SITE_SYNC_CLOCK_SKEW_TOLERANCE_SECONDS": self.site_sync_clock_skew_tolerance_seconds,
|
"SITE_SYNC_CLOCK_SKEW_TOLERANCE_SECONDS": self.site_sync_clock_skew_tolerance_seconds,
|
||||||
"OBJECT_KEY_MAX_LENGTH_BYTES": self.object_key_max_length_bytes,
|
"OBJECT_KEY_MAX_LENGTH_BYTES": self.object_key_max_length_bytes,
|
||||||
"OBJECT_CACHE_MAX_SIZE": self.object_cache_max_size,
|
"OBJECT_CACHE_MAX_SIZE": self.object_cache_max_size,
|
||||||
"META_READ_CACHE_MAX": self.meta_read_cache_max,
|
|
||||||
"BUCKET_CONFIG_CACHE_TTL_SECONDS": self.bucket_config_cache_ttl_seconds,
|
"BUCKET_CONFIG_CACHE_TTL_SECONDS": self.bucket_config_cache_ttl_seconds,
|
||||||
"OBJECT_TAG_LIMIT": self.object_tag_limit,
|
"OBJECT_TAG_LIMIT": self.object_tag_limit,
|
||||||
"ENCRYPTION_CHUNK_SIZE_BYTES": self.encryption_chunk_size_bytes,
|
"ENCRYPTION_CHUNK_SIZE_BYTES": self.encryption_chunk_size_bytes,
|
||||||
@@ -673,11 +663,9 @@ class AppConfig:
|
|||||||
"GC_MULTIPART_MAX_AGE_DAYS": self.gc_multipart_max_age_days,
|
"GC_MULTIPART_MAX_AGE_DAYS": self.gc_multipart_max_age_days,
|
||||||
"GC_LOCK_FILE_MAX_AGE_HOURS": self.gc_lock_file_max_age_hours,
|
"GC_LOCK_FILE_MAX_AGE_HOURS": self.gc_lock_file_max_age_hours,
|
||||||
"GC_DRY_RUN": self.gc_dry_run,
|
"GC_DRY_RUN": self.gc_dry_run,
|
||||||
"GC_IO_THROTTLE_MS": self.gc_io_throttle_ms,
|
|
||||||
"INTEGRITY_ENABLED": self.integrity_enabled,
|
"INTEGRITY_ENABLED": self.integrity_enabled,
|
||||||
"INTEGRITY_INTERVAL_HOURS": self.integrity_interval_hours,
|
"INTEGRITY_INTERVAL_HOURS": self.integrity_interval_hours,
|
||||||
"INTEGRITY_BATCH_SIZE": self.integrity_batch_size,
|
"INTEGRITY_BATCH_SIZE": self.integrity_batch_size,
|
||||||
"INTEGRITY_AUTO_HEAL": self.integrity_auto_heal,
|
"INTEGRITY_AUTO_HEAL": self.integrity_auto_heal,
|
||||||
"INTEGRITY_DRY_RUN": self.integrity_dry_run,
|
"INTEGRITY_DRY_RUN": self.integrity_dry_run,
|
||||||
"INTEGRITY_IO_THROTTLE_MS": self.integrity_io_throttle_ms,
|
|
||||||
}
|
}
|
||||||
@@ -21,10 +21,6 @@ if sys.platform != "win32":
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import myfsio_core as _rc
|
import myfsio_core as _rc
|
||||||
if not all(hasattr(_rc, f) for f in (
|
|
||||||
"encrypt_stream_chunked", "decrypt_stream_chunked",
|
|
||||||
)):
|
|
||||||
raise ImportError("myfsio_core is outdated, rebuild with: cd myfsio_core && maturin develop --release")
|
|
||||||
_HAS_RUST = True
|
_HAS_RUST = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_rc = None
|
_rc = None
|
||||||
@@ -175,21 +175,13 @@ def handle_app_error(error: AppError) -> Response:
|
|||||||
|
|
||||||
def handle_rate_limit_exceeded(e: RateLimitExceeded) -> Response:
|
def handle_rate_limit_exceeded(e: RateLimitExceeded) -> Response:
|
||||||
g.s3_error_code = "SlowDown"
|
g.s3_error_code = "SlowDown"
|
||||||
if request.path.startswith("/ui") or request.path.startswith("/buckets"):
|
|
||||||
wants_json = (
|
|
||||||
request.is_json or
|
|
||||||
request.headers.get("X-Requested-With") == "XMLHttpRequest" or
|
|
||||||
"application/json" in request.accept_mimetypes.values()
|
|
||||||
)
|
|
||||||
if wants_json:
|
|
||||||
return jsonify({"success": False, "error": {"code": "SlowDown", "message": "Please reduce your request rate."}}), 429
|
|
||||||
error = Element("Error")
|
error = Element("Error")
|
||||||
SubElement(error, "Code").text = "SlowDown"
|
SubElement(error, "Code").text = "SlowDown"
|
||||||
SubElement(error, "Message").text = "Please reduce your request rate."
|
SubElement(error, "Message").text = "Please reduce your request rate."
|
||||||
SubElement(error, "Resource").text = request.path
|
SubElement(error, "Resource").text = request.path
|
||||||
SubElement(error, "RequestId").text = getattr(g, "request_id", "")
|
SubElement(error, "RequestId").text = getattr(g, "request_id", "")
|
||||||
xml_bytes = tostring(error, encoding="utf-8")
|
xml_bytes = tostring(error, encoding="utf-8")
|
||||||
return Response(xml_bytes, status="429 Too Many Requests", mimetype="application/xml")
|
return Response(xml_bytes, status=429, mimetype="application/xml")
|
||||||
|
|
||||||
|
|
||||||
def register_error_handlers(app):
|
def register_error_handlers(app):
|
||||||
@@ -162,7 +162,6 @@ class GarbageCollector:
|
|||||||
lock_file_max_age_hours: float = 1.0,
|
lock_file_max_age_hours: float = 1.0,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
max_history: int = 50,
|
max_history: int = 50,
|
||||||
io_throttle_ms: int = 10,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.storage_root = Path(storage_root)
|
self.storage_root = Path(storage_root)
|
||||||
self.interval_seconds = interval_hours * 3600.0
|
self.interval_seconds = interval_hours * 3600.0
|
||||||
@@ -173,9 +172,6 @@ class GarbageCollector:
|
|||||||
self._timer: Optional[threading.Timer] = None
|
self._timer: Optional[threading.Timer] = None
|
||||||
self._shutdown = False
|
self._shutdown = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._scanning = False
|
|
||||||
self._scan_start_time: Optional[float] = None
|
|
||||||
self._io_throttle = max(0, io_throttle_ms) / 1000.0
|
|
||||||
self.history_store = GCHistoryStore(storage_root, max_records=max_history)
|
self.history_store = GCHistoryStore(storage_root, max_records=max_history)
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
@@ -216,30 +212,16 @@ class GarbageCollector:
|
|||||||
finally:
|
finally:
|
||||||
self._schedule_next()
|
self._schedule_next()
|
||||||
|
|
||||||
def run_now(self, dry_run: Optional[bool] = None) -> GCResult:
|
def run_now(self) -> GCResult:
|
||||||
if not self._lock.acquire(blocking=False):
|
start = time.time()
|
||||||
raise RuntimeError("GC is already in progress")
|
|
||||||
|
|
||||||
effective_dry_run = dry_run if dry_run is not None else self.dry_run
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._scanning = True
|
|
||||||
self._scan_start_time = time.time()
|
|
||||||
|
|
||||||
start = self._scan_start_time
|
|
||||||
result = GCResult()
|
result = GCResult()
|
||||||
|
|
||||||
original_dry_run = self.dry_run
|
|
||||||
self.dry_run = effective_dry_run
|
|
||||||
try:
|
|
||||||
self._clean_temp_files(result)
|
self._clean_temp_files(result)
|
||||||
self._clean_orphaned_multipart(result)
|
self._clean_orphaned_multipart(result)
|
||||||
self._clean_stale_locks(result)
|
self._clean_stale_locks(result)
|
||||||
self._clean_orphaned_metadata(result)
|
self._clean_orphaned_metadata(result)
|
||||||
self._clean_orphaned_versions(result)
|
self._clean_orphaned_versions(result)
|
||||||
self._clean_empty_dirs(result)
|
self._clean_empty_dirs(result)
|
||||||
finally:
|
|
||||||
self.dry_run = original_dry_run
|
|
||||||
|
|
||||||
result.execution_time_seconds = time.time() - start
|
result.execution_time_seconds = time.time() - start
|
||||||
|
|
||||||
@@ -258,39 +240,21 @@ class GarbageCollector:
|
|||||||
result.orphaned_version_bytes_freed / (1024 * 1024),
|
result.orphaned_version_bytes_freed / (1024 * 1024),
|
||||||
result.empty_dirs_removed,
|
result.empty_dirs_removed,
|
||||||
len(result.errors),
|
len(result.errors),
|
||||||
" (dry run)" if effective_dry_run else "",
|
" (dry run)" if self.dry_run else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
record = GCExecutionRecord(
|
record = GCExecutionRecord(
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
result=result.to_dict(),
|
result=result.to_dict(),
|
||||||
dry_run=effective_dry_run,
|
dry_run=self.dry_run,
|
||||||
)
|
)
|
||||||
self.history_store.add(record)
|
self.history_store.add(record)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
finally:
|
|
||||||
self._scanning = False
|
|
||||||
self._scan_start_time = None
|
|
||||||
self._lock.release()
|
|
||||||
|
|
||||||
def run_async(self, dry_run: Optional[bool] = None) -> bool:
|
|
||||||
if self._scanning:
|
|
||||||
return False
|
|
||||||
t = threading.Thread(target=self.run_now, args=(dry_run,), daemon=True)
|
|
||||||
t.start()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _system_path(self) -> Path:
|
def _system_path(self) -> Path:
|
||||||
return self.storage_root / self.SYSTEM_ROOT
|
return self.storage_root / self.SYSTEM_ROOT
|
||||||
|
|
||||||
def _throttle(self) -> bool:
|
|
||||||
if self._shutdown:
|
|
||||||
return True
|
|
||||||
if self._io_throttle > 0:
|
|
||||||
time.sleep(self._io_throttle)
|
|
||||||
return self._shutdown
|
|
||||||
|
|
||||||
def _list_bucket_names(self) -> List[str]:
|
def _list_bucket_names(self) -> List[str]:
|
||||||
names = []
|
names = []
|
||||||
try:
|
try:
|
||||||
@@ -307,8 +271,6 @@ class GarbageCollector:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
for entry in tmp_dir.iterdir():
|
for entry in tmp_dir.iterdir():
|
||||||
if self._throttle():
|
|
||||||
return
|
|
||||||
if not entry.is_file():
|
if not entry.is_file():
|
||||||
continue
|
continue
|
||||||
age = _file_age_hours(entry)
|
age = _file_age_hours(entry)
|
||||||
@@ -330,8 +292,6 @@ class GarbageCollector:
|
|||||||
bucket_names = self._list_bucket_names()
|
bucket_names = self._list_bucket_names()
|
||||||
|
|
||||||
for bucket_name in bucket_names:
|
for bucket_name in bucket_names:
|
||||||
if self._shutdown:
|
|
||||||
return
|
|
||||||
for multipart_root in (
|
for multipart_root in (
|
||||||
self._system_path() / self.SYSTEM_MULTIPART_DIR / bucket_name,
|
self._system_path() / self.SYSTEM_MULTIPART_DIR / bucket_name,
|
||||||
self.storage_root / bucket_name / ".multipart",
|
self.storage_root / bucket_name / ".multipart",
|
||||||
@@ -340,8 +300,6 @@ class GarbageCollector:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
for upload_dir in multipart_root.iterdir():
|
for upload_dir in multipart_root.iterdir():
|
||||||
if self._throttle():
|
|
||||||
return
|
|
||||||
if not upload_dir.is_dir():
|
if not upload_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
self._maybe_clean_upload(upload_dir, cutoff_hours, result)
|
self._maybe_clean_upload(upload_dir, cutoff_hours, result)
|
||||||
@@ -371,8 +329,6 @@ class GarbageCollector:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
for bucket_dir in buckets_root.iterdir():
|
for bucket_dir in buckets_root.iterdir():
|
||||||
if self._shutdown:
|
|
||||||
return
|
|
||||||
if not bucket_dir.is_dir():
|
if not bucket_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
locks_dir = bucket_dir / "locks"
|
locks_dir = bucket_dir / "locks"
|
||||||
@@ -380,8 +336,6 @@ class GarbageCollector:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
for lock_file in locks_dir.iterdir():
|
for lock_file in locks_dir.iterdir():
|
||||||
if self._throttle():
|
|
||||||
return
|
|
||||||
if not lock_file.is_file() or not lock_file.name.endswith(".lock"):
|
if not lock_file.is_file() or not lock_file.name.endswith(".lock"):
|
||||||
continue
|
continue
|
||||||
age = _file_age_hours(lock_file)
|
age = _file_age_hours(lock_file)
|
||||||
@@ -402,8 +356,6 @@ class GarbageCollector:
|
|||||||
bucket_names = self._list_bucket_names()
|
bucket_names = self._list_bucket_names()
|
||||||
|
|
||||||
for bucket_name in bucket_names:
|
for bucket_name in bucket_names:
|
||||||
if self._shutdown:
|
|
||||||
return
|
|
||||||
legacy_meta = self.storage_root / bucket_name / ".meta"
|
legacy_meta = self.storage_root / bucket_name / ".meta"
|
||||||
if legacy_meta.exists():
|
if legacy_meta.exists():
|
||||||
self._clean_legacy_metadata(bucket_name, legacy_meta, result)
|
self._clean_legacy_metadata(bucket_name, legacy_meta, result)
|
||||||
@@ -416,8 +368,6 @@ class GarbageCollector:
|
|||||||
bucket_path = self.storage_root / bucket_name
|
bucket_path = self.storage_root / bucket_name
|
||||||
try:
|
try:
|
||||||
for meta_file in meta_root.rglob("*.meta.json"):
|
for meta_file in meta_root.rglob("*.meta.json"):
|
||||||
if self._throttle():
|
|
||||||
return
|
|
||||||
if not meta_file.is_file():
|
if not meta_file.is_file():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -437,8 +387,6 @@ class GarbageCollector:
|
|||||||
bucket_path = self.storage_root / bucket_name
|
bucket_path = self.storage_root / bucket_name
|
||||||
try:
|
try:
|
||||||
for index_file in meta_root.rglob("_index.json"):
|
for index_file in meta_root.rglob("_index.json"):
|
||||||
if self._throttle():
|
|
||||||
return
|
|
||||||
if not index_file.is_file():
|
if not index_file.is_file():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -482,8 +430,6 @@ class GarbageCollector:
|
|||||||
bucket_names = self._list_bucket_names()
|
bucket_names = self._list_bucket_names()
|
||||||
|
|
||||||
for bucket_name in bucket_names:
|
for bucket_name in bucket_names:
|
||||||
if self._shutdown:
|
|
||||||
return
|
|
||||||
bucket_path = self.storage_root / bucket_name
|
bucket_path = self.storage_root / bucket_name
|
||||||
for versions_root in (
|
for versions_root in (
|
||||||
self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / self.BUCKET_VERSIONS_DIR,
|
self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / self.BUCKET_VERSIONS_DIR,
|
||||||
@@ -493,8 +439,6 @@ class GarbageCollector:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
for key_dir in versions_root.iterdir():
|
for key_dir in versions_root.iterdir():
|
||||||
if self._throttle():
|
|
||||||
return
|
|
||||||
if not key_dir.is_dir():
|
if not key_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
self._clean_versions_for_key(bucket_path, versions_root, key_dir, result)
|
self._clean_versions_for_key(bucket_path, versions_root, key_dir, result)
|
||||||
@@ -545,8 +489,6 @@ class GarbageCollector:
|
|||||||
self._remove_empty_dirs_recursive(root, root, result)
|
self._remove_empty_dirs_recursive(root, root, result)
|
||||||
|
|
||||||
def _remove_empty_dirs_recursive(self, path: Path, stop_at: Path, result: GCResult) -> bool:
|
def _remove_empty_dirs_recursive(self, path: Path, stop_at: Path, result: GCResult) -> bool:
|
||||||
if self._shutdown:
|
|
||||||
return False
|
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -557,8 +499,6 @@ class GarbageCollector:
|
|||||||
|
|
||||||
all_empty = True
|
all_empty = True
|
||||||
for child in children:
|
for child in children:
|
||||||
if self._throttle():
|
|
||||||
return False
|
|
||||||
if child.is_dir():
|
if child.is_dir():
|
||||||
if not self._remove_empty_dirs_recursive(child, stop_at, result):
|
if not self._remove_empty_dirs_recursive(child, stop_at, result):
|
||||||
all_empty = False
|
all_empty = False
|
||||||
@@ -580,17 +520,12 @@ class GarbageCollector:
|
|||||||
return [r.to_dict() for r in records]
|
return [r.to_dict() for r in records]
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
status: Dict[str, Any] = {
|
return {
|
||||||
"enabled": not self._shutdown or self._timer is not None,
|
"enabled": not self._shutdown or self._timer is not None,
|
||||||
"running": self._timer is not None and not self._shutdown,
|
"running": self._timer is not None and not self._shutdown,
|
||||||
"scanning": self._scanning,
|
|
||||||
"interval_hours": self.interval_seconds / 3600.0,
|
"interval_hours": self.interval_seconds / 3600.0,
|
||||||
"temp_file_max_age_hours": self.temp_file_max_age_hours,
|
"temp_file_max_age_hours": self.temp_file_max_age_hours,
|
||||||
"multipart_max_age_days": self.multipart_max_age_days,
|
"multipart_max_age_days": self.multipart_max_age_days,
|
||||||
"lock_file_max_age_hours": self.lock_file_max_age_hours,
|
"lock_file_max_age_hours": self.lock_file_max_age_hours,
|
||||||
"dry_run": self.dry_run,
|
"dry_run": self.dry_run,
|
||||||
"io_throttle_ms": round(self._io_throttle * 1000),
|
|
||||||
}
|
}
|
||||||
if self._scanning and self._scan_start_time:
|
|
||||||
status["scan_elapsed_seconds"] = time.time() - self._scan_start_time
|
|
||||||
return status
|
|
||||||
@@ -398,11 +398,9 @@ class IamService:
|
|||||||
record = self._user_records.get(user_id)
|
record = self._user_records.get(user_id)
|
||||||
if record:
|
if record:
|
||||||
self._check_expiry(access_key, record)
|
self._check_expiry(access_key, record)
|
||||||
self._enforce_key_and_user_status(access_key)
|
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
self._enforce_key_and_user_status(access_key)
|
|
||||||
user_id = self._key_index.get(access_key)
|
user_id = self._key_index.get(access_key)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise IamError("Unknown access key")
|
raise IamError("Unknown access key")
|
||||||
@@ -416,7 +414,6 @@ class IamService:
|
|||||||
|
|
||||||
def secret_for_key(self, access_key: str) -> str:
|
def secret_for_key(self, access_key: str) -> str:
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
self._enforce_key_and_user_status(access_key)
|
|
||||||
secret = self._key_secrets.get(access_key)
|
secret = self._key_secrets.get(access_key)
|
||||||
if not secret:
|
if not secret:
|
||||||
raise IamError("Unknown access key")
|
raise IamError("Unknown access key")
|
||||||
@@ -1031,16 +1028,6 @@ class IamService:
|
|||||||
user, _ = self._resolve_raw_user(access_key)
|
user, _ = self._resolve_raw_user(access_key)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def _enforce_key_and_user_status(self, access_key: str) -> None:
|
|
||||||
key_status = self._key_status.get(access_key, "active")
|
|
||||||
if key_status != "active":
|
|
||||||
raise IamError("Access key is inactive")
|
|
||||||
user_id = self._key_index.get(access_key)
|
|
||||||
if user_id:
|
|
||||||
record = self._user_records.get(user_id)
|
|
||||||
if record and not record.get("enabled", True):
|
|
||||||
raise IamError("User account is disabled")
|
|
||||||
|
|
||||||
def get_secret_key(self, access_key: str) -> str | None:
|
def get_secret_key(self, access_key: str) -> str | None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = self._secret_key_cache.get(access_key)
|
cached = self._secret_key_cache.get(access_key)
|
||||||
@@ -1052,7 +1039,6 @@ class IamService:
|
|||||||
record = self._user_records.get(user_id)
|
record = self._user_records.get(user_id)
|
||||||
if record:
|
if record:
|
||||||
self._check_expiry(access_key, record)
|
self._check_expiry(access_key, record)
|
||||||
self._enforce_key_and_user_status(access_key)
|
|
||||||
return secret_key
|
return secret_key
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
@@ -1063,7 +1049,6 @@ class IamService:
|
|||||||
record = self._user_records.get(user_id)
|
record = self._user_records.get(user_id)
|
||||||
if record:
|
if record:
|
||||||
self._check_expiry(access_key, record)
|
self._check_expiry(access_key, record)
|
||||||
self._enforce_key_and_user_status(access_key)
|
|
||||||
self._secret_key_cache[access_key] = (secret, now)
|
self._secret_key_cache[access_key] = (secret, now)
|
||||||
return secret
|
return secret
|
||||||
return None
|
return None
|
||||||
@@ -1079,11 +1064,9 @@ class IamService:
|
|||||||
record = self._user_records.get(user_id)
|
record = self._user_records.get(user_id)
|
||||||
if record:
|
if record:
|
||||||
self._check_expiry(access_key, record)
|
self._check_expiry(access_key, record)
|
||||||
self._enforce_key_and_user_status(access_key)
|
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
self._enforce_key_and_user_status(access_key)
|
|
||||||
user_id = self._key_index.get(access_key)
|
user_id = self._key_index.get(access_key)
|
||||||
if user_id:
|
if user_id:
|
||||||
record = self._user_records.get(user_id)
|
record = self._user_records.get(user_id)
|
||||||
@@ -12,8 +12,6 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import myfsio_core as _rc
|
import myfsio_core as _rc
|
||||||
if not hasattr(_rc, "md5_file"):
|
|
||||||
raise ImportError("myfsio_core is outdated, rebuild with: cd myfsio_core && maturin develop --release")
|
|
||||||
_HAS_RUST = True
|
_HAS_RUST = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_HAS_RUST = False
|
_HAS_RUST = False
|
||||||
@@ -164,111 +162,6 @@ class IntegrityHistoryStore:
|
|||||||
return self.load()[offset : offset + limit]
|
return self.load()[offset : offset + limit]
|
||||||
|
|
||||||
|
|
||||||
class IntegrityCursorStore:
|
|
||||||
def __init__(self, storage_root: Path) -> None:
|
|
||||||
self.storage_root = storage_root
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def _get_path(self) -> Path:
|
|
||||||
return self.storage_root / ".myfsio.sys" / "config" / "integrity_cursor.json"
|
|
||||||
|
|
||||||
def load(self) -> Dict[str, Any]:
|
|
||||||
path = self._get_path()
|
|
||||||
if not path.exists():
|
|
||||||
return {"buckets": {}}
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
if not isinstance(data.get("buckets"), dict):
|
|
||||||
return {"buckets": {}}
|
|
||||||
return data
|
|
||||||
except (OSError, ValueError, KeyError):
|
|
||||||
return {"buckets": {}}
|
|
||||||
|
|
||||||
def save(self, data: Dict[str, Any]) -> None:
|
|
||||||
path = self._get_path()
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
try:
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
except OSError as e:
|
|
||||||
logger.error("Failed to save integrity cursor: %s", e)
|
|
||||||
|
|
||||||
def update_bucket(
|
|
||||||
self,
|
|
||||||
bucket_name: str,
|
|
||||||
timestamp: float,
|
|
||||||
last_key: Optional[str] = None,
|
|
||||||
completed: bool = False,
|
|
||||||
) -> None:
|
|
||||||
with self._lock:
|
|
||||||
data = self.load()
|
|
||||||
entry = data["buckets"].get(bucket_name, {})
|
|
||||||
if completed:
|
|
||||||
entry["last_scanned"] = timestamp
|
|
||||||
entry.pop("last_key", None)
|
|
||||||
entry["completed"] = True
|
|
||||||
else:
|
|
||||||
entry["last_scanned"] = timestamp
|
|
||||||
if last_key is not None:
|
|
||||||
entry["last_key"] = last_key
|
|
||||||
entry["completed"] = False
|
|
||||||
data["buckets"][bucket_name] = entry
|
|
||||||
self.save(data)
|
|
||||||
|
|
||||||
def clean_stale(self, existing_buckets: List[str]) -> None:
|
|
||||||
with self._lock:
|
|
||||||
data = self.load()
|
|
||||||
existing_set = set(existing_buckets)
|
|
||||||
stale_keys = [k for k in data["buckets"] if k not in existing_set]
|
|
||||||
if stale_keys:
|
|
||||||
for k in stale_keys:
|
|
||||||
del data["buckets"][k]
|
|
||||||
self.save(data)
|
|
||||||
|
|
||||||
def get_last_key(self, bucket_name: str) -> Optional[str]:
|
|
||||||
data = self.load()
|
|
||||||
entry = data.get("buckets", {}).get(bucket_name)
|
|
||||||
if entry is None:
|
|
||||||
return None
|
|
||||||
return entry.get("last_key")
|
|
||||||
|
|
||||||
def get_bucket_order(self, bucket_names: List[str]) -> List[str]:
|
|
||||||
data = self.load()
|
|
||||||
buckets_info = data.get("buckets", {})
|
|
||||||
|
|
||||||
incomplete = []
|
|
||||||
complete = []
|
|
||||||
for name in bucket_names:
|
|
||||||
entry = buckets_info.get(name)
|
|
||||||
if entry is None:
|
|
||||||
incomplete.append((name, 0.0))
|
|
||||||
elif entry.get("last_key") is not None:
|
|
||||||
incomplete.append((name, entry.get("last_scanned", 0.0)))
|
|
||||||
else:
|
|
||||||
complete.append((name, entry.get("last_scanned", 0.0)))
|
|
||||||
|
|
||||||
incomplete.sort(key=lambda x: x[1])
|
|
||||||
complete.sort(key=lambda x: x[1])
|
|
||||||
|
|
||||||
return [n for n, _ in incomplete] + [n for n, _ in complete]
|
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
|
||||||
data = self.load()
|
|
||||||
buckets = data.get("buckets", {})
|
|
||||||
return {
|
|
||||||
"tracked_buckets": len(buckets),
|
|
||||||
"buckets": {
|
|
||||||
name: {
|
|
||||||
"last_scanned": info.get("last_scanned"),
|
|
||||||
"last_key": info.get("last_key"),
|
|
||||||
"completed": info.get("completed", False),
|
|
||||||
}
|
|
||||||
for name, info in buckets.items()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
MAX_ISSUES = 500
|
MAX_ISSUES = 500
|
||||||
|
|
||||||
|
|
||||||
@@ -287,7 +180,6 @@ class IntegrityChecker:
|
|||||||
auto_heal: bool = False,
|
auto_heal: bool = False,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
max_history: int = 50,
|
max_history: int = 50,
|
||||||
io_throttle_ms: int = 10,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.storage_root = Path(storage_root)
|
self.storage_root = Path(storage_root)
|
||||||
self.interval_seconds = interval_hours * 3600.0
|
self.interval_seconds = interval_hours * 3600.0
|
||||||
@@ -297,11 +189,7 @@ class IntegrityChecker:
|
|||||||
self._timer: Optional[threading.Timer] = None
|
self._timer: Optional[threading.Timer] = None
|
||||||
self._shutdown = False
|
self._shutdown = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._scanning = False
|
|
||||||
self._scan_start_time: Optional[float] = None
|
|
||||||
self._io_throttle = max(0, io_throttle_ms) / 1000.0
|
|
||||||
self.history_store = IntegrityHistoryStore(storage_root, max_records=max_history)
|
self.history_store = IntegrityHistoryStore(storage_root, max_records=max_history)
|
||||||
self.cursor_store = IntegrityCursorStore(self.storage_root)
|
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._timer is not None:
|
if self._timer is not None:
|
||||||
@@ -341,40 +229,24 @@ class IntegrityChecker:
|
|||||||
self._schedule_next()
|
self._schedule_next()
|
||||||
|
|
||||||
def run_now(self, auto_heal: Optional[bool] = None, dry_run: Optional[bool] = None) -> IntegrityResult:
|
def run_now(self, auto_heal: Optional[bool] = None, dry_run: Optional[bool] = None) -> IntegrityResult:
|
||||||
if not self._lock.acquire(blocking=False):
|
|
||||||
raise RuntimeError("Integrity scan is already in progress")
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._scanning = True
|
|
||||||
self._scan_start_time = time.time()
|
|
||||||
|
|
||||||
effective_auto_heal = auto_heal if auto_heal is not None else self.auto_heal
|
effective_auto_heal = auto_heal if auto_heal is not None else self.auto_heal
|
||||||
effective_dry_run = dry_run if dry_run is not None else self.dry_run
|
effective_dry_run = dry_run if dry_run is not None else self.dry_run
|
||||||
|
|
||||||
start = self._scan_start_time
|
start = time.time()
|
||||||
result = IntegrityResult()
|
result = IntegrityResult()
|
||||||
|
|
||||||
bucket_names = self._list_bucket_names()
|
bucket_names = self._list_bucket_names()
|
||||||
self.cursor_store.clean_stale(bucket_names)
|
|
||||||
ordered_buckets = self.cursor_store.get_bucket_order(bucket_names)
|
|
||||||
|
|
||||||
for bucket_name in ordered_buckets:
|
for bucket_name in bucket_names:
|
||||||
if self._batch_exhausted(result):
|
if result.objects_scanned >= self.batch_size:
|
||||||
break
|
break
|
||||||
result.buckets_scanned += 1
|
result.buckets_scanned += 1
|
||||||
cursor_key = self.cursor_store.get_last_key(bucket_name)
|
self._check_corrupted_objects(bucket_name, result, effective_auto_heal, effective_dry_run)
|
||||||
key_corrupted = self._check_corrupted_objects(bucket_name, result, effective_auto_heal, effective_dry_run, cursor_key)
|
self._check_orphaned_objects(bucket_name, result, effective_auto_heal, effective_dry_run)
|
||||||
key_orphaned = self._check_orphaned_objects(bucket_name, result, effective_auto_heal, effective_dry_run, cursor_key)
|
self._check_phantom_metadata(bucket_name, result, effective_auto_heal, effective_dry_run)
|
||||||
key_phantom = self._check_phantom_metadata(bucket_name, result, effective_auto_heal, effective_dry_run, cursor_key)
|
|
||||||
self._check_stale_versions(bucket_name, result, effective_auto_heal, effective_dry_run)
|
self._check_stale_versions(bucket_name, result, effective_auto_heal, effective_dry_run)
|
||||||
self._check_etag_cache(bucket_name, result, effective_auto_heal, effective_dry_run)
|
self._check_etag_cache(bucket_name, result, effective_auto_heal, effective_dry_run)
|
||||||
self._check_legacy_metadata(bucket_name, result, effective_auto_heal, effective_dry_run)
|
self._check_legacy_metadata(bucket_name, result, effective_auto_heal, effective_dry_run)
|
||||||
returned_keys = [k for k in (key_corrupted, key_orphaned, key_phantom) if k is not None]
|
|
||||||
bucket_exhausted = self._batch_exhausted(result)
|
|
||||||
if bucket_exhausted and returned_keys:
|
|
||||||
self.cursor_store.update_bucket(bucket_name, time.time(), last_key=min(returned_keys))
|
|
||||||
else:
|
|
||||||
self.cursor_store.update_bucket(bucket_name, time.time(), completed=True)
|
|
||||||
|
|
||||||
result.execution_time_seconds = time.time() - start
|
result.execution_time_seconds = time.time() - start
|
||||||
|
|
||||||
@@ -403,17 +275,6 @@ class IntegrityChecker:
|
|||||||
self.history_store.add(record)
|
self.history_store.add(record)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
finally:
|
|
||||||
self._scanning = False
|
|
||||||
self._scan_start_time = None
|
|
||||||
self._lock.release()
|
|
||||||
|
|
||||||
def run_async(self, auto_heal: Optional[bool] = None, dry_run: Optional[bool] = None) -> bool:
|
|
||||||
if self._scanning:
|
|
||||||
return False
|
|
||||||
t = threading.Thread(target=self.run_now, args=(auto_heal, dry_run), daemon=True)
|
|
||||||
t.start()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _system_path(self) -> Path:
|
def _system_path(self) -> Path:
|
||||||
return self.storage_root / self.SYSTEM_ROOT
|
return self.storage_root / self.SYSTEM_ROOT
|
||||||
@@ -428,121 +289,45 @@ class IntegrityChecker:
|
|||||||
pass
|
pass
|
||||||
return names
|
return names
|
||||||
|
|
||||||
def _throttle(self) -> bool:
|
|
||||||
if self._shutdown:
|
|
||||||
return True
|
|
||||||
if self._io_throttle > 0:
|
|
||||||
time.sleep(self._io_throttle)
|
|
||||||
return self._shutdown
|
|
||||||
|
|
||||||
def _batch_exhausted(self, result: IntegrityResult) -> bool:
|
|
||||||
return self._shutdown or result.objects_scanned >= self.batch_size
|
|
||||||
|
|
||||||
def _add_issue(self, result: IntegrityResult, issue: IntegrityIssue) -> None:
|
def _add_issue(self, result: IntegrityResult, issue: IntegrityIssue) -> None:
|
||||||
if len(result.issues) < MAX_ISSUES:
|
if len(result.issues) < MAX_ISSUES:
|
||||||
result.issues.append(issue)
|
result.issues.append(issue)
|
||||||
|
|
||||||
def _collect_index_keys(
|
def _check_corrupted_objects(
|
||||||
self, meta_root: Path, cursor_key: Optional[str] = None,
|
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool
|
||||||
) -> Dict[str, Dict[str, Any]]:
|
) -> None:
|
||||||
all_keys: Dict[str, Dict[str, Any]] = {}
|
bucket_path = self.storage_root / bucket_name
|
||||||
|
meta_root = self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / self.BUCKET_META_DIR
|
||||||
|
|
||||||
if not meta_root.exists():
|
if not meta_root.exists():
|
||||||
return all_keys
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for index_file in meta_root.rglob("_index.json"):
|
for index_file in meta_root.rglob("_index.json"):
|
||||||
|
if result.objects_scanned >= self.batch_size:
|
||||||
|
return
|
||||||
if not index_file.is_file():
|
if not index_file.is_file():
|
||||||
continue
|
continue
|
||||||
rel_dir = index_file.parent.relative_to(meta_root)
|
|
||||||
dir_prefix = "" if rel_dir == Path(".") else rel_dir.as_posix()
|
|
||||||
if cursor_key is not None and dir_prefix:
|
|
||||||
full_prefix = dir_prefix + "/"
|
|
||||||
if not cursor_key.startswith(full_prefix) and cursor_key > full_prefix:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
index_data = json.loads(index_file.read_text(encoding="utf-8"))
|
index_data = json.loads(index_file.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
continue
|
continue
|
||||||
for key_name, entry in index_data.items():
|
|
||||||
full_key = (dir_prefix + "/" + key_name) if dir_prefix else key_name
|
|
||||||
if cursor_key is not None and full_key <= cursor_key:
|
|
||||||
continue
|
|
||||||
all_keys[full_key] = {
|
|
||||||
"entry": entry,
|
|
||||||
"index_file": index_file,
|
|
||||||
"key_name": key_name,
|
|
||||||
}
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return all_keys
|
|
||||||
|
|
||||||
def _walk_bucket_files_sorted(
|
for key_name, entry in list(index_data.items()):
|
||||||
self, bucket_path: Path, cursor_key: Optional[str] = None,
|
if result.objects_scanned >= self.batch_size:
|
||||||
):
|
|
||||||
def _walk(dir_path: Path, prefix: str):
|
|
||||||
try:
|
|
||||||
entries = list(os.scandir(dir_path))
|
|
||||||
except OSError:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
def _sort_key(e):
|
rel_dir = index_file.parent.relative_to(meta_root)
|
||||||
if e.is_dir(follow_symlinks=False):
|
if rel_dir == Path("."):
|
||||||
return e.name + "/"
|
full_key = key_name
|
||||||
return e.name
|
else:
|
||||||
|
full_key = rel_dir.as_posix() + "/" + key_name
|
||||||
entries.sort(key=_sort_key)
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
if entry.is_dir(follow_symlinks=False):
|
|
||||||
if not prefix and entry.name in self.INTERNAL_FOLDERS:
|
|
||||||
continue
|
|
||||||
new_prefix = (prefix + "/" + entry.name) if prefix else entry.name
|
|
||||||
if cursor_key is not None:
|
|
||||||
full_prefix = new_prefix + "/"
|
|
||||||
if not cursor_key.startswith(full_prefix) and cursor_key > full_prefix:
|
|
||||||
continue
|
|
||||||
yield from _walk(Path(entry.path), new_prefix)
|
|
||||||
elif entry.is_file(follow_symlinks=False):
|
|
||||||
full_key = (prefix + "/" + entry.name) if prefix else entry.name
|
|
||||||
if cursor_key is not None and full_key <= cursor_key:
|
|
||||||
continue
|
|
||||||
yield full_key
|
|
||||||
|
|
||||||
yield from _walk(bucket_path, "")
|
|
||||||
|
|
||||||
def _check_corrupted_objects(
|
|
||||||
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool,
|
|
||||||
cursor_key: Optional[str] = None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return None
|
|
||||||
bucket_path = self.storage_root / bucket_name
|
|
||||||
meta_root = self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / self.BUCKET_META_DIR
|
|
||||||
|
|
||||||
if not meta_root.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
last_key = None
|
|
||||||
try:
|
|
||||||
all_keys = self._collect_index_keys(meta_root, cursor_key)
|
|
||||||
sorted_keys = sorted(all_keys.keys())
|
|
||||||
|
|
||||||
for full_key in sorted_keys:
|
|
||||||
if self._throttle():
|
|
||||||
return last_key
|
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return last_key
|
|
||||||
|
|
||||||
info = all_keys[full_key]
|
|
||||||
entry = info["entry"]
|
|
||||||
index_file = info["index_file"]
|
|
||||||
key_name = info["key_name"]
|
|
||||||
|
|
||||||
object_path = bucket_path / full_key
|
object_path = bucket_path / full_key
|
||||||
if not object_path.exists():
|
if not object_path.exists():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result.objects_scanned += 1
|
result.objects_scanned += 1
|
||||||
last_key = full_key
|
|
||||||
|
|
||||||
meta = entry.get("metadata", {}) if isinstance(entry, dict) else {}
|
meta = entry.get("metadata", {}) if isinstance(entry, dict) else {}
|
||||||
stored_etag = meta.get("__etag__")
|
stored_etag = meta.get("__etag__")
|
||||||
@@ -569,10 +354,6 @@ class IntegrityChecker:
|
|||||||
meta["__etag__"] = actual_etag
|
meta["__etag__"] = actual_etag
|
||||||
meta["__size__"] = str(stat.st_size)
|
meta["__size__"] = str(stat.st_size)
|
||||||
meta["__last_modified__"] = str(stat.st_mtime)
|
meta["__last_modified__"] = str(stat.st_mtime)
|
||||||
try:
|
|
||||||
index_data = json.loads(index_file.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
index_data = {}
|
|
||||||
index_data[key_name] = {"metadata": meta}
|
index_data[key_name] = {"metadata": meta}
|
||||||
self._atomic_write_index(index_file, index_data)
|
self._atomic_write_index(index_file, index_data)
|
||||||
issue.healed = True
|
issue.healed = True
|
||||||
@@ -584,30 +365,29 @@ class IntegrityChecker:
|
|||||||
self._add_issue(result, issue)
|
self._add_issue(result, issue)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
result.errors.append(f"check corrupted {bucket_name}: {e}")
|
result.errors.append(f"check corrupted {bucket_name}: {e}")
|
||||||
return last_key
|
|
||||||
|
|
||||||
def _check_orphaned_objects(
|
def _check_orphaned_objects(
|
||||||
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool,
|
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool
|
||||||
cursor_key: Optional[str] = None,
|
) -> None:
|
||||||
) -> Optional[str]:
|
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return None
|
|
||||||
bucket_path = self.storage_root / bucket_name
|
bucket_path = self.storage_root / bucket_name
|
||||||
meta_root = self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / self.BUCKET_META_DIR
|
meta_root = self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / self.BUCKET_META_DIR
|
||||||
|
|
||||||
last_key = None
|
|
||||||
try:
|
try:
|
||||||
for full_key in self._walk_bucket_files_sorted(bucket_path, cursor_key):
|
for entry in bucket_path.rglob("*"):
|
||||||
if self._throttle():
|
if result.objects_scanned >= self.batch_size:
|
||||||
return last_key
|
return
|
||||||
if self._batch_exhausted(result):
|
if not entry.is_file():
|
||||||
return last_key
|
continue
|
||||||
|
try:
|
||||||
|
rel = entry.relative_to(bucket_path)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if rel.parts and rel.parts[0] in self.INTERNAL_FOLDERS:
|
||||||
|
continue
|
||||||
|
|
||||||
result.objects_scanned += 1
|
full_key = rel.as_posix()
|
||||||
last_key = full_key
|
key_name = rel.name
|
||||||
key_path = Path(full_key)
|
parent = rel.parent
|
||||||
key_name = key_path.name
|
|
||||||
parent = key_path.parent
|
|
||||||
|
|
||||||
if parent == Path("."):
|
if parent == Path("."):
|
||||||
index_path = meta_root / "_index.json"
|
index_path = meta_root / "_index.json"
|
||||||
@@ -633,9 +413,8 @@ class IntegrityChecker:
|
|||||||
|
|
||||||
if auto_heal and not dry_run:
|
if auto_heal and not dry_run:
|
||||||
try:
|
try:
|
||||||
object_path = bucket_path / full_key
|
etag = _compute_etag(entry)
|
||||||
etag = _compute_etag(object_path)
|
stat = entry.stat()
|
||||||
stat = object_path.stat()
|
|
||||||
meta = {
|
meta = {
|
||||||
"__etag__": etag,
|
"__etag__": etag,
|
||||||
"__size__": str(stat.st_size),
|
"__size__": str(stat.st_size),
|
||||||
@@ -658,38 +437,36 @@ class IntegrityChecker:
|
|||||||
self._add_issue(result, issue)
|
self._add_issue(result, issue)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
result.errors.append(f"check orphaned {bucket_name}: {e}")
|
result.errors.append(f"check orphaned {bucket_name}: {e}")
|
||||||
return last_key
|
|
||||||
|
|
||||||
def _check_phantom_metadata(
|
def _check_phantom_metadata(
|
||||||
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool,
|
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool
|
||||||
cursor_key: Optional[str] = None,
|
) -> None:
|
||||||
) -> Optional[str]:
|
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return None
|
|
||||||
bucket_path = self.storage_root / bucket_name
|
bucket_path = self.storage_root / bucket_name
|
||||||
meta_root = self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / self.BUCKET_META_DIR
|
meta_root = self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / self.BUCKET_META_DIR
|
||||||
|
|
||||||
if not meta_root.exists():
|
if not meta_root.exists():
|
||||||
return None
|
return
|
||||||
|
|
||||||
last_key = None
|
|
||||||
try:
|
try:
|
||||||
all_keys = self._collect_index_keys(meta_root, cursor_key)
|
for index_file in meta_root.rglob("_index.json"):
|
||||||
sorted_keys = sorted(all_keys.keys())
|
if not index_file.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
index_data = json.loads(index_file.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
heal_by_index: Dict[Path, List[str]] = {}
|
keys_to_remove = []
|
||||||
|
for key_name in list(index_data.keys()):
|
||||||
for full_key in sorted_keys:
|
rel_dir = index_file.parent.relative_to(meta_root)
|
||||||
if self._batch_exhausted(result):
|
if rel_dir == Path("."):
|
||||||
break
|
full_key = key_name
|
||||||
|
else:
|
||||||
result.objects_scanned += 1
|
full_key = rel_dir.as_posix() + "/" + key_name
|
||||||
last_key = full_key
|
|
||||||
|
|
||||||
object_path = bucket_path / full_key
|
object_path = bucket_path / full_key
|
||||||
if not object_path.exists():
|
if not object_path.exists():
|
||||||
result.phantom_metadata += 1
|
result.phantom_metadata += 1
|
||||||
info = all_keys[full_key]
|
|
||||||
issue = IntegrityIssue(
|
issue = IntegrityIssue(
|
||||||
issue_type="phantom_metadata",
|
issue_type="phantom_metadata",
|
||||||
bucket=bucket_name,
|
bucket=bucket_name,
|
||||||
@@ -697,17 +474,14 @@ class IntegrityChecker:
|
|||||||
detail="metadata entry without file on disk",
|
detail="metadata entry without file on disk",
|
||||||
)
|
)
|
||||||
if auto_heal and not dry_run:
|
if auto_heal and not dry_run:
|
||||||
index_file = info["index_file"]
|
keys_to_remove.append(key_name)
|
||||||
heal_by_index.setdefault(index_file, []).append(info["key_name"])
|
|
||||||
issue.healed = True
|
issue.healed = True
|
||||||
issue.heal_action = "removed stale index entry"
|
issue.heal_action = "removed stale index entry"
|
||||||
result.issues_healed += 1
|
result.issues_healed += 1
|
||||||
self._add_issue(result, issue)
|
self._add_issue(result, issue)
|
||||||
|
|
||||||
if heal_by_index and auto_heal and not dry_run:
|
if keys_to_remove and auto_heal and not dry_run:
|
||||||
for index_file, keys_to_remove in heal_by_index.items():
|
|
||||||
try:
|
try:
|
||||||
index_data = json.loads(index_file.read_text(encoding="utf-8"))
|
|
||||||
for k in keys_to_remove:
|
for k in keys_to_remove:
|
||||||
index_data.pop(k, None)
|
index_data.pop(k, None)
|
||||||
if index_data:
|
if index_data:
|
||||||
@@ -718,13 +492,10 @@ class IntegrityChecker:
|
|||||||
result.errors.append(f"heal phantom {bucket_name}: {e}")
|
result.errors.append(f"heal phantom {bucket_name}: {e}")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
result.errors.append(f"check phantom {bucket_name}: {e}")
|
result.errors.append(f"check phantom {bucket_name}: {e}")
|
||||||
return last_key
|
|
||||||
|
|
||||||
def _check_stale_versions(
|
def _check_stale_versions(
|
||||||
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool
|
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return
|
|
||||||
versions_root = self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / self.BUCKET_VERSIONS_DIR
|
versions_root = self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / self.BUCKET_VERSIONS_DIR
|
||||||
|
|
||||||
if not versions_root.exists():
|
if not versions_root.exists():
|
||||||
@@ -732,10 +503,6 @@ class IntegrityChecker:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
for key_dir in versions_root.rglob("*"):
|
for key_dir in versions_root.rglob("*"):
|
||||||
if self._throttle():
|
|
||||||
return
|
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return
|
|
||||||
if not key_dir.is_dir():
|
if not key_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -743,9 +510,6 @@ class IntegrityChecker:
|
|||||||
json_files = {f.stem: f for f in key_dir.glob("*.json")}
|
json_files = {f.stem: f for f in key_dir.glob("*.json")}
|
||||||
|
|
||||||
for stem, bin_file in bin_files.items():
|
for stem, bin_file in bin_files.items():
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return
|
|
||||||
result.objects_scanned += 1
|
|
||||||
if stem not in json_files:
|
if stem not in json_files:
|
||||||
result.stale_versions += 1
|
result.stale_versions += 1
|
||||||
issue = IntegrityIssue(
|
issue = IntegrityIssue(
|
||||||
@@ -765,9 +529,6 @@ class IntegrityChecker:
|
|||||||
self._add_issue(result, issue)
|
self._add_issue(result, issue)
|
||||||
|
|
||||||
for stem, json_file in json_files.items():
|
for stem, json_file in json_files.items():
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return
|
|
||||||
result.objects_scanned += 1
|
|
||||||
if stem not in bin_files:
|
if stem not in bin_files:
|
||||||
result.stale_versions += 1
|
result.stale_versions += 1
|
||||||
issue = IntegrityIssue(
|
issue = IntegrityIssue(
|
||||||
@@ -791,8 +552,6 @@ class IntegrityChecker:
|
|||||||
def _check_etag_cache(
|
def _check_etag_cache(
|
||||||
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool
|
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return
|
|
||||||
etag_index_path = self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / "etag_index.json"
|
etag_index_path = self._system_path() / self.SYSTEM_BUCKETS_DIR / bucket_name / "etag_index.json"
|
||||||
|
|
||||||
if not etag_index_path.exists():
|
if not etag_index_path.exists():
|
||||||
@@ -810,9 +569,6 @@ class IntegrityChecker:
|
|||||||
found_mismatch = False
|
found_mismatch = False
|
||||||
|
|
||||||
for full_key, cached_etag in etag_cache.items():
|
for full_key, cached_etag in etag_cache.items():
|
||||||
if self._batch_exhausted(result):
|
|
||||||
break
|
|
||||||
result.objects_scanned += 1
|
|
||||||
key_path = Path(full_key)
|
key_path = Path(full_key)
|
||||||
key_name = key_path.name
|
key_name = key_path.name
|
||||||
parent = key_path.parent
|
parent = key_path.parent
|
||||||
@@ -862,8 +618,6 @@ class IntegrityChecker:
|
|||||||
def _check_legacy_metadata(
|
def _check_legacy_metadata(
|
||||||
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool
|
self, bucket_name: str, result: IntegrityResult, auto_heal: bool, dry_run: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return
|
|
||||||
legacy_meta_root = self.storage_root / bucket_name / ".meta"
|
legacy_meta_root = self.storage_root / bucket_name / ".meta"
|
||||||
if not legacy_meta_root.exists():
|
if not legacy_meta_root.exists():
|
||||||
return
|
return
|
||||||
@@ -872,14 +626,9 @@ class IntegrityChecker:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
for meta_file in legacy_meta_root.rglob("*.meta.json"):
|
for meta_file in legacy_meta_root.rglob("*.meta.json"):
|
||||||
if self._throttle():
|
|
||||||
return
|
|
||||||
if self._batch_exhausted(result):
|
|
||||||
return
|
|
||||||
if not meta_file.is_file():
|
if not meta_file.is_file():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result.objects_scanned += 1
|
|
||||||
try:
|
try:
|
||||||
rel = meta_file.relative_to(legacy_meta_root)
|
rel = meta_file.relative_to(legacy_meta_root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -979,17 +728,11 @@ class IntegrityChecker:
|
|||||||
return [r.to_dict() for r in records]
|
return [r.to_dict() for r in records]
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
status: Dict[str, Any] = {
|
return {
|
||||||
"enabled": not self._shutdown or self._timer is not None,
|
"enabled": not self._shutdown or self._timer is not None,
|
||||||
"running": self._timer is not None and not self._shutdown,
|
"running": self._timer is not None and not self._shutdown,
|
||||||
"scanning": self._scanning,
|
|
||||||
"interval_hours": self.interval_seconds / 3600.0,
|
"interval_hours": self.interval_seconds / 3600.0,
|
||||||
"batch_size": self.batch_size,
|
"batch_size": self.batch_size,
|
||||||
"auto_heal": self.auto_heal,
|
"auto_heal": self.auto_heal,
|
||||||
"dry_run": self.dry_run,
|
"dry_run": self.dry_run,
|
||||||
"io_throttle_ms": round(self._io_throttle * 1000),
|
|
||||||
}
|
}
|
||||||
if self._scanning and self._scan_start_time is not None:
|
|
||||||
status["scan_elapsed_seconds"] = round(time.time() - self._scan_start_time, 1)
|
|
||||||
status["cursor"] = self.cursor_store.get_info()
|
|
||||||
return status
|
|
||||||
@@ -19,10 +19,6 @@ from defusedxml.ElementTree import fromstring
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import myfsio_core as _rc
|
import myfsio_core as _rc
|
||||||
if not all(hasattr(_rc, f) for f in (
|
|
||||||
"verify_sigv4_signature", "derive_signing_key", "clear_signing_key_cache",
|
|
||||||
)):
|
|
||||||
raise ImportError("myfsio_core is outdated, rebuild with: cd myfsio_core && maturin develop --release")
|
|
||||||
_HAS_RUST = True
|
_HAS_RUST = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_rc = None
|
_rc = None
|
||||||
@@ -205,11 +201,6 @@ _SIGNING_KEY_CACHE_LOCK = threading.Lock()
|
|||||||
_SIGNING_KEY_CACHE_TTL = 60.0
|
_SIGNING_KEY_CACHE_TTL = 60.0
|
||||||
_SIGNING_KEY_CACHE_MAX_SIZE = 256
|
_SIGNING_KEY_CACHE_MAX_SIZE = 256
|
||||||
|
|
||||||
_SIGV4_HEADER_RE = re.compile(
|
|
||||||
r"AWS4-HMAC-SHA256 Credential=([^/]+)/([^/]+)/([^/]+)/([^/]+)/aws4_request, SignedHeaders=([^,]+), Signature=(.+)"
|
|
||||||
)
|
|
||||||
_SIGV4_REQUIRED_HEADERS = frozenset({'host', 'x-amz-date'})
|
|
||||||
|
|
||||||
|
|
||||||
def clear_signing_key_cache() -> None:
|
def clear_signing_key_cache() -> None:
|
||||||
if _HAS_RUST:
|
if _HAS_RUST:
|
||||||
@@ -268,7 +259,10 @@ def _get_canonical_uri(req: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
||||||
match = _SIGV4_HEADER_RE.match(auth_header)
|
match = re.match(
|
||||||
|
r"AWS4-HMAC-SHA256 Credential=([^/]+)/([^/]+)/([^/]+)/([^/]+)/aws4_request, SignedHeaders=([^,]+), Signature=(.+)",
|
||||||
|
auth_header,
|
||||||
|
)
|
||||||
if not match:
|
if not match:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -292,9 +286,14 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
if time_diff > tolerance:
|
if time_diff > tolerance:
|
||||||
raise IamError("Request timestamp too old or too far in the future")
|
raise IamError("Request timestamp too old or too far in the future")
|
||||||
|
|
||||||
|
required_headers = {'host', 'x-amz-date'}
|
||||||
signed_headers_set = set(signed_headers_str.split(';'))
|
signed_headers_set = set(signed_headers_str.split(';'))
|
||||||
if not _SIGV4_REQUIRED_HEADERS.issubset(signed_headers_set):
|
if not required_headers.issubset(signed_headers_set):
|
||||||
if not ({'host', 'date'}.issubset(signed_headers_set)):
|
if 'date' in signed_headers_set:
|
||||||
|
required_headers.remove('x-amz-date')
|
||||||
|
required_headers.add('date')
|
||||||
|
|
||||||
|
if not required_headers.issubset(signed_headers_set):
|
||||||
raise IamError("Required headers not signed")
|
raise IamError("Required headers not signed")
|
||||||
|
|
||||||
canonical_uri = _get_canonical_uri(req)
|
canonical_uri = _get_canonical_uri(req)
|
||||||
@@ -534,6 +533,21 @@ def _authorize_action(principal: Principal | None, bucket_name: str | None, acti
|
|||||||
raise iam_error or IamError("Access denied")
|
raise iam_error or IamError("Access denied")
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_bucket_policy(principal: Principal | None, bucket_name: str | None, object_key: str | None, action: str) -> None:
|
||||||
|
if not bucket_name:
|
||||||
|
return
|
||||||
|
policy_context = _build_policy_context()
|
||||||
|
decision = _bucket_policies().evaluate(
|
||||||
|
principal.access_key if principal else None,
|
||||||
|
bucket_name,
|
||||||
|
object_key,
|
||||||
|
action,
|
||||||
|
policy_context,
|
||||||
|
)
|
||||||
|
if decision == "deny":
|
||||||
|
raise IamError("Access denied by bucket policy")
|
||||||
|
|
||||||
|
|
||||||
def _object_principal(action: str, bucket_name: str, object_key: str):
|
def _object_principal(action: str, bucket_name: str, object_key: str):
|
||||||
principal, error = _require_principal()
|
principal, error = _require_principal()
|
||||||
try:
|
try:
|
||||||
@@ -542,7 +556,121 @@ def _object_principal(action: str, bucket_name: str, object_key: str):
|
|||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
if not error:
|
if not error:
|
||||||
return None, _error_response("AccessDenied", str(exc), 403)
|
return None, _error_response("AccessDenied", str(exc), 403)
|
||||||
|
if not _has_presign_params():
|
||||||
return None, error
|
return None, error
|
||||||
|
try:
|
||||||
|
principal = _validate_presigned_request(action, bucket_name, object_key)
|
||||||
|
_enforce_bucket_policy(principal, bucket_name, object_key, action)
|
||||||
|
return principal, None
|
||||||
|
except IamError as exc:
|
||||||
|
return None, _error_response("AccessDenied", str(exc), 403)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_presign_params() -> bool:
|
||||||
|
return bool(request.args.get("X-Amz-Algorithm"))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_presigned_request(action: str, bucket_name: str, object_key: str) -> Principal:
|
||||||
|
algorithm = request.args.get("X-Amz-Algorithm")
|
||||||
|
credential = request.args.get("X-Amz-Credential")
|
||||||
|
amz_date = request.args.get("X-Amz-Date")
|
||||||
|
signed_headers = request.args.get("X-Amz-SignedHeaders")
|
||||||
|
expires = request.args.get("X-Amz-Expires")
|
||||||
|
signature = request.args.get("X-Amz-Signature")
|
||||||
|
if not all([algorithm, credential, amz_date, signed_headers, expires, signature]):
|
||||||
|
raise IamError("Malformed presigned URL")
|
||||||
|
if algorithm != "AWS4-HMAC-SHA256":
|
||||||
|
raise IamError("Unsupported signing algorithm")
|
||||||
|
|
||||||
|
parts = credential.split("/")
|
||||||
|
if len(parts) != 5:
|
||||||
|
raise IamError("Invalid credential scope")
|
||||||
|
access_key, date_stamp, region, service, terminal = parts
|
||||||
|
if terminal != "aws4_request":
|
||||||
|
raise IamError("Invalid credential scope")
|
||||||
|
config_region = current_app.config["AWS_REGION"]
|
||||||
|
config_service = current_app.config["AWS_SERVICE"]
|
||||||
|
if region != config_region or service != config_service:
|
||||||
|
raise IamError("Credential scope mismatch")
|
||||||
|
|
||||||
|
try:
|
||||||
|
expiry = int(expires)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise IamError("Invalid expiration") from exc
|
||||||
|
min_expiry = current_app.config.get("PRESIGNED_URL_MIN_EXPIRY_SECONDS", 1)
|
||||||
|
max_expiry = current_app.config.get("PRESIGNED_URL_MAX_EXPIRY_SECONDS", 604800)
|
||||||
|
if expiry < min_expiry or expiry > max_expiry:
|
||||||
|
raise IamError(f"Expiration must be between {min_expiry} second(s) and {max_expiry} seconds")
|
||||||
|
|
||||||
|
try:
|
||||||
|
request_time = datetime.strptime(amz_date, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise IamError("Invalid X-Amz-Date") from exc
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
tolerance = timedelta(seconds=current_app.config.get("SIGV4_TIMESTAMP_TOLERANCE_SECONDS", 900))
|
||||||
|
if request_time > now + tolerance:
|
||||||
|
raise IamError("Request date is too far in the future")
|
||||||
|
if now > request_time + timedelta(seconds=expiry):
|
||||||
|
raise IamError("Presigned URL expired")
|
||||||
|
|
||||||
|
signed_headers_list = [header.strip().lower() for header in signed_headers.split(";") if header]
|
||||||
|
signed_headers_list.sort()
|
||||||
|
canonical_headers = _canonical_headers_from_request(signed_headers_list)
|
||||||
|
canonical_query = _canonical_query_from_request()
|
||||||
|
payload_hash = request.args.get("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD")
|
||||||
|
canonical_request = "\n".join(
|
||||||
|
[
|
||||||
|
request.method,
|
||||||
|
_canonical_uri(bucket_name, object_key),
|
||||||
|
canonical_query,
|
||||||
|
canonical_headers,
|
||||||
|
";".join(signed_headers_list),
|
||||||
|
payload_hash,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
hashed_request = hashlib.sha256(canonical_request.encode()).hexdigest()
|
||||||
|
scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
||||||
|
string_to_sign = "\n".join([
|
||||||
|
"AWS4-HMAC-SHA256",
|
||||||
|
amz_date,
|
||||||
|
scope,
|
||||||
|
hashed_request,
|
||||||
|
])
|
||||||
|
secret = _iam().secret_for_key(access_key)
|
||||||
|
signing_key = _derive_signing_key(secret, date_stamp, region, service)
|
||||||
|
expected = hmac.new(signing_key, string_to_sign.encode(), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(expected, signature):
|
||||||
|
raise IamError("Signature mismatch")
|
||||||
|
return _iam().principal_for_key(access_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_query_from_request() -> str:
|
||||||
|
parts = []
|
||||||
|
for key in sorted(request.args.keys()):
|
||||||
|
if key == "X-Amz-Signature":
|
||||||
|
continue
|
||||||
|
values = request.args.getlist(key)
|
||||||
|
encoded_key = quote(str(key), safe="-_.~")
|
||||||
|
for value in sorted(values):
|
||||||
|
encoded_value = quote(str(value), safe="-_.~")
|
||||||
|
parts.append(f"{encoded_key}={encoded_value}")
|
||||||
|
return "&".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_headers_from_request(headers: list[str]) -> str:
|
||||||
|
lines = []
|
||||||
|
for header in headers:
|
||||||
|
if header == "host":
|
||||||
|
api_base = current_app.config.get("API_BASE_URL")
|
||||||
|
if api_base:
|
||||||
|
value = urlparse(api_base).netloc
|
||||||
|
else:
|
||||||
|
value = request.host
|
||||||
|
else:
|
||||||
|
value = request.headers.get(header, "")
|
||||||
|
canonical_value = " ".join(value.strip().split()) if value else ""
|
||||||
|
lines.append(f"{header}:{canonical_value}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def _canonical_uri(bucket_name: str, object_key: str | None) -> str:
|
def _canonical_uri(bucket_name: str, object_key: str | None) -> str:
|
||||||
@@ -608,8 +736,8 @@ def _generate_presigned_url(
|
|||||||
host = parsed.netloc
|
host = parsed.netloc
|
||||||
scheme = parsed.scheme
|
scheme = parsed.scheme
|
||||||
else:
|
else:
|
||||||
host = request.host
|
host = request.headers.get("X-Forwarded-Host", request.host)
|
||||||
scheme = request.scheme or "http"
|
scheme = request.headers.get("X-Forwarded-Proto", request.scheme or "http")
|
||||||
|
|
||||||
canonical_headers = f"host:{host}\n"
|
canonical_headers = f"host:{host}\n"
|
||||||
canonical_request = "\n".join(
|
canonical_request = "\n".join(
|
||||||
@@ -882,7 +1010,7 @@ def _render_encryption_document(config: dict[str, Any]) -> Element:
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
def _stream_file(path, chunk_size: int = 1024 * 1024):
|
def _stream_file(path, chunk_size: int = 256 * 1024):
|
||||||
with path.open("rb") as handle:
|
with path.open("rb") as handle:
|
||||||
while True:
|
while True:
|
||||||
chunk = handle.read(chunk_size)
|
chunk = handle.read(chunk_size)
|
||||||
@@ -2833,12 +2961,9 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
is_encrypted = "x-amz-server-side-encryption" in metadata
|
is_encrypted = "x-amz-server-side-encryption" in metadata
|
||||||
|
|
||||||
cond_etag = metadata.get("__etag__")
|
cond_etag = metadata.get("__etag__")
|
||||||
_etag_was_healed = False
|
|
||||||
if not cond_etag and not is_encrypted:
|
if not cond_etag and not is_encrypted:
|
||||||
try:
|
try:
|
||||||
cond_etag = storage._compute_etag(path)
|
cond_etag = storage._compute_etag(path)
|
||||||
_etag_was_healed = True
|
|
||||||
storage.heal_missing_etag(bucket_name, object_key, cond_etag)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
cond_etag = None
|
cond_etag = None
|
||||||
if cond_etag:
|
if cond_etag:
|
||||||
@@ -2884,7 +3009,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
file_size = stat.st_size
|
file_size = stat.st_size
|
||||||
etag = cond_etag or storage._compute_etag(path)
|
etag = metadata.get("__etag__") or storage._compute_etag(path)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -2932,7 +3057,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
response = Response(status=200)
|
response = Response(status=200)
|
||||||
etag = cond_etag or storage._compute_etag(path)
|
etag = metadata.get("__etag__") or storage._compute_etag(path)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -3317,13 +3442,9 @@ def head_object(bucket_name: str, object_key: str) -> Response:
|
|||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "read", object_key=object_key)
|
_authorize_action(principal, bucket_name, "read", object_key=object_key)
|
||||||
storage = _storage()
|
path = _storage().get_object_path(bucket_name, object_key)
|
||||||
path = storage.get_object_path(bucket_name, object_key)
|
metadata = _storage().get_object_metadata(bucket_name, object_key)
|
||||||
metadata = storage.get_object_metadata(bucket_name, object_key)
|
etag = metadata.get("__etag__") or _storage()._compute_etag(path)
|
||||||
etag = metadata.get("__etag__")
|
|
||||||
if not etag:
|
|
||||||
etag = storage._compute_etag(path)
|
|
||||||
storage.heal_missing_etag(bucket_name, object_key, etag)
|
|
||||||
|
|
||||||
head_mtime = float(metadata["__last_modified__"]) if "__last_modified__" in metadata else None
|
head_mtime = float(metadata["__last_modified__"]) if "__last_modified__" in metadata else None
|
||||||
if head_mtime is None:
|
if head_mtime is None:
|
||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -21,21 +20,12 @@ from typing import Any, BinaryIO, Dict, Generator, List, Optional
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import myfsio_core as _rc
|
import myfsio_core as _rc
|
||||||
if not all(hasattr(_rc, f) for f in (
|
|
||||||
"validate_bucket_name", "validate_object_key", "md5_file",
|
|
||||||
"shallow_scan", "bucket_stats_scan", "search_objects_scan",
|
|
||||||
"stream_to_file_with_md5", "assemble_parts_with_md5",
|
|
||||||
"build_object_cache", "read_index_entry", "write_index_entry",
|
|
||||||
"delete_index_entry", "check_bucket_contents",
|
|
||||||
)):
|
|
||||||
raise ImportError("myfsio_core is outdated, rebuild with: cd myfsio_core && maturin develop --release")
|
|
||||||
_HAS_RUST = True
|
_HAS_RUST = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_rc = None
|
_rc = None
|
||||||
_HAS_RUST = False
|
_HAS_RUST = False
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
# Platform-specific file locking
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
import msvcrt
|
import msvcrt
|
||||||
|
|
||||||
@@ -200,7 +190,6 @@ class ObjectStorage:
|
|||||||
object_cache_max_size: int = 100,
|
object_cache_max_size: int = 100,
|
||||||
bucket_config_cache_ttl: float = 30.0,
|
bucket_config_cache_ttl: float = 30.0,
|
||||||
object_key_max_length_bytes: int = 1024,
|
object_key_max_length_bytes: int = 1024,
|
||||||
meta_read_cache_max: int = 2048,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.root = Path(root)
|
self.root = Path(root)
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -219,7 +208,7 @@ class ObjectStorage:
|
|||||||
self._sorted_key_cache: Dict[str, tuple[list[str], int]] = {}
|
self._sorted_key_cache: Dict[str, tuple[list[str], int]] = {}
|
||||||
self._meta_index_locks: Dict[str, threading.Lock] = {}
|
self._meta_index_locks: Dict[str, threading.Lock] = {}
|
||||||
self._meta_read_cache: OrderedDict[tuple, Optional[Dict[str, Any]]] = OrderedDict()
|
self._meta_read_cache: OrderedDict[tuple, Optional[Dict[str, Any]]] = OrderedDict()
|
||||||
self._meta_read_cache_max = meta_read_cache_max
|
self._meta_read_cache_max = 2048
|
||||||
self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ParentCleanup")
|
self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ParentCleanup")
|
||||||
self._stats_mem: Dict[str, Dict[str, int]] = {}
|
self._stats_mem: Dict[str, Dict[str, int]] = {}
|
||||||
self._stats_serial: Dict[str, int] = {}
|
self._stats_serial: Dict[str, int] = {}
|
||||||
@@ -229,7 +218,6 @@ class ObjectStorage:
|
|||||||
self._stats_flush_timer: Optional[threading.Timer] = None
|
self._stats_flush_timer: Optional[threading.Timer] = None
|
||||||
self._etag_index_dirty: set[str] = set()
|
self._etag_index_dirty: set[str] = set()
|
||||||
self._etag_index_flush_timer: Optional[threading.Timer] = None
|
self._etag_index_flush_timer: Optional[threading.Timer] = None
|
||||||
self._etag_index_mem: Dict[str, tuple[Dict[str, str], float]] = {}
|
|
||||||
|
|
||||||
def _get_bucket_lock(self, bucket_id: str) -> threading.Lock:
|
def _get_bucket_lock(self, bucket_id: str) -> threading.Lock:
|
||||||
with self._registry_lock:
|
with self._registry_lock:
|
||||||
@@ -439,7 +427,7 @@ class ObjectStorage:
|
|||||||
cache_path = self._system_bucket_root(bucket_id) / "stats.json"
|
cache_path = self._system_bucket_root(bucket_id) / "stats.json"
|
||||||
try:
|
try:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._atomic_write_json(cache_path, data, sync=False)
|
self._atomic_write_json(cache_path, data)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -614,7 +602,14 @@ class ObjectStorage:
|
|||||||
is_truncated=False, next_continuation_token=None,
|
is_truncated=False, next_continuation_token=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
meta_cache: Dict[str, str] = self._get_etag_index(bucket_id)
|
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
||||||
|
meta_cache: Dict[str, str] = {}
|
||||||
|
if etag_index_path.exists():
|
||||||
|
try:
|
||||||
|
with open(etag_index_path, 'r', encoding='utf-8') as f:
|
||||||
|
meta_cache = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
entries_files: list[tuple[str, int, float, Optional[str]]] = []
|
entries_files: list[tuple[str, int, float, Optional[str]]] = []
|
||||||
entries_dirs: list[str] = []
|
entries_dirs: list[str] = []
|
||||||
@@ -1084,30 +1079,6 @@ class ObjectStorage:
|
|||||||
safe_key = self._sanitize_object_key(object_key, self._object_key_max_length_bytes)
|
safe_key = self._sanitize_object_key(object_key, self._object_key_max_length_bytes)
|
||||||
return self._read_metadata(bucket_path.name, safe_key) or {}
|
return self._read_metadata(bucket_path.name, safe_key) or {}
|
||||||
|
|
||||||
def heal_missing_etag(self, bucket_name: str, object_key: str, etag: str) -> None:
|
|
||||||
"""Persist a computed ETag back to metadata (self-heal on read)."""
|
|
||||||
try:
|
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
|
||||||
if not bucket_path.exists():
|
|
||||||
return
|
|
||||||
bucket_id = bucket_path.name
|
|
||||||
safe_key = self._sanitize_object_key(object_key, self._object_key_max_length_bytes)
|
|
||||||
existing = self._read_metadata(bucket_id, safe_key) or {}
|
|
||||||
if existing.get("__etag__"):
|
|
||||||
return
|
|
||||||
existing["__etag__"] = etag
|
|
||||||
self._write_metadata(bucket_id, safe_key, existing)
|
|
||||||
with self._obj_cache_lock:
|
|
||||||
cached = self._object_cache.get(bucket_id)
|
|
||||||
if cached:
|
|
||||||
obj = cached[0].get(safe_key.as_posix())
|
|
||||||
if obj and not obj.etag:
|
|
||||||
obj.etag = etag
|
|
||||||
self._etag_index_dirty.add(bucket_id)
|
|
||||||
self._schedule_etag_index_flush()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to heal missing ETag for %s/%s", bucket_name, object_key)
|
|
||||||
|
|
||||||
def _cleanup_empty_parents(self, path: Path, stop_at: Path) -> None:
|
def _cleanup_empty_parents(self, path: Path, stop_at: Path) -> None:
|
||||||
"""Remove empty parent directories in a background thread.
|
"""Remove empty parent directories in a background thread.
|
||||||
|
|
||||||
@@ -2117,7 +2088,6 @@ class ObjectStorage:
|
|||||||
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(raw["etag_cache"], f)
|
json.dump(raw["etag_cache"], f)
|
||||||
self._etag_index_mem[bucket_id] = (dict(raw["etag_cache"]), etag_index_path.stat().st_mtime)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
for key, size, mtime, etag in raw["objects"]:
|
for key, size, mtime, etag in raw["objects"]:
|
||||||
@@ -2241,7 +2211,6 @@ class ObjectStorage:
|
|||||||
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(meta_cache, f)
|
json.dump(meta_cache, f)
|
||||||
self._etag_index_mem[bucket_id] = (dict(meta_cache), etag_index_path.stat().st_mtime)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -2355,25 +2324,6 @@ class ObjectStorage:
|
|||||||
self._etag_index_dirty.add(bucket_id)
|
self._etag_index_dirty.add(bucket_id)
|
||||||
self._schedule_etag_index_flush()
|
self._schedule_etag_index_flush()
|
||||||
|
|
||||||
def _get_etag_index(self, bucket_id: str) -> Dict[str, str]:
|
|
||||||
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
|
||||||
try:
|
|
||||||
current_mtime = etag_index_path.stat().st_mtime
|
|
||||||
except OSError:
|
|
||||||
return {}
|
|
||||||
cached = self._etag_index_mem.get(bucket_id)
|
|
||||||
if cached:
|
|
||||||
cache_dict, cached_mtime = cached
|
|
||||||
if current_mtime == cached_mtime:
|
|
||||||
return cache_dict
|
|
||||||
try:
|
|
||||||
with open(etag_index_path, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
self._etag_index_mem[bucket_id] = (data, current_mtime)
|
|
||||||
return data
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _schedule_etag_index_flush(self) -> None:
|
def _schedule_etag_index_flush(self) -> None:
|
||||||
if self._etag_index_flush_timer is None or not self._etag_index_flush_timer.is_alive():
|
if self._etag_index_flush_timer is None or not self._etag_index_flush_timer.is_alive():
|
||||||
self._etag_index_flush_timer = threading.Timer(5.0, self._flush_etag_indexes)
|
self._etag_index_flush_timer = threading.Timer(5.0, self._flush_etag_indexes)
|
||||||
@@ -2392,10 +2342,11 @@ class ObjectStorage:
|
|||||||
index = {k: v.etag for k, v in objects.items() if v.etag}
|
index = {k: v.etag for k, v in objects.items() if v.etag}
|
||||||
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
||||||
try:
|
try:
|
||||||
self._atomic_write_json(etag_index_path, index, sync=False)
|
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._etag_index_mem[bucket_id] = (index, etag_index_path.stat().st_mtime)
|
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(index, f)
|
||||||
except OSError:
|
except OSError:
|
||||||
logger.warning("Failed to flush etag index for bucket %s", bucket_id)
|
pass
|
||||||
|
|
||||||
def warm_cache(self, bucket_names: Optional[List[str]] = None) -> None:
|
def warm_cache(self, bucket_names: Optional[List[str]] = None) -> None:
|
||||||
"""Pre-warm the object cache for specified buckets or all buckets.
|
"""Pre-warm the object cache for specified buckets or all buckets.
|
||||||
@@ -2437,13 +2388,12 @@ class ObjectStorage:
|
|||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _atomic_write_json(path: Path, data: Any, *, sync: bool = True) -> None:
|
def _atomic_write_json(path: Path, data: Any) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_path = path.with_suffix(".tmp")
|
tmp_path = path.with_suffix(".tmp")
|
||||||
try:
|
try:
|
||||||
with tmp_path.open("w", encoding="utf-8") as f:
|
with tmp_path.open("w", encoding="utf-8") as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
if sync:
|
|
||||||
f.flush()
|
f.flush()
|
||||||
os.fsync(f.fileno())
|
os.fsync(f.fileno())
|
||||||
tmp_path.replace(path)
|
tmp_path.replace(path)
|
||||||
@@ -225,10 +225,10 @@ def _policy_allows_public_read(policy: dict[str, Any]) -> bool:
|
|||||||
|
|
||||||
def _bucket_access_descriptor(policy: dict[str, Any] | None) -> tuple[str, str]:
|
def _bucket_access_descriptor(policy: dict[str, Any] | None) -> tuple[str, str]:
|
||||||
if not policy:
|
if not policy:
|
||||||
return ("IAM only", "bg-secondary-subtle text-secondary-emphasis")
|
return ("IAM only", "text-bg-secondary")
|
||||||
if _policy_allows_public_read(policy):
|
if _policy_allows_public_read(policy):
|
||||||
return ("Public read", "bg-warning-subtle text-warning-emphasis")
|
return ("Public read", "text-bg-warning")
|
||||||
return ("Custom policy", "bg-info-subtle text-info-emphasis")
|
return ("Custom policy", "text-bg-info")
|
||||||
|
|
||||||
|
|
||||||
def _current_principal():
|
def _current_principal():
|
||||||
@@ -1063,27 +1063,6 @@ def bulk_delete_objects(bucket_name: str):
|
|||||||
return _respond(False, f"A maximum of {MAX_KEYS} objects can be deleted per request", status_code=400)
|
return _respond(False, f"A maximum of {MAX_KEYS} objects can be deleted per request", status_code=400)
|
||||||
|
|
||||||
unique_keys = list(dict.fromkeys(cleaned))
|
unique_keys = list(dict.fromkeys(cleaned))
|
||||||
|
|
||||||
folder_prefixes = [k for k in unique_keys if k.endswith("/")]
|
|
||||||
if folder_prefixes:
|
|
||||||
try:
|
|
||||||
client = get_session_s3_client()
|
|
||||||
for prefix in folder_prefixes:
|
|
||||||
unique_keys.remove(prefix)
|
|
||||||
paginator = client.get_paginator("list_objects_v2")
|
|
||||||
for page in paginator.paginate(Bucket=bucket_name, Prefix=prefix):
|
|
||||||
for obj in page.get("Contents", []):
|
|
||||||
if obj["Key"] not in unique_keys:
|
|
||||||
unique_keys.append(obj["Key"])
|
|
||||||
except (ClientError, EndpointConnectionError, ConnectionClosedError) as exc:
|
|
||||||
if isinstance(exc, ClientError):
|
|
||||||
err, status = handle_client_error(exc)
|
|
||||||
return _respond(False, err["error"], status_code=status)
|
|
||||||
return _respond(False, "S3 API server is unreachable", status_code=502)
|
|
||||||
|
|
||||||
if not unique_keys:
|
|
||||||
return _respond(False, "No objects found under the selected folders", status_code=400)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "delete")
|
_authorize_ui(principal, bucket_name, "delete")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
@@ -1114,17 +1093,13 @@ def bulk_delete_objects(bucket_name: str):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
client = get_session_s3_client()
|
client = get_session_s3_client()
|
||||||
deleted = []
|
objects_to_delete = [{"Key": k} for k in unique_keys]
|
||||||
errors = []
|
|
||||||
for i in range(0, len(unique_keys), 1000):
|
|
||||||
batch = unique_keys[i:i + 1000]
|
|
||||||
objects_to_delete = [{"Key": k} for k in batch]
|
|
||||||
resp = client.delete_objects(
|
resp = client.delete_objects(
|
||||||
Bucket=bucket_name,
|
Bucket=bucket_name,
|
||||||
Delete={"Objects": objects_to_delete, "Quiet": False},
|
Delete={"Objects": objects_to_delete, "Quiet": False},
|
||||||
)
|
)
|
||||||
deleted.extend(d["Key"] for d in resp.get("Deleted", []))
|
deleted = [d["Key"] for d in resp.get("Deleted", [])]
|
||||||
errors.extend({"key": e["Key"], "error": e.get("Message", e.get("Code", "Unknown error"))} for e in resp.get("Errors", []))
|
errors = [{"key": e["Key"], "error": e.get("Message", e.get("Code", "Unknown error"))} for e in resp.get("Errors", [])]
|
||||||
for key in deleted:
|
for key in deleted:
|
||||||
_replication_manager().trigger_replication(bucket_name, key, action="delete")
|
_replication_manager().trigger_replication(bucket_name, key, action="delete")
|
||||||
except (ClientError, EndpointConnectionError, ConnectionClosedError) as exc:
|
except (ClientError, EndpointConnectionError, ConnectionClosedError) as exc:
|
||||||
@@ -4151,7 +4126,7 @@ def system_dashboard():
|
|||||||
r = rec.get("result", {})
|
r = rec.get("result", {})
|
||||||
total_freed = r.get("temp_bytes_freed", 0) + r.get("multipart_bytes_freed", 0) + r.get("orphaned_version_bytes_freed", 0)
|
total_freed = r.get("temp_bytes_freed", 0) + r.get("multipart_bytes_freed", 0) + r.get("orphaned_version_bytes_freed", 0)
|
||||||
rec["bytes_freed_display"] = _format_bytes(total_freed)
|
rec["bytes_freed_display"] = _format_bytes(total_freed)
|
||||||
rec["timestamp_display"] = _format_datetime_display(datetime.fromtimestamp(rec["timestamp"], tz=dt_timezone.utc))
|
rec["timestamp_display"] = datetime.fromtimestamp(rec["timestamp"], tz=dt_timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
gc_history_records.append(rec)
|
gc_history_records.append(rec)
|
||||||
|
|
||||||
checker = current_app.extensions.get("integrity")
|
checker = current_app.extensions.get("integrity")
|
||||||
@@ -4160,7 +4135,7 @@ def system_dashboard():
|
|||||||
if checker:
|
if checker:
|
||||||
raw = checker.get_history(limit=10, offset=0)
|
raw = checker.get_history(limit=10, offset=0)
|
||||||
for rec in raw:
|
for rec in raw:
|
||||||
rec["timestamp_display"] = _format_datetime_display(datetime.fromtimestamp(rec["timestamp"], tz=dt_timezone.utc))
|
rec["timestamp_display"] = datetime.fromtimestamp(rec["timestamp"], tz=dt_timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
integrity_history_records.append(rec)
|
integrity_history_records.append(rec)
|
||||||
|
|
||||||
features = [
|
features = [
|
||||||
@@ -4188,7 +4163,6 @@ def system_dashboard():
|
|||||||
gc_history=gc_history_records,
|
gc_history=gc_history_records,
|
||||||
integrity_status=integrity_status,
|
integrity_status=integrity_status,
|
||||||
integrity_history=integrity_history_records,
|
integrity_history=integrity_history_records,
|
||||||
display_timezone=current_app.config.get("DISPLAY_TIMEZONE", "UTC"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4205,43 +4179,14 @@ def system_gc_run():
|
|||||||
return jsonify({"error": "GC is not enabled"}), 400
|
return jsonify({"error": "GC is not enabled"}), 400
|
||||||
|
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
started = gc.run_async(dry_run=payload.get("dry_run"))
|
original_dry_run = gc.dry_run
|
||||||
if not started:
|
if "dry_run" in payload:
|
||||||
return jsonify({"error": "GC is already in progress"}), 409
|
gc.dry_run = bool(payload["dry_run"])
|
||||||
return jsonify({"status": "started"})
|
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.get("/system/gc/status")
|
|
||||||
def system_gc_status():
|
|
||||||
principal = _current_principal()
|
|
||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:*")
|
result = gc.run_now()
|
||||||
except IamError:
|
finally:
|
||||||
return jsonify({"error": "Access denied"}), 403
|
gc.dry_run = original_dry_run
|
||||||
|
return jsonify(result.to_dict())
|
||||||
gc = current_app.extensions.get("gc")
|
|
||||||
if not gc:
|
|
||||||
return jsonify({"error": "GC is not enabled"}), 400
|
|
||||||
|
|
||||||
return jsonify(gc.get_status())
|
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.get("/system/gc/history")
|
|
||||||
def system_gc_history():
|
|
||||||
principal = _current_principal()
|
|
||||||
try:
|
|
||||||
_iam().authorize(principal, None, "iam:*")
|
|
||||||
except IamError:
|
|
||||||
return jsonify({"error": "Access denied"}), 403
|
|
||||||
|
|
||||||
gc = current_app.extensions.get("gc")
|
|
||||||
if not gc:
|
|
||||||
return jsonify({"executions": []})
|
|
||||||
|
|
||||||
limit = min(int(request.args.get("limit", 10)), 200)
|
|
||||||
offset = int(request.args.get("offset", 0))
|
|
||||||
records = gc.get_history(limit=limit, offset=offset)
|
|
||||||
return jsonify({"executions": records})
|
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.post("/system/integrity/run")
|
@ui_bp.post("/system/integrity/run")
|
||||||
@@ -4257,46 +4202,11 @@ def system_integrity_run():
|
|||||||
return jsonify({"error": "Integrity checker is not enabled"}), 400
|
return jsonify({"error": "Integrity checker is not enabled"}), 400
|
||||||
|
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
started = checker.run_async(
|
result = checker.run_now(
|
||||||
auto_heal=payload.get("auto_heal"),
|
auto_heal=payload.get("auto_heal"),
|
||||||
dry_run=payload.get("dry_run"),
|
dry_run=payload.get("dry_run"),
|
||||||
)
|
)
|
||||||
if not started:
|
return jsonify(result.to_dict())
|
||||||
return jsonify({"error": "A scan is already in progress"}), 409
|
|
||||||
return jsonify({"status": "started"})
|
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.get("/system/integrity/status")
|
|
||||||
def system_integrity_status():
|
|
||||||
principal = _current_principal()
|
|
||||||
try:
|
|
||||||
_iam().authorize(principal, None, "iam:*")
|
|
||||||
except IamError:
|
|
||||||
return jsonify({"error": "Access denied"}), 403
|
|
||||||
|
|
||||||
checker = current_app.extensions.get("integrity")
|
|
||||||
if not checker:
|
|
||||||
return jsonify({"error": "Integrity checker is not enabled"}), 400
|
|
||||||
|
|
||||||
return jsonify(checker.get_status())
|
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.get("/system/integrity/history")
|
|
||||||
def system_integrity_history():
|
|
||||||
principal = _current_principal()
|
|
||||||
try:
|
|
||||||
_iam().authorize(principal, None, "iam:*")
|
|
||||||
except IamError:
|
|
||||||
return jsonify({"error": "Access denied"}), 403
|
|
||||||
|
|
||||||
checker = current_app.extensions.get("integrity")
|
|
||||||
if not checker:
|
|
||||||
return jsonify({"executions": []})
|
|
||||||
|
|
||||||
limit = min(int(request.args.get("limit", 10)), 200)
|
|
||||||
offset = int(request.args.get("offset", 0))
|
|
||||||
records = checker.get_history(limit=limit, offset=offset)
|
|
||||||
return jsonify({"executions": records})
|
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.app_errorhandler(404)
|
@ui_bp.app_errorhandler(404)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
APP_VERSION = "0.4.3"
|
APP_VERSION = "0.4.0"
|
||||||
|
|
||||||
|
|
||||||
def get_version() -> str:
|
def get_version() -> str:
|
||||||
5
docker-entrypoint.sh
Normal file
5
docker-entrypoint.sh
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Run both services using the python runner in production mode
|
||||||
|
exec python run.py --prod
|
||||||
@@ -125,7 +125,7 @@ pub fn delete_index_entry(py: Python<'_>, path: &str, entry_name: &str) -> PyRes
|
|||||||
fs::write(&path_owned, serialized)
|
fs::write(&path_owned, serialized)
|
||||||
.map_err(|e| PyIOError::new_err(format!("Failed to write index: {}", e)))?;
|
.map_err(|e| PyIOError::new_err(format!("Failed to write index: {}", e)))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Deprecated Python Implementation
|
|
||||||
|
|
||||||
The Python implementation of MyFSIO is deprecated as of 2026-04-21.
|
|
||||||
|
|
||||||
The supported server runtime now lives in `../rust/myfsio-engine` and serves the S3 API and web UI from the Rust `myfsio-server` binary. Keep this tree for migration reference, compatibility checks, and legacy tests only.
|
|
||||||
|
|
||||||
For normal development and operations, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ../rust/myfsio-engine
|
|
||||||
cargo run -p myfsio-server --
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not add new product features to the Python implementation unless they are needed to unblock a migration or compare behavior with the Rust server.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
exec python run.py --prod
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Flask>=3.1.3
|
Flask>=3.1.2
|
||||||
Flask-Limiter>=4.1.1
|
Flask-Limiter>=4.1.1
|
||||||
Flask-Cors>=6.0.2
|
Flask-Cors>=6.0.2
|
||||||
Flask-WTF>=1.2.2
|
Flask-WTF>=1.2.2
|
||||||
@@ -6,8 +6,8 @@ python-dotenv>=1.2.1
|
|||||||
pytest>=9.0.2
|
pytest>=9.0.2
|
||||||
requests>=2.32.5
|
requests>=2.32.5
|
||||||
boto3>=1.42.14
|
boto3>=1.42.14
|
||||||
granian>=2.7.2
|
granian>=2.2.0
|
||||||
psutil>=7.2.2
|
psutil>=7.1.3
|
||||||
cryptography>=46.0.5
|
cryptography>=46.0.3
|
||||||
defusedxml>=0.7.1
|
defusedxml>=0.7.1
|
||||||
duckdb>=1.5.1
|
duckdb>=1.4.4
|
||||||
@@ -26,12 +26,6 @@ from typing import Optional
|
|||||||
from app import create_api_app, create_ui_app
|
from app import create_api_app, create_ui_app
|
||||||
from app.config import AppConfig
|
from app.config import AppConfig
|
||||||
from app.iam import IamService, IamError, ALLOWED_ACTIONS, _derive_fernet_key
|
from app.iam import IamService, IamError, ALLOWED_ACTIONS, _derive_fernet_key
|
||||||
from app.version import get_version
|
|
||||||
|
|
||||||
PYTHON_DEPRECATION_MESSAGE = (
|
|
||||||
"The Python MyFSIO runtime is deprecated as of 2026-04-21. "
|
|
||||||
"Use the Rust server in rust/myfsio-engine for supported development and production usage."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _server_host() -> str:
|
def _server_host() -> str:
|
||||||
@@ -235,11 +229,8 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument("--check-config", action="store_true", help="Validate configuration and exit")
|
parser.add_argument("--check-config", action="store_true", help="Validate configuration and exit")
|
||||||
parser.add_argument("--show-config", action="store_true", help="Show configuration summary and exit")
|
parser.add_argument("--show-config", action="store_true", help="Show configuration summary and exit")
|
||||||
parser.add_argument("--reset-cred", action="store_true", help="Reset admin credentials and exit")
|
parser.add_argument("--reset-cred", action="store_true", help="Reset admin credentials and exit")
|
||||||
parser.add_argument("--version", action="version", version=f"MyFSIO {get_version()}")
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
warnings.warn(PYTHON_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=1)
|
|
||||||
|
|
||||||
if args.reset_cred or args.mode == "reset-cred":
|
if args.reset_cred or args.mode == "reset-cred":
|
||||||
reset_credentials()
|
reset_credentials()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
logs
|
|
||||||
data
|
|
||||||
tmp
|
|
||||||
myfsio-engine/target
|
|
||||||
myfsio-engine/tests
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
FROM rust:1-slim-bookworm AS builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY myfsio-engine ./myfsio-engine
|
|
||||||
|
|
||||||
RUN cd myfsio-engine \
|
|
||||||
&& cargo build --release --bin myfsio-server \
|
|
||||||
&& strip target/release/myfsio-server
|
|
||||||
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& mkdir -p /app/data \
|
|
||||||
&& useradd -m -u 1000 myfsio \
|
|
||||||
&& chown -R myfsio:myfsio /app
|
|
||||||
|
|
||||||
COPY --from=builder /build/myfsio-engine/target/release/myfsio-server /usr/local/bin/myfsio-server
|
|
||||||
COPY --from=builder /build/myfsio-engine/crates/myfsio-server/templates /app/templates
|
|
||||||
COPY --from=builder /build/myfsio-engine/crates/myfsio-server/static /app/static
|
|
||||||
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
|
||||||
|
|
||||||
RUN chmod +x /app/docker-entrypoint.sh \
|
|
||||||
&& chown -R myfsio:myfsio /app
|
|
||||||
|
|
||||||
USER myfsio
|
|
||||||
|
|
||||||
EXPOSE 5000
|
|
||||||
EXPOSE 5100
|
|
||||||
ENV HOST=0.0.0.0 \
|
|
||||||
PORT=5000 \
|
|
||||||
UI_PORT=5100 \
|
|
||||||
STORAGE_ROOT=/app/data \
|
|
||||||
TEMPLATES_DIR=/app/templates \
|
|
||||||
STATIC_DIR=/app/static \
|
|
||||||
RUST_LOG=info
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -fsS "http://localhost:${PORT}/myfsio/health" || exit 1
|
|
||||||
|
|
||||||
CMD ["/app/docker-entrypoint.sh"]
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
exec /usr/local/bin/myfsio-server
|
|
||||||
5223
rust/myfsio-engine/Cargo.lock
generated
5223
rust/myfsio-engine/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,61 +0,0 @@
|
|||||||
[workspace]
|
|
||||||
resolver = "2"
|
|
||||||
members = [
|
|
||||||
"crates/myfsio-common",
|
|
||||||
"crates/myfsio-auth",
|
|
||||||
"crates/myfsio-crypto",
|
|
||||||
"crates/myfsio-storage",
|
|
||||||
"crates/myfsio-xml",
|
|
||||||
"crates/myfsio-server",
|
|
||||||
]
|
|
||||||
|
|
||||||
[workspace.package]
|
|
||||||
version = "0.4.3"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
axum = { version = "0.8" }
|
|
||||||
tower = { version = "0.5" }
|
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-gzip"] }
|
|
||||||
hyper = { version = "1" }
|
|
||||||
bytes = "1"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
|
||||||
hmac = "0.12"
|
|
||||||
sha2 = "0.10"
|
|
||||||
md-5 = "0.10"
|
|
||||||
hex = "0.4"
|
|
||||||
aes = "0.8"
|
|
||||||
aes-gcm = "0.10"
|
|
||||||
cbc = { version = "0.1", features = ["alloc"] }
|
|
||||||
hkdf = "0.12"
|
|
||||||
uuid = { version = "1", features = ["v4"] }
|
|
||||||
parking_lot = "0.12"
|
|
||||||
lru = "0.14"
|
|
||||||
percent-encoding = "2"
|
|
||||||
regex = "1"
|
|
||||||
unicode-normalization = "0.1"
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = "0.3"
|
|
||||||
thiserror = "2"
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
base64 = "0.22"
|
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
|
||||||
futures = "0.3"
|
|
||||||
dashmap = "6"
|
|
||||||
crc32fast = "1"
|
|
||||||
duckdb = { version = "1", features = ["bundled"] }
|
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls", "json"] }
|
|
||||||
aws-sdk-s3 = { version = "1", features = ["behavior-version-latest", "rt-tokio"] }
|
|
||||||
aws-config = { version = "1", features = ["behavior-version-latest"] }
|
|
||||||
aws-credential-types = "1"
|
|
||||||
aws-smithy-runtime-api = "1"
|
|
||||||
aws-smithy-types = "1"
|
|
||||||
async-trait = "0.1"
|
|
||||||
tera = "1"
|
|
||||||
cookie = "0.18"
|
|
||||||
subtle = "2"
|
|
||||||
clap = { version = "4", features = ["derive"] }
|
|
||||||
dotenvy = "0.15"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "myfsio-auth"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
myfsio-common = { path = "../myfsio-common" }
|
|
||||||
hmac = { workspace = true }
|
|
||||||
sha2 = { workspace = true }
|
|
||||||
hex = { workspace = true }
|
|
||||||
aes = { workspace = true }
|
|
||||||
cbc = { workspace = true }
|
|
||||||
base64 = { workspace = true }
|
|
||||||
pbkdf2 = "0.12"
|
|
||||||
rand = "0.8"
|
|
||||||
lru = { workspace = true }
|
|
||||||
parking_lot = { workspace = true }
|
|
||||||
percent-encoding = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
|
|
||||||
use base64::{engine::general_purpose::URL_SAFE, Engine};
|
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
use rand::RngCore;
|
|
||||||
use sha2::Sha256;
|
|
||||||
|
|
||||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
|
||||||
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
pub fn derive_fernet_key(secret: &str) -> String {
|
|
||||||
let mut derived = [0u8; 32];
|
|
||||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
|
||||||
secret.as_bytes(),
|
|
||||||
b"myfsio-iam-encryption",
|
|
||||||
100_000,
|
|
||||||
&mut derived,
|
|
||||||
);
|
|
||||||
URL_SAFE.encode(derived)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decrypt(key_b64: &str, token: &str) -> Result<Vec<u8>, &'static str> {
|
|
||||||
let key_bytes = URL_SAFE
|
|
||||||
.decode(key_b64)
|
|
||||||
.map_err(|_| "invalid fernet key base64")?;
|
|
||||||
if key_bytes.len() != 32 {
|
|
||||||
return Err("fernet key must be 32 bytes");
|
|
||||||
}
|
|
||||||
|
|
||||||
let signing_key = &key_bytes[..16];
|
|
||||||
let encryption_key = &key_bytes[16..];
|
|
||||||
|
|
||||||
let token_bytes = URL_SAFE
|
|
||||||
.decode(token)
|
|
||||||
.map_err(|_| "invalid fernet token base64")?;
|
|
||||||
|
|
||||||
if token_bytes.len() < 57 {
|
|
||||||
return Err("fernet token too short");
|
|
||||||
}
|
|
||||||
|
|
||||||
if token_bytes[0] != 0x80 {
|
|
||||||
return Err("invalid fernet version");
|
|
||||||
}
|
|
||||||
|
|
||||||
let hmac_offset = token_bytes.len() - 32;
|
|
||||||
let payload = &token_bytes[..hmac_offset];
|
|
||||||
let expected_hmac = &token_bytes[hmac_offset..];
|
|
||||||
|
|
||||||
let mut mac = HmacSha256::new_from_slice(signing_key).map_err(|_| "hmac key error")?;
|
|
||||||
mac.update(payload);
|
|
||||||
mac.verify_slice(expected_hmac)
|
|
||||||
.map_err(|_| "HMAC verification failed")?;
|
|
||||||
|
|
||||||
let iv = &token_bytes[9..25];
|
|
||||||
let ciphertext = &token_bytes[25..hmac_offset];
|
|
||||||
|
|
||||||
let plaintext = Aes128CbcDec::new(encryption_key.into(), iv.into())
|
|
||||||
.decrypt_padded_vec_mut::<Pkcs7>(ciphertext)
|
|
||||||
.map_err(|_| "AES-CBC decryption failed")?;
|
|
||||||
|
|
||||||
Ok(plaintext)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encrypt(key_b64: &str, plaintext: &[u8]) -> Result<String, &'static str> {
|
|
||||||
let key_bytes = URL_SAFE
|
|
||||||
.decode(key_b64)
|
|
||||||
.map_err(|_| "invalid fernet key base64")?;
|
|
||||||
if key_bytes.len() != 32 {
|
|
||||||
return Err("fernet key must be 32 bytes");
|
|
||||||
}
|
|
||||||
|
|
||||||
let signing_key = &key_bytes[..16];
|
|
||||||
let encryption_key = &key_bytes[16..];
|
|
||||||
|
|
||||||
let mut iv = [0u8; 16];
|
|
||||||
rand::thread_rng().fill_bytes(&mut iv);
|
|
||||||
|
|
||||||
let timestamp = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map_err(|_| "system time error")?
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let ciphertext = Aes128CbcEnc::new(encryption_key.into(), (&iv).into())
|
|
||||||
.encrypt_padded_vec_mut::<Pkcs7>(plaintext);
|
|
||||||
|
|
||||||
let mut payload = Vec::with_capacity(1 + 8 + 16 + ciphertext.len());
|
|
||||||
payload.push(0x80);
|
|
||||||
payload.extend_from_slice(×tamp.to_be_bytes());
|
|
||||||
payload.extend_from_slice(&iv);
|
|
||||||
payload.extend_from_slice(&ciphertext);
|
|
||||||
|
|
||||||
let mut mac = HmacSha256::new_from_slice(signing_key).map_err(|_| "hmac key error")?;
|
|
||||||
mac.update(&payload);
|
|
||||||
let tag = mac.finalize().into_bytes();
|
|
||||||
|
|
||||||
let mut token_bytes = payload;
|
|
||||||
token_bytes.extend_from_slice(&tag);
|
|
||||||
Ok(URL_SAFE.encode(&token_bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_derive_fernet_key_format() {
|
|
||||||
let key = derive_fernet_key("test-secret");
|
|
||||||
let decoded = URL_SAFE.decode(&key).unwrap();
|
|
||||||
assert_eq!(decoded.len(), 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_roundtrip_with_python_compat() {
|
|
||||||
let key = derive_fernet_key("dev-secret-key");
|
|
||||||
let decoded = URL_SAFE.decode(&key).unwrap();
|
|
||||||
assert_eq!(decoded.len(), 32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
|||||||
mod fernet;
|
|
||||||
pub mod iam;
|
|
||||||
pub mod principal;
|
|
||||||
pub mod sigv4;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub use myfsio_common::types::Principal;
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
use hmac::{Hmac, Mac};
|
|
||||||
use lru::LruCache;
|
|
||||||
use parking_lot::Mutex;
|
|
||||||
use percent_encoding::{percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::num::NonZeroUsize;
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
struct CacheEntry {
|
|
||||||
key: Vec<u8>,
|
|
||||||
created: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
static SIGNING_KEY_CACHE: LazyLock<Mutex<LruCache<(String, String, String, String), CacheEntry>>> =
|
|
||||||
LazyLock::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(256).unwrap())));
|
|
||||||
|
|
||||||
const CACHE_TTL_SECS: u64 = 60;
|
|
||||||
|
|
||||||
const AWS_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
|
||||||
.remove(b'-')
|
|
||||||
.remove(b'_')
|
|
||||||
.remove(b'.')
|
|
||||||
.remove(b'~');
|
|
||||||
|
|
||||||
fn hmac_sha256(key: &[u8], msg: &[u8]) -> Vec<u8> {
|
|
||||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key length is always valid");
|
|
||||||
mac.update(msg);
|
|
||||||
mac.finalize().into_bytes().to_vec()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sha256_hex(data: &[u8]) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(data);
|
|
||||||
hex::encode(hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn aws_uri_encode(input: &str) -> String {
|
|
||||||
percent_encode(input.as_bytes(), AWS_ENCODE_SET).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn derive_signing_key_cached(
|
|
||||||
secret_key: &str,
|
|
||||||
date_stamp: &str,
|
|
||||||
region: &str,
|
|
||||||
service: &str,
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let cache_key = (
|
|
||||||
secret_key.to_owned(),
|
|
||||||
date_stamp.to_owned(),
|
|
||||||
region.to_owned(),
|
|
||||||
service.to_owned(),
|
|
||||||
);
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut cache = SIGNING_KEY_CACHE.lock();
|
|
||||||
if let Some(entry) = cache.get(&cache_key) {
|
|
||||||
if entry.created.elapsed().as_secs() < CACHE_TTL_SECS {
|
|
||||||
return entry.key.clone();
|
|
||||||
}
|
|
||||||
cache.pop(&cache_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let k_date = hmac_sha256(
|
|
||||||
format!("AWS4{}", secret_key).as_bytes(),
|
|
||||||
date_stamp.as_bytes(),
|
|
||||||
);
|
|
||||||
let k_region = hmac_sha256(&k_date, region.as_bytes());
|
|
||||||
let k_service = hmac_sha256(&k_region, service.as_bytes());
|
|
||||||
let k_signing = hmac_sha256(&k_service, b"aws4_request");
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut cache = SIGNING_KEY_CACHE.lock();
|
|
||||||
cache.put(
|
|
||||||
cache_key,
|
|
||||||
CacheEntry {
|
|
||||||
key: k_signing.clone(),
|
|
||||||
created: Instant::now(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
k_signing
|
|
||||||
}
|
|
||||||
|
|
||||||
fn constant_time_compare_inner(a: &[u8], b: &[u8]) -> bool {
|
|
||||||
if a.len() != b.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let mut result: u8 = 0;
|
|
||||||
for (x, y) in a.iter().zip(b.iter()) {
|
|
||||||
result |= x ^ y;
|
|
||||||
}
|
|
||||||
result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_sigv4_signature(
|
|
||||||
method: &str,
|
|
||||||
canonical_uri: &str,
|
|
||||||
query_params: &[(String, String)],
|
|
||||||
signed_headers_str: &str,
|
|
||||||
header_values: &[(String, String)],
|
|
||||||
payload_hash: &str,
|
|
||||||
amz_date: &str,
|
|
||||||
date_stamp: &str,
|
|
||||||
region: &str,
|
|
||||||
service: &str,
|
|
||||||
secret_key: &str,
|
|
||||||
provided_signature: &str,
|
|
||||||
) -> bool {
|
|
||||||
let mut sorted_params = query_params.to_vec();
|
|
||||||
sorted_params.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
|
||||||
|
|
||||||
let canonical_query_string = sorted_params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{}={}", aws_uri_encode(k), aws_uri_encode(v)))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&");
|
|
||||||
|
|
||||||
let mut canonical_headers = String::new();
|
|
||||||
for (name, value) in header_values {
|
|
||||||
let lower_name = name.to_lowercase();
|
|
||||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
||||||
let final_value = if lower_name == "expect" && normalized.is_empty() {
|
|
||||||
"100-continue"
|
|
||||||
} else {
|
|
||||||
&normalized
|
|
||||||
};
|
|
||||||
canonical_headers.push_str(&lower_name);
|
|
||||||
canonical_headers.push(':');
|
|
||||||
canonical_headers.push_str(final_value);
|
|
||||||
canonical_headers.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
let canonical_request = format!(
|
|
||||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
|
||||||
method,
|
|
||||||
canonical_uri,
|
|
||||||
canonical_query_string,
|
|
||||||
canonical_headers,
|
|
||||||
signed_headers_str,
|
|
||||||
payload_hash
|
|
||||||
);
|
|
||||||
|
|
||||||
let credential_scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
|
|
||||||
let cr_hash = sha256_hex(canonical_request.as_bytes());
|
|
||||||
let string_to_sign = format!(
|
|
||||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
|
||||||
amz_date, credential_scope, cr_hash
|
|
||||||
);
|
|
||||||
|
|
||||||
let signing_key = derive_signing_key_cached(secret_key, date_stamp, region, service);
|
|
||||||
let calculated = hmac_sha256(&signing_key, string_to_sign.as_bytes());
|
|
||||||
let calculated_hex = hex::encode(&calculated);
|
|
||||||
|
|
||||||
constant_time_compare_inner(calculated_hex.as_bytes(), provided_signature.as_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn derive_signing_key(
|
|
||||||
secret_key: &str,
|
|
||||||
date_stamp: &str,
|
|
||||||
region: &str,
|
|
||||||
service: &str,
|
|
||||||
) -> Vec<u8> {
|
|
||||||
derive_signing_key_cached(secret_key, date_stamp, region, service)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn compute_signature(signing_key: &[u8], string_to_sign: &str) -> String {
|
|
||||||
let sig = hmac_sha256(signing_key, string_to_sign.as_bytes());
|
|
||||||
hex::encode(sig)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn compute_post_policy_signature(signing_key: &[u8], policy_b64: &str) -> String {
|
|
||||||
let sig = hmac_sha256(signing_key, policy_b64.as_bytes());
|
|
||||||
hex::encode(sig)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_string_to_sign(
|
|
||||||
amz_date: &str,
|
|
||||||
credential_scope: &str,
|
|
||||||
canonical_request: &str,
|
|
||||||
) -> String {
|
|
||||||
let cr_hash = sha256_hex(canonical_request.as_bytes());
|
|
||||||
format!(
|
|
||||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
|
||||||
amz_date, credential_scope, cr_hash
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn constant_time_compare(a: &str, b: &str) -> bool {
|
|
||||||
constant_time_compare_inner(a.as_bytes(), b.as_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_signing_key_cache() {
|
|
||||||
SIGNING_KEY_CACHE.lock().clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_derive_signing_key() {
|
|
||||||
let key = derive_signing_key(
|
|
||||||
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
||||||
"20130524",
|
|
||||||
"us-east-1",
|
|
||||||
"s3",
|
|
||||||
);
|
|
||||||
assert_eq!(key.len(), 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_derive_signing_key_cached() {
|
|
||||||
let key1 = derive_signing_key("secret", "20240101", "us-east-1", "s3");
|
|
||||||
let key2 = derive_signing_key("secret", "20240101", "us-east-1", "s3");
|
|
||||||
assert_eq!(key1, key2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_constant_time_compare() {
|
|
||||||
assert!(constant_time_compare("abc", "abc"));
|
|
||||||
assert!(!constant_time_compare("abc", "abd"));
|
|
||||||
assert!(!constant_time_compare("abc", "abcd"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_build_string_to_sign() {
|
|
||||||
let result = build_string_to_sign(
|
|
||||||
"20130524T000000Z",
|
|
||||||
"20130524/us-east-1/s3/aws4_request",
|
|
||||||
"GET\n/\n\nhost:example.com\n\nhost\nUNSIGNED-PAYLOAD",
|
|
||||||
);
|
|
||||||
assert!(result.starts_with("AWS4-HMAC-SHA256\n"));
|
|
||||||
assert!(result.contains("20130524T000000Z"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_aws_uri_encode() {
|
|
||||||
assert_eq!(aws_uri_encode("hello world"), "hello%20world");
|
|
||||||
assert_eq!(aws_uri_encode("test-file_name.txt"), "test-file_name.txt");
|
|
||||||
assert_eq!(aws_uri_encode("a/b"), "a%2Fb");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_verify_sigv4_roundtrip() {
|
|
||||||
let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
|
|
||||||
let date_stamp = "20130524";
|
|
||||||
let region = "us-east-1";
|
|
||||||
let service = "s3";
|
|
||||||
let amz_date = "20130524T000000Z";
|
|
||||||
|
|
||||||
let signing_key = derive_signing_key(secret, date_stamp, region, service);
|
|
||||||
|
|
||||||
let canonical_request =
|
|
||||||
"GET\n/\n\nhost:examplebucket.s3.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD";
|
|
||||||
let string_to_sign = build_string_to_sign(
|
|
||||||
amz_date,
|
|
||||||
&format!("{}/{}/{}/aws4_request", date_stamp, region, service),
|
|
||||||
canonical_request,
|
|
||||||
);
|
|
||||||
|
|
||||||
let signature = compute_signature(&signing_key, &string_to_sign);
|
|
||||||
|
|
||||||
let result = verify_sigv4_signature(
|
|
||||||
"GET",
|
|
||||||
"/",
|
|
||||||
&[],
|
|
||||||
"host",
|
|
||||||
&[(
|
|
||||||
"host".to_string(),
|
|
||||||
"examplebucket.s3.amazonaws.com".to_string(),
|
|
||||||
)],
|
|
||||||
"UNSIGNED-PAYLOAD",
|
|
||||||
amz_date,
|
|
||||||
date_stamp,
|
|
||||||
region,
|
|
||||||
service,
|
|
||||||
secret,
|
|
||||||
&signature,
|
|
||||||
);
|
|
||||||
assert!(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "myfsio-common"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
pub const SYSTEM_ROOT: &str = ".myfsio.sys";
|
|
||||||
pub const SYSTEM_BUCKETS_DIR: &str = "buckets";
|
|
||||||
pub const SYSTEM_MULTIPART_DIR: &str = "multipart";
|
|
||||||
pub const BUCKET_META_DIR: &str = "meta";
|
|
||||||
pub const BUCKET_VERSIONS_DIR: &str = "versions";
|
|
||||||
pub const BUCKET_CONFIG_FILE: &str = ".bucket.json";
|
|
||||||
pub const STATS_FILE: &str = "stats.json";
|
|
||||||
pub const ETAG_INDEX_FILE: &str = "etag_index.json";
|
|
||||||
pub const INDEX_FILE: &str = "_index.json";
|
|
||||||
pub const MANIFEST_FILE: &str = "manifest.json";
|
|
||||||
|
|
||||||
pub const INTERNAL_FOLDERS: &[&str] = &[".meta", ".versions", ".multipart"];
|
|
||||||
|
|
||||||
pub const DEFAULT_REGION: &str = "us-east-1";
|
|
||||||
pub const AWS_SERVICE: &str = "s3";
|
|
||||||
|
|
||||||
pub const DEFAULT_MAX_KEYS: usize = 1000;
|
|
||||||
pub const DEFAULT_OBJECT_KEY_MAX_BYTES: usize = 1024;
|
|
||||||
pub const DEFAULT_CHUNK_SIZE: usize = 65536;
|
|
||||||
pub const STREAM_CHUNK_SIZE: usize = 1_048_576;
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
use std::fmt;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum S3ErrorCode {
|
|
||||||
AccessDenied,
|
|
||||||
BadDigest,
|
|
||||||
BucketAlreadyExists,
|
|
||||||
BucketNotEmpty,
|
|
||||||
EntityTooLarge,
|
|
||||||
InternalError,
|
|
||||||
InvalidAccessKeyId,
|
|
||||||
InvalidArgument,
|
|
||||||
InvalidBucketName,
|
|
||||||
InvalidKey,
|
|
||||||
InvalidPolicyDocument,
|
|
||||||
InvalidRange,
|
|
||||||
InvalidRequest,
|
|
||||||
InvalidTag,
|
|
||||||
MalformedXML,
|
|
||||||
MethodNotAllowed,
|
|
||||||
NoSuchBucket,
|
|
||||||
NoSuchKey,
|
|
||||||
NoSuchUpload,
|
|
||||||
NoSuchVersion,
|
|
||||||
NoSuchTagSet,
|
|
||||||
PreconditionFailed,
|
|
||||||
NotModified,
|
|
||||||
QuotaExceeded,
|
|
||||||
SignatureDoesNotMatch,
|
|
||||||
SlowDown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl S3ErrorCode {
|
|
||||||
pub fn http_status(&self) -> u16 {
|
|
||||||
match self {
|
|
||||||
Self::AccessDenied => 403,
|
|
||||||
Self::BadDigest => 400,
|
|
||||||
Self::BucketAlreadyExists => 409,
|
|
||||||
Self::BucketNotEmpty => 409,
|
|
||||||
Self::EntityTooLarge => 413,
|
|
||||||
Self::InternalError => 500,
|
|
||||||
Self::InvalidAccessKeyId => 403,
|
|
||||||
Self::InvalidArgument => 400,
|
|
||||||
Self::InvalidBucketName => 400,
|
|
||||||
Self::InvalidKey => 400,
|
|
||||||
Self::InvalidPolicyDocument => 400,
|
|
||||||
Self::InvalidRange => 416,
|
|
||||||
Self::InvalidRequest => 400,
|
|
||||||
Self::InvalidTag => 400,
|
|
||||||
Self::MalformedXML => 400,
|
|
||||||
Self::MethodNotAllowed => 405,
|
|
||||||
Self::NoSuchBucket => 404,
|
|
||||||
Self::NoSuchKey => 404,
|
|
||||||
Self::NoSuchUpload => 404,
|
|
||||||
Self::NoSuchVersion => 404,
|
|
||||||
Self::NoSuchTagSet => 404,
|
|
||||||
Self::PreconditionFailed => 412,
|
|
||||||
Self::NotModified => 304,
|
|
||||||
Self::QuotaExceeded => 403,
|
|
||||||
Self::SignatureDoesNotMatch => 403,
|
|
||||||
Self::SlowDown => 429,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::AccessDenied => "AccessDenied",
|
|
||||||
Self::BadDigest => "BadDigest",
|
|
||||||
Self::BucketAlreadyExists => "BucketAlreadyExists",
|
|
||||||
Self::BucketNotEmpty => "BucketNotEmpty",
|
|
||||||
Self::EntityTooLarge => "EntityTooLarge",
|
|
||||||
Self::InternalError => "InternalError",
|
|
||||||
Self::InvalidAccessKeyId => "InvalidAccessKeyId",
|
|
||||||
Self::InvalidArgument => "InvalidArgument",
|
|
||||||
Self::InvalidBucketName => "InvalidBucketName",
|
|
||||||
Self::InvalidKey => "InvalidKey",
|
|
||||||
Self::InvalidPolicyDocument => "InvalidPolicyDocument",
|
|
||||||
Self::InvalidRange => "InvalidRange",
|
|
||||||
Self::InvalidRequest => "InvalidRequest",
|
|
||||||
Self::InvalidTag => "InvalidTag",
|
|
||||||
Self::MalformedXML => "MalformedXML",
|
|
||||||
Self::MethodNotAllowed => "MethodNotAllowed",
|
|
||||||
Self::NoSuchBucket => "NoSuchBucket",
|
|
||||||
Self::NoSuchKey => "NoSuchKey",
|
|
||||||
Self::NoSuchUpload => "NoSuchUpload",
|
|
||||||
Self::NoSuchVersion => "NoSuchVersion",
|
|
||||||
Self::NoSuchTagSet => "NoSuchTagSet",
|
|
||||||
Self::PreconditionFailed => "PreconditionFailed",
|
|
||||||
Self::NotModified => "NotModified",
|
|
||||||
Self::QuotaExceeded => "QuotaExceeded",
|
|
||||||
Self::SignatureDoesNotMatch => "SignatureDoesNotMatch",
|
|
||||||
Self::SlowDown => "SlowDown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn default_message(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::AccessDenied => "Access Denied",
|
|
||||||
Self::BadDigest => "The Content-MD5 or checksum value you specified did not match what we received",
|
|
||||||
Self::BucketAlreadyExists => "The requested bucket name is not available",
|
|
||||||
Self::BucketNotEmpty => "The bucket you tried to delete is not empty",
|
|
||||||
Self::EntityTooLarge => "Your proposed upload exceeds the maximum allowed size",
|
|
||||||
Self::InternalError => "We encountered an internal error. Please try again.",
|
|
||||||
Self::InvalidAccessKeyId => "The access key ID you provided does not exist",
|
|
||||||
Self::InvalidArgument => "Invalid argument",
|
|
||||||
Self::InvalidBucketName => "The specified bucket is not valid",
|
|
||||||
Self::InvalidKey => "The specified key is not valid",
|
|
||||||
Self::InvalidPolicyDocument => "The content of the form does not meet the conditions specified in the policy document",
|
|
||||||
Self::InvalidRange => "The requested range is not satisfiable",
|
|
||||||
Self::InvalidRequest => "Invalid request",
|
|
||||||
Self::InvalidTag => "The Tagging header is invalid",
|
|
||||||
Self::MalformedXML => "The XML you provided was not well-formed",
|
|
||||||
Self::MethodNotAllowed => "The specified method is not allowed against this resource",
|
|
||||||
Self::NoSuchBucket => "The specified bucket does not exist",
|
|
||||||
Self::NoSuchKey => "The specified key does not exist",
|
|
||||||
Self::NoSuchUpload => "The specified multipart upload does not exist",
|
|
||||||
Self::NoSuchVersion => "The specified version does not exist",
|
|
||||||
Self::NoSuchTagSet => "The TagSet does not exist",
|
|
||||||
Self::PreconditionFailed => "At least one of the preconditions you specified did not hold",
|
|
||||||
Self::NotModified => "Not Modified",
|
|
||||||
Self::QuotaExceeded => "The bucket quota has been exceeded",
|
|
||||||
Self::SignatureDoesNotMatch => "The request signature we calculated does not match the signature you provided",
|
|
||||||
Self::SlowDown => "Please reduce your request rate",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for S3ErrorCode {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.write_str(self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct S3Error {
|
|
||||||
pub code: S3ErrorCode,
|
|
||||||
pub message: String,
|
|
||||||
pub resource: String,
|
|
||||||
pub request_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl S3Error {
|
|
||||||
pub fn new(code: S3ErrorCode, message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
code,
|
|
||||||
message: message.into(),
|
|
||||||
resource: String::new(),
|
|
||||||
request_id: String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_code(code: S3ErrorCode) -> Self {
|
|
||||||
Self::new(code, code.default_message())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
|
|
||||||
self.resource = resource.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
|
|
||||||
self.request_id = request_id.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn http_status(&self) -> u16 {
|
|
||||||
self.code.http_status()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_xml(&self) -> String {
|
|
||||||
format!(
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
|
||||||
<Error>\
|
|
||||||
<Code>{}</Code>\
|
|
||||||
<Message>{}</Message>\
|
|
||||||
<Resource>{}</Resource>\
|
|
||||||
<RequestId>{}</RequestId>\
|
|
||||||
</Error>",
|
|
||||||
self.code.as_str(),
|
|
||||||
xml_escape(&self.message),
|
|
||||||
xml_escape(&self.resource),
|
|
||||||
xml_escape(&self.request_id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for S3Error {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}: {}", self.code, self.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for S3Error {}
|
|
||||||
|
|
||||||
fn xml_escape(s: &str) -> String {
|
|
||||||
s.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
.replace('\'', "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_error_codes() {
|
|
||||||
assert_eq!(S3ErrorCode::NoSuchKey.http_status(), 404);
|
|
||||||
assert_eq!(S3ErrorCode::AccessDenied.http_status(), 403);
|
|
||||||
assert_eq!(S3ErrorCode::NoSuchBucket.as_str(), "NoSuchBucket");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_error_to_xml() {
|
|
||||||
let err = S3Error::from_code(S3ErrorCode::NoSuchKey)
|
|
||||||
.with_resource("/test-bucket/test-key")
|
|
||||||
.with_request_id("abc123");
|
|
||||||
let xml = err.to_xml();
|
|
||||||
assert!(xml.contains("<Code>NoSuchKey</Code>"));
|
|
||||||
assert!(xml.contains("<Resource>/test-bucket/test-key</Resource>"));
|
|
||||||
assert!(xml.contains("<RequestId>abc123</RequestId>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_xml_escape() {
|
|
||||||
let err = S3Error::new(S3ErrorCode::InvalidArgument, "key <test> & \"value\"")
|
|
||||||
.with_resource("/bucket/key&");
|
|
||||||
let xml = err.to_xml();
|
|
||||||
assert!(xml.contains("<test>"));
|
|
||||||
assert!(xml.contains("&"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod constants;
|
|
||||||
pub mod error;
|
|
||||||
pub mod types;
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ObjectMeta {
|
|
||||||
pub key: String,
|
|
||||||
pub size: u64,
|
|
||||||
pub last_modified: DateTime<Utc>,
|
|
||||||
pub etag: Option<String>,
|
|
||||||
pub content_type: Option<String>,
|
|
||||||
pub storage_class: Option<String>,
|
|
||||||
pub metadata: HashMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ObjectMeta {
|
|
||||||
pub fn new(key: String, size: u64, last_modified: DateTime<Utc>) -> Self {
|
|
||||||
Self {
|
|
||||||
key,
|
|
||||||
size,
|
|
||||||
last_modified,
|
|
||||||
etag: None,
|
|
||||||
content_type: None,
|
|
||||||
storage_class: Some("STANDARD".to_string()),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct BucketMeta {
|
|
||||||
pub name: String,
|
|
||||||
pub creation_date: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct BucketStats {
|
|
||||||
pub objects: u64,
|
|
||||||
pub bytes: u64,
|
|
||||||
pub version_count: u64,
|
|
||||||
pub version_bytes: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BucketStats {
|
|
||||||
pub fn total_objects(&self) -> u64 {
|
|
||||||
self.objects + self.version_count
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn total_bytes(&self) -> u64 {
|
|
||||||
self.bytes + self.version_bytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ListObjectsResult {
|
|
||||||
pub objects: Vec<ObjectMeta>,
|
|
||||||
pub is_truncated: bool,
|
|
||||||
pub next_continuation_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ShallowListResult {
|
|
||||||
pub objects: Vec<ObjectMeta>,
|
|
||||||
pub common_prefixes: Vec<String>,
|
|
||||||
pub is_truncated: bool,
|
|
||||||
pub next_continuation_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct ListParams {
|
|
||||||
pub max_keys: usize,
|
|
||||||
pub continuation_token: Option<String>,
|
|
||||||
pub prefix: Option<String>,
|
|
||||||
pub start_after: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct ShallowListParams {
|
|
||||||
pub prefix: String,
|
|
||||||
pub delimiter: String,
|
|
||||||
pub max_keys: usize,
|
|
||||||
pub continuation_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PartMeta {
|
|
||||||
pub part_number: u32,
|
|
||||||
pub etag: String,
|
|
||||||
pub size: u64,
|
|
||||||
pub last_modified: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct PartInfo {
|
|
||||||
pub part_number: u32,
|
|
||||||
pub etag: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MultipartUploadInfo {
|
|
||||||
pub upload_id: String,
|
|
||||||
pub key: String,
|
|
||||||
pub initiated: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct VersionInfo {
|
|
||||||
pub version_id: String,
|
|
||||||
pub key: String,
|
|
||||||
pub size: u64,
|
|
||||||
pub last_modified: DateTime<Utc>,
|
|
||||||
pub etag: Option<String>,
|
|
||||||
pub is_latest: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_delete_marker: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Tag {
|
|
||||||
pub key: String,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct BucketConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub versioning_enabled: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tags: Vec<Tag>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub cors: Option<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub encryption: Option<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub lifecycle: Option<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub website: Option<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub quota: Option<QuotaConfig>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub acl: Option<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub notification: Option<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub logging: Option<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub object_lock: Option<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub policy: Option<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub replication: Option<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct QuotaConfig {
|
|
||||||
pub max_bytes: Option<u64>,
|
|
||||||
pub max_objects: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Principal {
|
|
||||||
pub access_key: String,
|
|
||||||
pub user_id: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub is_admin: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Principal {
|
|
||||||
pub fn new(access_key: String, user_id: String, display_name: String, is_admin: bool) -> Self {
|
|
||||||
Self {
|
|
||||||
access_key,
|
|
||||||
user_id,
|
|
||||||
display_name,
|
|
||||||
is_admin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "myfsio-crypto"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
myfsio-common = { path = "../myfsio-common" }
|
|
||||||
md-5 = { workspace = true }
|
|
||||||
sha2 = { workspace = true }
|
|
||||||
hex = { workspace = true }
|
|
||||||
aes-gcm = { workspace = true }
|
|
||||||
hkdf = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
base64 = { workspace = true }
|
|
||||||
rand = "0.8"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
|
||||||
tempfile = "3"
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
use aes_gcm::aead::Aead;
|
|
||||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
|
||||||
use hkdf::Hkdf;
|
|
||||||
use sha2::Sha256;
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::{Read, Seek, SeekFrom, Write};
|
|
||||||
use std::path::Path;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
const DEFAULT_CHUNK_SIZE: usize = 65536;
|
|
||||||
const HEADER_SIZE: usize = 4;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum CryptoError {
|
|
||||||
#[error("IO error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
#[error("Invalid key size: expected 32 bytes, got {0}")]
|
|
||||||
InvalidKeySize(usize),
|
|
||||||
#[error("Invalid nonce size: expected 12 bytes, got {0}")]
|
|
||||||
InvalidNonceSize(usize),
|
|
||||||
#[error("Encryption failed: {0}")]
|
|
||||||
EncryptionFailed(String),
|
|
||||||
#[error("Decryption failed at chunk {0}")]
|
|
||||||
DecryptionFailed(u32),
|
|
||||||
#[error("HKDF expand failed: {0}")]
|
|
||||||
HkdfFailed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_exact_chunk(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
||||||
let mut filled = 0;
|
|
||||||
while filled < buf.len() {
|
|
||||||
match reader.read(&mut buf[filled..]) {
|
|
||||||
Ok(0) => break,
|
|
||||||
Ok(n) => filled += n,
|
|
||||||
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(filled)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn derive_chunk_nonce(base_nonce: &[u8], chunk_index: u32) -> Result<[u8; 12], CryptoError> {
|
|
||||||
let hkdf = Hkdf::<Sha256>::new(Some(base_nonce), b"chunk_nonce");
|
|
||||||
let mut okm = [0u8; 12];
|
|
||||||
hkdf.expand(&chunk_index.to_be_bytes(), &mut okm)
|
|
||||||
.map_err(|e| CryptoError::HkdfFailed(e.to_string()))?;
|
|
||||||
Ok(okm)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encrypt_stream_chunked(
|
|
||||||
input_path: &Path,
|
|
||||||
output_path: &Path,
|
|
||||||
key: &[u8],
|
|
||||||
base_nonce: &[u8],
|
|
||||||
chunk_size: Option<usize>,
|
|
||||||
) -> Result<u32, CryptoError> {
|
|
||||||
if key.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(key.len()));
|
|
||||||
}
|
|
||||||
if base_nonce.len() != 12 {
|
|
||||||
return Err(CryptoError::InvalidNonceSize(base_nonce.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunk_size = chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
|
|
||||||
let key_arr: [u8; 32] = key.try_into().unwrap();
|
|
||||||
let nonce_arr: [u8; 12] = base_nonce.try_into().unwrap();
|
|
||||||
let cipher = Aes256Gcm::new(&key_arr.into());
|
|
||||||
|
|
||||||
let mut infile = File::open(input_path)?;
|
|
||||||
let mut outfile = File::create(output_path)?;
|
|
||||||
|
|
||||||
outfile.write_all(&[0u8; 4])?;
|
|
||||||
|
|
||||||
let mut buf = vec![0u8; chunk_size];
|
|
||||||
let mut chunk_index: u32 = 0;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let n = read_exact_chunk(&mut infile, &mut buf)?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nonce_bytes = derive_chunk_nonce(&nonce_arr, chunk_index)?;
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let encrypted = cipher
|
|
||||||
.encrypt(nonce, &buf[..n])
|
|
||||||
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let size = encrypted.len() as u32;
|
|
||||||
outfile.write_all(&size.to_be_bytes())?;
|
|
||||||
outfile.write_all(&encrypted)?;
|
|
||||||
|
|
||||||
chunk_index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
outfile.seek(SeekFrom::Start(0))?;
|
|
||||||
outfile.write_all(&chunk_index.to_be_bytes())?;
|
|
||||||
|
|
||||||
Ok(chunk_index)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decrypt_stream_chunked(
|
|
||||||
input_path: &Path,
|
|
||||||
output_path: &Path,
|
|
||||||
key: &[u8],
|
|
||||||
base_nonce: &[u8],
|
|
||||||
) -> Result<u32, CryptoError> {
|
|
||||||
if key.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(key.len()));
|
|
||||||
}
|
|
||||||
if base_nonce.len() != 12 {
|
|
||||||
return Err(CryptoError::InvalidNonceSize(base_nonce.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let key_arr: [u8; 32] = key.try_into().unwrap();
|
|
||||||
let nonce_arr: [u8; 12] = base_nonce.try_into().unwrap();
|
|
||||||
let cipher = Aes256Gcm::new(&key_arr.into());
|
|
||||||
|
|
||||||
let mut infile = File::open(input_path)?;
|
|
||||||
let mut outfile = File::create(output_path)?;
|
|
||||||
|
|
||||||
let mut header = [0u8; HEADER_SIZE];
|
|
||||||
infile.read_exact(&mut header)?;
|
|
||||||
let chunk_count = u32::from_be_bytes(header);
|
|
||||||
|
|
||||||
let mut size_buf = [0u8; HEADER_SIZE];
|
|
||||||
for chunk_index in 0..chunk_count {
|
|
||||||
infile.read_exact(&mut size_buf)?;
|
|
||||||
let chunk_size = u32::from_be_bytes(size_buf) as usize;
|
|
||||||
|
|
||||||
let mut encrypted = vec![0u8; chunk_size];
|
|
||||||
infile.read_exact(&mut encrypted)?;
|
|
||||||
|
|
||||||
let nonce_bytes = derive_chunk_nonce(&nonce_arr, chunk_index)?;
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let decrypted = cipher
|
|
||||||
.decrypt(nonce, encrypted.as_ref())
|
|
||||||
.map_err(|_| CryptoError::DecryptionFailed(chunk_index))?;
|
|
||||||
|
|
||||||
outfile.write_all(&decrypted)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(chunk_count)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn encrypt_stream_chunked_async(
|
|
||||||
input_path: &Path,
|
|
||||||
output_path: &Path,
|
|
||||||
key: &[u8],
|
|
||||||
base_nonce: &[u8],
|
|
||||||
chunk_size: Option<usize>,
|
|
||||||
) -> Result<u32, CryptoError> {
|
|
||||||
let input_path = input_path.to_owned();
|
|
||||||
let output_path = output_path.to_owned();
|
|
||||||
let key = key.to_vec();
|
|
||||||
let base_nonce = base_nonce.to_vec();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
encrypt_stream_chunked(&input_path, &output_path, &key, &base_nonce, chunk_size)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| CryptoError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn decrypt_stream_chunked_async(
|
|
||||||
input_path: &Path,
|
|
||||||
output_path: &Path,
|
|
||||||
key: &[u8],
|
|
||||||
base_nonce: &[u8],
|
|
||||||
) -> Result<u32, CryptoError> {
|
|
||||||
let input_path = input_path.to_owned();
|
|
||||||
let output_path = output_path.to_owned();
|
|
||||||
let key = key.to_vec();
|
|
||||||
let base_nonce = base_nonce.to_vec();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
decrypt_stream_chunked(&input_path, &output_path, &key, &base_nonce)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| CryptoError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::io::Write as IoWrite;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encrypt_decrypt_roundtrip() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let input = dir.path().join("input.bin");
|
|
||||||
let encrypted = dir.path().join("encrypted.bin");
|
|
||||||
let decrypted = dir.path().join("decrypted.bin");
|
|
||||||
|
|
||||||
let data = b"Hello, this is a test of AES-256-GCM chunked encryption!";
|
|
||||||
std::fs::File::create(&input)
|
|
||||||
.unwrap()
|
|
||||||
.write_all(data)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let key = [0x42u8; 32];
|
|
||||||
let nonce = [0x01u8; 12];
|
|
||||||
|
|
||||||
let chunks = encrypt_stream_chunked(&input, &encrypted, &key, &nonce, Some(16)).unwrap();
|
|
||||||
assert!(chunks > 0);
|
|
||||||
|
|
||||||
let chunks2 = decrypt_stream_chunked(&encrypted, &decrypted, &key, &nonce).unwrap();
|
|
||||||
assert_eq!(chunks, chunks2);
|
|
||||||
|
|
||||||
let result = std::fs::read(&decrypted).unwrap();
|
|
||||||
assert_eq!(result, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_key_size() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let input = dir.path().join("input.bin");
|
|
||||||
std::fs::File::create(&input)
|
|
||||||
.unwrap()
|
|
||||||
.write_all(b"test")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let result = encrypt_stream_chunked(
|
|
||||||
&input,
|
|
||||||
&dir.path().join("out"),
|
|
||||||
&[0u8; 16],
|
|
||||||
&[0u8; 12],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(matches!(result, Err(CryptoError::InvalidKeySize(16))));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_wrong_key_fails_decrypt() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let input = dir.path().join("input.bin");
|
|
||||||
let encrypted = dir.path().join("encrypted.bin");
|
|
||||||
let decrypted = dir.path().join("decrypted.bin");
|
|
||||||
|
|
||||||
std::fs::File::create(&input)
|
|
||||||
.unwrap()
|
|
||||||
.write_all(b"secret data")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let key = [0x42u8; 32];
|
|
||||||
let nonce = [0x01u8; 12];
|
|
||||||
encrypt_stream_chunked(&input, &encrypted, &key, &nonce, None).unwrap();
|
|
||||||
|
|
||||||
let wrong_key = [0x43u8; 32];
|
|
||||||
let result = decrypt_stream_chunked(&encrypted, &decrypted, &wrong_key, &nonce);
|
|
||||||
assert!(matches!(result, Err(CryptoError::DecryptionFailed(_))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
use base64::engine::general_purpose::STANDARD as B64;
|
|
||||||
use base64::Engine;
|
|
||||||
use rand::RngCore;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use crate::aes_gcm::{decrypt_stream_chunked, encrypt_stream_chunked, CryptoError};
|
|
||||||
use crate::kms::KmsService;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum SseAlgorithm {
|
|
||||||
Aes256,
|
|
||||||
AwsKms,
|
|
||||||
CustomerProvided,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SseAlgorithm {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SseAlgorithm::Aes256 => "AES256",
|
|
||||||
SseAlgorithm::AwsKms => "aws:kms",
|
|
||||||
SseAlgorithm::CustomerProvided => "AES256",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct EncryptionContext {
|
|
||||||
pub algorithm: SseAlgorithm,
|
|
||||||
pub kms_key_id: Option<String>,
|
|
||||||
pub customer_key: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct EncryptionMetadata {
|
|
||||||
pub algorithm: String,
|
|
||||||
pub nonce: String,
|
|
||||||
pub encrypted_data_key: Option<String>,
|
|
||||||
pub kms_key_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EncryptionMetadata {
|
|
||||||
pub fn to_metadata_map(&self) -> HashMap<String, String> {
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
map.insert(
|
|
||||||
"x-amz-server-side-encryption".to_string(),
|
|
||||||
self.algorithm.clone(),
|
|
||||||
);
|
|
||||||
map.insert("x-amz-encryption-nonce".to_string(), self.nonce.clone());
|
|
||||||
if let Some(ref dk) = self.encrypted_data_key {
|
|
||||||
map.insert("x-amz-encrypted-data-key".to_string(), dk.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref kid) = self.kms_key_id {
|
|
||||||
map.insert("x-amz-encryption-key-id".to_string(), kid.clone());
|
|
||||||
}
|
|
||||||
map
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_metadata(meta: &HashMap<String, String>) -> Option<Self> {
|
|
||||||
let algorithm = meta.get("x-amz-server-side-encryption")?;
|
|
||||||
let nonce = meta.get("x-amz-encryption-nonce")?;
|
|
||||||
Some(Self {
|
|
||||||
algorithm: algorithm.clone(),
|
|
||||||
nonce: nonce.clone(),
|
|
||||||
encrypted_data_key: meta.get("x-amz-encrypted-data-key").cloned(),
|
|
||||||
kms_key_id: meta.get("x-amz-encryption-key-id").cloned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_encrypted(meta: &HashMap<String, String>) -> bool {
|
|
||||||
meta.contains_key("x-amz-server-side-encryption")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clean_metadata(meta: &mut HashMap<String, String>) {
|
|
||||||
meta.remove("x-amz-server-side-encryption");
|
|
||||||
meta.remove("x-amz-encryption-nonce");
|
|
||||||
meta.remove("x-amz-encrypted-data-key");
|
|
||||||
meta.remove("x-amz-encryption-key-id");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct EncryptionService {
|
|
||||||
master_key: [u8; 32],
|
|
||||||
kms: Option<std::sync::Arc<KmsService>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EncryptionService {
|
|
||||||
pub fn new(master_key: [u8; 32], kms: Option<std::sync::Arc<KmsService>>) -> Self {
|
|
||||||
Self { master_key, kms }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_data_key(&self) -> ([u8; 32], [u8; 12]) {
|
|
||||||
let mut data_key = [0u8; 32];
|
|
||||||
let mut nonce = [0u8; 12];
|
|
||||||
rand::thread_rng().fill_bytes(&mut data_key);
|
|
||||||
rand::thread_rng().fill_bytes(&mut nonce);
|
|
||||||
(data_key, nonce)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wrap_data_key(&self, data_key: &[u8; 32]) -> Result<String, CryptoError> {
|
|
||||||
use aes_gcm::aead::Aead;
|
|
||||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
|
||||||
|
|
||||||
let cipher = Aes256Gcm::new((&self.master_key).into());
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let encrypted = cipher
|
|
||||||
.encrypt(nonce, data_key.as_slice())
|
|
||||||
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut combined = Vec::with_capacity(12 + encrypted.len());
|
|
||||||
combined.extend_from_slice(&nonce_bytes);
|
|
||||||
combined.extend_from_slice(&encrypted);
|
|
||||||
Ok(B64.encode(&combined))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unwrap_data_key(&self, wrapped_b64: &str) -> Result<[u8; 32], CryptoError> {
|
|
||||||
use aes_gcm::aead::Aead;
|
|
||||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
|
||||||
|
|
||||||
let combined = B64.decode(wrapped_b64).map_err(|e| {
|
|
||||||
CryptoError::EncryptionFailed(format!("Bad wrapped key encoding: {}", e))
|
|
||||||
})?;
|
|
||||||
if combined.len() < 12 {
|
|
||||||
return Err(CryptoError::EncryptionFailed(
|
|
||||||
"Wrapped key too short".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
|
||||||
let cipher = Aes256Gcm::new((&self.master_key).into());
|
|
||||||
let nonce = Nonce::from_slice(nonce_bytes);
|
|
||||||
|
|
||||||
let plaintext = cipher
|
|
||||||
.decrypt(nonce, ciphertext)
|
|
||||||
.map_err(|_| CryptoError::DecryptionFailed(0))?;
|
|
||||||
|
|
||||||
if plaintext.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(plaintext.len()));
|
|
||||||
}
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
key.copy_from_slice(&plaintext);
|
|
||||||
Ok(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn encrypt_object(
|
|
||||||
&self,
|
|
||||||
input_path: &Path,
|
|
||||||
output_path: &Path,
|
|
||||||
ctx: &EncryptionContext,
|
|
||||||
) -> Result<EncryptionMetadata, CryptoError> {
|
|
||||||
let (data_key, nonce) = self.generate_data_key();
|
|
||||||
|
|
||||||
let (encrypted_data_key, kms_key_id) = match ctx.algorithm {
|
|
||||||
SseAlgorithm::Aes256 => {
|
|
||||||
let wrapped = self.wrap_data_key(&data_key)?;
|
|
||||||
(Some(wrapped), None)
|
|
||||||
}
|
|
||||||
SseAlgorithm::AwsKms => {
|
|
||||||
let kms = self
|
|
||||||
.kms
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| CryptoError::EncryptionFailed("KMS not available".into()))?;
|
|
||||||
let kid = ctx
|
|
||||||
.kms_key_id
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| CryptoError::EncryptionFailed("No KMS key ID".into()))?;
|
|
||||||
let ciphertext = kms.encrypt_data(kid, &data_key).await?;
|
|
||||||
(Some(B64.encode(&ciphertext)), Some(kid.clone()))
|
|
||||||
}
|
|
||||||
SseAlgorithm::CustomerProvided => (None, None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let actual_key = if ctx.algorithm == SseAlgorithm::CustomerProvided {
|
|
||||||
let ck = ctx
|
|
||||||
.customer_key
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| CryptoError::EncryptionFailed("No customer key provided".into()))?;
|
|
||||||
if ck.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(ck.len()));
|
|
||||||
}
|
|
||||||
let mut k = [0u8; 32];
|
|
||||||
k.copy_from_slice(ck);
|
|
||||||
k
|
|
||||||
} else {
|
|
||||||
data_key
|
|
||||||
};
|
|
||||||
|
|
||||||
let ip = input_path.to_owned();
|
|
||||||
let op = output_path.to_owned();
|
|
||||||
let ak = actual_key;
|
|
||||||
let n = nonce;
|
|
||||||
tokio::task::spawn_blocking(move || encrypt_stream_chunked(&ip, &op, &ak, &n, None))
|
|
||||||
.await
|
|
||||||
.map_err(|e| CryptoError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))??;
|
|
||||||
|
|
||||||
Ok(EncryptionMetadata {
|
|
||||||
algorithm: ctx.algorithm.as_str().to_string(),
|
|
||||||
nonce: B64.encode(nonce),
|
|
||||||
encrypted_data_key,
|
|
||||||
kms_key_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn decrypt_object(
|
|
||||||
&self,
|
|
||||||
input_path: &Path,
|
|
||||||
output_path: &Path,
|
|
||||||
enc_meta: &EncryptionMetadata,
|
|
||||||
customer_key: Option<&[u8]>,
|
|
||||||
) -> Result<(), CryptoError> {
|
|
||||||
let nonce_bytes = B64
|
|
||||||
.decode(&enc_meta.nonce)
|
|
||||||
.map_err(|e| CryptoError::EncryptionFailed(format!("Bad nonce encoding: {}", e)))?;
|
|
||||||
if nonce_bytes.len() != 12 {
|
|
||||||
return Err(CryptoError::InvalidNonceSize(nonce_bytes.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let data_key: [u8; 32] = if let Some(ck) = customer_key {
|
|
||||||
if ck.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(ck.len()));
|
|
||||||
}
|
|
||||||
let mut k = [0u8; 32];
|
|
||||||
k.copy_from_slice(ck);
|
|
||||||
k
|
|
||||||
} else if enc_meta.algorithm == "aws:kms" {
|
|
||||||
let kms = self
|
|
||||||
.kms
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| CryptoError::EncryptionFailed("KMS not available".into()))?;
|
|
||||||
let kid = enc_meta
|
|
||||||
.kms_key_id
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| CryptoError::EncryptionFailed("No KMS key ID in metadata".into()))?;
|
|
||||||
let encrypted_dk = enc_meta.encrypted_data_key.as_ref().ok_or_else(|| {
|
|
||||||
CryptoError::EncryptionFailed("No encrypted data key in metadata".into())
|
|
||||||
})?;
|
|
||||||
let ct = B64.decode(encrypted_dk).map_err(|e| {
|
|
||||||
CryptoError::EncryptionFailed(format!("Bad data key encoding: {}", e))
|
|
||||||
})?;
|
|
||||||
let dk = kms.decrypt_data(kid, &ct).await?;
|
|
||||||
if dk.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(dk.len()));
|
|
||||||
}
|
|
||||||
let mut k = [0u8; 32];
|
|
||||||
k.copy_from_slice(&dk);
|
|
||||||
k
|
|
||||||
} else {
|
|
||||||
let wrapped = enc_meta.encrypted_data_key.as_ref().ok_or_else(|| {
|
|
||||||
CryptoError::EncryptionFailed("No encrypted data key in metadata".into())
|
|
||||||
})?;
|
|
||||||
self.unwrap_data_key(wrapped)?
|
|
||||||
};
|
|
||||||
|
|
||||||
let ip = input_path.to_owned();
|
|
||||||
let op = output_path.to_owned();
|
|
||||||
let nb: [u8; 12] = nonce_bytes.try_into().unwrap();
|
|
||||||
tokio::task::spawn_blocking(move || decrypt_stream_chunked(&ip, &op, &data_key, &nb))
|
|
||||||
.await
|
|
||||||
.map_err(|e| CryptoError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))??;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
fn test_master_key() -> [u8; 32] {
|
|
||||||
[0x42u8; 32]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_wrap_unwrap_data_key() {
|
|
||||||
let svc = EncryptionService::new(test_master_key(), None);
|
|
||||||
let dk = [0xAAu8; 32];
|
|
||||||
let wrapped = svc.wrap_data_key(&dk).unwrap();
|
|
||||||
let unwrapped = svc.unwrap_data_key(&wrapped).unwrap();
|
|
||||||
assert_eq!(dk, unwrapped);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_encrypt_decrypt_object_sse_s3() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let input = dir.path().join("plain.bin");
|
|
||||||
let encrypted = dir.path().join("enc.bin");
|
|
||||||
let decrypted = dir.path().join("dec.bin");
|
|
||||||
|
|
||||||
let data = b"SSE-S3 encrypted content for testing!";
|
|
||||||
std::fs::File::create(&input)
|
|
||||||
.unwrap()
|
|
||||||
.write_all(data)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let svc = EncryptionService::new(test_master_key(), None);
|
|
||||||
|
|
||||||
let ctx = EncryptionContext {
|
|
||||||
algorithm: SseAlgorithm::Aes256,
|
|
||||||
kms_key_id: None,
|
|
||||||
customer_key: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let meta = svc.encrypt_object(&input, &encrypted, &ctx).await.unwrap();
|
|
||||||
assert_eq!(meta.algorithm, "AES256");
|
|
||||||
assert!(meta.encrypted_data_key.is_some());
|
|
||||||
|
|
||||||
svc.decrypt_object(&encrypted, &decrypted, &meta, None)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let result = std::fs::read(&decrypted).unwrap();
|
|
||||||
assert_eq!(result, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_encrypt_decrypt_object_sse_c() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let input = dir.path().join("plain.bin");
|
|
||||||
let encrypted = dir.path().join("enc.bin");
|
|
||||||
let decrypted = dir.path().join("dec.bin");
|
|
||||||
|
|
||||||
let data = b"SSE-C encrypted content!";
|
|
||||||
std::fs::File::create(&input)
|
|
||||||
.unwrap()
|
|
||||||
.write_all(data)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let customer_key = [0xBBu8; 32];
|
|
||||||
let svc = EncryptionService::new(test_master_key(), None);
|
|
||||||
|
|
||||||
let ctx = EncryptionContext {
|
|
||||||
algorithm: SseAlgorithm::CustomerProvided,
|
|
||||||
kms_key_id: None,
|
|
||||||
customer_key: Some(customer_key.to_vec()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let meta = svc.encrypt_object(&input, &encrypted, &ctx).await.unwrap();
|
|
||||||
assert!(meta.encrypted_data_key.is_none());
|
|
||||||
|
|
||||||
svc.decrypt_object(&encrypted, &decrypted, &meta, Some(&customer_key))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let result = std::fs::read(&decrypted).unwrap();
|
|
||||||
assert_eq!(result, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encryption_metadata_roundtrip() {
|
|
||||||
let meta = EncryptionMetadata {
|
|
||||||
algorithm: "AES256".to_string(),
|
|
||||||
nonce: "dGVzdG5vbmNlMTI=".to_string(),
|
|
||||||
encrypted_data_key: Some("c29tZWtleQ==".to_string()),
|
|
||||||
kms_key_id: None,
|
|
||||||
};
|
|
||||||
let map = meta.to_metadata_map();
|
|
||||||
let restored = EncryptionMetadata::from_metadata(&map).unwrap();
|
|
||||||
assert_eq!(restored.algorithm, "AES256");
|
|
||||||
assert_eq!(restored.nonce, meta.nonce);
|
|
||||||
assert_eq!(restored.encrypted_data_key, meta.encrypted_data_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_encrypted() {
|
|
||||||
let mut meta = HashMap::new();
|
|
||||||
assert!(!EncryptionMetadata::is_encrypted(&meta));
|
|
||||||
meta.insert(
|
|
||||||
"x-amz-server-side-encryption".to_string(),
|
|
||||||
"AES256".to_string(),
|
|
||||||
);
|
|
||||||
assert!(EncryptionMetadata::is_encrypted(&meta));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
use md5::{Digest, Md5};
|
|
||||||
use sha2::Sha256;
|
|
||||||
use std::io::Read;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
const CHUNK_SIZE: usize = 65536;
|
|
||||||
|
|
||||||
pub fn md5_file(path: &Path) -> std::io::Result<String> {
|
|
||||||
let mut file = std::fs::File::open(path)?;
|
|
||||||
let mut hasher = Md5::new();
|
|
||||||
let mut buf = vec![0u8; CHUNK_SIZE];
|
|
||||||
loop {
|
|
||||||
let n = file.read(&mut buf)?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
hasher.update(&buf[..n]);
|
|
||||||
}
|
|
||||||
Ok(format!("{:x}", hasher.finalize()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn md5_bytes(data: &[u8]) -> String {
|
|
||||||
let mut hasher = Md5::new();
|
|
||||||
hasher.update(data);
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sha256_file(path: &Path) -> std::io::Result<String> {
|
|
||||||
let mut file = std::fs::File::open(path)?;
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
let mut buf = vec![0u8; CHUNK_SIZE];
|
|
||||||
loop {
|
|
||||||
let n = file.read(&mut buf)?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
hasher.update(&buf[..n]);
|
|
||||||
}
|
|
||||||
Ok(format!("{:x}", hasher.finalize()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sha256_bytes(data: &[u8]) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(data);
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn md5_sha256_file(path: &Path) -> std::io::Result<(String, String)> {
|
|
||||||
let mut file = std::fs::File::open(path)?;
|
|
||||||
let mut md5_hasher = Md5::new();
|
|
||||||
let mut sha_hasher = Sha256::new();
|
|
||||||
let mut buf = vec![0u8; CHUNK_SIZE];
|
|
||||||
loop {
|
|
||||||
let n = file.read(&mut buf)?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
md5_hasher.update(&buf[..n]);
|
|
||||||
sha_hasher.update(&buf[..n]);
|
|
||||||
}
|
|
||||||
Ok((
|
|
||||||
format!("{:x}", md5_hasher.finalize()),
|
|
||||||
format!("{:x}", sha_hasher.finalize()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn md5_file_async(path: &Path) -> std::io::Result<String> {
|
|
||||||
let path = path.to_owned();
|
|
||||||
tokio::task::spawn_blocking(move || md5_file(&path))
|
|
||||||
.await
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn sha256_file_async(path: &Path) -> std::io::Result<String> {
|
|
||||||
let path = path.to_owned();
|
|
||||||
tokio::task::spawn_blocking(move || sha256_file(&path))
|
|
||||||
.await
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn md5_sha256_file_async(path: &Path) -> std::io::Result<(String, String)> {
|
|
||||||
let path = path.to_owned();
|
|
||||||
tokio::task::spawn_blocking(move || md5_sha256_file(&path))
|
|
||||||
.await
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_md5_bytes() {
|
|
||||||
assert_eq!(md5_bytes(b""), "d41d8cd98f00b204e9800998ecf8427e");
|
|
||||||
assert_eq!(md5_bytes(b"hello"), "5d41402abc4b2a76b9719d911017c592");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sha256_bytes() {
|
|
||||||
let hash = sha256_bytes(b"hello");
|
|
||||||
assert_eq!(
|
|
||||||
hash,
|
|
||||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_md5_file() {
|
|
||||||
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
|
||||||
tmp.write_all(b"hello").unwrap();
|
|
||||||
tmp.flush().unwrap();
|
|
||||||
let hash = md5_file(tmp.path()).unwrap();
|
|
||||||
assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_md5_sha256_file() {
|
|
||||||
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
|
||||||
tmp.write_all(b"hello").unwrap();
|
|
||||||
tmp.flush().unwrap();
|
|
||||||
let (md5, sha) = md5_sha256_file(tmp.path()).unwrap();
|
|
||||||
assert_eq!(md5, "5d41402abc4b2a76b9719d911017c592");
|
|
||||||
assert_eq!(
|
|
||||||
sha,
|
|
||||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_md5_file_async() {
|
|
||||||
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
|
||||||
tmp.write_all(b"hello").unwrap();
|
|
||||||
tmp.flush().unwrap();
|
|
||||||
let hash = md5_file_async(tmp.path()).await.unwrap();
|
|
||||||
assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,451 +0,0 @@
|
|||||||
use aes_gcm::aead::Aead;
|
|
||||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
|
||||||
use base64::Engine;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use rand::RngCore;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
use crate::aes_gcm::CryptoError;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct KmsKey {
|
|
||||||
#[serde(rename = "KeyId")]
|
|
||||||
pub key_id: String,
|
|
||||||
#[serde(rename = "Arn")]
|
|
||||||
pub arn: String,
|
|
||||||
#[serde(rename = "Description")]
|
|
||||||
pub description: String,
|
|
||||||
#[serde(rename = "CreationDate")]
|
|
||||||
pub creation_date: DateTime<Utc>,
|
|
||||||
#[serde(rename = "Enabled")]
|
|
||||||
pub enabled: bool,
|
|
||||||
#[serde(rename = "KeyState")]
|
|
||||||
pub key_state: String,
|
|
||||||
#[serde(rename = "KeyUsage")]
|
|
||||||
pub key_usage: String,
|
|
||||||
#[serde(rename = "KeySpec")]
|
|
||||||
pub key_spec: String,
|
|
||||||
#[serde(rename = "EncryptedKeyMaterial")]
|
|
||||||
pub encrypted_key_material: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct KmsStore {
|
|
||||||
keys: Vec<KmsKey>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct KmsService {
|
|
||||||
keys_path: PathBuf,
|
|
||||||
master_key: Arc<RwLock<[u8; 32]>>,
|
|
||||||
keys: Arc<RwLock<Vec<KmsKey>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KmsService {
|
|
||||||
pub async fn new(keys_dir: &Path) -> Result<Self, CryptoError> {
|
|
||||||
std::fs::create_dir_all(keys_dir).map_err(CryptoError::Io)?;
|
|
||||||
|
|
||||||
let keys_path = keys_dir.join("kms_keys.json");
|
|
||||||
|
|
||||||
let master_key = Self::load_or_create_master_key(&keys_dir.join("kms_master.key"))?;
|
|
||||||
|
|
||||||
let keys = if keys_path.exists() {
|
|
||||||
let data = std::fs::read_to_string(&keys_path).map_err(CryptoError::Io)?;
|
|
||||||
let store: KmsStore = serde_json::from_str(&data)
|
|
||||||
.map_err(|e| CryptoError::EncryptionFailed(format!("Bad KMS store: {}", e)))?;
|
|
||||||
store.keys
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
keys_path,
|
|
||||||
master_key: Arc::new(RwLock::new(master_key)),
|
|
||||||
keys: Arc::new(RwLock::new(keys)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_or_create_master_key(path: &Path) -> Result<[u8; 32], CryptoError> {
|
|
||||||
if path.exists() {
|
|
||||||
let encoded = std::fs::read_to_string(path).map_err(CryptoError::Io)?;
|
|
||||||
let decoded = B64.decode(encoded.trim()).map_err(|e| {
|
|
||||||
CryptoError::EncryptionFailed(format!("Bad master key encoding: {}", e))
|
|
||||||
})?;
|
|
||||||
if decoded.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(decoded.len()));
|
|
||||||
}
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
key.copy_from_slice(&decoded);
|
|
||||||
Ok(key)
|
|
||||||
} else {
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut key);
|
|
||||||
let encoded = B64.encode(key);
|
|
||||||
std::fs::write(path, &encoded).map_err(CryptoError::Io)?;
|
|
||||||
Ok(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encrypt_key_material(
|
|
||||||
master_key: &[u8; 32],
|
|
||||||
plaintext_key: &[u8],
|
|
||||||
) -> Result<String, CryptoError> {
|
|
||||||
let cipher = Aes256Gcm::new(master_key.into());
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let ciphertext = cipher
|
|
||||||
.encrypt(nonce, plaintext_key)
|
|
||||||
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut combined = Vec::with_capacity(12 + ciphertext.len());
|
|
||||||
combined.extend_from_slice(&nonce_bytes);
|
|
||||||
combined.extend_from_slice(&ciphertext);
|
|
||||||
Ok(B64.encode(&combined))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrypt_key_material(
|
|
||||||
master_key: &[u8; 32],
|
|
||||||
encrypted_b64: &str,
|
|
||||||
) -> Result<Vec<u8>, CryptoError> {
|
|
||||||
let combined = B64.decode(encrypted_b64).map_err(|e| {
|
|
||||||
CryptoError::EncryptionFailed(format!("Bad key material encoding: {}", e))
|
|
||||||
})?;
|
|
||||||
if combined.len() < 12 {
|
|
||||||
return Err(CryptoError::EncryptionFailed(
|
|
||||||
"Encrypted key material too short".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
|
||||||
let cipher = Aes256Gcm::new(master_key.into());
|
|
||||||
let nonce = Nonce::from_slice(nonce_bytes);
|
|
||||||
|
|
||||||
cipher
|
|
||||||
.decrypt(nonce, ciphertext)
|
|
||||||
.map_err(|_| CryptoError::DecryptionFailed(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self) -> Result<(), CryptoError> {
|
|
||||||
let keys = self.keys.read().await;
|
|
||||||
let store = KmsStore { keys: keys.clone() };
|
|
||||||
let json = serde_json::to_string_pretty(&store)
|
|
||||||
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
|
|
||||||
std::fs::write(&self.keys_path, json).map_err(CryptoError::Io)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_key(&self, description: &str) -> Result<KmsKey, CryptoError> {
|
|
||||||
let key_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
let arn = format!("arn:aws:kms:local:000000000000:key/{}", key_id);
|
|
||||||
|
|
||||||
let mut plaintext_key = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut plaintext_key);
|
|
||||||
|
|
||||||
let master = self.master_key.read().await;
|
|
||||||
let encrypted = Self::encrypt_key_material(&master, &plaintext_key)?;
|
|
||||||
|
|
||||||
let kms_key = KmsKey {
|
|
||||||
key_id: key_id.clone(),
|
|
||||||
arn,
|
|
||||||
description: description.to_string(),
|
|
||||||
creation_date: Utc::now(),
|
|
||||||
enabled: true,
|
|
||||||
key_state: "Enabled".to_string(),
|
|
||||||
key_usage: "ENCRYPT_DECRYPT".to_string(),
|
|
||||||
key_spec: "SYMMETRIC_DEFAULT".to_string(),
|
|
||||||
encrypted_key_material: encrypted,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.keys.write().await.push(kms_key.clone());
|
|
||||||
self.save().await?;
|
|
||||||
Ok(kms_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_keys(&self) -> Vec<KmsKey> {
|
|
||||||
self.keys.read().await.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_key(&self, key_id: &str) -> Option<KmsKey> {
|
|
||||||
let keys = self.keys.read().await;
|
|
||||||
keys.iter()
|
|
||||||
.find(|k| k.key_id == key_id || k.arn == key_id)
|
|
||||||
.cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_key(&self, key_id: &str) -> Result<bool, CryptoError> {
|
|
||||||
let mut keys = self.keys.write().await;
|
|
||||||
let len_before = keys.len();
|
|
||||||
keys.retain(|k| k.key_id != key_id && k.arn != key_id);
|
|
||||||
let removed = keys.len() < len_before;
|
|
||||||
drop(keys);
|
|
||||||
if removed {
|
|
||||||
self.save().await?;
|
|
||||||
}
|
|
||||||
Ok(removed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn enable_key(&self, key_id: &str) -> Result<bool, CryptoError> {
|
|
||||||
let mut keys = self.keys.write().await;
|
|
||||||
if let Some(key) = keys.iter_mut().find(|k| k.key_id == key_id) {
|
|
||||||
key.enabled = true;
|
|
||||||
key.key_state = "Enabled".to_string();
|
|
||||||
drop(keys);
|
|
||||||
self.save().await?;
|
|
||||||
Ok(true)
|
|
||||||
} else {
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn disable_key(&self, key_id: &str) -> Result<bool, CryptoError> {
|
|
||||||
let mut keys = self.keys.write().await;
|
|
||||||
if let Some(key) = keys.iter_mut().find(|k| k.key_id == key_id) {
|
|
||||||
key.enabled = false;
|
|
||||||
key.key_state = "Disabled".to_string();
|
|
||||||
drop(keys);
|
|
||||||
self.save().await?;
|
|
||||||
Ok(true)
|
|
||||||
} else {
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn decrypt_data_key(&self, key_id: &str) -> Result<Vec<u8>, CryptoError> {
|
|
||||||
let keys = self.keys.read().await;
|
|
||||||
let key = keys
|
|
||||||
.iter()
|
|
||||||
.find(|k| k.key_id == key_id || k.arn == key_id)
|
|
||||||
.ok_or_else(|| CryptoError::EncryptionFailed("KMS key not found".to_string()))?;
|
|
||||||
|
|
||||||
if !key.enabled {
|
|
||||||
return Err(CryptoError::EncryptionFailed(
|
|
||||||
"KMS key is disabled".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let master = self.master_key.read().await;
|
|
||||||
Self::decrypt_key_material(&master, &key.encrypted_key_material)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn encrypt_data(
|
|
||||||
&self,
|
|
||||||
key_id: &str,
|
|
||||||
plaintext: &[u8],
|
|
||||||
) -> Result<Vec<u8>, CryptoError> {
|
|
||||||
let data_key = self.decrypt_data_key(key_id).await?;
|
|
||||||
if data_key.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(data_key.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let key_arr: [u8; 32] = data_key.try_into().unwrap();
|
|
||||||
let cipher = Aes256Gcm::new(&key_arr.into());
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let ciphertext = cipher
|
|
||||||
.encrypt(nonce, plaintext)
|
|
||||||
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
|
||||||
result.extend_from_slice(&nonce_bytes);
|
|
||||||
result.extend_from_slice(&ciphertext);
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn decrypt_data(
|
|
||||||
&self,
|
|
||||||
key_id: &str,
|
|
||||||
ciphertext: &[u8],
|
|
||||||
) -> Result<Vec<u8>, CryptoError> {
|
|
||||||
if ciphertext.len() < 12 {
|
|
||||||
return Err(CryptoError::EncryptionFailed(
|
|
||||||
"Ciphertext too short".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let data_key = self.decrypt_data_key(key_id).await?;
|
|
||||||
if data_key.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(data_key.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let key_arr: [u8; 32] = data_key.try_into().unwrap();
|
|
||||||
let (nonce_bytes, ct) = ciphertext.split_at(12);
|
|
||||||
let cipher = Aes256Gcm::new(&key_arr.into());
|
|
||||||
let nonce = Nonce::from_slice(nonce_bytes);
|
|
||||||
|
|
||||||
cipher
|
|
||||||
.decrypt(nonce, ct)
|
|
||||||
.map_err(|_| CryptoError::DecryptionFailed(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn generate_data_key(
|
|
||||||
&self,
|
|
||||||
key_id: &str,
|
|
||||||
num_bytes: usize,
|
|
||||||
) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
|
|
||||||
let kms_key = self.decrypt_data_key(key_id).await?;
|
|
||||||
if kms_key.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(kms_key.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut plaintext_key = vec![0u8; num_bytes];
|
|
||||||
rand::thread_rng().fill_bytes(&mut plaintext_key);
|
|
||||||
|
|
||||||
let key_arr: [u8; 32] = kms_key.try_into().unwrap();
|
|
||||||
let cipher = Aes256Gcm::new(&key_arr.into());
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let encrypted = cipher
|
|
||||||
.encrypt(nonce, plaintext_key.as_slice())
|
|
||||||
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut wrapped = Vec::with_capacity(12 + encrypted.len());
|
|
||||||
wrapped.extend_from_slice(&nonce_bytes);
|
|
||||||
wrapped.extend_from_slice(&encrypted);
|
|
||||||
|
|
||||||
Ok((plaintext_key, wrapped))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_or_create_master_key(keys_dir: &Path) -> Result<[u8; 32], CryptoError> {
|
|
||||||
std::fs::create_dir_all(keys_dir).map_err(CryptoError::Io)?;
|
|
||||||
let path = keys_dir.join("master.key");
|
|
||||||
|
|
||||||
if path.exists() {
|
|
||||||
let encoded = std::fs::read_to_string(&path).map_err(CryptoError::Io)?;
|
|
||||||
let decoded = B64.decode(encoded.trim()).map_err(|e| {
|
|
||||||
CryptoError::EncryptionFailed(format!("Bad master key encoding: {}", e))
|
|
||||||
})?;
|
|
||||||
if decoded.len() != 32 {
|
|
||||||
return Err(CryptoError::InvalidKeySize(decoded.len()));
|
|
||||||
}
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
key.copy_from_slice(&decoded);
|
|
||||||
Ok(key)
|
|
||||||
} else {
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut key);
|
|
||||||
let encoded = B64.encode(key);
|
|
||||||
std::fs::write(&path, &encoded).map_err(CryptoError::Io)?;
|
|
||||||
Ok(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_and_list_keys() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let kms = KmsService::new(dir.path()).await.unwrap();
|
|
||||||
|
|
||||||
let key = kms.create_key("test key").await.unwrap();
|
|
||||||
assert!(key.enabled);
|
|
||||||
assert_eq!(key.description, "test key");
|
|
||||||
assert!(key.key_id.len() > 0);
|
|
||||||
|
|
||||||
let keys = kms.list_keys().await;
|
|
||||||
assert_eq!(keys.len(), 1);
|
|
||||||
assert_eq!(keys[0].key_id, key.key_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_enable_disable_key() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let kms = KmsService::new(dir.path()).await.unwrap();
|
|
||||||
|
|
||||||
let key = kms.create_key("toggle").await.unwrap();
|
|
||||||
assert!(key.enabled);
|
|
||||||
|
|
||||||
kms.disable_key(&key.key_id).await.unwrap();
|
|
||||||
let k = kms.get_key(&key.key_id).await.unwrap();
|
|
||||||
assert!(!k.enabled);
|
|
||||||
|
|
||||||
kms.enable_key(&key.key_id).await.unwrap();
|
|
||||||
let k = kms.get_key(&key.key_id).await.unwrap();
|
|
||||||
assert!(k.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_key() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let kms = KmsService::new(dir.path()).await.unwrap();
|
|
||||||
|
|
||||||
let key = kms.create_key("doomed").await.unwrap();
|
|
||||||
assert!(kms.delete_key(&key.key_id).await.unwrap());
|
|
||||||
assert!(kms.get_key(&key.key_id).await.is_none());
|
|
||||||
assert_eq!(kms.list_keys().await.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_encrypt_decrypt_data() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let kms = KmsService::new(dir.path()).await.unwrap();
|
|
||||||
|
|
||||||
let key = kms.create_key("enc-key").await.unwrap();
|
|
||||||
let plaintext = b"Hello, KMS!";
|
|
||||||
|
|
||||||
let ciphertext = kms.encrypt_data(&key.key_id, plaintext).await.unwrap();
|
|
||||||
assert_ne!(&ciphertext, plaintext);
|
|
||||||
|
|
||||||
let decrypted = kms.decrypt_data(&key.key_id, &ciphertext).await.unwrap();
|
|
||||||
assert_eq!(decrypted, plaintext);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_generate_data_key() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let kms = KmsService::new(dir.path()).await.unwrap();
|
|
||||||
|
|
||||||
let key = kms.create_key("data-key-gen").await.unwrap();
|
|
||||||
let (plaintext, wrapped) = kms.generate_data_key(&key.key_id, 32).await.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(plaintext.len(), 32);
|
|
||||||
assert!(wrapped.len() > 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_disabled_key_cannot_encrypt() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let kms = KmsService::new(dir.path()).await.unwrap();
|
|
||||||
|
|
||||||
let key = kms.create_key("disabled").await.unwrap();
|
|
||||||
kms.disable_key(&key.key_id).await.unwrap();
|
|
||||||
|
|
||||||
let result = kms.encrypt_data(&key.key_id, b"test").await;
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_persistence_across_reload() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
|
|
||||||
let key_id = {
|
|
||||||
let kms = KmsService::new(dir.path()).await.unwrap();
|
|
||||||
let key = kms.create_key("persistent").await.unwrap();
|
|
||||||
key.key_id
|
|
||||||
};
|
|
||||||
|
|
||||||
let kms2 = KmsService::new(dir.path()).await.unwrap();
|
|
||||||
let key = kms2.get_key(&key_id).await;
|
|
||||||
assert!(key.is_some());
|
|
||||||
assert_eq!(key.unwrap().description, "persistent");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_master_key_roundtrip() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let key1 = load_or_create_master_key(dir.path()).await.unwrap();
|
|
||||||
let key2 = load_or_create_master_key(dir.path()).await.unwrap();
|
|
||||||
assert_eq!(key1, key2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
pub mod aes_gcm;
|
|
||||||
pub mod encryption;
|
|
||||||
pub mod hashing;
|
|
||||||
pub mod kms;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "myfsio-server"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
myfsio-common = { path = "../myfsio-common" }
|
|
||||||
myfsio-auth = { path = "../myfsio-auth" }
|
|
||||||
myfsio-crypto = { path = "../myfsio-crypto" }
|
|
||||||
myfsio-storage = { path = "../myfsio-storage" }
|
|
||||||
myfsio-xml = { path = "../myfsio-xml" }
|
|
||||||
base64 = { workspace = true }
|
|
||||||
md-5 = { workspace = true }
|
|
||||||
axum = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tower = { workspace = true }
|
|
||||||
tower-http = { workspace = true }
|
|
||||||
hyper = { workspace = true }
|
|
||||||
bytes = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
tracing-subscriber = { workspace = true }
|
|
||||||
tokio-util = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
futures = { workspace = true }
|
|
||||||
http-body-util = "0.1"
|
|
||||||
percent-encoding = { workspace = true }
|
|
||||||
quick-xml = { workspace = true }
|
|
||||||
mime_guess = "2"
|
|
||||||
crc32fast = { workspace = true }
|
|
||||||
sha2 = { workspace = true }
|
|
||||||
duckdb = { workspace = true }
|
|
||||||
roxmltree = "0.20"
|
|
||||||
parking_lot = { workspace = true }
|
|
||||||
regex = "1"
|
|
||||||
multer = "3"
|
|
||||||
reqwest = { workspace = true }
|
|
||||||
aws-sdk-s3 = { workspace = true }
|
|
||||||
aws-config = { workspace = true }
|
|
||||||
aws-credential-types = { workspace = true }
|
|
||||||
aws-smithy-types = { workspace = true }
|
|
||||||
async-trait = { workspace = true }
|
|
||||||
rand = "0.8"
|
|
||||||
tera = { workspace = true }
|
|
||||||
cookie = { workspace = true }
|
|
||||||
subtle = { workspace = true }
|
|
||||||
clap = { workspace = true }
|
|
||||||
dotenvy = { workspace = true }
|
|
||||||
sysinfo = "0.32"
|
|
||||||
aes-gcm = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
tower = { workspace = true, features = ["util"] }
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
use std::net::SocketAddr;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ServerConfig {
|
|
||||||
pub bind_addr: SocketAddr,
|
|
||||||
pub ui_bind_addr: SocketAddr,
|
|
||||||
pub storage_root: PathBuf,
|
|
||||||
pub region: String,
|
|
||||||
pub iam_config_path: PathBuf,
|
|
||||||
pub sigv4_timestamp_tolerance_secs: u64,
|
|
||||||
pub presigned_url_min_expiry: u64,
|
|
||||||
pub presigned_url_max_expiry: u64,
|
|
||||||
pub secret_key: Option<String>,
|
|
||||||
pub encryption_enabled: bool,
|
|
||||||
pub kms_enabled: bool,
|
|
||||||
pub gc_enabled: bool,
|
|
||||||
pub integrity_enabled: bool,
|
|
||||||
pub metrics_enabled: bool,
|
|
||||||
pub metrics_history_enabled: bool,
|
|
||||||
pub metrics_interval_minutes: u64,
|
|
||||||
pub metrics_retention_hours: u64,
|
|
||||||
pub metrics_history_interval_minutes: u64,
|
|
||||||
pub metrics_history_retention_hours: u64,
|
|
||||||
pub lifecycle_enabled: bool,
|
|
||||||
pub website_hosting_enabled: bool,
|
|
||||||
pub replication_connect_timeout_secs: u64,
|
|
||||||
pub replication_read_timeout_secs: u64,
|
|
||||||
pub replication_max_retries: u32,
|
|
||||||
pub replication_streaming_threshold_bytes: u64,
|
|
||||||
pub replication_max_failures_per_bucket: usize,
|
|
||||||
pub site_sync_enabled: bool,
|
|
||||||
pub site_sync_interval_secs: u64,
|
|
||||||
pub site_sync_batch_size: usize,
|
|
||||||
pub site_sync_connect_timeout_secs: u64,
|
|
||||||
pub site_sync_read_timeout_secs: u64,
|
|
||||||
pub site_sync_max_retries: u32,
|
|
||||||
pub site_sync_clock_skew_tolerance: f64,
|
|
||||||
pub ui_enabled: bool,
|
|
||||||
pub templates_dir: PathBuf,
|
|
||||||
pub static_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServerConfig {
|
|
||||||
pub fn from_env() -> Self {
|
|
||||||
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
|
||||||
let port: u16 = std::env::var("PORT")
|
|
||||||
.unwrap_or_else(|_| "5000".to_string())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(5000);
|
|
||||||
let ui_port: u16 = std::env::var("UI_PORT")
|
|
||||||
.unwrap_or_else(|_| "5100".to_string())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(5100);
|
|
||||||
let storage_root = std::env::var("STORAGE_ROOT").unwrap_or_else(|_| "./data".to_string());
|
|
||||||
let region = std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
|
||||||
|
|
||||||
let storage_path = PathBuf::from(&storage_root);
|
|
||||||
let iam_config_path = std::env::var("IAM_CONFIG")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| {
|
|
||||||
storage_path
|
|
||||||
.join(".myfsio.sys")
|
|
||||||
.join("config")
|
|
||||||
.join("iam.json")
|
|
||||||
});
|
|
||||||
|
|
||||||
let sigv4_timestamp_tolerance_secs: u64 =
|
|
||||||
std::env::var("SIGV4_TIMESTAMP_TOLERANCE_SECONDS")
|
|
||||||
.unwrap_or_else(|_| "900".to_string())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(900);
|
|
||||||
|
|
||||||
let presigned_url_min_expiry: u64 = std::env::var("PRESIGNED_URL_MIN_EXPIRY_SECONDS")
|
|
||||||
.unwrap_or_else(|_| "1".to_string())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(1);
|
|
||||||
|
|
||||||
let presigned_url_max_expiry: u64 = std::env::var("PRESIGNED_URL_MAX_EXPIRY_SECONDS")
|
|
||||||
.unwrap_or_else(|_| "604800".to_string())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(604800);
|
|
||||||
|
|
||||||
let secret_key = {
|
|
||||||
let env_key = std::env::var("SECRET_KEY").ok();
|
|
||||||
match env_key {
|
|
||||||
Some(k) if !k.is_empty() && k != "dev-secret-key" => Some(k),
|
|
||||||
_ => {
|
|
||||||
let secret_file = storage_path
|
|
||||||
.join(".myfsio.sys")
|
|
||||||
.join("config")
|
|
||||||
.join(".secret");
|
|
||||||
std::fs::read_to_string(&secret_file)
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let encryption_enabled = parse_bool_env("ENCRYPTION_ENABLED", false);
|
|
||||||
|
|
||||||
let kms_enabled = parse_bool_env("KMS_ENABLED", false);
|
|
||||||
|
|
||||||
let gc_enabled = parse_bool_env("GC_ENABLED", false);
|
|
||||||
|
|
||||||
let integrity_enabled = parse_bool_env("INTEGRITY_ENABLED", false);
|
|
||||||
|
|
||||||
let metrics_enabled = parse_bool_env("OPERATION_METRICS_ENABLED", false);
|
|
||||||
|
|
||||||
let metrics_history_enabled = parse_bool_env("METRICS_HISTORY_ENABLED", false);
|
|
||||||
|
|
||||||
let metrics_interval_minutes = parse_u64_env("OPERATION_METRICS_INTERVAL_MINUTES", 5);
|
|
||||||
let metrics_retention_hours = parse_u64_env("OPERATION_METRICS_RETENTION_HOURS", 24);
|
|
||||||
let metrics_history_interval_minutes = parse_u64_env("METRICS_HISTORY_INTERVAL_MINUTES", 5);
|
|
||||||
let metrics_history_retention_hours = parse_u64_env("METRICS_HISTORY_RETENTION_HOURS", 24);
|
|
||||||
|
|
||||||
let lifecycle_enabled = parse_bool_env("LIFECYCLE_ENABLED", false);
|
|
||||||
|
|
||||||
let website_hosting_enabled = parse_bool_env("WEBSITE_HOSTING_ENABLED", false);
|
|
||||||
|
|
||||||
let replication_connect_timeout_secs =
|
|
||||||
parse_u64_env("REPLICATION_CONNECT_TIMEOUT_SECONDS", 5);
|
|
||||||
let replication_read_timeout_secs = parse_u64_env("REPLICATION_READ_TIMEOUT_SECONDS", 30);
|
|
||||||
let replication_max_retries = parse_u64_env("REPLICATION_MAX_RETRIES", 2) as u32;
|
|
||||||
let replication_streaming_threshold_bytes =
|
|
||||||
parse_u64_env("REPLICATION_STREAMING_THRESHOLD_BYTES", 10_485_760);
|
|
||||||
let replication_max_failures_per_bucket =
|
|
||||||
parse_u64_env("REPLICATION_MAX_FAILURES_PER_BUCKET", 50) as usize;
|
|
||||||
|
|
||||||
let site_sync_enabled = parse_bool_env("SITE_SYNC_ENABLED", false);
|
|
||||||
let site_sync_interval_secs = parse_u64_env("SITE_SYNC_INTERVAL_SECONDS", 60);
|
|
||||||
let site_sync_batch_size = parse_u64_env("SITE_SYNC_BATCH_SIZE", 100) as usize;
|
|
||||||
let site_sync_connect_timeout_secs = parse_u64_env("SITE_SYNC_CONNECT_TIMEOUT_SECONDS", 10);
|
|
||||||
let site_sync_read_timeout_secs = parse_u64_env("SITE_SYNC_READ_TIMEOUT_SECONDS", 120);
|
|
||||||
let site_sync_max_retries = parse_u64_env("SITE_SYNC_MAX_RETRIES", 2) as u32;
|
|
||||||
let site_sync_clock_skew_tolerance: f64 =
|
|
||||||
std::env::var("SITE_SYNC_CLOCK_SKEW_TOLERANCE_SECONDS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.unwrap_or(1.0);
|
|
||||||
|
|
||||||
let ui_enabled = parse_bool_env("UI_ENABLED", true);
|
|
||||||
let templates_dir = std::env::var("TEMPLATES_DIR")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| default_templates_dir());
|
|
||||||
let static_dir = std::env::var("STATIC_DIR")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| default_static_dir());
|
|
||||||
|
|
||||||
let host_ip: std::net::IpAddr = host.parse().unwrap();
|
|
||||||
Self {
|
|
||||||
bind_addr: SocketAddr::new(host_ip, port),
|
|
||||||
ui_bind_addr: SocketAddr::new(host_ip, ui_port),
|
|
||||||
storage_root: storage_path,
|
|
||||||
region,
|
|
||||||
iam_config_path,
|
|
||||||
sigv4_timestamp_tolerance_secs,
|
|
||||||
presigned_url_min_expiry,
|
|
||||||
presigned_url_max_expiry,
|
|
||||||
secret_key,
|
|
||||||
encryption_enabled,
|
|
||||||
kms_enabled,
|
|
||||||
gc_enabled,
|
|
||||||
integrity_enabled,
|
|
||||||
metrics_enabled,
|
|
||||||
metrics_history_enabled,
|
|
||||||
metrics_interval_minutes,
|
|
||||||
metrics_retention_hours,
|
|
||||||
metrics_history_interval_minutes,
|
|
||||||
metrics_history_retention_hours,
|
|
||||||
lifecycle_enabled,
|
|
||||||
website_hosting_enabled,
|
|
||||||
replication_connect_timeout_secs,
|
|
||||||
replication_read_timeout_secs,
|
|
||||||
replication_max_retries,
|
|
||||||
replication_streaming_threshold_bytes,
|
|
||||||
replication_max_failures_per_bucket,
|
|
||||||
site_sync_enabled,
|
|
||||||
site_sync_interval_secs,
|
|
||||||
site_sync_batch_size,
|
|
||||||
site_sync_connect_timeout_secs,
|
|
||||||
site_sync_read_timeout_secs,
|
|
||||||
site_sync_max_retries,
|
|
||||||
site_sync_clock_skew_tolerance,
|
|
||||||
ui_enabled,
|
|
||||||
templates_dir,
|
|
||||||
static_dir,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_templates_dir() -> PathBuf {
|
|
||||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
||||||
manifest_dir.join("templates")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_static_dir() -> PathBuf {
|
|
||||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
||||||
for candidate in [
|
|
||||||
manifest_dir.join("static"),
|
|
||||||
manifest_dir.join("..").join("..").join("..").join("static"),
|
|
||||||
] {
|
|
||||||
if candidate.exists() {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
manifest_dir.join("static")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_u64_env(key: &str, default: u64) -> u64 {
|
|
||||||
std::env::var(key)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.unwrap_or(default)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_bool_env(key: &str, default: bool) -> bool {
|
|
||||||
std::env::var(key)
|
|
||||||
.ok()
|
|
||||||
.map(|value| {
|
|
||||||
matches!(
|
|
||||||
value.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"1" | "true" | "yes" | "on"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or(default)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,184 +0,0 @@
|
|||||||
use std::pin::Pin;
|
|
||||||
use std::task::{Context, Poll};
|
|
||||||
|
|
||||||
use bytes::{Buf, BytesMut};
|
|
||||||
use tokio::io::{AsyncRead, ReadBuf};
|
|
||||||
|
|
||||||
enum State {
|
|
||||||
ReadSize,
|
|
||||||
ReadData(u64),
|
|
||||||
ReadTrailer,
|
|
||||||
Finished,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AwsChunkedStream<S> {
|
|
||||||
inner: S,
|
|
||||||
buffer: BytesMut,
|
|
||||||
state: State,
|
|
||||||
pending: BytesMut,
|
|
||||||
eof: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S> AwsChunkedStream<S> {
|
|
||||||
pub fn new(inner: S) -> Self {
|
|
||||||
Self {
|
|
||||||
inner,
|
|
||||||
buffer: BytesMut::with_capacity(8192),
|
|
||||||
state: State::ReadSize,
|
|
||||||
pending: BytesMut::new(),
|
|
||||||
eof: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_crlf(&self) -> Option<usize> {
|
|
||||||
for i in 0..self.buffer.len().saturating_sub(1) {
|
|
||||||
if self.buffer[i] == b'\r' && self.buffer[i + 1] == b'\n' {
|
|
||||||
return Some(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_chunk_size(line: &[u8]) -> std::io::Result<u64> {
|
|
||||||
let text = std::str::from_utf8(line).map_err(|_| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"invalid chunk size encoding",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let head = text.split(';').next().unwrap_or("").trim();
|
|
||||||
u64::from_str_radix(head, 16).map_err(|_| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
format!("invalid chunk size: {}", head),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_advance(&mut self, out: &mut ReadBuf<'_>) -> std::io::Result<bool> {
|
|
||||||
loop {
|
|
||||||
if out.remaining() == 0 {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.pending.is_empty() {
|
|
||||||
let take = std::cmp::min(self.pending.len(), out.remaining());
|
|
||||||
out.put_slice(&self.pending[..take]);
|
|
||||||
self.pending.advance(take);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.state {
|
|
||||||
State::Finished => return Ok(true),
|
|
||||||
State::ReadSize => {
|
|
||||||
let idx = match self.find_crlf() {
|
|
||||||
Some(i) => i,
|
|
||||||
None => return Ok(false),
|
|
||||||
};
|
|
||||||
let line = self.buffer.split_to(idx);
|
|
||||||
self.buffer.advance(2);
|
|
||||||
let size = Self::parse_chunk_size(&line)?;
|
|
||||||
if size == 0 {
|
|
||||||
self.state = State::ReadTrailer;
|
|
||||||
} else {
|
|
||||||
self.state = State::ReadData(size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
State::ReadData(remaining) => {
|
|
||||||
if self.buffer.is_empty() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let avail = std::cmp::min(self.buffer.len() as u64, remaining) as usize;
|
|
||||||
let take = std::cmp::min(avail, out.remaining());
|
|
||||||
out.put_slice(&self.buffer[..take]);
|
|
||||||
self.buffer.advance(take);
|
|
||||||
let new_remaining = remaining - take as u64;
|
|
||||||
if new_remaining == 0 {
|
|
||||||
if self.buffer.len() < 2 {
|
|
||||||
self.state = State::ReadData(0);
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
if &self.buffer[..2] != b"\r\n" {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"malformed chunk terminator",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.buffer.advance(2);
|
|
||||||
self.state = State::ReadSize;
|
|
||||||
} else {
|
|
||||||
self.state = State::ReadData(new_remaining);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
State::ReadTrailer => {
|
|
||||||
let idx = match self.find_crlf() {
|
|
||||||
Some(i) => i,
|
|
||||||
None => return Ok(false),
|
|
||||||
};
|
|
||||||
if idx == 0 {
|
|
||||||
self.buffer.advance(2);
|
|
||||||
self.state = State::Finished;
|
|
||||||
} else {
|
|
||||||
self.buffer.advance(idx + 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S> AsyncRead for AwsChunkedStream<S>
|
|
||||||
where
|
|
||||||
S: AsyncRead + Unpin,
|
|
||||||
{
|
|
||||||
fn poll_read(
|
|
||||||
mut self: Pin<&mut Self>,
|
|
||||||
cx: &mut Context<'_>,
|
|
||||||
buf: &mut ReadBuf<'_>,
|
|
||||||
) -> Poll<std::io::Result<()>> {
|
|
||||||
loop {
|
|
||||||
let before = buf.filled().len();
|
|
||||||
let done = match self.try_advance(buf) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => return Poll::Ready(Err(e)),
|
|
||||||
};
|
|
||||||
if buf.filled().len() > before {
|
|
||||||
return Poll::Ready(Ok(()));
|
|
||||||
}
|
|
||||||
if done {
|
|
||||||
return Poll::Ready(Ok(()));
|
|
||||||
}
|
|
||||||
if self.eof {
|
|
||||||
return Poll::Ready(Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::UnexpectedEof,
|
|
||||||
"unexpected EOF in aws-chunked stream",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tmp = [0u8; 8192];
|
|
||||||
let mut rb = ReadBuf::new(&mut tmp);
|
|
||||||
match Pin::new(&mut self.inner).poll_read(cx, &mut rb) {
|
|
||||||
Poll::Ready(Ok(())) => {
|
|
||||||
let n = rb.filled().len();
|
|
||||||
if n == 0 {
|
|
||||||
self.eof = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
self.buffer.extend_from_slice(rb.filled());
|
|
||||||
}
|
|
||||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
|
||||||
Poll::Pending => return Poll::Pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_body(body: axum::body::Body) -> impl AsyncRead + Send + Unpin {
|
|
||||||
use futures::TryStreamExt;
|
|
||||||
let stream = tokio_util::io::StreamReader::new(
|
|
||||||
http_body_util::BodyStream::new(body)
|
|
||||||
.map_ok(|frame| frame.into_data().unwrap_or_default())
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)),
|
|
||||||
);
|
|
||||||
AwsChunkedStream::new(stream)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,541 +0,0 @@
|
|||||||
use aes_gcm::aead::Aead;
|
|
||||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
|
||||||
use base64::Engine;
|
|
||||||
use rand::RngCore;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
fn json_ok(value: Value) -> Response {
|
|
||||||
(
|
|
||||||
StatusCode::OK,
|
|
||||||
[("content-type", "application/json")],
|
|
||||||
value.to_string(),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_err(status: StatusCode, msg: &str) -> Response {
|
|
||||||
(
|
|
||||||
status,
|
|
||||||
[("content-type", "application/json")],
|
|
||||||
json!({"error": msg}).to_string(),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_json(body: Body) -> Result<Value, Response> {
|
|
||||||
let body_bytes = http_body_util::BodyExt::collect(body)
|
|
||||||
.await
|
|
||||||
.map_err(|_| json_err(StatusCode::BAD_REQUEST, "Invalid request body"))?
|
|
||||||
.to_bytes();
|
|
||||||
if body_bytes.is_empty() {
|
|
||||||
Ok(json!({}))
|
|
||||||
} else {
|
|
||||||
serde_json::from_slice(&body_bytes)
|
|
||||||
.map_err(|_| json_err(StatusCode::BAD_REQUEST, "Invalid JSON"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_kms(
|
|
||||||
state: &AppState,
|
|
||||||
) -> Result<&std::sync::Arc<myfsio_crypto::kms::KmsService>, Response> {
|
|
||||||
state
|
|
||||||
.kms
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| json_err(StatusCode::SERVICE_UNAVAILABLE, "KMS not enabled"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_b64(value: &str, field: &str) -> Result<Vec<u8>, Response> {
|
|
||||||
B64.decode(value).map_err(|_| {
|
|
||||||
json_err(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
&format!("Invalid base64 {}", field),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_str<'a>(value: &'a Value, names: &[&str], message: &str) -> Result<&'a str, Response> {
|
|
||||||
for name in names {
|
|
||||||
if let Some(found) = value.get(*name).and_then(|v| v.as_str()) {
|
|
||||||
return Ok(found);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(json_err(StatusCode::BAD_REQUEST, message))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_keys(State(state): State<AppState>) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let keys = kms.list_keys().await;
|
|
||||||
let keys_json: Vec<Value> = keys
|
|
||||||
.iter()
|
|
||||||
.map(|k| {
|
|
||||||
json!({
|
|
||||||
"KeyId": k.key_id,
|
|
||||||
"Arn": k.arn,
|
|
||||||
"Description": k.description,
|
|
||||||
"CreationDate": k.creation_date.to_rfc3339(),
|
|
||||||
"Enabled": k.enabled,
|
|
||||||
"KeyState": k.key_state,
|
|
||||||
"KeyUsage": k.key_usage,
|
|
||||||
"KeySpec": k.key_spec,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
json_ok(json!({"keys": keys_json}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_key(State(state): State<AppState>, body: Body) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let req = match read_json(body).await {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let description = req
|
|
||||||
.get("Description")
|
|
||||||
.or_else(|| req.get("description"))
|
|
||||||
.and_then(|d| d.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
match kms.create_key(description).await {
|
|
||||||
Ok(key) => json_ok(json!({
|
|
||||||
"KeyId": key.key_id,
|
|
||||||
"Arn": key.arn,
|
|
||||||
"Description": key.description,
|
|
||||||
"CreationDate": key.creation_date.to_rfc3339(),
|
|
||||||
"Enabled": key.enabled,
|
|
||||||
"KeyState": key.key_state,
|
|
||||||
})),
|
|
||||||
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_key(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
axum::extract::Path(key_id): axum::extract::Path<String>,
|
|
||||||
) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
match kms.get_key(&key_id).await {
|
|
||||||
Some(key) => json_ok(json!({
|
|
||||||
"KeyId": key.key_id,
|
|
||||||
"Arn": key.arn,
|
|
||||||
"Description": key.description,
|
|
||||||
"CreationDate": key.creation_date.to_rfc3339(),
|
|
||||||
"Enabled": key.enabled,
|
|
||||||
"KeyState": key.key_state,
|
|
||||||
"KeyUsage": key.key_usage,
|
|
||||||
"KeySpec": key.key_spec,
|
|
||||||
})),
|
|
||||||
None => json_err(StatusCode::NOT_FOUND, "Key not found"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_key(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
axum::extract::Path(key_id): axum::extract::Path<String>,
|
|
||||||
) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
match kms.delete_key(&key_id).await {
|
|
||||||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
|
||||||
Ok(false) => json_err(StatusCode::NOT_FOUND, "Key not found"),
|
|
||||||
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn enable_key(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
axum::extract::Path(key_id): axum::extract::Path<String>,
|
|
||||||
) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
match kms.enable_key(&key_id).await {
|
|
||||||
Ok(true) => json_ok(json!({"status": "enabled"})),
|
|
||||||
Ok(false) => json_err(StatusCode::NOT_FOUND, "Key not found"),
|
|
||||||
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn disable_key(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
axum::extract::Path(key_id): axum::extract::Path<String>,
|
|
||||||
) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
match kms.disable_key(&key_id).await {
|
|
||||||
Ok(true) => json_ok(json!({"status": "disabled"})),
|
|
||||||
Ok(false) => json_err(StatusCode::NOT_FOUND, "Key not found"),
|
|
||||||
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn encrypt(State(state): State<AppState>, body: Body) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let req = match read_json(body).await {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let key_id = match require_str(&req, &["KeyId", "key_id"], "Missing KeyId") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let plaintext_b64 = match require_str(&req, &["Plaintext", "plaintext"], "Missing Plaintext") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let plaintext = match decode_b64(plaintext_b64, "Plaintext") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
match kms.encrypt_data(key_id, &plaintext).await {
|
|
||||||
Ok(ct) => json_ok(json!({
|
|
||||||
"KeyId": key_id,
|
|
||||||
"CiphertextBlob": B64.encode(&ct),
|
|
||||||
})),
|
|
||||||
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn decrypt(State(state): State<AppState>, body: Body) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let req = match read_json(body).await {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let key_id = match require_str(&req, &["KeyId", "key_id"], "Missing KeyId") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let ciphertext_b64 = match require_str(
|
|
||||||
&req,
|
|
||||||
&["CiphertextBlob", "ciphertext_blob"],
|
|
||||||
"Missing CiphertextBlob",
|
|
||||||
) {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let ciphertext = match decode_b64(ciphertext_b64, "CiphertextBlob") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
match kms.decrypt_data(key_id, &ciphertext).await {
|
|
||||||
Ok(pt) => json_ok(json!({
|
|
||||||
"KeyId": key_id,
|
|
||||||
"Plaintext": B64.encode(&pt),
|
|
||||||
})),
|
|
||||||
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn generate_data_key(State(state): State<AppState>, body: Body) -> Response {
|
|
||||||
generate_data_key_inner(state, body, true).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn generate_data_key_without_plaintext(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
body: Body,
|
|
||||||
) -> Response {
|
|
||||||
generate_data_key_inner(state, body, false).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_data_key_inner(state: AppState, body: Body, include_plaintext: bool) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let req = match read_json(body).await {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let key_id = match require_str(&req, &["KeyId", "key_id"], "Missing KeyId") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let num_bytes = req
|
|
||||||
.get("NumberOfBytes")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(32) as usize;
|
|
||||||
|
|
||||||
if !(1..=1024).contains(&num_bytes) {
|
|
||||||
return json_err(StatusCode::BAD_REQUEST, "NumberOfBytes must be 1-1024");
|
|
||||||
}
|
|
||||||
|
|
||||||
match kms.generate_data_key(key_id, num_bytes).await {
|
|
||||||
Ok((plaintext, wrapped)) => {
|
|
||||||
let mut value = json!({
|
|
||||||
"KeyId": key_id,
|
|
||||||
"CiphertextBlob": B64.encode(&wrapped),
|
|
||||||
});
|
|
||||||
if include_plaintext {
|
|
||||||
value["Plaintext"] = json!(B64.encode(&plaintext));
|
|
||||||
}
|
|
||||||
json_ok(value)
|
|
||||||
}
|
|
||||||
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn re_encrypt(State(state): State<AppState>, body: Body) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let req = match read_json(body).await {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let ciphertext_b64 = match require_str(
|
|
||||||
&req,
|
|
||||||
&["CiphertextBlob", "ciphertext_blob"],
|
|
||||||
"CiphertextBlob is required",
|
|
||||||
) {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let destination_key_id = match require_str(
|
|
||||||
&req,
|
|
||||||
&["DestinationKeyId", "destination_key_id"],
|
|
||||||
"DestinationKeyId is required",
|
|
||||||
) {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let ciphertext = match decode_b64(ciphertext_b64, "CiphertextBlob") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let keys = kms.list_keys().await;
|
|
||||||
let mut source_key_id: Option<String> = None;
|
|
||||||
let mut plaintext: Option<Vec<u8>> = None;
|
|
||||||
for key in keys {
|
|
||||||
if !key.enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Ok(value) = kms.decrypt_data(&key.key_id, &ciphertext).await {
|
|
||||||
source_key_id = Some(key.key_id);
|
|
||||||
plaintext = Some(value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(source_key_id) = source_key_id else {
|
|
||||||
return json_err(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"Could not determine source key for CiphertextBlob",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
let plaintext = plaintext.unwrap_or_default();
|
|
||||||
|
|
||||||
match kms.encrypt_data(destination_key_id, &plaintext).await {
|
|
||||||
Ok(new_ciphertext) => json_ok(json!({
|
|
||||||
"CiphertextBlob": B64.encode(&new_ciphertext),
|
|
||||||
"SourceKeyId": source_key_id,
|
|
||||||
"KeyId": destination_key_id,
|
|
||||||
})),
|
|
||||||
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn generate_random(State(state): State<AppState>, body: Body) -> Response {
|
|
||||||
if let Err(response) = require_kms(&state) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
let req = match read_json(body).await {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let num_bytes = req
|
|
||||||
.get("NumberOfBytes")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(32) as usize;
|
|
||||||
|
|
||||||
if !(1..=1024).contains(&num_bytes) {
|
|
||||||
return json_err(StatusCode::BAD_REQUEST, "NumberOfBytes must be 1-1024");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut bytes = vec![0u8; num_bytes];
|
|
||||||
rand::thread_rng().fill_bytes(&mut bytes);
|
|
||||||
json_ok(json!({
|
|
||||||
"Plaintext": B64.encode(bytes),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn client_generate_key(State(state): State<AppState>) -> Response {
|
|
||||||
let _ = state;
|
|
||||||
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut key);
|
|
||||||
json_ok(json!({
|
|
||||||
"Key": B64.encode(key),
|
|
||||||
"Algorithm": "AES-256-GCM",
|
|
||||||
"KeySize": 32,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn client_encrypt(State(state): State<AppState>, body: Body) -> Response {
|
|
||||||
let _ = state;
|
|
||||||
let req = match read_json(body).await {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let plaintext_b64 =
|
|
||||||
match require_str(&req, &["Plaintext", "plaintext"], "Plaintext is required") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let key_b64 = match require_str(&req, &["Key", "key"], "Key is required") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let plaintext = match decode_b64(plaintext_b64, "Plaintext") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let key_bytes = match decode_b64(key_b64, "Key") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
if key_bytes.len() != 32 {
|
|
||||||
return json_err(StatusCode::BAD_REQUEST, "Key must decode to 32 bytes");
|
|
||||||
}
|
|
||||||
|
|
||||||
let cipher = match Aes256Gcm::new_from_slice(&key_bytes) {
|
|
||||||
Ok(cipher) => cipher,
|
|
||||||
Err(_) => return json_err(StatusCode::BAD_REQUEST, "Invalid encryption key"),
|
|
||||||
};
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
match cipher.encrypt(nonce, plaintext.as_ref()) {
|
|
||||||
Ok(ciphertext) => json_ok(json!({
|
|
||||||
"Ciphertext": B64.encode(ciphertext),
|
|
||||||
"Nonce": B64.encode(nonce_bytes),
|
|
||||||
"Algorithm": "AES-256-GCM",
|
|
||||||
})),
|
|
||||||
Err(e) => json_err(StatusCode::BAD_REQUEST, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn client_decrypt(State(state): State<AppState>, body: Body) -> Response {
|
|
||||||
let _ = state;
|
|
||||||
let req = match read_json(body).await {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let ciphertext_b64 = match require_str(
|
|
||||||
&req,
|
|
||||||
&["Ciphertext", "ciphertext"],
|
|
||||||
"Ciphertext is required",
|
|
||||||
) {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let nonce_b64 = match require_str(&req, &["Nonce", "nonce"], "Nonce is required") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let key_b64 = match require_str(&req, &["Key", "key"], "Key is required") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let ciphertext = match decode_b64(ciphertext_b64, "Ciphertext") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let nonce_bytes = match decode_b64(nonce_b64, "Nonce") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let key_bytes = match decode_b64(key_b64, "Key") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
if key_bytes.len() != 32 {
|
|
||||||
return json_err(StatusCode::BAD_REQUEST, "Key must decode to 32 bytes");
|
|
||||||
}
|
|
||||||
if nonce_bytes.len() != 12 {
|
|
||||||
return json_err(StatusCode::BAD_REQUEST, "Nonce must decode to 12 bytes");
|
|
||||||
}
|
|
||||||
|
|
||||||
let cipher = match Aes256Gcm::new_from_slice(&key_bytes) {
|
|
||||||
Ok(cipher) => cipher,
|
|
||||||
Err(_) => return json_err(StatusCode::BAD_REQUEST, "Invalid encryption key"),
|
|
||||||
};
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
match cipher.decrypt(nonce, ciphertext.as_ref()) {
|
|
||||||
Ok(plaintext) => json_ok(json!({
|
|
||||||
"Plaintext": B64.encode(plaintext),
|
|
||||||
})),
|
|
||||||
Err(e) => json_err(StatusCode::BAD_REQUEST, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn materials(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
axum::extract::Path(key_id): axum::extract::Path<String>,
|
|
||||||
body: Body,
|
|
||||||
) -> Response {
|
|
||||||
let kms = match require_kms(&state) {
|
|
||||||
Ok(kms) => kms,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let _ = match read_json(body).await {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
match kms.generate_data_key(&key_id, 32).await {
|
|
||||||
Ok((plaintext, wrapped)) => json_ok(json!({
|
|
||||||
"PlaintextKey": B64.encode(plaintext),
|
|
||||||
"EncryptedKey": B64.encode(wrapped),
|
|
||||||
"KeyId": key_id,
|
|
||||||
"Algorithm": "AES-256-GCM",
|
|
||||||
"KeyWrapAlgorithm": "kms",
|
|
||||||
})),
|
|
||||||
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,578 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{HeaderMap, HeaderName, StatusCode};
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use base64::Engine;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use crc32fast::Hasher;
|
|
||||||
use duckdb::types::ValueRef;
|
|
||||||
use duckdb::Connection;
|
|
||||||
use futures::stream;
|
|
||||||
use http_body_util::BodyExt;
|
|
||||||
use myfsio_common::error::{S3Error, S3ErrorCode};
|
|
||||||
use myfsio_storage::traits::StorageEngine;
|
|
||||||
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
#[link(name = "Rstrtmgr")]
|
|
||||||
extern "system" {}
|
|
||||||
|
|
||||||
const CHUNK_SIZE: usize = 65_536;
|
|
||||||
|
|
||||||
pub async fn post_select_object_content(
|
|
||||||
state: &AppState,
|
|
||||||
bucket: &str,
|
|
||||||
key: &str,
|
|
||||||
headers: &HeaderMap,
|
|
||||||
body: Body,
|
|
||||||
) -> Response {
|
|
||||||
if let Some(resp) = require_xml_content_type(headers) {
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
let body_bytes = match body.collect().await {
|
|
||||||
Ok(collected) => collected.to_bytes(),
|
|
||||||
Err(_) => {
|
|
||||||
return s3_error_response(S3Error::new(
|
|
||||||
S3ErrorCode::MalformedXML,
|
|
||||||
"Unable to parse XML document",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let request = match parse_select_request(&body_bytes) {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(err) => return s3_error_response(err),
|
|
||||||
};
|
|
||||||
|
|
||||||
let object_path = match state.storage.get_object_path(bucket, key).await {
|
|
||||||
Ok(path) => path,
|
|
||||||
Err(_) => {
|
|
||||||
return s3_error_response(S3Error::new(S3ErrorCode::NoSuchKey, "Object not found"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let join_res =
|
|
||||||
tokio::task::spawn_blocking(move || execute_select_query(object_path, request)).await;
|
|
||||||
let chunks = match join_res {
|
|
||||||
Ok(Ok(chunks)) => chunks,
|
|
||||||
Ok(Err(message)) => {
|
|
||||||
return s3_error_response(S3Error::new(S3ErrorCode::InvalidRequest, message));
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return s3_error_response(S3Error::new(
|
|
||||||
S3ErrorCode::InternalError,
|
|
||||||
"SelectObjectContent execution failed",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let bytes_returned: usize = chunks.iter().map(|c| c.len()).sum();
|
|
||||||
let mut events: Vec<Bytes> = Vec::with_capacity(chunks.len() + 2);
|
|
||||||
for chunk in chunks {
|
|
||||||
events.push(Bytes::from(encode_select_event("Records", &chunk)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let stats_payload = build_stats_xml(0, bytes_returned);
|
|
||||||
events.push(Bytes::from(encode_select_event(
|
|
||||||
"Stats",
|
|
||||||
stats_payload.as_bytes(),
|
|
||||||
)));
|
|
||||||
events.push(Bytes::from(encode_select_event("End", b"")));
|
|
||||||
|
|
||||||
let stream = stream::iter(events.into_iter().map(Ok::<Bytes, std::io::Error>));
|
|
||||||
let body = Body::from_stream(stream);
|
|
||||||
|
|
||||||
let mut response = (StatusCode::OK, body).into_response();
|
|
||||||
response.headers_mut().insert(
|
|
||||||
HeaderName::from_static("content-type"),
|
|
||||||
"application/octet-stream".parse().unwrap(),
|
|
||||||
);
|
|
||||||
response.headers_mut().insert(
|
|
||||||
HeaderName::from_static("x-amz-request-charged"),
|
|
||||||
"requester".parse().unwrap(),
|
|
||||||
);
|
|
||||||
response
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct SelectRequest {
|
|
||||||
expression: String,
|
|
||||||
input_format: InputFormat,
|
|
||||||
output_format: OutputFormat,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
enum InputFormat {
|
|
||||||
Csv(CsvInputConfig),
|
|
||||||
Json(JsonInputConfig),
|
|
||||||
Parquet,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct CsvInputConfig {
|
|
||||||
file_header_info: String,
|
|
||||||
field_delimiter: String,
|
|
||||||
quote_character: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct JsonInputConfig {
|
|
||||||
json_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
enum OutputFormat {
|
|
||||||
Csv(CsvOutputConfig),
|
|
||||||
Json(JsonOutputConfig),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct CsvOutputConfig {
|
|
||||||
field_delimiter: String,
|
|
||||||
record_delimiter: String,
|
|
||||||
quote_character: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct JsonOutputConfig {
|
|
||||||
record_delimiter: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_select_request(payload: &[u8]) -> Result<SelectRequest, S3Error> {
|
|
||||||
let xml = String::from_utf8_lossy(payload);
|
|
||||||
let doc = roxmltree::Document::parse(&xml)
|
|
||||||
.map_err(|_| S3Error::new(S3ErrorCode::MalformedXML, "Unable to parse XML document"))?;
|
|
||||||
|
|
||||||
let root = doc.root_element();
|
|
||||||
if root.tag_name().name() != "SelectObjectContentRequest" {
|
|
||||||
return Err(S3Error::new(
|
|
||||||
S3ErrorCode::MalformedXML,
|
|
||||||
"Root element must be SelectObjectContentRequest",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let expression = child_text(&root, "Expression")
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.ok_or_else(|| S3Error::new(S3ErrorCode::InvalidRequest, "Expression is required"))?;
|
|
||||||
|
|
||||||
let expression_type = child_text(&root, "ExpressionType").unwrap_or_else(|| "SQL".to_string());
|
|
||||||
if !expression_type.eq_ignore_ascii_case("SQL") {
|
|
||||||
return Err(S3Error::new(
|
|
||||||
S3ErrorCode::InvalidRequest,
|
|
||||||
"Only SQL expression type is supported",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let input_node = child(&root, "InputSerialization").ok_or_else(|| {
|
|
||||||
S3Error::new(
|
|
||||||
S3ErrorCode::InvalidRequest,
|
|
||||||
"InputSerialization is required",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let output_node = child(&root, "OutputSerialization").ok_or_else(|| {
|
|
||||||
S3Error::new(
|
|
||||||
S3ErrorCode::InvalidRequest,
|
|
||||||
"OutputSerialization is required",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let input_format = parse_input_format(&input_node)?;
|
|
||||||
let output_format = parse_output_format(&output_node)?;
|
|
||||||
|
|
||||||
Ok(SelectRequest {
|
|
||||||
expression,
|
|
||||||
input_format,
|
|
||||||
output_format,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_input_format(node: &roxmltree::Node<'_, '_>) -> Result<InputFormat, S3Error> {
|
|
||||||
if let Some(csv_node) = child(node, "CSV") {
|
|
||||||
return Ok(InputFormat::Csv(CsvInputConfig {
|
|
||||||
file_header_info: child_text(&csv_node, "FileHeaderInfo")
|
|
||||||
.unwrap_or_else(|| "NONE".to_string())
|
|
||||||
.to_ascii_uppercase(),
|
|
||||||
field_delimiter: child_text(&csv_node, "FieldDelimiter")
|
|
||||||
.unwrap_or_else(|| ",".to_string()),
|
|
||||||
quote_character: child_text(&csv_node, "QuoteCharacter")
|
|
||||||
.unwrap_or_else(|| "\"".to_string()),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(json_node) = child(node, "JSON") {
|
|
||||||
return Ok(InputFormat::Json(JsonInputConfig {
|
|
||||||
json_type: child_text(&json_node, "Type")
|
|
||||||
.unwrap_or_else(|| "DOCUMENT".to_string())
|
|
||||||
.to_ascii_uppercase(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if child(node, "Parquet").is_some() {
|
|
||||||
return Ok(InputFormat::Parquet);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(S3Error::new(
|
|
||||||
S3ErrorCode::InvalidRequest,
|
|
||||||
"InputSerialization must specify CSV, JSON, or Parquet",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_output_format(node: &roxmltree::Node<'_, '_>) -> Result<OutputFormat, S3Error> {
|
|
||||||
if let Some(csv_node) = child(node, "CSV") {
|
|
||||||
return Ok(OutputFormat::Csv(CsvOutputConfig {
|
|
||||||
field_delimiter: child_text(&csv_node, "FieldDelimiter")
|
|
||||||
.unwrap_or_else(|| ",".to_string()),
|
|
||||||
record_delimiter: child_text(&csv_node, "RecordDelimiter")
|
|
||||||
.unwrap_or_else(|| "\n".to_string()),
|
|
||||||
quote_character: child_text(&csv_node, "QuoteCharacter")
|
|
||||||
.unwrap_or_else(|| "\"".to_string()),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(json_node) = child(node, "JSON") {
|
|
||||||
return Ok(OutputFormat::Json(JsonOutputConfig {
|
|
||||||
record_delimiter: child_text(&json_node, "RecordDelimiter")
|
|
||||||
.unwrap_or_else(|| "\n".to_string()),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(S3Error::new(
|
|
||||||
S3ErrorCode::InvalidRequest,
|
|
||||||
"OutputSerialization must specify CSV or JSON",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn child<'a, 'input>(
|
|
||||||
node: &'a roxmltree::Node<'a, 'input>,
|
|
||||||
name: &str,
|
|
||||||
) -> Option<roxmltree::Node<'a, 'input>> {
|
|
||||||
node.children()
|
|
||||||
.find(|n| n.is_element() && n.tag_name().name() == name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn child_text(node: &roxmltree::Node<'_, '_>, name: &str) -> Option<String> {
|
|
||||||
child(node, name)
|
|
||||||
.and_then(|n| n.text())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute_select_query(path: PathBuf, request: SelectRequest) -> Result<Vec<Vec<u8>>, String> {
|
|
||||||
let conn =
|
|
||||||
Connection::open_in_memory().map_err(|e| format!("DuckDB connection error: {}", e))?;
|
|
||||||
|
|
||||||
load_input_table(&conn, &path, &request.input_format)?;
|
|
||||||
|
|
||||||
let expression = request
|
|
||||||
.expression
|
|
||||||
.replace("s3object", "data")
|
|
||||||
.replace("S3Object", "data");
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(&expression)
|
|
||||||
.map_err(|e| format!("SQL execution error: {}", e))?;
|
|
||||||
let mut rows = stmt
|
|
||||||
.query([])
|
|
||||||
.map_err(|e| format!("SQL execution error: {}", e))?;
|
|
||||||
let stmt_ref = rows
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| "SQL execution error: statement metadata unavailable".to_string())?;
|
|
||||||
let col_count = stmt_ref.column_count();
|
|
||||||
let mut columns: Vec<String> = Vec::with_capacity(col_count);
|
|
||||||
for i in 0..col_count {
|
|
||||||
let name = stmt_ref
|
|
||||||
.column_name(i)
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.unwrap_or_else(|_| format!("_{}", i));
|
|
||||||
columns.push(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
match request.output_format {
|
|
||||||
OutputFormat::Csv(cfg) => collect_csv_chunks(&mut rows, col_count, cfg),
|
|
||||||
OutputFormat::Json(cfg) => collect_json_chunks(&mut rows, col_count, &columns, cfg),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_input_table(conn: &Connection, path: &Path, input: &InputFormat) -> Result<(), String> {
|
|
||||||
let path_str = path.to_string_lossy().replace('\\', "/");
|
|
||||||
match input {
|
|
||||||
InputFormat::Csv(cfg) => {
|
|
||||||
let header = cfg.file_header_info == "USE" || cfg.file_header_info == "IGNORE";
|
|
||||||
let delimiter = normalize_single_char(&cfg.field_delimiter, ',');
|
|
||||||
let quote = normalize_single_char(&cfg.quote_character, '"');
|
|
||||||
|
|
||||||
let sql = format!(
|
|
||||||
"CREATE TABLE data AS SELECT * FROM read_csv('{}', header={}, delim='{}', quote='{}')",
|
|
||||||
sql_escape(&path_str),
|
|
||||||
if header { "true" } else { "false" },
|
|
||||||
sql_escape(&delimiter),
|
|
||||||
sql_escape("e)
|
|
||||||
);
|
|
||||||
conn.execute_batch(&sql)
|
|
||||||
.map_err(|e| format!("Failed loading CSV data: {}", e))?;
|
|
||||||
}
|
|
||||||
InputFormat::Json(cfg) => {
|
|
||||||
let format = if cfg.json_type == "LINES" {
|
|
||||||
"newline_delimited"
|
|
||||||
} else {
|
|
||||||
"array"
|
|
||||||
};
|
|
||||||
let sql = format!(
|
|
||||||
"CREATE TABLE data AS SELECT * FROM read_json_auto('{}', format='{}')",
|
|
||||||
sql_escape(&path_str),
|
|
||||||
format
|
|
||||||
);
|
|
||||||
conn.execute_batch(&sql)
|
|
||||||
.map_err(|e| format!("Failed loading JSON data: {}", e))?;
|
|
||||||
}
|
|
||||||
InputFormat::Parquet => {
|
|
||||||
let sql = format!(
|
|
||||||
"CREATE TABLE data AS SELECT * FROM read_parquet('{}')",
|
|
||||||
sql_escape(&path_str)
|
|
||||||
);
|
|
||||||
conn.execute_batch(&sql)
|
|
||||||
.map_err(|e| format!("Failed loading Parquet data: {}", e))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sql_escape(value: &str) -> String {
|
|
||||||
value.replace('\'', "''")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_single_char(value: &str, default_char: char) -> String {
|
|
||||||
value.chars().next().unwrap_or(default_char).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_csv_chunks(
|
|
||||||
rows: &mut duckdb::Rows<'_>,
|
|
||||||
col_count: usize,
|
|
||||||
cfg: CsvOutputConfig,
|
|
||||||
) -> Result<Vec<Vec<u8>>, String> {
|
|
||||||
let delimiter = cfg.field_delimiter;
|
|
||||||
let record_delimiter = cfg.record_delimiter;
|
|
||||||
let quote = cfg.quote_character;
|
|
||||||
|
|
||||||
let mut chunks: Vec<Vec<u8>> = Vec::new();
|
|
||||||
let mut buffer = String::new();
|
|
||||||
|
|
||||||
while let Some(row) = rows
|
|
||||||
.next()
|
|
||||||
.map_err(|e| format!("SQL execution error: {}", e))?
|
|
||||||
{
|
|
||||||
let mut fields: Vec<String> = Vec::with_capacity(col_count);
|
|
||||||
for i in 0..col_count {
|
|
||||||
let value = row
|
|
||||||
.get_ref(i)
|
|
||||||
.map_err(|e| format!("SQL execution error: {}", e))?;
|
|
||||||
if matches!(value, ValueRef::Null) {
|
|
||||||
fields.push(String::new());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut text = value_ref_to_string(value);
|
|
||||||
if text.contains(&delimiter)
|
|
||||||
|| text.contains("e)
|
|
||||||
|| text.contains(&record_delimiter)
|
|
||||||
{
|
|
||||||
text = text.replace("e, &(quote.clone() + "e));
|
|
||||||
text = format!("{}{}{}", quote, text, quote);
|
|
||||||
}
|
|
||||||
fields.push(text);
|
|
||||||
}
|
|
||||||
buffer.push_str(&fields.join(&delimiter));
|
|
||||||
buffer.push_str(&record_delimiter);
|
|
||||||
|
|
||||||
while buffer.len() >= CHUNK_SIZE {
|
|
||||||
let rest = buffer.split_off(CHUNK_SIZE);
|
|
||||||
chunks.push(buffer.into_bytes());
|
|
||||||
buffer = rest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !buffer.is_empty() {
|
|
||||||
chunks.push(buffer.into_bytes());
|
|
||||||
}
|
|
||||||
Ok(chunks)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_json_chunks(
|
|
||||||
rows: &mut duckdb::Rows<'_>,
|
|
||||||
col_count: usize,
|
|
||||||
columns: &[String],
|
|
||||||
cfg: JsonOutputConfig,
|
|
||||||
) -> Result<Vec<Vec<u8>>, String> {
|
|
||||||
let record_delimiter = cfg.record_delimiter;
|
|
||||||
let mut chunks: Vec<Vec<u8>> = Vec::new();
|
|
||||||
let mut buffer = String::new();
|
|
||||||
|
|
||||||
while let Some(row) = rows
|
|
||||||
.next()
|
|
||||||
.map_err(|e| format!("SQL execution error: {}", e))?
|
|
||||||
{
|
|
||||||
let mut record: HashMap<String, serde_json::Value> = HashMap::with_capacity(col_count);
|
|
||||||
for i in 0..col_count {
|
|
||||||
let value = row
|
|
||||||
.get_ref(i)
|
|
||||||
.map_err(|e| format!("SQL execution error: {}", e))?;
|
|
||||||
let key = columns.get(i).cloned().unwrap_or_else(|| format!("_{}", i));
|
|
||||||
record.insert(key, value_ref_to_json(value));
|
|
||||||
}
|
|
||||||
let line = serde_json::to_string(&record)
|
|
||||||
.map_err(|e| format!("JSON output encoding failed: {}", e))?;
|
|
||||||
buffer.push_str(&line);
|
|
||||||
buffer.push_str(&record_delimiter);
|
|
||||||
|
|
||||||
while buffer.len() >= CHUNK_SIZE {
|
|
||||||
let rest = buffer.split_off(CHUNK_SIZE);
|
|
||||||
chunks.push(buffer.into_bytes());
|
|
||||||
buffer = rest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !buffer.is_empty() {
|
|
||||||
chunks.push(buffer.into_bytes());
|
|
||||||
}
|
|
||||||
Ok(chunks)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value_ref_to_string(value: ValueRef<'_>) -> String {
|
|
||||||
match value {
|
|
||||||
ValueRef::Null => String::new(),
|
|
||||||
ValueRef::Boolean(v) => v.to_string(),
|
|
||||||
ValueRef::TinyInt(v) => v.to_string(),
|
|
||||||
ValueRef::SmallInt(v) => v.to_string(),
|
|
||||||
ValueRef::Int(v) => v.to_string(),
|
|
||||||
ValueRef::BigInt(v) => v.to_string(),
|
|
||||||
ValueRef::UTinyInt(v) => v.to_string(),
|
|
||||||
ValueRef::USmallInt(v) => v.to_string(),
|
|
||||||
ValueRef::UInt(v) => v.to_string(),
|
|
||||||
ValueRef::UBigInt(v) => v.to_string(),
|
|
||||||
ValueRef::Float(v) => v.to_string(),
|
|
||||||
ValueRef::Double(v) => v.to_string(),
|
|
||||||
ValueRef::Decimal(v) => v.to_string(),
|
|
||||||
ValueRef::Text(v) => String::from_utf8_lossy(v).into_owned(),
|
|
||||||
ValueRef::Blob(v) => base64::engine::general_purpose::STANDARD.encode(v),
|
|
||||||
_ => format!("{:?}", value),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value_ref_to_json(value: ValueRef<'_>) -> serde_json::Value {
|
|
||||||
match value {
|
|
||||||
ValueRef::Null => serde_json::Value::Null,
|
|
||||||
ValueRef::Boolean(v) => serde_json::Value::Bool(v),
|
|
||||||
ValueRef::TinyInt(v) => serde_json::json!(v),
|
|
||||||
ValueRef::SmallInt(v) => serde_json::json!(v),
|
|
||||||
ValueRef::Int(v) => serde_json::json!(v),
|
|
||||||
ValueRef::BigInt(v) => serde_json::json!(v),
|
|
||||||
ValueRef::UTinyInt(v) => serde_json::json!(v),
|
|
||||||
ValueRef::USmallInt(v) => serde_json::json!(v),
|
|
||||||
ValueRef::UInt(v) => serde_json::json!(v),
|
|
||||||
ValueRef::UBigInt(v) => serde_json::json!(v),
|
|
||||||
ValueRef::Float(v) => serde_json::json!(v),
|
|
||||||
ValueRef::Double(v) => serde_json::json!(v),
|
|
||||||
ValueRef::Decimal(v) => serde_json::Value::String(v.to_string()),
|
|
||||||
ValueRef::Text(v) => serde_json::Value::String(String::from_utf8_lossy(v).into_owned()),
|
|
||||||
ValueRef::Blob(v) => {
|
|
||||||
serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(v))
|
|
||||||
}
|
|
||||||
_ => serde_json::Value::String(format!("{:?}", value)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_xml_content_type(headers: &HeaderMap) -> Option<Response> {
|
|
||||||
let value = headers
|
|
||||||
.get("content-type")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
if value.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let lowered = value.to_ascii_lowercase();
|
|
||||||
if lowered.starts_with("application/xml") || lowered.starts_with("text/xml") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(s3_error_response(S3Error::new(
|
|
||||||
S3ErrorCode::InvalidRequest,
|
|
||||||
"Content-Type must be application/xml or text/xml",
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn s3_error_response(err: S3Error) -> Response {
|
|
||||||
let status =
|
|
||||||
StatusCode::from_u16(err.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
let resource = if err.resource.is_empty() {
|
|
||||||
"/".to_string()
|
|
||||||
} else {
|
|
||||||
err.resource.clone()
|
|
||||||
};
|
|
||||||
let body = err
|
|
||||||
.with_resource(resource)
|
|
||||||
.with_request_id(uuid::Uuid::new_v4().simple().to_string())
|
|
||||||
.to_xml();
|
|
||||||
(status, [("content-type", "application/xml")], body).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_stats_xml(bytes_scanned: usize, bytes_returned: usize) -> String {
|
|
||||||
format!(
|
|
||||||
"<Stats><BytesScanned>{}</BytesScanned><BytesProcessed>{}</BytesProcessed><BytesReturned>{}</BytesReturned></Stats>",
|
|
||||||
bytes_scanned,
|
|
||||||
bytes_scanned,
|
|
||||||
bytes_returned
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_select_event(event_type: &str, payload: &[u8]) -> Vec<u8> {
|
|
||||||
let mut headers = Vec::new();
|
|
||||||
headers.extend(encode_select_header(":event-type", event_type));
|
|
||||||
if event_type == "Records" {
|
|
||||||
headers.extend(encode_select_header(
|
|
||||||
":content-type",
|
|
||||||
"application/octet-stream",
|
|
||||||
));
|
|
||||||
} else if event_type == "Stats" {
|
|
||||||
headers.extend(encode_select_header(":content-type", "text/xml"));
|
|
||||||
}
|
|
||||||
headers.extend(encode_select_header(":message-type", "event"));
|
|
||||||
|
|
||||||
let headers_len = headers.len() as u32;
|
|
||||||
let total_len = 4 + 4 + 4 + headers.len() + payload.len() + 4;
|
|
||||||
|
|
||||||
let mut message = Vec::with_capacity(total_len);
|
|
||||||
let mut prelude = Vec::with_capacity(8);
|
|
||||||
prelude.extend((total_len as u32).to_be_bytes());
|
|
||||||
prelude.extend(headers_len.to_be_bytes());
|
|
||||||
|
|
||||||
let prelude_crc = crc32(&prelude);
|
|
||||||
message.extend(prelude);
|
|
||||||
message.extend(prelude_crc.to_be_bytes());
|
|
||||||
message.extend(headers);
|
|
||||||
message.extend(payload);
|
|
||||||
|
|
||||||
let msg_crc = crc32(&message);
|
|
||||||
message.extend(msg_crc.to_be_bytes());
|
|
||||||
message
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_select_header(name: &str, value: &str) -> Vec<u8> {
|
|
||||||
let name_bytes = name.as_bytes();
|
|
||||||
let value_bytes = value.as_bytes();
|
|
||||||
let mut header = Vec::with_capacity(1 + name_bytes.len() + 1 + 2 + value_bytes.len());
|
|
||||||
header.push(name_bytes.len() as u8);
|
|
||||||
header.extend(name_bytes);
|
|
||||||
header.push(7);
|
|
||||||
header.extend((value_bytes.len() as u16).to_be_bytes());
|
|
||||||
header.extend(value_bytes);
|
|
||||||
header
|
|
||||||
}
|
|
||||||
|
|
||||||
fn crc32(data: &[u8]) -> u32 {
|
|
||||||
let mut hasher = Hasher::new();
|
|
||||||
hasher.update(data);
|
|
||||||
hasher.finalize()
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::error::Error as StdError;
|
|
||||||
|
|
||||||
use axum::extract::{Extension, Form, State};
|
|
||||||
use axum::http::{header, HeaderMap, StatusCode};
|
|
||||||
use axum::response::{IntoResponse, Redirect, Response};
|
|
||||||
use tera::Context;
|
|
||||||
|
|
||||||
use crate::middleware::session::SessionHandle;
|
|
||||||
use crate::session::FlashMessage;
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
pub async fn login_page(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
) -> Response {
|
|
||||||
if session.read(|s| s.is_authenticated()) {
|
|
||||||
return Redirect::to("/ui/buckets").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut ctx = base_context(&session, None);
|
|
||||||
let flashed = session.write(|s| s.take_flash());
|
|
||||||
inject_flash(&mut ctx, flashed);
|
|
||||||
|
|
||||||
render(&state, "login.html", &ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct LoginForm {
|
|
||||||
pub access_key: String,
|
|
||||||
pub secret_key: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub csrf_token: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub next: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn login_submit(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
Form(form): Form<LoginForm>,
|
|
||||||
) -> Response {
|
|
||||||
let access_key = form.access_key.trim();
|
|
||||||
let secret_key = form.secret_key.trim();
|
|
||||||
|
|
||||||
match state.iam.get_secret_key(access_key) {
|
|
||||||
Some(expected) if constant_time_eq_str(&expected, secret_key) => {
|
|
||||||
let display = state
|
|
||||||
.iam
|
|
||||||
.get_user(access_key)
|
|
||||||
.await
|
|
||||||
.and_then(|v| {
|
|
||||||
v.get("display_name")
|
|
||||||
.and_then(|d| d.as_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| access_key.to_string());
|
|
||||||
|
|
||||||
session.write(|s| {
|
|
||||||
s.user_id = Some(access_key.to_string());
|
|
||||||
s.display_name = Some(display);
|
|
||||||
s.rotate_csrf();
|
|
||||||
s.push_flash("success", "Signed in successfully.");
|
|
||||||
});
|
|
||||||
|
|
||||||
let next = form
|
|
||||||
.next
|
|
||||||
.as_deref()
|
|
||||||
.filter(|n| n.starts_with("/ui/") || *n == "/ui")
|
|
||||||
.unwrap_or("/ui/buckets")
|
|
||||||
.to_string();
|
|
||||||
Redirect::to(&next).into_response()
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
session.write(|s| {
|
|
||||||
s.push_flash("danger", "Invalid access key or secret key.");
|
|
||||||
});
|
|
||||||
Redirect::to("/login").into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn logout(Extension(session): Extension<SessionHandle>) -> Response {
|
|
||||||
session.write(|s| {
|
|
||||||
s.user_id = None;
|
|
||||||
s.display_name = None;
|
|
||||||
s.flash.clear();
|
|
||||||
s.rotate_csrf();
|
|
||||||
s.push_flash("info", "Signed out.");
|
|
||||||
});
|
|
||||||
Redirect::to("/login").into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn csrf_error_page(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
) -> Response {
|
|
||||||
let ctx = base_context(&session, None);
|
|
||||||
let mut resp = render(&state, "csrf_error.html", &ctx);
|
|
||||||
*resp.status_mut() = StatusCode::FORBIDDEN;
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn root_redirect() -> Response {
|
|
||||||
Redirect::to("/ui/buckets").into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn not_found_page(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
) -> Response {
|
|
||||||
let ctx = base_context(&session, None);
|
|
||||||
let mut resp = render(&state, "404.html", &ctx);
|
|
||||||
*resp.status_mut() = StatusCode::NOT_FOUND;
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn require_login(
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
req: axum::extract::Request,
|
|
||||||
next: axum::middleware::Next,
|
|
||||||
) -> Response {
|
|
||||||
if session.read(|s| s.is_authenticated()) {
|
|
||||||
return next.run(req).await;
|
|
||||||
}
|
|
||||||
let path = req.uri().path().to_string();
|
|
||||||
let query = req
|
|
||||||
.uri()
|
|
||||||
.query()
|
|
||||||
.map(|q| format!("?{}", q))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let next_url = format!("{}{}", path, query);
|
|
||||||
let encoded =
|
|
||||||
percent_encoding::utf8_percent_encode(&next_url, percent_encoding::NON_ALPHANUMERIC)
|
|
||||||
.to_string();
|
|
||||||
let target = format!("/login?next={}", encoded);
|
|
||||||
Redirect::to(&target).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render(state: &AppState, template: &str, ctx: &Context) -> Response {
|
|
||||||
let engine = match &state.templates {
|
|
||||||
Some(e) => e,
|
|
||||||
None => {
|
|
||||||
return (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Templates not configured",
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match engine.render(template, ctx) {
|
|
||||||
Ok(html) => {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
header::CONTENT_TYPE,
|
|
||||||
"text/html; charset=utf-8".parse().unwrap(),
|
|
||||||
);
|
|
||||||
(StatusCode::OK, headers, html).into_response()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let mut detail = format!("{}", e);
|
|
||||||
let mut src = StdError::source(&e);
|
|
||||||
while let Some(s) = src {
|
|
||||||
detail.push_str(" | ");
|
|
||||||
detail.push_str(&s.to_string());
|
|
||||||
src = s.source();
|
|
||||||
}
|
|
||||||
tracing::error!("Template render failed ({}): {}", template, detail);
|
|
||||||
let fallback_ctx = Context::new();
|
|
||||||
let body = if template != "500.html" {
|
|
||||||
engine
|
|
||||||
.render("500.html", &fallback_ctx)
|
|
||||||
.unwrap_or_else(|_| "Internal Server Error".to_string())
|
|
||||||
} else {
|
|
||||||
"Internal Server Error".to_string()
|
|
||||||
};
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
header::CONTENT_TYPE,
|
|
||||||
"text/html; charset=utf-8".parse().unwrap(),
|
|
||||||
);
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, headers, body).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn base_context(session: &SessionHandle, endpoint: Option<&str>) -> Context {
|
|
||||||
let mut ctx = Context::new();
|
|
||||||
let snapshot = session.snapshot();
|
|
||||||
ctx.insert("csrf_token_value", &snapshot.csrf_token);
|
|
||||||
ctx.insert("is_authenticated", &snapshot.user_id.is_some());
|
|
||||||
ctx.insert("current_user", &snapshot.user_id);
|
|
||||||
ctx.insert("current_user_display_name", &snapshot.display_name);
|
|
||||||
ctx.insert("current_endpoint", &endpoint.unwrap_or(""));
|
|
||||||
ctx.insert("request_args", &HashMap::<String, String>::new());
|
|
||||||
ctx.insert("null", &serde_json::Value::Null);
|
|
||||||
ctx.insert("none", &serde_json::Value::Null);
|
|
||||||
ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inject_flash(ctx: &mut Context, flashed: Vec<FlashMessage>) {
|
|
||||||
ctx.insert("flashed_messages", &flashed);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn constant_time_eq_str(a: &str, b: &str) -> bool {
|
|
||||||
if a.len() != b.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
subtle::ConstantTimeEq::ct_eq(a.as_bytes(), b.as_bytes()).into()
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,559 +0,0 @@
|
|||||||
pub mod config;
|
|
||||||
pub mod handlers;
|
|
||||||
pub mod middleware;
|
|
||||||
pub mod services;
|
|
||||||
pub mod session;
|
|
||||||
pub mod state;
|
|
||||||
pub mod stores;
|
|
||||||
pub mod templates;
|
|
||||||
|
|
||||||
use axum::Router;
|
|
||||||
|
|
||||||
pub const SERVER_HEADER: &str = concat!("MyFSIO-Rust/", env!("CARGO_PKG_VERSION"));
|
|
||||||
|
|
||||||
pub fn create_ui_router(state: state::AppState) -> Router {
|
|
||||||
use axum::routing::{delete, get, post, put};
|
|
||||||
use handlers::ui;
|
|
||||||
use handlers::ui_api;
|
|
||||||
use handlers::ui_pages;
|
|
||||||
|
|
||||||
let protected = Router::new()
|
|
||||||
.route("/", get(ui::root_redirect))
|
|
||||||
.route("/ui", get(ui::root_redirect))
|
|
||||||
.route("/ui/", get(ui::root_redirect))
|
|
||||||
.route(
|
|
||||||
"/ui/buckets",
|
|
||||||
get(ui_pages::buckets_overview).post(ui_pages::create_bucket),
|
|
||||||
)
|
|
||||||
.route("/ui/buckets/create", post(ui_pages::create_bucket))
|
|
||||||
.route("/ui/buckets/{bucket_name}", get(ui_pages::bucket_detail))
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/delete",
|
|
||||||
post(ui_pages::delete_bucket),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/versioning",
|
|
||||||
post(ui_pages::update_bucket_versioning),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/quota",
|
|
||||||
post(ui_pages::update_bucket_quota),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/encryption",
|
|
||||||
post(ui_pages::update_bucket_encryption),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/policy",
|
|
||||||
post(ui_pages::update_bucket_policy),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/replication",
|
|
||||||
post(ui_pages::update_bucket_replication),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/website",
|
|
||||||
post(ui_pages::update_bucket_website),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/upload",
|
|
||||||
post(ui_api::upload_object),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/multipart/initiate",
|
|
||||||
post(ui_api::initiate_multipart_upload),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/multipart/{upload_id}/part",
|
|
||||||
put(ui_api::upload_multipart_part),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/multipart/{upload_id}/parts",
|
|
||||||
put(ui_api::upload_multipart_part),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/multipart/{upload_id}/complete",
|
|
||||||
post(ui_api::complete_multipart_upload),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/multipart/{upload_id}/abort",
|
|
||||||
delete(ui_api::abort_multipart_upload),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/multipart/{upload_id}",
|
|
||||||
delete(ui_api::abort_multipart_upload),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/objects",
|
|
||||||
get(ui_api::list_bucket_objects),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/objects/stream",
|
|
||||||
get(ui_api::stream_bucket_objects),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/folders",
|
|
||||||
get(ui_api::list_bucket_folders),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/copy-targets",
|
|
||||||
get(ui_api::list_copy_targets),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/list-for-copy",
|
|
||||||
get(ui_api::list_copy_targets),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/objects/bulk-delete",
|
|
||||||
post(ui_api::bulk_delete_objects),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/objects/bulk-download",
|
|
||||||
post(ui_api::bulk_download_objects),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/objects/{*rest}",
|
|
||||||
get(ui_api::object_get_dispatch).post(ui_api::object_post_dispatch),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/acl",
|
|
||||||
get(ui_api::bucket_acl).post(ui_api::update_bucket_acl),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/cors",
|
|
||||||
get(ui_api::bucket_cors).post(ui_api::update_bucket_cors),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/lifecycle",
|
|
||||||
get(ui_api::bucket_lifecycle).post(ui_api::update_bucket_lifecycle),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/lifecycle/history",
|
|
||||||
get(ui_api::lifecycle_history),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/replication/status",
|
|
||||||
get(ui_api::replication_status),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/replication/failures",
|
|
||||||
get(ui_api::replication_failures).delete(ui_api::clear_replication_failures),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/replication/failures/retry",
|
|
||||||
post(ui_api::retry_replication_failure),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/replication/failures/retry-all",
|
|
||||||
post(ui_api::retry_all_replication_failures),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/replication/failures/dismiss",
|
|
||||||
delete(ui_api::dismiss_replication_failure),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/replication/failures/clear",
|
|
||||||
delete(ui_api::clear_replication_failures),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/replication/failures/{*rest}",
|
|
||||||
post(ui_api::retry_replication_failure_path)
|
|
||||||
.delete(ui_api::dismiss_replication_failure_path),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/bulk-delete",
|
|
||||||
post(ui_api::bulk_delete_objects),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/bulk-download",
|
|
||||||
post(ui_api::bulk_download_objects),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/archived",
|
|
||||||
get(ui_api::archived_objects),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/buckets/{bucket_name}/archived/{*rest}",
|
|
||||||
post(ui_api::archived_post_dispatch),
|
|
||||||
)
|
|
||||||
.route("/ui/iam", get(ui_pages::iam_dashboard))
|
|
||||||
.route("/ui/iam/users", post(ui_pages::create_iam_user))
|
|
||||||
.route("/ui/iam/users/{user_id}", post(ui_pages::update_iam_user))
|
|
||||||
.route(
|
|
||||||
"/ui/iam/users/{user_id}/delete",
|
|
||||||
post(ui_pages::delete_iam_user),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/iam/users/{user_id}/update",
|
|
||||||
post(ui_pages::update_iam_user),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/iam/users/{user_id}/policies",
|
|
||||||
post(ui_pages::update_iam_policies),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/iam/users/{user_id}/expiry",
|
|
||||||
post(ui_pages::update_iam_expiry),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/iam/users/{user_id}/rotate-secret",
|
|
||||||
post(ui_pages::rotate_iam_secret),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/iam/users/{user_id}/rotate",
|
|
||||||
post(ui_pages::rotate_iam_secret),
|
|
||||||
)
|
|
||||||
.route("/ui/connections/create", post(ui_pages::create_connection))
|
|
||||||
.route("/ui/connections/test", post(ui_api::test_connection))
|
|
||||||
.route(
|
|
||||||
"/ui/connections/{connection_id}",
|
|
||||||
post(ui_pages::update_connection),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/connections/{connection_id}/update",
|
|
||||||
post(ui_pages::update_connection),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/connections/{connection_id}/delete",
|
|
||||||
post(ui_pages::delete_connection),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/connections/{connection_id}/health",
|
|
||||||
get(ui_api::connection_health),
|
|
||||||
)
|
|
||||||
.route("/ui/sites", get(ui_pages::sites_dashboard))
|
|
||||||
.route("/ui/sites/local", post(ui_pages::update_local_site))
|
|
||||||
.route("/ui/sites/peers", post(ui_pages::add_peer_site))
|
|
||||||
.route(
|
|
||||||
"/ui/sites/peers/{site_id}/update",
|
|
||||||
post(ui_pages::update_peer_site),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/sites/peers/{site_id}/delete",
|
|
||||||
post(ui_pages::delete_peer_site),
|
|
||||||
)
|
|
||||||
.route("/ui/sites/peers/{site_id}/health", get(ui_api::peer_health))
|
|
||||||
.route(
|
|
||||||
"/ui/sites/peers/{site_id}/sync-stats",
|
|
||||||
get(ui_api::peer_sync_stats),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/sites/peers/{site_id}/bidirectional-status",
|
|
||||||
get(ui_api::peer_bidirectional_status),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/connections",
|
|
||||||
get(ui_pages::connections_dashboard).post(ui_pages::create_connection),
|
|
||||||
)
|
|
||||||
.route("/ui/metrics", get(ui_pages::metrics_dashboard))
|
|
||||||
.route(
|
|
||||||
"/ui/metrics/settings",
|
|
||||||
get(ui_api::metrics_settings).put(ui_api::update_metrics_settings),
|
|
||||||
)
|
|
||||||
.route("/ui/metrics/api", get(ui_api::metrics_api))
|
|
||||||
.route("/ui/metrics/history", get(ui_api::metrics_history))
|
|
||||||
.route("/ui/metrics/operations", get(ui_api::metrics_operations))
|
|
||||||
.route(
|
|
||||||
"/ui/metrics/operations/history",
|
|
||||||
get(ui_api::metrics_operations_history),
|
|
||||||
)
|
|
||||||
.route("/ui/system", get(ui_pages::system_dashboard))
|
|
||||||
.route("/ui/system/gc/status", get(ui_api::gc_status_ui))
|
|
||||||
.route("/ui/system/gc/run", post(ui_api::gc_run_ui))
|
|
||||||
.route("/ui/system/gc/history", get(ui_api::gc_history_ui))
|
|
||||||
.route(
|
|
||||||
"/ui/system/integrity/status",
|
|
||||||
get(ui_api::integrity_status_ui),
|
|
||||||
)
|
|
||||||
.route("/ui/system/integrity/run", post(ui_api::integrity_run_ui))
|
|
||||||
.route(
|
|
||||||
"/ui/system/integrity/history",
|
|
||||||
get(ui_api::integrity_history_ui),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/website-domains",
|
|
||||||
get(ui_pages::website_domains_dashboard),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/website-domains/create",
|
|
||||||
post(ui_pages::create_website_domain),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/website-domains/{domain}",
|
|
||||||
post(ui_pages::update_website_domain),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/website-domains/{domain}/update",
|
|
||||||
post(ui_pages::update_website_domain),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/website-domains/{domain}/delete",
|
|
||||||
post(ui_pages::delete_website_domain),
|
|
||||||
)
|
|
||||||
.route("/ui/replication/new", get(ui_pages::replication_wizard))
|
|
||||||
.route(
|
|
||||||
"/ui/replication/create",
|
|
||||||
post(ui_pages::create_peer_replication_rules_from_query),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/ui/sites/peers/{site_id}/replication-rules",
|
|
||||||
post(ui_pages::create_peer_replication_rules),
|
|
||||||
)
|
|
||||||
.route("/ui/docs", get(ui_pages::docs_page))
|
|
||||||
.layer(axum::middleware::from_fn(ui::require_login));
|
|
||||||
|
|
||||||
let public = Router::new()
|
|
||||||
.route("/login", get(ui::login_page).post(ui::login_submit))
|
|
||||||
.route("/logout", post(ui::logout).get(ui::logout))
|
|
||||||
.route("/csrf-error", get(ui::csrf_error_page));
|
|
||||||
|
|
||||||
let session_state = middleware::SessionLayerState {
|
|
||||||
store: state.sessions.clone(),
|
|
||||||
secure: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let static_service = tower_http::services::ServeDir::new(&state.config.static_dir);
|
|
||||||
|
|
||||||
protected
|
|
||||||
.merge(public)
|
|
||||||
.fallback(ui::not_found_page)
|
|
||||||
.layer(axum::middleware::from_fn(middleware::csrf_layer))
|
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
|
||||||
session_state,
|
|
||||||
middleware::session_layer,
|
|
||||||
))
|
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
|
||||||
state.clone(),
|
|
||||||
middleware::ui_metrics_layer,
|
|
||||||
))
|
|
||||||
.with_state(state)
|
|
||||||
.nest_service("/static", static_service)
|
|
||||||
.layer(axum::middleware::from_fn(middleware::server_header))
|
|
||||||
.layer(tower_http::compression::CompressionLayer::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_router(state: state::AppState) -> Router {
|
|
||||||
let mut router = Router::new()
|
|
||||||
.route("/myfsio/health", axum::routing::get(handlers::health_check))
|
|
||||||
.route("/", axum::routing::get(handlers::list_buckets))
|
|
||||||
.route(
|
|
||||||
"/{bucket}",
|
|
||||||
axum::routing::put(handlers::create_bucket)
|
|
||||||
.get(handlers::get_bucket)
|
|
||||||
.delete(handlers::delete_bucket)
|
|
||||||
.head(handlers::head_bucket)
|
|
||||||
.post(handlers::post_bucket),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{bucket}/",
|
|
||||||
axum::routing::put(handlers::create_bucket)
|
|
||||||
.get(handlers::get_bucket)
|
|
||||||
.delete(handlers::delete_bucket)
|
|
||||||
.head(handlers::head_bucket)
|
|
||||||
.post(handlers::post_bucket),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{bucket}/{*key}",
|
|
||||||
axum::routing::put(handlers::put_object)
|
|
||||||
.get(handlers::get_object)
|
|
||||||
.delete(handlers::delete_object)
|
|
||||||
.head(handlers::head_object)
|
|
||||||
.post(handlers::post_object),
|
|
||||||
);
|
|
||||||
|
|
||||||
if state.config.kms_enabled {
|
|
||||||
router = router
|
|
||||||
.route(
|
|
||||||
"/kms/keys",
|
|
||||||
axum::routing::get(handlers::kms::list_keys).post(handlers::kms::create_key),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/kms/keys/{key_id}",
|
|
||||||
axum::routing::get(handlers::kms::get_key).delete(handlers::kms::delete_key),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/kms/keys/{key_id}/enable",
|
|
||||||
axum::routing::post(handlers::kms::enable_key),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/kms/keys/{key_id}/disable",
|
|
||||||
axum::routing::post(handlers::kms::disable_key),
|
|
||||||
)
|
|
||||||
.route("/kms/encrypt", axum::routing::post(handlers::kms::encrypt))
|
|
||||||
.route("/kms/decrypt", axum::routing::post(handlers::kms::decrypt))
|
|
||||||
.route(
|
|
||||||
"/kms/generate-data-key",
|
|
||||||
axum::routing::post(handlers::kms::generate_data_key),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/kms/generate-data-key-without-plaintext",
|
|
||||||
axum::routing::post(handlers::kms::generate_data_key_without_plaintext),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/kms/re-encrypt",
|
|
||||||
axum::routing::post(handlers::kms::re_encrypt),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/kms/generate-random",
|
|
||||||
axum::routing::post(handlers::kms::generate_random),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/kms/client/generate-key",
|
|
||||||
axum::routing::post(handlers::kms::client_generate_key),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/kms/client/encrypt",
|
|
||||||
axum::routing::post(handlers::kms::client_encrypt),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/kms/client/decrypt",
|
|
||||||
axum::routing::post(handlers::kms::client_decrypt),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/kms/materials/{key_id}",
|
|
||||||
axum::routing::post(handlers::kms::materials),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
router = router
|
|
||||||
.route(
|
|
||||||
"/admin/site",
|
|
||||||
axum::routing::get(handlers::admin::get_local_site)
|
|
||||||
.put(handlers::admin::update_local_site),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/sites",
|
|
||||||
axum::routing::get(handlers::admin::list_all_sites)
|
|
||||||
.post(handlers::admin::register_peer_site),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/sites/{site_id}",
|
|
||||||
axum::routing::get(handlers::admin::get_peer_site)
|
|
||||||
.put(handlers::admin::update_peer_site)
|
|
||||||
.delete(handlers::admin::delete_peer_site),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/sites/{site_id}/health",
|
|
||||||
axum::routing::get(handlers::admin::check_peer_health)
|
|
||||||
.post(handlers::admin::check_peer_health),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/sites/{site_id}/bidirectional-status",
|
|
||||||
axum::routing::get(handlers::admin::check_bidirectional_status),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/topology",
|
|
||||||
axum::routing::get(handlers::admin::get_topology),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/site/local",
|
|
||||||
axum::routing::get(handlers::admin::get_local_site)
|
|
||||||
.put(handlers::admin::update_local_site),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/site/all",
|
|
||||||
axum::routing::get(handlers::admin::list_all_sites),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/site/peers",
|
|
||||||
axum::routing::post(handlers::admin::register_peer_site),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/site/peers/{site_id}",
|
|
||||||
axum::routing::get(handlers::admin::get_peer_site)
|
|
||||||
.put(handlers::admin::update_peer_site)
|
|
||||||
.delete(handlers::admin::delete_peer_site),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/site/peers/{site_id}/health",
|
|
||||||
axum::routing::post(handlers::admin::check_peer_health),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/site/topology",
|
|
||||||
axum::routing::get(handlers::admin::get_topology),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/site/peers/{site_id}/bidirectional-status",
|
|
||||||
axum::routing::get(handlers::admin::check_bidirectional_status),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/iam/users",
|
|
||||||
axum::routing::get(handlers::admin::iam_list_users),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/iam/users/{identifier}",
|
|
||||||
axum::routing::get(handlers::admin::iam_get_user),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/iam/users/{identifier}/policies",
|
|
||||||
axum::routing::get(handlers::admin::iam_get_user_policies),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/iam/users/{identifier}/access-keys",
|
|
||||||
axum::routing::post(handlers::admin::iam_create_access_key),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/iam/users/{identifier}/keys",
|
|
||||||
axum::routing::post(handlers::admin::iam_create_access_key),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/iam/users/{identifier}/access-keys/{access_key}",
|
|
||||||
axum::routing::delete(handlers::admin::iam_delete_access_key),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/iam/users/{identifier}/keys/{access_key}",
|
|
||||||
axum::routing::delete(handlers::admin::iam_delete_access_key),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/iam/users/{identifier}/disable",
|
|
||||||
axum::routing::post(handlers::admin::iam_disable_user),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/iam/users/{identifier}/enable",
|
|
||||||
axum::routing::post(handlers::admin::iam_enable_user),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/website-domains",
|
|
||||||
axum::routing::get(handlers::admin::list_website_domains)
|
|
||||||
.post(handlers::admin::create_website_domain),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/website-domains/{domain}",
|
|
||||||
axum::routing::get(handlers::admin::get_website_domain)
|
|
||||||
.put(handlers::admin::update_website_domain)
|
|
||||||
.delete(handlers::admin::delete_website_domain),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/gc/status",
|
|
||||||
axum::routing::get(handlers::admin::gc_status),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/gc/run",
|
|
||||||
axum::routing::post(handlers::admin::gc_run),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/gc/history",
|
|
||||||
axum::routing::get(handlers::admin::gc_history),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/integrity/status",
|
|
||||||
axum::routing::get(handlers::admin::integrity_status),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/integrity/run",
|
|
||||||
axum::routing::post(handlers::admin::integrity_run),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/admin/integrity/history",
|
|
||||||
axum::routing::get(handlers::admin::integrity_history),
|
|
||||||
);
|
|
||||||
|
|
||||||
router
|
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
|
||||||
state.clone(),
|
|
||||||
middleware::auth_layer,
|
|
||||||
))
|
|
||||||
.layer(axum::middleware::from_fn(middleware::server_header))
|
|
||||||
.layer(tower_http::compression::CompressionLayer::new())
|
|
||||||
.with_state(state)
|
|
||||||
}
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
use clap::{Parser, Subcommand};
|
|
||||||
use myfsio_server::config::ServerConfig;
|
|
||||||
use myfsio_server::state::AppState;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(
|
|
||||||
name = "myfsio",
|
|
||||||
version,
|
|
||||||
about = "MyFSIO S3-compatible storage engine"
|
|
||||||
)]
|
|
||||||
struct Cli {
|
|
||||||
#[arg(long, help = "Validate configuration and exit")]
|
|
||||||
check_config: bool,
|
|
||||||
#[arg(long, help = "Show configuration summary and exit")]
|
|
||||||
show_config: bool,
|
|
||||||
#[arg(long, help = "Reset admin credentials and exit")]
|
|
||||||
reset_cred: bool,
|
|
||||||
#[command(subcommand)]
|
|
||||||
command: Option<Command>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum Command {
|
|
||||||
Serve,
|
|
||||||
Version,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
load_env_files();
|
|
||||||
tracing_subscriber::fmt::init();
|
|
||||||
|
|
||||||
let cli = Cli::parse();
|
|
||||||
let config = ServerConfig::from_env();
|
|
||||||
|
|
||||||
if cli.reset_cred {
|
|
||||||
reset_admin_credentials(&config);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if cli.check_config || cli.show_config {
|
|
||||||
print_config_summary(&config);
|
|
||||||
if cli.check_config {
|
|
||||||
let issues = validate_config(&config);
|
|
||||||
for issue in &issues {
|
|
||||||
println!("{issue}");
|
|
||||||
}
|
|
||||||
if issues.iter().any(|issue| issue.starts_with("CRITICAL:")) {
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match cli.command.unwrap_or(Command::Serve) {
|
|
||||||
Command::Version => {
|
|
||||||
println!("myfsio {}", env!("CARGO_PKG_VERSION"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Command::Serve => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_iam_bootstrap(&config);
|
|
||||||
let bind_addr = config.bind_addr;
|
|
||||||
let ui_bind_addr = config.ui_bind_addr;
|
|
||||||
|
|
||||||
tracing::info!("MyFSIO Rust Engine starting — API on {}", bind_addr);
|
|
||||||
if config.ui_enabled {
|
|
||||||
tracing::info!("UI will bind on {}", ui_bind_addr);
|
|
||||||
}
|
|
||||||
tracing::info!("Storage root: {}", config.storage_root.display());
|
|
||||||
tracing::info!("Region: {}", config.region);
|
|
||||||
tracing::info!(
|
|
||||||
"Encryption: {}, KMS: {}, GC: {}, Lifecycle: {}, Integrity: {}, Metrics History: {}, Operation Metrics: {}, UI: {}",
|
|
||||||
config.encryption_enabled,
|
|
||||||
config.kms_enabled,
|
|
||||||
config.gc_enabled,
|
|
||||||
config.lifecycle_enabled,
|
|
||||||
config.integrity_enabled,
|
|
||||||
config.metrics_history_enabled,
|
|
||||||
config.metrics_enabled,
|
|
||||||
config.ui_enabled
|
|
||||||
);
|
|
||||||
|
|
||||||
let state = if config.encryption_enabled || config.kms_enabled {
|
|
||||||
AppState::new_with_encryption(config.clone()).await
|
|
||||||
} else {
|
|
||||||
AppState::new(config.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut bg_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(ref gc) = state.gc {
|
|
||||||
bg_handles.push(gc.clone().start_background());
|
|
||||||
tracing::info!("GC background service started");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref integrity) = state.integrity {
|
|
||||||
bg_handles.push(integrity.clone().start_background());
|
|
||||||
tracing::info!("Integrity checker background service started");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref metrics) = state.metrics {
|
|
||||||
bg_handles.push(metrics.clone().start_background());
|
|
||||||
tracing::info!("Metrics collector background service started");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref system_metrics) = state.system_metrics {
|
|
||||||
bg_handles.push(system_metrics.clone().start_background());
|
|
||||||
tracing::info!("System metrics history collector started");
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.lifecycle_enabled {
|
|
||||||
let lifecycle =
|
|
||||||
std::sync::Arc::new(myfsio_server::services::lifecycle::LifecycleService::new(
|
|
||||||
state.storage.clone(),
|
|
||||||
config.storage_root.clone(),
|
|
||||||
myfsio_server::services::lifecycle::LifecycleConfig::default(),
|
|
||||||
));
|
|
||||||
bg_handles.push(lifecycle.start_background());
|
|
||||||
tracing::info!("Lifecycle manager background service started");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref site_sync) = state.site_sync {
|
|
||||||
let worker = site_sync.clone();
|
|
||||||
bg_handles.push(tokio::spawn(async move {
|
|
||||||
worker.run().await;
|
|
||||||
}));
|
|
||||||
tracing::info!("Site sync worker started");
|
|
||||||
}
|
|
||||||
|
|
||||||
let ui_enabled = config.ui_enabled;
|
|
||||||
let api_app = myfsio_server::create_router(state.clone());
|
|
||||||
let ui_app = if ui_enabled {
|
|
||||||
Some(myfsio_server::create_ui_router(state.clone()))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let api_listener = match tokio::net::TcpListener::bind(bind_addr).await {
|
|
||||||
Ok(listener) => listener,
|
|
||||||
Err(err) => {
|
|
||||||
if err.kind() == std::io::ErrorKind::AddrInUse {
|
|
||||||
tracing::error!("API port already in use: {}", bind_addr);
|
|
||||||
} else {
|
|
||||||
tracing::error!("Failed to bind API {}: {}", bind_addr, err);
|
|
||||||
}
|
|
||||||
for handle in bg_handles {
|
|
||||||
handle.abort();
|
|
||||||
}
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tracing::info!("API listening on {}", bind_addr);
|
|
||||||
|
|
||||||
let ui_listener = if let Some(ref app) = ui_app {
|
|
||||||
let _ = app;
|
|
||||||
match tokio::net::TcpListener::bind(ui_bind_addr).await {
|
|
||||||
Ok(listener) => {
|
|
||||||
tracing::info!("UI listening on {}", ui_bind_addr);
|
|
||||||
Some(listener)
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
if err.kind() == std::io::ErrorKind::AddrInUse {
|
|
||||||
tracing::error!("UI port already in use: {}", ui_bind_addr);
|
|
||||||
} else {
|
|
||||||
tracing::error!("Failed to bind UI {}: {}", ui_bind_addr, err);
|
|
||||||
}
|
|
||||||
for handle in bg_handles {
|
|
||||||
handle.abort();
|
|
||||||
}
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let shutdown = shutdown_signal_shared();
|
|
||||||
let api_shutdown = shutdown.clone();
|
|
||||||
let api_task = tokio::spawn(async move {
|
|
||||||
axum::serve(api_listener, api_app)
|
|
||||||
.with_graceful_shutdown(async move {
|
|
||||||
api_shutdown.notified().await;
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
|
|
||||||
let ui_task = if let (Some(listener), Some(app)) = (ui_listener, ui_app) {
|
|
||||||
let ui_shutdown = shutdown.clone();
|
|
||||||
Some(tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app)
|
|
||||||
.with_graceful_shutdown(async move {
|
|
||||||
ui_shutdown.notified().await;
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::signal::ctrl_c()
|
|
||||||
.await
|
|
||||||
.expect("Failed to listen for Ctrl+C");
|
|
||||||
tracing::info!("Shutdown signal received");
|
|
||||||
shutdown.notify_waiters();
|
|
||||||
|
|
||||||
if let Err(err) = api_task.await.unwrap_or(Ok(())) {
|
|
||||||
tracing::error!("API server exited with error: {}", err);
|
|
||||||
}
|
|
||||||
if let Some(task) = ui_task {
|
|
||||||
if let Err(err) = task.await.unwrap_or(Ok(())) {
|
|
||||||
tracing::error!("UI server exited with error: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for handle in bg_handles {
|
|
||||||
handle.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_config_summary(config: &ServerConfig) {
|
|
||||||
println!("MyFSIO Rust Configuration");
|
|
||||||
println!("Version: {}", env!("CARGO_PKG_VERSION"));
|
|
||||||
println!("API bind: {}", config.bind_addr);
|
|
||||||
println!("UI bind: {}", config.ui_bind_addr);
|
|
||||||
println!("UI enabled: {}", config.ui_enabled);
|
|
||||||
println!("Storage root: {}", config.storage_root.display());
|
|
||||||
println!("IAM config: {}", config.iam_config_path.display());
|
|
||||||
println!("Region: {}", config.region);
|
|
||||||
println!("Encryption enabled: {}", config.encryption_enabled);
|
|
||||||
println!("KMS enabled: {}", config.kms_enabled);
|
|
||||||
println!("GC enabled: {}", config.gc_enabled);
|
|
||||||
println!("Integrity enabled: {}", config.integrity_enabled);
|
|
||||||
println!("Lifecycle enabled: {}", config.lifecycle_enabled);
|
|
||||||
println!(
|
|
||||||
"Website hosting enabled: {}",
|
|
||||||
config.website_hosting_enabled
|
|
||||||
);
|
|
||||||
println!("Site sync enabled: {}", config.site_sync_enabled);
|
|
||||||
println!(
|
|
||||||
"Metrics history enabled: {}",
|
|
||||||
config.metrics_history_enabled
|
|
||||||
);
|
|
||||||
println!("Operation metrics enabled: {}", config.metrics_enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_config(config: &ServerConfig) -> Vec<String> {
|
|
||||||
let mut issues = Vec::new();
|
|
||||||
|
|
||||||
if config.ui_enabled && config.bind_addr == config.ui_bind_addr {
|
|
||||||
issues.push(
|
|
||||||
"CRITICAL: API and UI bind addresses cannot be identical when UI is enabled."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if config.presigned_url_min_expiry > config.presigned_url_max_expiry {
|
|
||||||
issues.push("CRITICAL: PRESIGNED_URL_MIN_EXPIRY_SECONDS cannot exceed PRESIGNED_URL_MAX_EXPIRY_SECONDS.".to_string());
|
|
||||||
}
|
|
||||||
if let Err(err) = std::fs::create_dir_all(&config.storage_root) {
|
|
||||||
issues.push(format!(
|
|
||||||
"CRITICAL: Cannot create storage root {}: {}",
|
|
||||||
config.storage_root.display(),
|
|
||||||
err
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(parent) = config.iam_config_path.parent() {
|
|
||||||
if let Err(err) = std::fs::create_dir_all(parent) {
|
|
||||||
issues.push(format!(
|
|
||||||
"CRITICAL: Cannot create IAM config directory {}: {}",
|
|
||||||
parent.display(),
|
|
||||||
err
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.encryption_enabled && config.secret_key.is_none() {
|
|
||||||
issues.push(
|
|
||||||
"WARNING: ENCRYPTION_ENABLED=true but SECRET_KEY is not configured; secure-at-rest config encryption is unavailable.".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if config.site_sync_enabled && !config.website_hosting_enabled {
|
|
||||||
issues.push(
|
|
||||||
"INFO: SITE_SYNC_ENABLED=true without WEBSITE_HOSTING_ENABLED; this is valid but unrelated.".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
issues
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shutdown_signal_shared() -> std::sync::Arc<tokio::sync::Notify> {
|
|
||||||
std::sync::Arc::new(tokio::sync::Notify::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_env_files() {
|
|
||||||
let cwd = std::env::current_dir().ok();
|
|
||||||
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
|
|
||||||
candidates.push(std::path::PathBuf::from("/opt/myfsio/myfsio.env"));
|
|
||||||
if let Some(ref dir) = cwd {
|
|
||||||
candidates.push(dir.join(".env"));
|
|
||||||
candidates.push(dir.join("myfsio.env"));
|
|
||||||
for ancestor in dir.ancestors().skip(1).take(4) {
|
|
||||||
candidates.push(ancestor.join(".env"));
|
|
||||||
candidates.push(ancestor.join("myfsio.env"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
for path in candidates {
|
|
||||||
if !seen.insert(path.clone()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if path.is_file() {
|
|
||||||
match dotenvy::from_path_override(&path) {
|
|
||||||
Ok(()) => eprintln!("Loaded env file: {}", path.display()),
|
|
||||||
Err(e) => eprintln!("Failed to load env file {}: {}", path.display(), e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_iam_bootstrap(config: &ServerConfig) {
|
|
||||||
let iam_path = &config.iam_config_path;
|
|
||||||
if iam_path.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let access_key = std::env::var("ADMIN_ACCESS_KEY")
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or_else(|| format!("AK{}", uuid::Uuid::new_v4().simple()));
|
|
||||||
let secret_key = std::env::var("ADMIN_SECRET_KEY")
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or_else(|| format!("SK{}", uuid::Uuid::new_v4().simple()));
|
|
||||||
|
|
||||||
let user_id = format!("u-{}", &uuid::Uuid::new_v4().simple().to_string()[..16]);
|
|
||||||
let created_at = chrono::Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"version": 2,
|
|
||||||
"users": [{
|
|
||||||
"user_id": user_id,
|
|
||||||
"display_name": "Local Admin",
|
|
||||||
"enabled": true,
|
|
||||||
"access_keys": [{
|
|
||||||
"access_key": access_key,
|
|
||||||
"secret_key": secret_key,
|
|
||||||
"status": "active",
|
|
||||||
"created_at": created_at,
|
|
||||||
}],
|
|
||||||
"policies": [{
|
|
||||||
"bucket": "*",
|
|
||||||
"actions": ["*"],
|
|
||||||
"prefix": "*",
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let json = match serde_json::to_string_pretty(&body) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to serialize IAM bootstrap config: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(parent) = iam_path.parent() {
|
|
||||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to create IAM config dir {}: {}",
|
|
||||||
parent.display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = std::fs::write(iam_path, json) {
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to write IAM bootstrap config {}: {}",
|
|
||||||
iam_path.display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("============================================================");
|
|
||||||
tracing::info!("MYFSIO - ADMIN CREDENTIALS INITIALIZED");
|
|
||||||
tracing::info!("============================================================");
|
|
||||||
tracing::info!("Access Key: {}", access_key);
|
|
||||||
tracing::info!("Secret Key: {}", secret_key);
|
|
||||||
tracing::info!("Saved to: {}", iam_path.display());
|
|
||||||
tracing::info!("============================================================");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset_admin_credentials(config: &ServerConfig) {
|
|
||||||
if let Some(parent) = config.iam_config_path.parent() {
|
|
||||||
if let Err(err) = std::fs::create_dir_all(parent) {
|
|
||||||
eprintln!(
|
|
||||||
"Failed to create IAM config directory {}: {}",
|
|
||||||
parent.display(),
|
|
||||||
err
|
|
||||||
);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.iam_config_path.exists() {
|
|
||||||
let backup = config
|
|
||||||
.iam_config_path
|
|
||||||
.with_extension(format!("bak-{}", chrono::Utc::now().timestamp()));
|
|
||||||
if let Err(err) = std::fs::rename(&config.iam_config_path, &backup) {
|
|
||||||
eprintln!(
|
|
||||||
"Failed to back up existing IAM config {}: {}",
|
|
||||||
config.iam_config_path.display(),
|
|
||||||
err
|
|
||||||
);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
println!("Backed up existing IAM config to {}", backup.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_iam_bootstrap(config);
|
|
||||||
println!("Admin credentials reset.");
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
|||||||
mod auth;
|
|
||||||
pub mod session;
|
|
||||||
|
|
||||||
pub use auth::auth_layer;
|
|
||||||
pub use session::{csrf_layer, session_layer, SessionHandle, SessionLayerState};
|
|
||||||
|
|
||||||
use axum::extract::{Request, State};
|
|
||||||
use axum::middleware::Next;
|
|
||||||
use axum::response::Response;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
pub async fn server_header(req: Request, next: Next) -> Response {
|
|
||||||
let mut resp = next.run(req).await;
|
|
||||||
resp.headers_mut()
|
|
||||||
.insert("server", crate::SERVER_HEADER.parse().unwrap());
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ui_metrics_layer(State(state): State<AppState>, req: Request, next: Next) -> Response {
|
|
||||||
let metrics = match state.metrics.clone() {
|
|
||||||
Some(m) => m,
|
|
||||||
None => return next.run(req).await,
|
|
||||||
};
|
|
||||||
let start = Instant::now();
|
|
||||||
let method = req.method().clone();
|
|
||||||
let path = req.uri().path().to_string();
|
|
||||||
let endpoint_type = classify_ui_endpoint(&path);
|
|
||||||
let bytes_in = req
|
|
||||||
.headers()
|
|
||||||
.get(axum::http::header::CONTENT_LENGTH)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let response = next.run(req).await;
|
|
||||||
|
|
||||||
let latency_ms = start.elapsed().as_secs_f64() * 1000.0;
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let bytes_out = response
|
|
||||||
.headers()
|
|
||||||
.get(axum::http::header::CONTENT_LENGTH)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let error_code = if status >= 400 { Some("UIError") } else { None };
|
|
||||||
metrics.record_request(
|
|
||||||
method.as_str(),
|
|
||||||
endpoint_type,
|
|
||||||
status,
|
|
||||||
latency_ms,
|
|
||||||
bytes_in,
|
|
||||||
bytes_out,
|
|
||||||
error_code,
|
|
||||||
);
|
|
||||||
|
|
||||||
response
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify_ui_endpoint(path: &str) -> &'static str {
|
|
||||||
if path.contains("/upload") {
|
|
||||||
"ui_upload"
|
|
||||||
} else if path.starts_with("/ui/buckets/") {
|
|
||||||
"ui_bucket"
|
|
||||||
} else if path.starts_with("/ui/iam") {
|
|
||||||
"ui_iam"
|
|
||||||
} else if path.starts_with("/ui/sites") {
|
|
||||||
"ui_sites"
|
|
||||||
} else if path.starts_with("/ui/connections") {
|
|
||||||
"ui_connections"
|
|
||||||
} else if path.starts_with("/ui/metrics") {
|
|
||||||
"ui_metrics"
|
|
||||||
} else if path.starts_with("/ui/system") {
|
|
||||||
"ui_system"
|
|
||||||
} else if path.starts_with("/ui/website-domains") {
|
|
||||||
"ui_website_domains"
|
|
||||||
} else if path.starts_with("/ui/replication") {
|
|
||||||
"ui_replication"
|
|
||||||
} else if path.starts_with("/login") || path.starts_with("/logout") {
|
|
||||||
"ui_auth"
|
|
||||||
} else {
|
|
||||||
"ui_other"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::extract::{Request, State};
|
|
||||||
use axum::http::{header, HeaderValue, StatusCode};
|
|
||||||
use axum::middleware::Next;
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use cookie::{Cookie, SameSite};
|
|
||||||
use parking_lot::Mutex;
|
|
||||||
|
|
||||||
use crate::session::{
|
|
||||||
csrf_tokens_match, SessionData, SessionStore, CSRF_FIELD_NAME, CSRF_HEADER_NAME,
|
|
||||||
SESSION_COOKIE_NAME,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SessionLayerState {
|
|
||||||
pub store: Arc<SessionStore>,
|
|
||||||
pub secure: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SessionHandle {
|
|
||||||
pub id: String,
|
|
||||||
inner: Arc<Mutex<SessionData>>,
|
|
||||||
dirty: Arc<Mutex<bool>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionHandle {
|
|
||||||
pub fn new(id: String, data: SessionData) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
inner: Arc::new(Mutex::new(data)),
|
|
||||||
dirty: Arc::new(Mutex::new(false)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read<R>(&self, f: impl FnOnce(&SessionData) -> R) -> R {
|
|
||||||
let guard = self.inner.lock();
|
|
||||||
f(&guard)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write<R>(&self, f: impl FnOnce(&mut SessionData) -> R) -> R {
|
|
||||||
let mut guard = self.inner.lock();
|
|
||||||
let out = f(&mut guard);
|
|
||||||
*self.dirty.lock() = true;
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn snapshot(&self) -> SessionData {
|
|
||||||
self.inner.lock().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_dirty(&self) -> bool {
|
|
||||||
*self.dirty.lock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn session_layer(
|
|
||||||
State(state): State<SessionLayerState>,
|
|
||||||
mut req: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
let cookie_id = extract_session_cookie(&req);
|
|
||||||
|
|
||||||
let (session_id, session_data, is_new) =
|
|
||||||
match cookie_id.and_then(|id| state.store.get(&id).map(|data| (id.clone(), data))) {
|
|
||||||
Some((id, data)) => (id, data, false),
|
|
||||||
None => {
|
|
||||||
let (id, data) = state.store.create();
|
|
||||||
(id, data, true)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let handle = SessionHandle::new(session_id.clone(), session_data);
|
|
||||||
req.extensions_mut().insert(handle.clone());
|
|
||||||
|
|
||||||
let mut resp = next.run(req).await;
|
|
||||||
|
|
||||||
if handle.is_dirty() {
|
|
||||||
state.store.save(&handle.id, handle.snapshot());
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_new {
|
|
||||||
let cookie = build_session_cookie(&session_id, state.secure);
|
|
||||||
if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) {
|
|
||||||
resp.headers_mut().append(header::SET_COOKIE, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn csrf_layer(req: Request, next: Next) -> Response {
|
|
||||||
const CSRF_HEADER_ALIAS: &str = "x-csrftoken";
|
|
||||||
|
|
||||||
let method = req.method().clone();
|
|
||||||
let needs_check = matches!(
|
|
||||||
method,
|
|
||||||
axum::http::Method::POST
|
|
||||||
| axum::http::Method::PUT
|
|
||||||
| axum::http::Method::PATCH
|
|
||||||
| axum::http::Method::DELETE
|
|
||||||
);
|
|
||||||
|
|
||||||
if !needs_check {
|
|
||||||
return next.run(req).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_ui = req.uri().path().starts_with("/ui/")
|
|
||||||
|| req.uri().path() == "/ui"
|
|
||||||
|| req.uri().path() == "/login"
|
|
||||||
|| req.uri().path() == "/logout";
|
|
||||||
if !is_ui {
|
|
||||||
return next.run(req).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let handle = match req.extensions().get::<SessionHandle>() {
|
|
||||||
Some(h) => h.clone(),
|
|
||||||
None => return (StatusCode::FORBIDDEN, "Missing session").into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let expected = handle.read(|s| s.csrf_token.clone());
|
|
||||||
|
|
||||||
let header_token = req
|
|
||||||
.headers()
|
|
||||||
.get(CSRF_HEADER_NAME)
|
|
||||||
.or_else(|| req.headers().get(CSRF_HEADER_ALIAS))
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
if let Some(token) = header_token.as_deref() {
|
|
||||||
if csrf_tokens_match(&expected, token) {
|
|
||||||
return next.run(req).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_type = req
|
|
||||||
.headers()
|
|
||||||
.get(header::CONTENT_TYPE)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let (parts, body) = req.into_parts();
|
|
||||||
let bytes = match axum::body::to_bytes(body, usize::MAX).await {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(_) => return (StatusCode::BAD_REQUEST, "Body read failed").into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let form_token = if content_type.starts_with("application/x-www-form-urlencoded") {
|
|
||||||
extract_form_token(&bytes)
|
|
||||||
} else if content_type.starts_with("multipart/form-data") {
|
|
||||||
extract_multipart_token(&content_type, &bytes)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(token) = form_token {
|
|
||||||
if csrf_tokens_match(&expected, &token) {
|
|
||||||
let req = Request::from_parts(parts, axum::body::Body::from(bytes));
|
|
||||||
return next.run(req).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::warn!(
|
|
||||||
path = %parts.uri.path(),
|
|
||||||
content_type = %content_type,
|
|
||||||
expected_len = expected.len(),
|
|
||||||
header_present = header_token.is_some(),
|
|
||||||
"CSRF token mismatch"
|
|
||||||
);
|
|
||||||
(StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_multipart_token(content_type: &str, body: &[u8]) -> Option<String> {
|
|
||||||
let boundary = multer::parse_boundary(content_type).ok()?;
|
|
||||||
let prefix = format!("--{}", boundary);
|
|
||||||
let text = std::str::from_utf8(body).ok()?;
|
|
||||||
let needle = "name=\"csrf_token\"";
|
|
||||||
let idx = text.find(needle)?;
|
|
||||||
let after = &text[idx + needle.len()..];
|
|
||||||
let body_start = after.find("\r\n\r\n")? + 4;
|
|
||||||
let tail = &after[body_start..];
|
|
||||||
let end = tail
|
|
||||||
.find(&format!("\r\n--{}", prefix.trim_start_matches("--")))
|
|
||||||
.or_else(|| tail.find("\r\n--"))
|
|
||||||
.unwrap_or(tail.len());
|
|
||||||
Some(tail[..end].trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_session_cookie(req: &Request) -> Option<String> {
|
|
||||||
let raw = req.headers().get(header::COOKIE)?.to_str().ok()?;
|
|
||||||
for pair in raw.split(';') {
|
|
||||||
if let Ok(cookie) = Cookie::parse(pair.trim().to_string()) {
|
|
||||||
if cookie.name() == SESSION_COOKIE_NAME {
|
|
||||||
return Some(cookie.value().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_session_cookie(id: &str, secure: bool) -> Cookie<'static> {
|
|
||||||
let mut cookie = Cookie::new(SESSION_COOKIE_NAME, id.to_string());
|
|
||||||
cookie.set_http_only(true);
|
|
||||||
cookie.set_same_site(SameSite::Lax);
|
|
||||||
cookie.set_secure(secure);
|
|
||||||
cookie.set_path("/");
|
|
||||||
cookie
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_form_token(body: &[u8]) -> Option<String> {
|
|
||||||
let text = std::str::from_utf8(body).ok()?;
|
|
||||||
let prefix = format!("{}=", CSRF_FIELD_NAME);
|
|
||||||
for pair in text.split('&') {
|
|
||||||
if let Some(rest) = pair.strip_prefix(&prefix) {
|
|
||||||
return urldecode(rest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn urldecode(s: &str) -> Option<String> {
|
|
||||||
percent_encoding::percent_decode_str(&s.replace('+', " "))
|
|
||||||
.decode_utf8()
|
|
||||||
.ok()
|
|
||||||
.map(|c| c.into_owned())
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
use parking_lot::RwLock;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LoggingConfiguration {
|
|
||||||
pub target_bucket: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub target_prefix: String,
|
|
||||||
#[serde(default = "default_enabled")]
|
|
||||||
pub enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_enabled() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct StoredLoggingFile {
|
|
||||||
#[serde(rename = "LoggingEnabled")]
|
|
||||||
logging_enabled: Option<StoredLoggingEnabled>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct StoredLoggingEnabled {
|
|
||||||
#[serde(rename = "TargetBucket")]
|
|
||||||
target_bucket: String,
|
|
||||||
#[serde(rename = "TargetPrefix", default)]
|
|
||||||
target_prefix: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AccessLoggingService {
|
|
||||||
storage_root: PathBuf,
|
|
||||||
cache: RwLock<HashMap<String, Option<LoggingConfiguration>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccessLoggingService {
|
|
||||||
pub fn new(storage_root: &Path) -> Self {
|
|
||||||
Self {
|
|
||||||
storage_root: storage_root.to_path_buf(),
|
|
||||||
cache: RwLock::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_path(&self, bucket: &str) -> PathBuf {
|
|
||||||
self.storage_root
|
|
||||||
.join(".myfsio.sys")
|
|
||||||
.join("buckets")
|
|
||||||
.join(bucket)
|
|
||||||
.join("logging.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, bucket: &str) -> Option<LoggingConfiguration> {
|
|
||||||
if let Some(cached) = self.cache.read().get(bucket).cloned() {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = self.config_path(bucket);
|
|
||||||
let config = if path.exists() {
|
|
||||||
std::fs::read_to_string(&path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str::<StoredLoggingFile>(&s).ok())
|
|
||||||
.and_then(|f| f.logging_enabled)
|
|
||||||
.map(|e| LoggingConfiguration {
|
|
||||||
target_bucket: e.target_bucket,
|
|
||||||
target_prefix: e.target_prefix,
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
self.cache
|
|
||||||
.write()
|
|
||||||
.insert(bucket.to_string(), config.clone());
|
|
||||||
config
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set(&self, bucket: &str, config: LoggingConfiguration) -> std::io::Result<()> {
|
|
||||||
let path = self.config_path(bucket);
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let stored = StoredLoggingFile {
|
|
||||||
logging_enabled: Some(StoredLoggingEnabled {
|
|
||||||
target_bucket: config.target_bucket.clone(),
|
|
||||||
target_prefix: config.target_prefix.clone(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string_pretty(&stored)
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
|
||||||
std::fs::write(&path, json)?;
|
|
||||||
self.cache.write().insert(bucket.to_string(), Some(config));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete(&self, bucket: &str) {
|
|
||||||
let path = self.config_path(bucket);
|
|
||||||
if path.exists() {
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
self.cache.write().insert(bucket.to_string(), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
|
|
||||||
pub const ACL_METADATA_KEY: &str = "__acl__";
|
|
||||||
pub const GRANTEE_ALL_USERS: &str = "*";
|
|
||||||
pub const GRANTEE_AUTHENTICATED_USERS: &str = "authenticated";
|
|
||||||
|
|
||||||
const ACL_PERMISSION_FULL_CONTROL: &str = "FULL_CONTROL";
|
|
||||||
const ACL_PERMISSION_WRITE: &str = "WRITE";
|
|
||||||
const ACL_PERMISSION_WRITE_ACP: &str = "WRITE_ACP";
|
|
||||||
const ACL_PERMISSION_READ: &str = "READ";
|
|
||||||
const ACL_PERMISSION_READ_ACP: &str = "READ_ACP";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct AclGrant {
|
|
||||||
pub grantee: String,
|
|
||||||
pub permission: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct Acl {
|
|
||||||
pub owner: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub grants: Vec<AclGrant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Acl {
|
|
||||||
pub fn allowed_actions(
|
|
||||||
&self,
|
|
||||||
principal_id: Option<&str>,
|
|
||||||
is_authenticated: bool,
|
|
||||||
) -> HashSet<&'static str> {
|
|
||||||
let mut actions = HashSet::new();
|
|
||||||
if let Some(principal_id) = principal_id {
|
|
||||||
if principal_id == self.owner {
|
|
||||||
actions.extend(permission_to_actions(ACL_PERMISSION_FULL_CONTROL));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for grant in &self.grants {
|
|
||||||
if grant.grantee == GRANTEE_ALL_USERS {
|
|
||||||
actions.extend(permission_to_actions(&grant.permission));
|
|
||||||
} else if grant.grantee == GRANTEE_AUTHENTICATED_USERS && is_authenticated {
|
|
||||||
actions.extend(permission_to_actions(&grant.permission));
|
|
||||||
} else if let Some(principal_id) = principal_id {
|
|
||||||
if grant.grantee == principal_id {
|
|
||||||
actions.extend(permission_to_actions(&grant.permission));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
actions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_canned_acl(canned_acl: &str, owner: &str) -> Acl {
|
|
||||||
let owner_grant = AclGrant {
|
|
||||||
grantee: owner.to_string(),
|
|
||||||
permission: ACL_PERMISSION_FULL_CONTROL.to_string(),
|
|
||||||
};
|
|
||||||
match canned_acl {
|
|
||||||
"public-read" => Acl {
|
|
||||||
owner: owner.to_string(),
|
|
||||||
grants: vec![
|
|
||||||
owner_grant,
|
|
||||||
AclGrant {
|
|
||||||
grantee: GRANTEE_ALL_USERS.to_string(),
|
|
||||||
permission: ACL_PERMISSION_READ.to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"public-read-write" => Acl {
|
|
||||||
owner: owner.to_string(),
|
|
||||||
grants: vec![
|
|
||||||
owner_grant,
|
|
||||||
AclGrant {
|
|
||||||
grantee: GRANTEE_ALL_USERS.to_string(),
|
|
||||||
permission: ACL_PERMISSION_READ.to_string(),
|
|
||||||
},
|
|
||||||
AclGrant {
|
|
||||||
grantee: GRANTEE_ALL_USERS.to_string(),
|
|
||||||
permission: ACL_PERMISSION_WRITE.to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"authenticated-read" => Acl {
|
|
||||||
owner: owner.to_string(),
|
|
||||||
grants: vec![
|
|
||||||
owner_grant,
|
|
||||||
AclGrant {
|
|
||||||
grantee: GRANTEE_AUTHENTICATED_USERS.to_string(),
|
|
||||||
permission: ACL_PERMISSION_READ.to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"bucket-owner-read" | "bucket-owner-full-control" | "private" | _ => Acl {
|
|
||||||
owner: owner.to_string(),
|
|
||||||
grants: vec![owner_grant],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn acl_to_xml(acl: &Acl) -> String {
|
|
||||||
let mut xml = format!(
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
|
||||||
<AccessControlPolicy xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
|
|
||||||
<Owner><ID>{}</ID><DisplayName>{}</DisplayName></Owner>\
|
|
||||||
<AccessControlList>",
|
|
||||||
xml_escape(&acl.owner),
|
|
||||||
xml_escape(&acl.owner),
|
|
||||||
);
|
|
||||||
for grant in &acl.grants {
|
|
||||||
xml.push_str("<Grant>");
|
|
||||||
match grant.grantee.as_str() {
|
|
||||||
GRANTEE_ALL_USERS => {
|
|
||||||
xml.push_str(
|
|
||||||
"<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"Group\">\
|
|
||||||
<URI>http://acs.amazonaws.com/groups/global/AllUsers</URI>\
|
|
||||||
</Grantee>",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
GRANTEE_AUTHENTICATED_USERS => {
|
|
||||||
xml.push_str(
|
|
||||||
"<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"Group\">\
|
|
||||||
<URI>http://acs.amazonaws.com/groups/global/AuthenticatedUsers</URI>\
|
|
||||||
</Grantee>",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
xml.push_str(&format!(
|
|
||||||
"<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"CanonicalUser\">\
|
|
||||||
<ID>{}</ID><DisplayName>{}</DisplayName>\
|
|
||||||
</Grantee>",
|
|
||||||
xml_escape(other),
|
|
||||||
xml_escape(other),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xml.push_str(&format!(
|
|
||||||
"<Permission>{}</Permission></Grant>",
|
|
||||||
xml_escape(&grant.permission)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
xml.push_str("</AccessControlList></AccessControlPolicy>");
|
|
||||||
xml
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn acl_from_bucket_config(value: &Value) -> Option<Acl> {
|
|
||||||
match value {
|
|
||||||
Value::String(raw) => acl_from_xml(raw).or_else(|| serde_json::from_str(raw).ok()),
|
|
||||||
Value::Object(_) => serde_json::from_value(value.clone()).ok(),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn acl_from_object_metadata(metadata: &HashMap<String, String>) -> Option<Acl> {
|
|
||||||
metadata
|
|
||||||
.get(ACL_METADATA_KEY)
|
|
||||||
.and_then(|raw| serde_json::from_str::<Acl>(raw).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn store_object_acl(metadata: &mut HashMap<String, String>, acl: &Acl) {
|
|
||||||
if let Ok(serialized) = serde_json::to_string(acl) {
|
|
||||||
metadata.insert(ACL_METADATA_KEY.to_string(), serialized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn acl_from_xml(xml: &str) -> Option<Acl> {
|
|
||||||
let doc = roxmltree::Document::parse(xml).ok()?;
|
|
||||||
let owner = doc
|
|
||||||
.descendants()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "Owner")
|
|
||||||
.and_then(|node| {
|
|
||||||
node.children()
|
|
||||||
.find(|child| child.is_element() && child.tag_name().name() == "ID")
|
|
||||||
.and_then(|child| child.text())
|
|
||||||
})
|
|
||||||
.unwrap_or("myfsio")
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let mut grants = Vec::new();
|
|
||||||
for grant in doc
|
|
||||||
.descendants()
|
|
||||||
.filter(|node| node.is_element() && node.tag_name().name() == "Grant")
|
|
||||||
{
|
|
||||||
let permission = grant
|
|
||||||
.children()
|
|
||||||
.find(|child| child.is_element() && child.tag_name().name() == "Permission")
|
|
||||||
.and_then(|child| child.text())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
if permission.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let grantee_node = grant
|
|
||||||
.children()
|
|
||||||
.find(|child| child.is_element() && child.tag_name().name() == "Grantee");
|
|
||||||
let grantee = grantee_node
|
|
||||||
.and_then(|node| {
|
|
||||||
let uri = node
|
|
||||||
.children()
|
|
||||||
.find(|child| child.is_element() && child.tag_name().name() == "URI")
|
|
||||||
.and_then(|child| child.text())
|
|
||||||
.map(|text| text.trim().to_string());
|
|
||||||
match uri.as_deref() {
|
|
||||||
Some("http://acs.amazonaws.com/groups/global/AllUsers") => {
|
|
||||||
Some(GRANTEE_ALL_USERS.to_string())
|
|
||||||
}
|
|
||||||
Some("http://acs.amazonaws.com/groups/global/AuthenticatedUsers") => {
|
|
||||||
Some(GRANTEE_AUTHENTICATED_USERS.to_string())
|
|
||||||
}
|
|
||||||
_ => node
|
|
||||||
.children()
|
|
||||||
.find(|child| child.is_element() && child.tag_name().name() == "ID")
|
|
||||||
.and_then(|child| child.text())
|
|
||||||
.map(|text| text.trim().to_string()),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
if grantee.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
grants.push(AclGrant {
|
|
||||||
grantee,
|
|
||||||
permission,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Acl { owner, grants })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn permission_to_actions(permission: &str) -> &'static [&'static str] {
|
|
||||||
match permission {
|
|
||||||
ACL_PERMISSION_FULL_CONTROL => &["read", "write", "delete", "list", "share"],
|
|
||||||
ACL_PERMISSION_WRITE => &["write", "delete"],
|
|
||||||
ACL_PERMISSION_WRITE_ACP => &["share"],
|
|
||||||
ACL_PERMISSION_READ => &["read", "list"],
|
|
||||||
ACL_PERMISSION_READ_ACP => &["share"],
|
|
||||||
_ => &[],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn xml_escape(s: &str) -> String {
|
|
||||||
s.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
.replace('\'', "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn canned_acl_grants_public_read() {
|
|
||||||
let acl = create_canned_acl("public-read", "owner");
|
|
||||||
let actions = acl.allowed_actions(None, false);
|
|
||||||
assert!(actions.contains("read"));
|
|
||||||
assert!(actions.contains("list"));
|
|
||||||
assert!(!actions.contains("write"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn xml_round_trip_preserves_grants() {
|
|
||||||
let acl = create_canned_acl("authenticated-read", "owner");
|
|
||||||
let parsed = acl_from_bucket_config(&Value::String(acl_to_xml(&acl))).unwrap();
|
|
||||||
assert_eq!(parsed.owner, "owner");
|
|
||||||
assert_eq!(parsed.grants.len(), 2);
|
|
||||||
assert!(parsed
|
|
||||||
.grants
|
|
||||||
.iter()
|
|
||||||
.any(|grant| grant.grantee == GRANTEE_AUTHENTICATED_USERS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
use serde_json::{json, Value};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
pub struct GcConfig {
|
|
||||||
pub interval_hours: f64,
|
|
||||||
pub temp_file_max_age_hours: f64,
|
|
||||||
pub multipart_max_age_days: u64,
|
|
||||||
pub lock_file_max_age_hours: f64,
|
|
||||||
pub dry_run: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GcConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
interval_hours: 6.0,
|
|
||||||
temp_file_max_age_hours: 24.0,
|
|
||||||
multipart_max_age_days: 7,
|
|
||||||
lock_file_max_age_hours: 1.0,
|
|
||||||
dry_run: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GcService {
|
|
||||||
storage_root: PathBuf,
|
|
||||||
config: GcConfig,
|
|
||||||
running: Arc<RwLock<bool>>,
|
|
||||||
started_at: Arc<RwLock<Option<Instant>>>,
|
|
||||||
history: Arc<RwLock<Vec<Value>>>,
|
|
||||||
history_path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GcService {
|
|
||||||
pub fn new(storage_root: PathBuf, config: GcConfig) -> Self {
|
|
||||||
let history_path = storage_root
|
|
||||||
.join(".myfsio.sys")
|
|
||||||
.join("config")
|
|
||||||
.join("gc_history.json");
|
|
||||||
|
|
||||||
let history = if history_path.exists() {
|
|
||||||
std::fs::read_to_string(&history_path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
|
||||||
.and_then(|v| v.get("executions").and_then(|e| e.as_array().cloned()))
|
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
storage_root,
|
|
||||||
config,
|
|
||||||
running: Arc::new(RwLock::new(false)),
|
|
||||||
started_at: Arc::new(RwLock::new(None)),
|
|
||||||
history: Arc::new(RwLock::new(history)),
|
|
||||||
history_path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn status(&self) -> Value {
|
|
||||||
let running = *self.running.read().await;
|
|
||||||
let scan_elapsed_seconds = self
|
|
||||||
.started_at
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.as_ref()
|
|
||||||
.map(|started| started.elapsed().as_secs_f64());
|
|
||||||
json!({
|
|
||||||
"enabled": true,
|
|
||||||
"running": running,
|
|
||||||
"scanning": running,
|
|
||||||
"scan_elapsed_seconds": scan_elapsed_seconds,
|
|
||||||
"interval_hours": self.config.interval_hours,
|
|
||||||
"temp_file_max_age_hours": self.config.temp_file_max_age_hours,
|
|
||||||
"multipart_max_age_days": self.config.multipart_max_age_days,
|
|
||||||
"lock_file_max_age_hours": self.config.lock_file_max_age_hours,
|
|
||||||
"dry_run": self.config.dry_run,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn history(&self) -> Value {
|
|
||||||
let history = self.history.read().await;
|
|
||||||
let mut executions: Vec<Value> = history.iter().cloned().collect();
|
|
||||||
executions.reverse();
|
|
||||||
json!({ "executions": executions })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_now(&self, dry_run: bool) -> Result<Value, String> {
|
|
||||||
{
|
|
||||||
let mut running = self.running.write().await;
|
|
||||||
if *running {
|
|
||||||
return Err("GC already running".to_string());
|
|
||||||
}
|
|
||||||
*running = true;
|
|
||||||
}
|
|
||||||
*self.started_at.write().await = Some(Instant::now());
|
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
let result = self.execute_gc(dry_run || self.config.dry_run).await;
|
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
|
||||||
|
|
||||||
*self.running.write().await = false;
|
|
||||||
*self.started_at.write().await = None;
|
|
||||||
|
|
||||||
let mut result_json = result.clone();
|
|
||||||
if let Some(obj) = result_json.as_object_mut() {
|
|
||||||
obj.insert("execution_time_seconds".to_string(), json!(elapsed));
|
|
||||||
}
|
|
||||||
|
|
||||||
let record = json!({
|
|
||||||
"timestamp": chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
|
|
||||||
"dry_run": dry_run || self.config.dry_run,
|
|
||||||
"result": result_json,
|
|
||||||
});
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut history = self.history.write().await;
|
|
||||||
history.push(record);
|
|
||||||
if history.len() > 50 {
|
|
||||||
let excess = history.len() - 50;
|
|
||||||
history.drain(..excess);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.save_history().await;
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute_gc(&self, dry_run: bool) -> Value {
|
|
||||||
let mut temp_files_deleted = 0u64;
|
|
||||||
let mut temp_bytes_freed = 0u64;
|
|
||||||
let mut multipart_uploads_deleted = 0u64;
|
|
||||||
let mut lock_files_deleted = 0u64;
|
|
||||||
let mut empty_dirs_removed = 0u64;
|
|
||||||
let mut errors: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
let now = std::time::SystemTime::now();
|
|
||||||
let temp_max_age =
|
|
||||||
std::time::Duration::from_secs_f64(self.config.temp_file_max_age_hours * 3600.0);
|
|
||||||
let multipart_max_age =
|
|
||||||
std::time::Duration::from_secs(self.config.multipart_max_age_days * 86400);
|
|
||||||
let lock_max_age =
|
|
||||||
std::time::Duration::from_secs_f64(self.config.lock_file_max_age_hours * 3600.0);
|
|
||||||
|
|
||||||
let tmp_dir = self.storage_root.join(".myfsio.sys").join("tmp");
|
|
||||||
if tmp_dir.exists() {
|
|
||||||
match std::fs::read_dir(&tmp_dir) {
|
|
||||||
Ok(entries) => {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
if let Ok(metadata) = entry.metadata() {
|
|
||||||
if let Ok(modified) = metadata.modified() {
|
|
||||||
if let Ok(age) = now.duration_since(modified) {
|
|
||||||
if age > temp_max_age {
|
|
||||||
let size = metadata.len();
|
|
||||||
if !dry_run {
|
|
||||||
if let Err(e) = std::fs::remove_file(entry.path()) {
|
|
||||||
errors.push(format!(
|
|
||||||
"Failed to remove temp file: {}",
|
|
||||||
e
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
temp_files_deleted += 1;
|
|
||||||
temp_bytes_freed += size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => errors.push(format!("Failed to read tmp dir: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let multipart_dir = self.storage_root.join(".myfsio.sys").join("multipart");
|
|
||||||
if multipart_dir.exists() {
|
|
||||||
if let Ok(bucket_dirs) = std::fs::read_dir(&multipart_dir) {
|
|
||||||
for bucket_entry in bucket_dirs.flatten() {
|
|
||||||
if let Ok(uploads) = std::fs::read_dir(bucket_entry.path()) {
|
|
||||||
for upload in uploads.flatten() {
|
|
||||||
if let Ok(metadata) = upload.metadata() {
|
|
||||||
if let Ok(modified) = metadata.modified() {
|
|
||||||
if let Ok(age) = now.duration_since(modified) {
|
|
||||||
if age > multipart_max_age {
|
|
||||||
if !dry_run {
|
|
||||||
let _ = std::fs::remove_dir_all(upload.path());
|
|
||||||
}
|
|
||||||
multipart_uploads_deleted += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let buckets_dir = self.storage_root.join(".myfsio.sys").join("buckets");
|
|
||||||
if buckets_dir.exists() {
|
|
||||||
if let Ok(bucket_dirs) = std::fs::read_dir(&buckets_dir) {
|
|
||||||
for bucket_entry in bucket_dirs.flatten() {
|
|
||||||
let locks_dir = bucket_entry.path().join("locks");
|
|
||||||
if locks_dir.exists() {
|
|
||||||
if let Ok(locks) = std::fs::read_dir(&locks_dir) {
|
|
||||||
for lock in locks.flatten() {
|
|
||||||
if let Ok(metadata) = lock.metadata() {
|
|
||||||
if let Ok(modified) = metadata.modified() {
|
|
||||||
if let Ok(age) = now.duration_since(modified) {
|
|
||||||
if age > lock_max_age {
|
|
||||||
if !dry_run {
|
|
||||||
let _ = std::fs::remove_file(lock.path());
|
|
||||||
}
|
|
||||||
lock_files_deleted += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !dry_run {
|
|
||||||
for dir in [&tmp_dir, &multipart_dir] {
|
|
||||||
if dir.exists() {
|
|
||||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
if entry.path().is_dir() {
|
|
||||||
if let Ok(mut contents) = std::fs::read_dir(entry.path()) {
|
|
||||||
if contents.next().is_none() {
|
|
||||||
let _ = std::fs::remove_dir(entry.path());
|
|
||||||
empty_dirs_removed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json!({
|
|
||||||
"temp_files_deleted": temp_files_deleted,
|
|
||||||
"temp_bytes_freed": temp_bytes_freed,
|
|
||||||
"multipart_uploads_deleted": multipart_uploads_deleted,
|
|
||||||
"lock_files_deleted": lock_files_deleted,
|
|
||||||
"empty_dirs_removed": empty_dirs_removed,
|
|
||||||
"errors": errors,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_history(&self) {
|
|
||||||
let history = self.history.read().await;
|
|
||||||
let data = json!({ "executions": *history });
|
|
||||||
if let Some(parent) = self.history_path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
let _ = std::fs::write(
|
|
||||||
&self.history_path,
|
|
||||||
serde_json::to_string_pretty(&data).unwrap_or_default(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_background(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
|
|
||||||
let interval = std::time::Duration::from_secs_f64(self.config.interval_hours * 3600.0);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut timer = tokio::time::interval(interval);
|
|
||||||
timer.tick().await;
|
|
||||||
loop {
|
|
||||||
timer.tick().await;
|
|
||||||
tracing::info!("GC cycle starting");
|
|
||||||
match self.run_now(false).await {
|
|
||||||
Ok(result) => tracing::info!("GC cycle complete: {:?}", result),
|
|
||||||
Err(e) => tracing::warn!("GC cycle failed: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,732 +0,0 @@
|
|||||||
use myfsio_common::constants::{
|
|
||||||
BUCKET_META_DIR, BUCKET_VERSIONS_DIR, INDEX_FILE, SYSTEM_BUCKETS_DIR, SYSTEM_ROOT,
|
|
||||||
};
|
|
||||||
use myfsio_storage::fs_backend::FsStorageBackend;
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
const MAX_ISSUES: usize = 500;
|
|
||||||
const INTERNAL_FOLDERS: &[&str] = &[".meta", ".versions", ".multipart"];
|
|
||||||
|
|
||||||
pub struct IntegrityConfig {
|
|
||||||
pub interval_hours: f64,
|
|
||||||
pub batch_size: usize,
|
|
||||||
pub auto_heal: bool,
|
|
||||||
pub dry_run: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for IntegrityConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
interval_hours: 24.0,
|
|
||||||
batch_size: 10_000,
|
|
||||||
auto_heal: false,
|
|
||||||
dry_run: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct IntegrityService {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
storage: Arc<FsStorageBackend>,
|
|
||||||
storage_root: PathBuf,
|
|
||||||
config: IntegrityConfig,
|
|
||||||
running: Arc<RwLock<bool>>,
|
|
||||||
started_at: Arc<RwLock<Option<Instant>>>,
|
|
||||||
history: Arc<RwLock<Vec<Value>>>,
|
|
||||||
history_path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct ScanState {
|
|
||||||
objects_scanned: u64,
|
|
||||||
buckets_scanned: u64,
|
|
||||||
corrupted_objects: u64,
|
|
||||||
orphaned_objects: u64,
|
|
||||||
phantom_metadata: u64,
|
|
||||||
stale_versions: u64,
|
|
||||||
etag_cache_inconsistencies: u64,
|
|
||||||
issues: Vec<Value>,
|
|
||||||
errors: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScanState {
|
|
||||||
fn batch_exhausted(&self, batch_size: usize) -> bool {
|
|
||||||
self.objects_scanned >= batch_size as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_issue(&mut self, issue_type: &str, bucket: &str, key: &str, detail: String) {
|
|
||||||
if self.issues.len() < MAX_ISSUES {
|
|
||||||
self.issues.push(json!({
|
|
||||||
"issue_type": issue_type,
|
|
||||||
"bucket": bucket,
|
|
||||||
"key": key,
|
|
||||||
"detail": detail,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn into_json(self, elapsed: f64) -> Value {
|
|
||||||
json!({
|
|
||||||
"objects_scanned": self.objects_scanned,
|
|
||||||
"buckets_scanned": self.buckets_scanned,
|
|
||||||
"corrupted_objects": self.corrupted_objects,
|
|
||||||
"orphaned_objects": self.orphaned_objects,
|
|
||||||
"phantom_metadata": self.phantom_metadata,
|
|
||||||
"stale_versions": self.stale_versions,
|
|
||||||
"etag_cache_inconsistencies": self.etag_cache_inconsistencies,
|
|
||||||
"issues_healed": 0,
|
|
||||||
"issues": self.issues,
|
|
||||||
"errors": self.errors,
|
|
||||||
"execution_time_seconds": elapsed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntegrityService {
|
|
||||||
pub fn new(
|
|
||||||
storage: Arc<FsStorageBackend>,
|
|
||||||
storage_root: &Path,
|
|
||||||
config: IntegrityConfig,
|
|
||||||
) -> Self {
|
|
||||||
let history_path = storage_root
|
|
||||||
.join(SYSTEM_ROOT)
|
|
||||||
.join("config")
|
|
||||||
.join("integrity_history.json");
|
|
||||||
|
|
||||||
let history = if history_path.exists() {
|
|
||||||
std::fs::read_to_string(&history_path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
|
||||||
.and_then(|v| v.get("executions").and_then(|e| e.as_array().cloned()))
|
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
storage,
|
|
||||||
storage_root: storage_root.to_path_buf(),
|
|
||||||
config,
|
|
||||||
running: Arc::new(RwLock::new(false)),
|
|
||||||
started_at: Arc::new(RwLock::new(None)),
|
|
||||||
history: Arc::new(RwLock::new(history)),
|
|
||||||
history_path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn status(&self) -> Value {
|
|
||||||
let running = *self.running.read().await;
|
|
||||||
let scan_elapsed_seconds = self
|
|
||||||
.started_at
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.as_ref()
|
|
||||||
.map(|started| started.elapsed().as_secs_f64());
|
|
||||||
json!({
|
|
||||||
"enabled": true,
|
|
||||||
"running": running,
|
|
||||||
"scanning": running,
|
|
||||||
"scan_elapsed_seconds": scan_elapsed_seconds,
|
|
||||||
"interval_hours": self.config.interval_hours,
|
|
||||||
"batch_size": self.config.batch_size,
|
|
||||||
"auto_heal": self.config.auto_heal,
|
|
||||||
"dry_run": self.config.dry_run,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn history(&self) -> Value {
|
|
||||||
let history = self.history.read().await;
|
|
||||||
let mut executions: Vec<Value> = history.iter().cloned().collect();
|
|
||||||
executions.reverse();
|
|
||||||
json!({ "executions": executions })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_now(&self, dry_run: bool, auto_heal: bool) -> Result<Value, String> {
|
|
||||||
{
|
|
||||||
let mut running = self.running.write().await;
|
|
||||||
if *running {
|
|
||||||
return Err("Integrity check already running".to_string());
|
|
||||||
}
|
|
||||||
*running = true;
|
|
||||||
}
|
|
||||||
*self.started_at.write().await = Some(Instant::now());
|
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
let storage_root = self.storage_root.clone();
|
|
||||||
let batch_size = self.config.batch_size;
|
|
||||||
let result =
|
|
||||||
tokio::task::spawn_blocking(move || scan_all_buckets(&storage_root, batch_size))
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
let mut st = ScanState::default();
|
|
||||||
st.errors.push(format!("scan task failed: {}", e));
|
|
||||||
st
|
|
||||||
});
|
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
|
||||||
|
|
||||||
*self.running.write().await = false;
|
|
||||||
*self.started_at.write().await = None;
|
|
||||||
|
|
||||||
let result_json = result.into_json(elapsed);
|
|
||||||
|
|
||||||
let record = json!({
|
|
||||||
"timestamp": chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
|
|
||||||
"dry_run": dry_run,
|
|
||||||
"auto_heal": auto_heal,
|
|
||||||
"result": result_json.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut history = self.history.write().await;
|
|
||||||
history.push(record);
|
|
||||||
if history.len() > 50 {
|
|
||||||
let excess = history.len() - 50;
|
|
||||||
history.drain(..excess);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.save_history().await;
|
|
||||||
|
|
||||||
Ok(result_json)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_history(&self) {
|
|
||||||
let history = self.history.read().await;
|
|
||||||
let data = json!({ "executions": *history });
|
|
||||||
if let Some(parent) = self.history_path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
let _ = std::fs::write(
|
|
||||||
&self.history_path,
|
|
||||||
serde_json::to_string_pretty(&data).unwrap_or_default(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_background(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
|
|
||||||
let interval = std::time::Duration::from_secs_f64(self.config.interval_hours * 3600.0);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut timer = tokio::time::interval(interval);
|
|
||||||
timer.tick().await;
|
|
||||||
loop {
|
|
||||||
timer.tick().await;
|
|
||||||
tracing::info!("Integrity check starting");
|
|
||||||
match self.run_now(false, false).await {
|
|
||||||
Ok(result) => tracing::info!("Integrity check complete: {:?}", result),
|
|
||||||
Err(e) => tracing::warn!("Integrity check failed: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scan_all_buckets(storage_root: &Path, batch_size: usize) -> ScanState {
|
|
||||||
let mut state = ScanState::default();
|
|
||||||
let buckets = match list_bucket_names(storage_root) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
state.errors.push(format!("list buckets: {}", e));
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for bucket in &buckets {
|
|
||||||
if state.batch_exhausted(batch_size) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
state.buckets_scanned += 1;
|
|
||||||
|
|
||||||
let bucket_path = storage_root.join(bucket);
|
|
||||||
let meta_root = storage_root
|
|
||||||
.join(SYSTEM_ROOT)
|
|
||||||
.join(SYSTEM_BUCKETS_DIR)
|
|
||||||
.join(bucket)
|
|
||||||
.join(BUCKET_META_DIR);
|
|
||||||
|
|
||||||
let index_entries = collect_index_entries(&meta_root);
|
|
||||||
|
|
||||||
check_corrupted(&mut state, bucket, &bucket_path, &index_entries, batch_size);
|
|
||||||
check_phantom(&mut state, bucket, &bucket_path, &index_entries, batch_size);
|
|
||||||
check_orphaned(&mut state, bucket, &bucket_path, &index_entries, batch_size);
|
|
||||||
check_stale_versions(&mut state, storage_root, bucket, batch_size);
|
|
||||||
check_etag_cache(&mut state, storage_root, bucket, &index_entries, batch_size);
|
|
||||||
}
|
|
||||||
|
|
||||||
state
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_bucket_names(storage_root: &Path) -> std::io::Result<Vec<String>> {
|
|
||||||
let mut names = Vec::new();
|
|
||||||
if !storage_root.exists() {
|
|
||||||
return Ok(names);
|
|
||||||
}
|
|
||||||
for entry in std::fs::read_dir(storage_root)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
if name == SYSTEM_ROOT {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
|
||||||
names.push(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(names)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
struct IndexEntryInfo {
|
|
||||||
entry: Value,
|
|
||||||
index_file: PathBuf,
|
|
||||||
key_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_index_entries(meta_root: &Path) -> HashMap<String, IndexEntryInfo> {
|
|
||||||
let mut out: HashMap<String, IndexEntryInfo> = HashMap::new();
|
|
||||||
if !meta_root.exists() {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stack: Vec<PathBuf> = vec![meta_root.to_path_buf()];
|
|
||||||
while let Some(dir) = stack.pop() {
|
|
||||||
let rd = match std::fs::read_dir(&dir) {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
for entry in rd.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
let ft = match entry.file_type() {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
if ft.is_dir() {
|
|
||||||
stack.push(path);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if entry.file_name().to_string_lossy() != INDEX_FILE {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let rel_dir = match path.parent().and_then(|p| p.strip_prefix(meta_root).ok()) {
|
|
||||||
Some(p) => p.to_path_buf(),
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let dir_prefix = if rel_dir.as_os_str().is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
rel_dir
|
|
||||||
.components()
|
|
||||||
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("/")
|
|
||||||
};
|
|
||||||
|
|
||||||
let content = match std::fs::read_to_string(&path) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let index_data: Map<String, Value> = match serde_json::from_str(&content) {
|
|
||||||
Ok(Value::Object(m)) => m,
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (key_name, entry_val) in index_data {
|
|
||||||
let full_key = if dir_prefix.is_empty() {
|
|
||||||
key_name.clone()
|
|
||||||
} else {
|
|
||||||
format!("{}/{}", dir_prefix, key_name)
|
|
||||||
};
|
|
||||||
out.insert(
|
|
||||||
full_key,
|
|
||||||
IndexEntryInfo {
|
|
||||||
entry: entry_val,
|
|
||||||
index_file: path.clone(),
|
|
||||||
key_name,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stored_etag(entry: &Value) -> Option<String> {
|
|
||||||
entry
|
|
||||||
.get("metadata")
|
|
||||||
.and_then(|m| m.get("__etag__"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_corrupted(
|
|
||||||
state: &mut ScanState,
|
|
||||||
bucket: &str,
|
|
||||||
bucket_path: &Path,
|
|
||||||
entries: &HashMap<String, IndexEntryInfo>,
|
|
||||||
batch_size: usize,
|
|
||||||
) {
|
|
||||||
let mut keys: Vec<&String> = entries.keys().collect();
|
|
||||||
keys.sort();
|
|
||||||
|
|
||||||
for full_key in keys {
|
|
||||||
if state.batch_exhausted(batch_size) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let info = &entries[full_key];
|
|
||||||
let object_path = bucket_path.join(full_key);
|
|
||||||
if !object_path.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
state.objects_scanned += 1;
|
|
||||||
|
|
||||||
let Some(stored) = stored_etag(&info.entry) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
match myfsio_crypto::hashing::md5_file(&object_path) {
|
|
||||||
Ok(actual) => {
|
|
||||||
if actual != stored {
|
|
||||||
state.corrupted_objects += 1;
|
|
||||||
state.push_issue(
|
|
||||||
"corrupted_object",
|
|
||||||
bucket,
|
|
||||||
full_key,
|
|
||||||
format!("stored_etag={} actual_etag={}", stored, actual),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => state
|
|
||||||
.errors
|
|
||||||
.push(format!("hash {}/{}: {}", bucket, full_key, e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_phantom(
|
|
||||||
state: &mut ScanState,
|
|
||||||
bucket: &str,
|
|
||||||
bucket_path: &Path,
|
|
||||||
entries: &HashMap<String, IndexEntryInfo>,
|
|
||||||
batch_size: usize,
|
|
||||||
) {
|
|
||||||
let mut keys: Vec<&String> = entries.keys().collect();
|
|
||||||
keys.sort();
|
|
||||||
|
|
||||||
for full_key in keys {
|
|
||||||
if state.batch_exhausted(batch_size) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.objects_scanned += 1;
|
|
||||||
let object_path = bucket_path.join(full_key);
|
|
||||||
if !object_path.exists() {
|
|
||||||
state.phantom_metadata += 1;
|
|
||||||
state.push_issue(
|
|
||||||
"phantom_metadata",
|
|
||||||
bucket,
|
|
||||||
full_key,
|
|
||||||
"metadata entry without file on disk".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_orphaned(
|
|
||||||
state: &mut ScanState,
|
|
||||||
bucket: &str,
|
|
||||||
bucket_path: &Path,
|
|
||||||
entries: &HashMap<String, IndexEntryInfo>,
|
|
||||||
batch_size: usize,
|
|
||||||
) {
|
|
||||||
let indexed: HashSet<&String> = entries.keys().collect();
|
|
||||||
let mut stack: Vec<(PathBuf, String)> = vec![(bucket_path.to_path_buf(), String::new())];
|
|
||||||
|
|
||||||
while let Some((dir, prefix)) = stack.pop() {
|
|
||||||
if state.batch_exhausted(batch_size) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let rd = match std::fs::read_dir(&dir) {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
for entry in rd.flatten() {
|
|
||||||
if state.batch_exhausted(batch_size) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
let ft = match entry.file_type() {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
if ft.is_dir() {
|
|
||||||
if prefix.is_empty() && INTERNAL_FOLDERS.contains(&name.as_str()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let new_prefix = if prefix.is_empty() {
|
|
||||||
name
|
|
||||||
} else {
|
|
||||||
format!("{}/{}", prefix, name)
|
|
||||||
};
|
|
||||||
stack.push((entry.path(), new_prefix));
|
|
||||||
} else if ft.is_file() {
|
|
||||||
let full_key = if prefix.is_empty() {
|
|
||||||
name
|
|
||||||
} else {
|
|
||||||
format!("{}/{}", prefix, name)
|
|
||||||
};
|
|
||||||
state.objects_scanned += 1;
|
|
||||||
if !indexed.contains(&full_key) {
|
|
||||||
state.orphaned_objects += 1;
|
|
||||||
state.push_issue(
|
|
||||||
"orphaned_object",
|
|
||||||
bucket,
|
|
||||||
&full_key,
|
|
||||||
"file exists without metadata entry".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_stale_versions(
|
|
||||||
state: &mut ScanState,
|
|
||||||
storage_root: &Path,
|
|
||||||
bucket: &str,
|
|
||||||
batch_size: usize,
|
|
||||||
) {
|
|
||||||
let versions_root = storage_root
|
|
||||||
.join(SYSTEM_ROOT)
|
|
||||||
.join(SYSTEM_BUCKETS_DIR)
|
|
||||||
.join(bucket)
|
|
||||||
.join(BUCKET_VERSIONS_DIR);
|
|
||||||
if !versions_root.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stack: Vec<PathBuf> = vec![versions_root.clone()];
|
|
||||||
while let Some(dir) = stack.pop() {
|
|
||||||
if state.batch_exhausted(batch_size) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let rd = match std::fs::read_dir(&dir) {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut bin_stems: HashMap<String, PathBuf> = HashMap::new();
|
|
||||||
let mut json_stems: HashMap<String, PathBuf> = HashMap::new();
|
|
||||||
let mut subdirs: Vec<PathBuf> = Vec::new();
|
|
||||||
|
|
||||||
for entry in rd.flatten() {
|
|
||||||
let ft = match entry.file_type() {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let path = entry.path();
|
|
||||||
if ft.is_dir() {
|
|
||||||
subdirs.push(path);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
if let Some(stem) = name.strip_suffix(".bin") {
|
|
||||||
bin_stems.insert(stem.to_string(), path);
|
|
||||||
} else if let Some(stem) = name.strip_suffix(".json") {
|
|
||||||
json_stems.insert(stem.to_string(), path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (stem, path) in &bin_stems {
|
|
||||||
if state.batch_exhausted(batch_size) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.objects_scanned += 1;
|
|
||||||
if !json_stems.contains_key(stem) {
|
|
||||||
state.stale_versions += 1;
|
|
||||||
let key = path
|
|
||||||
.strip_prefix(&versions_root)
|
|
||||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
|
||||||
.unwrap_or_else(|_| path.display().to_string());
|
|
||||||
state.push_issue(
|
|
||||||
"stale_version",
|
|
||||||
bucket,
|
|
||||||
&key,
|
|
||||||
"version data without manifest".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (stem, path) in &json_stems {
|
|
||||||
if state.batch_exhausted(batch_size) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.objects_scanned += 1;
|
|
||||||
if !bin_stems.contains_key(stem) {
|
|
||||||
state.stale_versions += 1;
|
|
||||||
let key = path
|
|
||||||
.strip_prefix(&versions_root)
|
|
||||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
|
||||||
.unwrap_or_else(|_| path.display().to_string());
|
|
||||||
state.push_issue(
|
|
||||||
"stale_version",
|
|
||||||
bucket,
|
|
||||||
&key,
|
|
||||||
"version manifest without data".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stack.extend(subdirs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_etag_cache(
|
|
||||||
state: &mut ScanState,
|
|
||||||
storage_root: &Path,
|
|
||||||
bucket: &str,
|
|
||||||
entries: &HashMap<String, IndexEntryInfo>,
|
|
||||||
batch_size: usize,
|
|
||||||
) {
|
|
||||||
let etag_index_path = storage_root
|
|
||||||
.join(SYSTEM_ROOT)
|
|
||||||
.join(SYSTEM_BUCKETS_DIR)
|
|
||||||
.join(bucket)
|
|
||||||
.join("etag_index.json");
|
|
||||||
if !etag_index_path.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cache: HashMap<String, Value> = match std::fs::read_to_string(&etag_index_path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
|
||||||
{
|
|
||||||
Some(Value::Object(m)) => m.into_iter().collect(),
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (full_key, cached_val) in cache {
|
|
||||||
if state.batch_exhausted(batch_size) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.objects_scanned += 1;
|
|
||||||
let Some(cached_etag) = cached_val.as_str() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(info) = entries.get(&full_key) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(stored) = stored_etag(&info.entry) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if cached_etag != stored {
|
|
||||||
state.etag_cache_inconsistencies += 1;
|
|
||||||
state.push_issue(
|
|
||||||
"etag_cache_inconsistency",
|
|
||||||
bucket,
|
|
||||||
&full_key,
|
|
||||||
format!("cached_etag={} index_etag={}", cached_etag, stored),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
fn md5_hex(bytes: &[u8]) -> String {
|
|
||||||
myfsio_crypto::hashing::md5_bytes(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_index(meta_dir: &Path, entries: &[(&str, &str)]) {
|
|
||||||
fs::create_dir_all(meta_dir).unwrap();
|
|
||||||
let mut map = Map::new();
|
|
||||||
for (name, etag) in entries {
|
|
||||||
map.insert(
|
|
||||||
name.to_string(),
|
|
||||||
json!({ "metadata": { "__etag__": etag } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
fs::write(
|
|
||||||
meta_dir.join(INDEX_FILE),
|
|
||||||
serde_json::to_string(&Value::Object(map)).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scan_detects_each_issue_type() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let root = tmp.path();
|
|
||||||
let bucket = "testbucket";
|
|
||||||
let bucket_path = root.join(bucket);
|
|
||||||
let meta_root = root
|
|
||||||
.join(SYSTEM_ROOT)
|
|
||||||
.join(SYSTEM_BUCKETS_DIR)
|
|
||||||
.join(bucket)
|
|
||||||
.join(BUCKET_META_DIR);
|
|
||||||
fs::create_dir_all(&bucket_path).unwrap();
|
|
||||||
|
|
||||||
let clean_bytes = b"clean file contents";
|
|
||||||
let clean_etag = md5_hex(clean_bytes);
|
|
||||||
fs::write(bucket_path.join("clean.txt"), clean_bytes).unwrap();
|
|
||||||
|
|
||||||
let corrupted_bytes = b"actual content";
|
|
||||||
fs::write(bucket_path.join("corrupted.txt"), corrupted_bytes).unwrap();
|
|
||||||
|
|
||||||
fs::write(bucket_path.join("orphan.txt"), b"no metadata").unwrap();
|
|
||||||
|
|
||||||
write_index(
|
|
||||||
&meta_root,
|
|
||||||
&[
|
|
||||||
("clean.txt", &clean_etag),
|
|
||||||
("corrupted.txt", "00000000000000000000000000000000"),
|
|
||||||
("phantom.txt", "deadbeefdeadbeefdeadbeefdeadbeef"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
let versions_root = root
|
|
||||||
.join(SYSTEM_ROOT)
|
|
||||||
.join(SYSTEM_BUCKETS_DIR)
|
|
||||||
.join(bucket)
|
|
||||||
.join(BUCKET_VERSIONS_DIR)
|
|
||||||
.join("someobject");
|
|
||||||
fs::create_dir_all(&versions_root).unwrap();
|
|
||||||
fs::write(versions_root.join("v1.bin"), b"orphan bin").unwrap();
|
|
||||||
fs::write(versions_root.join("v2.json"), b"{}").unwrap();
|
|
||||||
|
|
||||||
let etag_index = root
|
|
||||||
.join(SYSTEM_ROOT)
|
|
||||||
.join(SYSTEM_BUCKETS_DIR)
|
|
||||||
.join(bucket)
|
|
||||||
.join("etag_index.json");
|
|
||||||
fs::write(
|
|
||||||
&etag_index,
|
|
||||||
serde_json::to_string(&json!({ "clean.txt": "stale-cached-etag" })).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let state = scan_all_buckets(root, 10_000);
|
|
||||||
|
|
||||||
assert_eq!(state.corrupted_objects, 1, "corrupted");
|
|
||||||
assert_eq!(state.phantom_metadata, 1, "phantom");
|
|
||||||
assert_eq!(state.orphaned_objects, 1, "orphaned");
|
|
||||||
assert_eq!(state.stale_versions, 2, "stale versions");
|
|
||||||
assert_eq!(state.etag_cache_inconsistencies, 1, "etag cache");
|
|
||||||
assert_eq!(state.buckets_scanned, 1);
|
|
||||||
assert!(
|
|
||||||
state.errors.is_empty(),
|
|
||||||
"unexpected errors: {:?}",
|
|
||||||
state.errors
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skips_system_root_as_bucket() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
fs::create_dir_all(tmp.path().join(SYSTEM_ROOT).join("config")).unwrap();
|
|
||||||
let state = scan_all_buckets(tmp.path(), 100);
|
|
||||||
assert_eq!(state.buckets_scanned, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,637 +0,0 @@
|
|||||||
use chrono::{DateTime, Duration, Utc};
|
|
||||||
use myfsio_storage::fs_backend::FsStorageBackend;
|
|
||||||
use myfsio_storage::traits::StorageEngine;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
pub struct LifecycleConfig {
|
|
||||||
pub interval_seconds: u64,
|
|
||||||
pub max_history_per_bucket: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LifecycleConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
interval_seconds: 3600,
|
|
||||||
max_history_per_bucket: 50,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LifecycleExecutionRecord {
|
|
||||||
pub timestamp: f64,
|
|
||||||
pub bucket_name: String,
|
|
||||||
pub objects_deleted: u64,
|
|
||||||
pub versions_deleted: u64,
|
|
||||||
pub uploads_aborted: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub errors: Vec<String>,
|
|
||||||
pub execution_time_seconds: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
struct BucketLifecycleResult {
|
|
||||||
bucket_name: String,
|
|
||||||
objects_deleted: u64,
|
|
||||||
versions_deleted: u64,
|
|
||||||
uploads_aborted: u64,
|
|
||||||
errors: Vec<String>,
|
|
||||||
execution_time_seconds: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
struct ParsedLifecycleRule {
|
|
||||||
status: String,
|
|
||||||
prefix: String,
|
|
||||||
expiration_days: Option<u64>,
|
|
||||||
expiration_date: Option<DateTime<Utc>>,
|
|
||||||
noncurrent_days: Option<u64>,
|
|
||||||
abort_incomplete_multipart_days: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LifecycleService {
|
|
||||||
storage: Arc<FsStorageBackend>,
|
|
||||||
storage_root: PathBuf,
|
|
||||||
config: LifecycleConfig,
|
|
||||||
running: Arc<RwLock<bool>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LifecycleService {
|
|
||||||
pub fn new(
|
|
||||||
storage: Arc<FsStorageBackend>,
|
|
||||||
storage_root: impl Into<PathBuf>,
|
|
||||||
config: LifecycleConfig,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
storage,
|
|
||||||
storage_root: storage_root.into(),
|
|
||||||
config,
|
|
||||||
running: Arc::new(RwLock::new(false)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_cycle(&self) -> Result<Value, String> {
|
|
||||||
{
|
|
||||||
let mut running = self.running.write().await;
|
|
||||||
if *running {
|
|
||||||
return Err("Lifecycle already running".to_string());
|
|
||||||
}
|
|
||||||
*running = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = self.evaluate_rules().await;
|
|
||||||
*self.running.write().await = false;
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn evaluate_rules(&self) -> Value {
|
|
||||||
let buckets = match self.storage.list_buckets().await {
|
|
||||||
Ok(buckets) => buckets,
|
|
||||||
Err(err) => return json!({ "error": err.to_string() }),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut bucket_results = Vec::new();
|
|
||||||
let mut total_objects_deleted = 0u64;
|
|
||||||
let mut total_versions_deleted = 0u64;
|
|
||||||
let mut total_uploads_aborted = 0u64;
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
|
|
||||||
for bucket in &buckets {
|
|
||||||
let started_at = std::time::Instant::now();
|
|
||||||
let mut result = BucketLifecycleResult {
|
|
||||||
bucket_name: bucket.name.clone(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = match self.storage.get_bucket_config(&bucket.name).await {
|
|
||||||
Ok(config) => config,
|
|
||||||
Err(err) => {
|
|
||||||
result.errors.push(err.to_string());
|
|
||||||
result.execution_time_seconds = started_at.elapsed().as_secs_f64();
|
|
||||||
self.append_history(&result);
|
|
||||||
errors.extend(result.errors.clone());
|
|
||||||
bucket_results.push(result);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Some(lifecycle) = config.lifecycle.as_ref() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let rules = parse_lifecycle_rules(lifecycle);
|
|
||||||
if rules.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for rule in &rules {
|
|
||||||
if rule.status != "Enabled" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(err) = self
|
|
||||||
.apply_expiration_rule(&bucket.name, rule, &mut result)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
result.errors.push(err);
|
|
||||||
}
|
|
||||||
if let Some(err) = self
|
|
||||||
.apply_noncurrent_expiration_rule(&bucket.name, rule, &mut result)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
result.errors.push(err);
|
|
||||||
}
|
|
||||||
if let Some(err) = self
|
|
||||||
.apply_abort_incomplete_multipart_rule(&bucket.name, rule, &mut result)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
result.errors.push(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.execution_time_seconds = started_at.elapsed().as_secs_f64();
|
|
||||||
if result.objects_deleted > 0
|
|
||||||
|| result.versions_deleted > 0
|
|
||||||
|| result.uploads_aborted > 0
|
|
||||||
|| !result.errors.is_empty()
|
|
||||||
{
|
|
||||||
total_objects_deleted += result.objects_deleted;
|
|
||||||
total_versions_deleted += result.versions_deleted;
|
|
||||||
total_uploads_aborted += result.uploads_aborted;
|
|
||||||
errors.extend(result.errors.clone());
|
|
||||||
self.append_history(&result);
|
|
||||||
bucket_results.push(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json!({
|
|
||||||
"objects_deleted": total_objects_deleted,
|
|
||||||
"versions_deleted": total_versions_deleted,
|
|
||||||
"multipart_aborted": total_uploads_aborted,
|
|
||||||
"buckets_evaluated": buckets.len(),
|
|
||||||
"results": bucket_results.iter().map(result_to_json).collect::<Vec<_>>(),
|
|
||||||
"errors": errors,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn apply_expiration_rule(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
rule: &ParsedLifecycleRule,
|
|
||||||
result: &mut BucketLifecycleResult,
|
|
||||||
) -> Option<String> {
|
|
||||||
let cutoff = if let Some(days) = rule.expiration_days {
|
|
||||||
Some(Utc::now() - Duration::days(days as i64))
|
|
||||||
} else {
|
|
||||||
rule.expiration_date
|
|
||||||
};
|
|
||||||
let Some(cutoff) = cutoff else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
let params = myfsio_common::types::ListParams {
|
|
||||||
max_keys: 10_000,
|
|
||||||
prefix: if rule.prefix.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(rule.prefix.clone())
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
match self.storage.list_objects(bucket, ¶ms).await {
|
|
||||||
Ok(objects) => {
|
|
||||||
for object in &objects.objects {
|
|
||||||
if object.last_modified < cutoff {
|
|
||||||
if let Err(err) = self.storage.delete_object(bucket, &object.key).await {
|
|
||||||
result
|
|
||||||
.errors
|
|
||||||
.push(format!("{}:{}: {}", bucket, object.key, err));
|
|
||||||
} else {
|
|
||||||
result.objects_deleted += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Err(err) => Some(format!("Failed to list objects for {}: {}", bucket, err)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn apply_noncurrent_expiration_rule(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
rule: &ParsedLifecycleRule,
|
|
||||||
result: &mut BucketLifecycleResult,
|
|
||||||
) -> Option<String> {
|
|
||||||
let Some(days) = rule.noncurrent_days else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let cutoff = Utc::now() - Duration::days(days as i64);
|
|
||||||
let versions_root = version_root_for_bucket(&self.storage_root, bucket);
|
|
||||||
if !versions_root.exists() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stack = VecDeque::from([versions_root]);
|
|
||||||
while let Some(current) = stack.pop_front() {
|
|
||||||
let entries = match std::fs::read_dir(¤t) {
|
|
||||||
Ok(entries) => entries,
|
|
||||||
Err(err) => return Some(err.to_string()),
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let file_type = match entry.file_type() {
|
|
||||||
Ok(file_type) => file_type,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
if file_type.is_dir() {
|
|
||||||
stack.push_back(entry.path());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if entry.path().extension().and_then(|ext| ext.to_str()) != Some("json") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let contents = match std::fs::read_to_string(entry.path()) {
|
|
||||||
Ok(contents) => contents,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let Ok(manifest) = serde_json::from_str::<Value>(&contents) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let key = manifest
|
|
||||||
.get("key")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
if !rule.prefix.is_empty() && !key.starts_with(&rule.prefix) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let archived_at = manifest
|
|
||||||
.get("archived_at")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
|
||||||
.map(|value| value.with_timezone(&Utc));
|
|
||||||
if archived_at.is_none() || archived_at.unwrap() >= cutoff {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let version_id = manifest
|
|
||||||
.get("version_id")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let data_path = entry.path().with_file_name(format!("{}.bin", version_id));
|
|
||||||
let _ = std::fs::remove_file(&data_path);
|
|
||||||
let _ = std::fs::remove_file(entry.path());
|
|
||||||
result.versions_deleted += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn apply_abort_incomplete_multipart_rule(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
rule: &ParsedLifecycleRule,
|
|
||||||
result: &mut BucketLifecycleResult,
|
|
||||||
) -> Option<String> {
|
|
||||||
let Some(days) = rule.abort_incomplete_multipart_days else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let cutoff = Utc::now() - Duration::days(days as i64);
|
|
||||||
match self.storage.list_multipart_uploads(bucket).await {
|
|
||||||
Ok(uploads) => {
|
|
||||||
for upload in &uploads {
|
|
||||||
if upload.initiated < cutoff {
|
|
||||||
if let Err(err) = self
|
|
||||||
.storage
|
|
||||||
.abort_multipart(bucket, &upload.upload_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
result
|
|
||||||
.errors
|
|
||||||
.push(format!("abort {}: {}", upload.upload_id, err));
|
|
||||||
} else {
|
|
||||||
result.uploads_aborted += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Err(err) => Some(format!(
|
|
||||||
"Failed to list multipart uploads for {}: {}",
|
|
||||||
bucket, err
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_history(&self, result: &BucketLifecycleResult) {
|
|
||||||
let path = lifecycle_history_path(&self.storage_root, &result.bucket_name);
|
|
||||||
let mut history = load_history(&path);
|
|
||||||
history.insert(
|
|
||||||
0,
|
|
||||||
LifecycleExecutionRecord {
|
|
||||||
timestamp: Utc::now().timestamp_millis() as f64 / 1000.0,
|
|
||||||
bucket_name: result.bucket_name.clone(),
|
|
||||||
objects_deleted: result.objects_deleted,
|
|
||||||
versions_deleted: result.versions_deleted,
|
|
||||||
uploads_aborted: result.uploads_aborted,
|
|
||||||
errors: result.errors.clone(),
|
|
||||||
execution_time_seconds: result.execution_time_seconds,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
history.truncate(self.config.max_history_per_bucket);
|
|
||||||
let payload = json!({
|
|
||||||
"executions": history,
|
|
||||||
});
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
let _ = std::fs::write(
|
|
||||||
&path,
|
|
||||||
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_background(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
|
|
||||||
let interval = std::time::Duration::from_secs(self.config.interval_seconds);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut timer = tokio::time::interval(interval);
|
|
||||||
timer.tick().await;
|
|
||||||
loop {
|
|
||||||
timer.tick().await;
|
|
||||||
tracing::info!("Lifecycle evaluation starting");
|
|
||||||
match self.run_cycle().await {
|
|
||||||
Ok(result) => tracing::info!("Lifecycle cycle complete: {:?}", result),
|
|
||||||
Err(err) => tracing::warn!("Lifecycle cycle failed: {}", err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_history(storage_root: &Path, bucket_name: &str, limit: usize, offset: usize) -> Value {
|
|
||||||
let path = lifecycle_history_path(storage_root, bucket_name);
|
|
||||||
let mut history = load_history(&path);
|
|
||||||
let total = history.len();
|
|
||||||
let executions = history
|
|
||||||
.drain(offset.min(total)..)
|
|
||||||
.take(limit)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
json!({
|
|
||||||
"executions": executions,
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
"enabled": true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_history(path: &Path) -> Vec<LifecycleExecutionRecord> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
std::fs::read_to_string(path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|contents| serde_json::from_str::<Value>(&contents).ok())
|
|
||||||
.and_then(|value| value.get("executions").cloned())
|
|
||||||
.and_then(|value| serde_json::from_value::<Vec<LifecycleExecutionRecord>>(value).ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lifecycle_history_path(storage_root: &Path, bucket_name: &str) -> PathBuf {
|
|
||||||
storage_root
|
|
||||||
.join(".myfsio.sys")
|
|
||||||
.join("buckets")
|
|
||||||
.join(bucket_name)
|
|
||||||
.join("lifecycle_history.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn version_root_for_bucket(storage_root: &Path, bucket_name: &str) -> PathBuf {
|
|
||||||
storage_root
|
|
||||||
.join(".myfsio.sys")
|
|
||||||
.join("buckets")
|
|
||||||
.join(bucket_name)
|
|
||||||
.join("versions")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_lifecycle_rules(value: &Value) -> Vec<ParsedLifecycleRule> {
|
|
||||||
match value {
|
|
||||||
Value::String(raw) => parse_lifecycle_rules_from_string(raw),
|
|
||||||
Value::Array(items) => items.iter().filter_map(parse_lifecycle_rule).collect(),
|
|
||||||
Value::Object(map) => map
|
|
||||||
.get("Rules")
|
|
||||||
.and_then(|rules| rules.as_array())
|
|
||||||
.map(|rules| rules.iter().filter_map(parse_lifecycle_rule).collect())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
_ => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_lifecycle_rules_from_string(raw: &str) -> Vec<ParsedLifecycleRule> {
|
|
||||||
if let Ok(json) = serde_json::from_str::<Value>(raw) {
|
|
||||||
return parse_lifecycle_rules(&json);
|
|
||||||
}
|
|
||||||
let Ok(doc) = roxmltree::Document::parse(raw) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
doc.descendants()
|
|
||||||
.filter(|node| node.is_element() && node.tag_name().name() == "Rule")
|
|
||||||
.map(|rule| ParsedLifecycleRule {
|
|
||||||
status: child_text(&rule, "Status").unwrap_or_else(|| "Enabled".to_string()),
|
|
||||||
prefix: child_text(&rule, "Prefix")
|
|
||||||
.or_else(|| {
|
|
||||||
rule.descendants()
|
|
||||||
.find(|node| {
|
|
||||||
node.is_element()
|
|
||||||
&& node.tag_name().name() == "Filter"
|
|
||||||
&& node.children().any(|child| {
|
|
||||||
child.is_element() && child.tag_name().name() == "Prefix"
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.and_then(|filter| child_text(&filter, "Prefix"))
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
expiration_days: rule
|
|
||||||
.descendants()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "Expiration")
|
|
||||||
.and_then(|expiration| child_text(&expiration, "Days"))
|
|
||||||
.and_then(|value| value.parse::<u64>().ok()),
|
|
||||||
expiration_date: rule
|
|
||||||
.descendants()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "Expiration")
|
|
||||||
.and_then(|expiration| child_text(&expiration, "Date"))
|
|
||||||
.as_deref()
|
|
||||||
.and_then(parse_datetime),
|
|
||||||
noncurrent_days: rule
|
|
||||||
.descendants()
|
|
||||||
.find(|node| {
|
|
||||||
node.is_element() && node.tag_name().name() == "NoncurrentVersionExpiration"
|
|
||||||
})
|
|
||||||
.and_then(|node| child_text(&node, "NoncurrentDays"))
|
|
||||||
.and_then(|value| value.parse::<u64>().ok()),
|
|
||||||
abort_incomplete_multipart_days: rule
|
|
||||||
.descendants()
|
|
||||||
.find(|node| {
|
|
||||||
node.is_element() && node.tag_name().name() == "AbortIncompleteMultipartUpload"
|
|
||||||
})
|
|
||||||
.and_then(|node| child_text(&node, "DaysAfterInitiation"))
|
|
||||||
.and_then(|value| value.parse::<u64>().ok()),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_lifecycle_rule(value: &Value) -> Option<ParsedLifecycleRule> {
|
|
||||||
let map = value.as_object()?;
|
|
||||||
Some(ParsedLifecycleRule {
|
|
||||||
status: map
|
|
||||||
.get("Status")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or("Enabled")
|
|
||||||
.to_string(),
|
|
||||||
prefix: map
|
|
||||||
.get("Prefix")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.or_else(|| {
|
|
||||||
map.get("Filter")
|
|
||||||
.and_then(|value| value.get("Prefix"))
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
expiration_days: map
|
|
||||||
.get("Expiration")
|
|
||||||
.and_then(|value| value.get("Days"))
|
|
||||||
.and_then(|value| value.as_u64()),
|
|
||||||
expiration_date: map
|
|
||||||
.get("Expiration")
|
|
||||||
.and_then(|value| value.get("Date"))
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.and_then(parse_datetime),
|
|
||||||
noncurrent_days: map
|
|
||||||
.get("NoncurrentVersionExpiration")
|
|
||||||
.and_then(|value| value.get("NoncurrentDays"))
|
|
||||||
.and_then(|value| value.as_u64()),
|
|
||||||
abort_incomplete_multipart_days: map
|
|
||||||
.get("AbortIncompleteMultipartUpload")
|
|
||||||
.and_then(|value| value.get("DaysAfterInitiation"))
|
|
||||||
.and_then(|value| value.as_u64()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_datetime(value: &str) -> Option<DateTime<Utc>> {
|
|
||||||
DateTime::parse_from_rfc3339(value)
|
|
||||||
.ok()
|
|
||||||
.map(|value| value.with_timezone(&Utc))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn child_text(node: &roxmltree::Node<'_, '_>, name: &str) -> Option<String> {
|
|
||||||
node.children()
|
|
||||||
.find(|child| child.is_element() && child.tag_name().name() == name)
|
|
||||||
.and_then(|child| child.text())
|
|
||||||
.map(|text| text.trim().to_string())
|
|
||||||
.filter(|text| !text.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn result_to_json(result: &BucketLifecycleResult) -> Value {
|
|
||||||
json!({
|
|
||||||
"bucket_name": result.bucket_name,
|
|
||||||
"objects_deleted": result.objects_deleted,
|
|
||||||
"versions_deleted": result.versions_deleted,
|
|
||||||
"uploads_aborted": result.uploads_aborted,
|
|
||||||
"errors": result.errors,
|
|
||||||
"execution_time_seconds": result.execution_time_seconds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use chrono::Duration;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_rules_from_xml() {
|
|
||||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<LifecycleConfiguration>
|
|
||||||
<Rule>
|
|
||||||
<Status>Enabled</Status>
|
|
||||||
<Filter><Prefix>logs/</Prefix></Filter>
|
|
||||||
<Expiration><Days>10</Days></Expiration>
|
|
||||||
<NoncurrentVersionExpiration><NoncurrentDays>30</NoncurrentDays></NoncurrentVersionExpiration>
|
|
||||||
<AbortIncompleteMultipartUpload><DaysAfterInitiation>7</DaysAfterInitiation></AbortIncompleteMultipartUpload>
|
|
||||||
</Rule>
|
|
||||||
</LifecycleConfiguration>"#;
|
|
||||||
let rules = parse_lifecycle_rules(&Value::String(xml.to_string()));
|
|
||||||
assert_eq!(rules.len(), 1);
|
|
||||||
assert_eq!(rules[0].prefix, "logs/");
|
|
||||||
assert_eq!(rules[0].expiration_days, Some(10));
|
|
||||||
assert_eq!(rules[0].noncurrent_days, Some(30));
|
|
||||||
assert_eq!(rules[0].abort_incomplete_multipart_days, Some(7));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn run_cycle_writes_history_and_deletes_noncurrent_versions() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let storage = Arc::new(FsStorageBackend::new(tmp.path().to_path_buf()));
|
|
||||||
storage.create_bucket("docs").await.unwrap();
|
|
||||||
storage.set_versioning("docs", true).await.unwrap();
|
|
||||||
|
|
||||||
storage
|
|
||||||
.put_object(
|
|
||||||
"docs",
|
|
||||||
"logs/file.txt",
|
|
||||||
Box::pin(std::io::Cursor::new(b"old".to_vec())),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
storage
|
|
||||||
.put_object(
|
|
||||||
"docs",
|
|
||||||
"logs/file.txt",
|
|
||||||
Box::pin(std::io::Cursor::new(b"new".to_vec())),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let versions_root = version_root_for_bucket(tmp.path(), "docs")
|
|
||||||
.join("logs")
|
|
||||||
.join("file.txt");
|
|
||||||
let manifest = std::fs::read_dir(&versions_root)
|
|
||||||
.unwrap()
|
|
||||||
.flatten()
|
|
||||||
.find(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("json"))
|
|
||||||
.unwrap()
|
|
||||||
.path();
|
|
||||||
let old_manifest = json!({
|
|
||||||
"version_id": "ver-1",
|
|
||||||
"key": "logs/file.txt",
|
|
||||||
"size": 3,
|
|
||||||
"archived_at": (Utc::now() - Duration::days(45)).to_rfc3339(),
|
|
||||||
"etag": "etag",
|
|
||||||
});
|
|
||||||
std::fs::write(&manifest, serde_json::to_string(&old_manifest).unwrap()).unwrap();
|
|
||||||
std::fs::write(manifest.with_file_name("ver-1.bin"), b"old").unwrap();
|
|
||||||
|
|
||||||
let lifecycle_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<LifecycleConfiguration>
|
|
||||||
<Rule>
|
|
||||||
<Status>Enabled</Status>
|
|
||||||
<Filter><Prefix>logs/</Prefix></Filter>
|
|
||||||
<NoncurrentVersionExpiration><NoncurrentDays>30</NoncurrentDays></NoncurrentVersionExpiration>
|
|
||||||
</Rule>
|
|
||||||
</LifecycleConfiguration>"#;
|
|
||||||
let mut config = storage.get_bucket_config("docs").await.unwrap();
|
|
||||||
config.lifecycle = Some(Value::String(lifecycle_xml.to_string()));
|
|
||||||
storage.set_bucket_config("docs", &config).await.unwrap();
|
|
||||||
|
|
||||||
let service =
|
|
||||||
LifecycleService::new(storage.clone(), tmp.path(), LifecycleConfig::default());
|
|
||||||
let result = service.run_cycle().await.unwrap();
|
|
||||||
assert_eq!(result["versions_deleted"], 1);
|
|
||||||
|
|
||||||
let history = read_history(tmp.path(), "docs", 50, 0);
|
|
||||||
assert_eq!(history["total"], 1);
|
|
||||||
assert_eq!(history["executions"][0]["versions_deleted"], 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use parking_lot::Mutex;
|
|
||||||
use rand::Rng;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
const MAX_LATENCY_SAMPLES: usize = 5000;
|
|
||||||
|
|
||||||
pub struct MetricsConfig {
|
|
||||||
pub interval_minutes: u64,
|
|
||||||
pub retention_hours: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MetricsConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
interval_minutes: 5,
|
|
||||||
retention_hours: 24,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct OperationStats {
|
|
||||||
count: u64,
|
|
||||||
success_count: u64,
|
|
||||||
error_count: u64,
|
|
||||||
latency_sum_ms: f64,
|
|
||||||
latency_min_ms: f64,
|
|
||||||
latency_max_ms: f64,
|
|
||||||
bytes_in: u64,
|
|
||||||
bytes_out: u64,
|
|
||||||
latency_samples: Vec<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for OperationStats {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
count: 0,
|
|
||||||
success_count: 0,
|
|
||||||
error_count: 0,
|
|
||||||
latency_sum_ms: 0.0,
|
|
||||||
latency_min_ms: f64::INFINITY,
|
|
||||||
latency_max_ms: 0.0,
|
|
||||||
bytes_in: 0,
|
|
||||||
bytes_out: 0,
|
|
||||||
latency_samples: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OperationStats {
|
|
||||||
fn record(&mut self, latency_ms: f64, success: bool, bytes_in: u64, bytes_out: u64) {
|
|
||||||
self.count += 1;
|
|
||||||
if success {
|
|
||||||
self.success_count += 1;
|
|
||||||
} else {
|
|
||||||
self.error_count += 1;
|
|
||||||
}
|
|
||||||
self.latency_sum_ms += latency_ms;
|
|
||||||
if latency_ms < self.latency_min_ms {
|
|
||||||
self.latency_min_ms = latency_ms;
|
|
||||||
}
|
|
||||||
if latency_ms > self.latency_max_ms {
|
|
||||||
self.latency_max_ms = latency_ms;
|
|
||||||
}
|
|
||||||
self.bytes_in += bytes_in;
|
|
||||||
self.bytes_out += bytes_out;
|
|
||||||
|
|
||||||
if self.latency_samples.len() < MAX_LATENCY_SAMPLES {
|
|
||||||
self.latency_samples.push(latency_ms);
|
|
||||||
} else {
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
let j = rng.gen_range(0..self.count as usize);
|
|
||||||
if j < MAX_LATENCY_SAMPLES {
|
|
||||||
self.latency_samples[j] = latency_ms;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_percentile(sorted: &[f64], p: f64) -> f64 {
|
|
||||||
if sorted.is_empty() {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
let k = (sorted.len() - 1) as f64 * (p / 100.0);
|
|
||||||
let f = k.floor() as usize;
|
|
||||||
let c = (f + 1).min(sorted.len() - 1);
|
|
||||||
let d = k - f as f64;
|
|
||||||
sorted[f] + d * (sorted[c] - sorted[f])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_json(&self) -> Value {
|
|
||||||
let avg = if self.count > 0 {
|
|
||||||
self.latency_sum_ms / self.count as f64
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let min = if self.latency_min_ms.is_infinite() {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
self.latency_min_ms
|
|
||||||
};
|
|
||||||
let mut sorted = self.latency_samples.clone();
|
|
||||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
||||||
json!({
|
|
||||||
"count": self.count,
|
|
||||||
"success_count": self.success_count,
|
|
||||||
"error_count": self.error_count,
|
|
||||||
"latency_avg_ms": round2(avg),
|
|
||||||
"latency_min_ms": round2(min),
|
|
||||||
"latency_max_ms": round2(self.latency_max_ms),
|
|
||||||
"latency_p50_ms": round2(Self::compute_percentile(&sorted, 50.0)),
|
|
||||||
"latency_p95_ms": round2(Self::compute_percentile(&sorted, 95.0)),
|
|
||||||
"latency_p99_ms": round2(Self::compute_percentile(&sorted, 99.0)),
|
|
||||||
"bytes_in": self.bytes_in,
|
|
||||||
"bytes_out": self.bytes_out,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn round2(v: f64) -> f64 {
|
|
||||||
(v * 100.0).round() / 100.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MetricsSnapshot {
|
|
||||||
pub timestamp: DateTime<Utc>,
|
|
||||||
pub window_seconds: u64,
|
|
||||||
pub by_method: HashMap<String, Value>,
|
|
||||||
pub by_endpoint: HashMap<String, Value>,
|
|
||||||
pub by_status_class: HashMap<String, u64>,
|
|
||||||
pub error_codes: HashMap<String, u64>,
|
|
||||||
pub totals: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Inner {
|
|
||||||
by_method: HashMap<String, OperationStats>,
|
|
||||||
by_endpoint: HashMap<String, OperationStats>,
|
|
||||||
by_status_class: HashMap<String, u64>,
|
|
||||||
error_codes: HashMap<String, u64>,
|
|
||||||
totals: OperationStats,
|
|
||||||
window_start: f64,
|
|
||||||
snapshots: Vec<MetricsSnapshot>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct MetricsService {
|
|
||||||
config: MetricsConfig,
|
|
||||||
inner: Arc<Mutex<Inner>>,
|
|
||||||
snapshots_path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MetricsService {
|
|
||||||
pub fn new(storage_root: &Path, config: MetricsConfig) -> Self {
|
|
||||||
let snapshots_path = storage_root
|
|
||||||
.join(".myfsio.sys")
|
|
||||||
.join("config")
|
|
||||||
.join("operation_metrics.json");
|
|
||||||
|
|
||||||
let mut snapshots: Vec<MetricsSnapshot> = if snapshots_path.exists() {
|
|
||||||
std::fs::read_to_string(&snapshots_path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
|
||||||
.and_then(|v| {
|
|
||||||
v.get("snapshots").and_then(|s| {
|
|
||||||
serde_json::from_value::<Vec<MetricsSnapshot>>(s.clone()).ok()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
let cutoff = now_secs() - (config.retention_hours * 3600) as f64;
|
|
||||||
snapshots.retain(|s| s.timestamp.timestamp() as f64 > cutoff);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
inner: Arc::new(Mutex::new(Inner {
|
|
||||||
by_method: HashMap::new(),
|
|
||||||
by_endpoint: HashMap::new(),
|
|
||||||
by_status_class: HashMap::new(),
|
|
||||||
error_codes: HashMap::new(),
|
|
||||||
totals: OperationStats::default(),
|
|
||||||
window_start: now_secs(),
|
|
||||||
snapshots,
|
|
||||||
})),
|
|
||||||
snapshots_path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_request(
|
|
||||||
&self,
|
|
||||||
method: &str,
|
|
||||||
endpoint_type: &str,
|
|
||||||
status_code: u16,
|
|
||||||
latency_ms: f64,
|
|
||||||
bytes_in: u64,
|
|
||||||
bytes_out: u64,
|
|
||||||
error_code: Option<&str>,
|
|
||||||
) {
|
|
||||||
let success = (200..400).contains(&status_code);
|
|
||||||
let status_class = format!("{}xx", status_code / 100);
|
|
||||||
|
|
||||||
let mut inner = self.inner.lock();
|
|
||||||
inner
|
|
||||||
.by_method
|
|
||||||
.entry(method.to_string())
|
|
||||||
.or_default()
|
|
||||||
.record(latency_ms, success, bytes_in, bytes_out);
|
|
||||||
inner
|
|
||||||
.by_endpoint
|
|
||||||
.entry(endpoint_type.to_string())
|
|
||||||
.or_default()
|
|
||||||
.record(latency_ms, success, bytes_in, bytes_out);
|
|
||||||
*inner.by_status_class.entry(status_class).or_insert(0) += 1;
|
|
||||||
if let Some(code) = error_code {
|
|
||||||
*inner.error_codes.entry(code.to_string()).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
inner
|
|
||||||
.totals
|
|
||||||
.record(latency_ms, success, bytes_in, bytes_out);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_current_stats(&self) -> Value {
|
|
||||||
let inner = self.inner.lock();
|
|
||||||
let window_seconds = (now_secs() - inner.window_start).max(0.0) as u64;
|
|
||||||
let by_method: HashMap<String, Value> = inner
|
|
||||||
.by_method
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.to_json()))
|
|
||||||
.collect();
|
|
||||||
let by_endpoint: HashMap<String, Value> = inner
|
|
||||||
.by_endpoint
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.to_json()))
|
|
||||||
.collect();
|
|
||||||
json!({
|
|
||||||
"timestamp": Utc::now().to_rfc3339(),
|
|
||||||
"window_seconds": window_seconds,
|
|
||||||
"by_method": by_method,
|
|
||||||
"by_endpoint": by_endpoint,
|
|
||||||
"by_status_class": inner.by_status_class,
|
|
||||||
"error_codes": inner.error_codes,
|
|
||||||
"totals": inner.totals.to_json(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_history(&self, hours: Option<u64>) -> Vec<MetricsSnapshot> {
|
|
||||||
let inner = self.inner.lock();
|
|
||||||
let mut snapshots = inner.snapshots.clone();
|
|
||||||
if let Some(h) = hours {
|
|
||||||
let cutoff = now_secs() - (h * 3600) as f64;
|
|
||||||
snapshots.retain(|s| s.timestamp.timestamp() as f64 > cutoff);
|
|
||||||
}
|
|
||||||
snapshots
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn snapshot(&self) -> Value {
|
|
||||||
let current = self.get_current_stats();
|
|
||||||
let history = self.get_history(None);
|
|
||||||
json!({
|
|
||||||
"enabled": true,
|
|
||||||
"current": current,
|
|
||||||
"snapshots": history,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn take_snapshot(&self) {
|
|
||||||
let snapshot = {
|
|
||||||
let mut inner = self.inner.lock();
|
|
||||||
let window_seconds = (now_secs() - inner.window_start).max(0.0) as u64;
|
|
||||||
|
|
||||||
let by_method: HashMap<String, Value> = inner
|
|
||||||
.by_method
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.to_json()))
|
|
||||||
.collect();
|
|
||||||
let by_endpoint: HashMap<String, Value> = inner
|
|
||||||
.by_endpoint
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.to_json()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let snap = MetricsSnapshot {
|
|
||||||
timestamp: Utc::now(),
|
|
||||||
window_seconds,
|
|
||||||
by_method,
|
|
||||||
by_endpoint,
|
|
||||||
by_status_class: inner.by_status_class.clone(),
|
|
||||||
error_codes: inner.error_codes.clone(),
|
|
||||||
totals: inner.totals.to_json(),
|
|
||||||
};
|
|
||||||
|
|
||||||
inner.snapshots.push(snap.clone());
|
|
||||||
let cutoff = now_secs() - (self.config.retention_hours * 3600) as f64;
|
|
||||||
inner
|
|
||||||
.snapshots
|
|
||||||
.retain(|s| s.timestamp.timestamp() as f64 > cutoff);
|
|
||||||
|
|
||||||
inner.by_method.clear();
|
|
||||||
inner.by_endpoint.clear();
|
|
||||||
inner.by_status_class.clear();
|
|
||||||
inner.error_codes.clear();
|
|
||||||
inner.totals = OperationStats::default();
|
|
||||||
inner.window_start = now_secs();
|
|
||||||
|
|
||||||
snap
|
|
||||||
};
|
|
||||||
let _ = snapshot;
|
|
||||||
self.save_snapshots();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_snapshots(&self) {
|
|
||||||
let snapshots = { self.inner.lock().snapshots.clone() };
|
|
||||||
if let Some(parent) = self.snapshots_path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
let data = json!({ "snapshots": snapshots });
|
|
||||||
let _ = std::fs::write(
|
|
||||||
&self.snapshots_path,
|
|
||||||
serde_json::to_string_pretty(&data).unwrap_or_default(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_background(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
|
|
||||||
let interval = std::time::Duration::from_secs(self.config.interval_minutes * 60);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut timer = tokio::time::interval(interval);
|
|
||||||
timer.tick().await;
|
|
||||||
loop {
|
|
||||||
timer.tick().await;
|
|
||||||
self.take_snapshot();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn classify_endpoint(path: &str) -> &'static str {
|
|
||||||
if path.is_empty() || path == "/" {
|
|
||||||
return "service";
|
|
||||||
}
|
|
||||||
let trimmed = path.trim_end_matches('/');
|
|
||||||
if trimmed.starts_with("/ui") {
|
|
||||||
return "ui";
|
|
||||||
}
|
|
||||||
if trimmed.starts_with("/kms") {
|
|
||||||
return "kms";
|
|
||||||
}
|
|
||||||
if trimmed.starts_with("/myfsio") {
|
|
||||||
return "service";
|
|
||||||
}
|
|
||||||
let parts: Vec<&str> = trimmed.trim_start_matches('/').split('/').collect();
|
|
||||||
match parts.len() {
|
|
||||||
0 => "service",
|
|
||||||
1 => "bucket",
|
|
||||||
_ => "object",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_secs() -> f64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs_f64())
|
|
||||||
.unwrap_or(0.0)
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
pub mod access_logging;
|
|
||||||
pub mod acl;
|
|
||||||
pub mod gc;
|
|
||||||
pub mod integrity;
|
|
||||||
pub mod lifecycle;
|
|
||||||
pub mod metrics;
|
|
||||||
pub mod notifications;
|
|
||||||
pub mod object_lock;
|
|
||||||
pub mod replication;
|
|
||||||
pub mod s3_client;
|
|
||||||
pub mod site_registry;
|
|
||||||
pub mod site_sync;
|
|
||||||
pub mod system_metrics;
|
|
||||||
pub mod website_domains;
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
use crate::state::AppState;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use myfsio_storage::traits::StorageEngine;
|
|
||||||
use serde::Serialize;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct WebhookDestination {
|
|
||||||
pub url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct NotificationConfiguration {
|
|
||||||
pub id: String,
|
|
||||||
pub events: Vec<String>,
|
|
||||||
pub destination: WebhookDestination,
|
|
||||||
pub prefix_filter: String,
|
|
||||||
pub suffix_filter: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct NotificationEvent {
|
|
||||||
#[serde(rename = "eventVersion")]
|
|
||||||
event_version: &'static str,
|
|
||||||
#[serde(rename = "eventSource")]
|
|
||||||
event_source: &'static str,
|
|
||||||
#[serde(rename = "awsRegion")]
|
|
||||||
aws_region: &'static str,
|
|
||||||
#[serde(rename = "eventTime")]
|
|
||||||
event_time: String,
|
|
||||||
#[serde(rename = "eventName")]
|
|
||||||
event_name: String,
|
|
||||||
#[serde(rename = "userIdentity")]
|
|
||||||
user_identity: serde_json::Value,
|
|
||||||
#[serde(rename = "requestParameters")]
|
|
||||||
request_parameters: serde_json::Value,
|
|
||||||
#[serde(rename = "responseElements")]
|
|
||||||
response_elements: serde_json::Value,
|
|
||||||
s3: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NotificationConfiguration {
|
|
||||||
pub fn matches_event(&self, event_name: &str, object_key: &str) -> bool {
|
|
||||||
let event_match = self.events.iter().any(|pattern| {
|
|
||||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
|
||||||
event_name.starts_with(prefix)
|
|
||||||
} else {
|
|
||||||
pattern == event_name
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if !event_match {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if !self.prefix_filter.is_empty() && !object_key.starts_with(&self.prefix_filter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if !self.suffix_filter.is_empty() && !object_key.ends_with(&self.suffix_filter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_notification_configurations(
|
|
||||||
xml: &str,
|
|
||||||
) -> Result<Vec<NotificationConfiguration>, String> {
|
|
||||||
let doc = roxmltree::Document::parse(xml).map_err(|err| err.to_string())?;
|
|
||||||
let mut configs = Vec::new();
|
|
||||||
|
|
||||||
for webhook in doc
|
|
||||||
.descendants()
|
|
||||||
.filter(|node| node.is_element() && node.tag_name().name() == "WebhookConfiguration")
|
|
||||||
{
|
|
||||||
let id = child_text(&webhook, "Id").unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
|
||||||
let events = webhook
|
|
||||||
.children()
|
|
||||||
.filter(|node| node.is_element() && node.tag_name().name() == "Event")
|
|
||||||
.filter_map(|node| node.text())
|
|
||||||
.map(|text| text.trim().to_string())
|
|
||||||
.filter(|text| !text.is_empty())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let destination = webhook
|
|
||||||
.children()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "Destination");
|
|
||||||
let url = destination
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|node| child_text(node, "Url"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
if url.trim().is_empty() {
|
|
||||||
return Err("Destination URL is required".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut prefix_filter = String::new();
|
|
||||||
let mut suffix_filter = String::new();
|
|
||||||
if let Some(filter) = webhook
|
|
||||||
.children()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "Filter")
|
|
||||||
{
|
|
||||||
if let Some(key) = filter
|
|
||||||
.children()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "S3Key")
|
|
||||||
{
|
|
||||||
for rule in key
|
|
||||||
.children()
|
|
||||||
.filter(|node| node.is_element() && node.tag_name().name() == "FilterRule")
|
|
||||||
{
|
|
||||||
let name = child_text(&rule, "Name").unwrap_or_default();
|
|
||||||
let value = child_text(&rule, "Value").unwrap_or_default();
|
|
||||||
if name == "prefix" {
|
|
||||||
prefix_filter = value;
|
|
||||||
} else if name == "suffix" {
|
|
||||||
suffix_filter = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
configs.push(NotificationConfiguration {
|
|
||||||
id,
|
|
||||||
events,
|
|
||||||
destination: WebhookDestination { url },
|
|
||||||
prefix_filter,
|
|
||||||
suffix_filter,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(configs)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn emit_object_created(
|
|
||||||
state: &AppState,
|
|
||||||
bucket: &str,
|
|
||||||
key: &str,
|
|
||||||
size: u64,
|
|
||||||
etag: Option<&str>,
|
|
||||||
request_id: &str,
|
|
||||||
source_ip: &str,
|
|
||||||
user_identity: &str,
|
|
||||||
operation: &str,
|
|
||||||
) {
|
|
||||||
emit_notifications(
|
|
||||||
state.clone(),
|
|
||||||
bucket.to_string(),
|
|
||||||
key.to_string(),
|
|
||||||
format!("s3:ObjectCreated:{}", operation),
|
|
||||||
size,
|
|
||||||
etag.unwrap_or_default().to_string(),
|
|
||||||
request_id.to_string(),
|
|
||||||
source_ip.to_string(),
|
|
||||||
user_identity.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn emit_object_removed(
|
|
||||||
state: &AppState,
|
|
||||||
bucket: &str,
|
|
||||||
key: &str,
|
|
||||||
request_id: &str,
|
|
||||||
source_ip: &str,
|
|
||||||
user_identity: &str,
|
|
||||||
operation: &str,
|
|
||||||
) {
|
|
||||||
emit_notifications(
|
|
||||||
state.clone(),
|
|
||||||
bucket.to_string(),
|
|
||||||
key.to_string(),
|
|
||||||
format!("s3:ObjectRemoved:{}", operation),
|
|
||||||
0,
|
|
||||||
String::new(),
|
|
||||||
request_id.to_string(),
|
|
||||||
source_ip.to_string(),
|
|
||||||
user_identity.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_notifications(
|
|
||||||
state: AppState,
|
|
||||||
bucket: String,
|
|
||||||
key: String,
|
|
||||||
event_name: String,
|
|
||||||
size: u64,
|
|
||||||
etag: String,
|
|
||||||
request_id: String,
|
|
||||||
source_ip: String,
|
|
||||||
user_identity: String,
|
|
||||||
) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let config = match state.storage.get_bucket_config(&bucket).await {
|
|
||||||
Ok(config) => config,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
let raw = match config.notification {
|
|
||||||
Some(serde_json::Value::String(raw)) => raw,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let configs = match parse_notification_configurations(&raw) {
|
|
||||||
Ok(configs) => configs,
|
|
||||||
Err(err) => {
|
|
||||||
tracing::warn!("Invalid notification config for bucket {}: {}", bucket, err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let record = NotificationEvent {
|
|
||||||
event_version: "2.1",
|
|
||||||
event_source: "myfsio:s3",
|
|
||||||
aws_region: "local",
|
|
||||||
event_time: format_event_time(Utc::now()),
|
|
||||||
event_name: event_name.clone(),
|
|
||||||
user_identity: json!({ "principalId": if user_identity.is_empty() { "ANONYMOUS" } else { &user_identity } }),
|
|
||||||
request_parameters: json!({ "sourceIPAddress": if source_ip.is_empty() { "127.0.0.1" } else { &source_ip } }),
|
|
||||||
response_elements: json!({
|
|
||||||
"x-amz-request-id": request_id,
|
|
||||||
"x-amz-id-2": request_id,
|
|
||||||
}),
|
|
||||||
s3: json!({
|
|
||||||
"s3SchemaVersion": "1.0",
|
|
||||||
"configurationId": "notification",
|
|
||||||
"bucket": {
|
|
||||||
"name": bucket,
|
|
||||||
"ownerIdentity": { "principalId": "local" },
|
|
||||||
"arn": format!("arn:aws:s3:::{}", bucket),
|
|
||||||
},
|
|
||||||
"object": {
|
|
||||||
"key": key,
|
|
||||||
"size": size,
|
|
||||||
"eTag": etag,
|
|
||||||
"versionId": "null",
|
|
||||||
"sequencer": format!("{:016X}", Utc::now().timestamp_millis()),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
let payload = json!({ "Records": [record] });
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
for config in configs {
|
|
||||||
if !config.matches_event(&event_name, &key) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let result = client
|
|
||||||
.post(&config.destination.url)
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
if let Err(err) = result {
|
|
||||||
tracing::warn!(
|
|
||||||
"Failed to deliver notification for {} to {}: {}",
|
|
||||||
event_name,
|
|
||||||
config.destination.url,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_event_time(value: DateTime<Utc>) -> String {
|
|
||||||
value.format("%Y-%m-%dT%H:%M:%S.000Z").to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn child_text(node: &roxmltree::Node<'_, '_>, name: &str) -> Option<String> {
|
|
||||||
node.children()
|
|
||||||
.find(|child| child.is_element() && child.tag_name().name() == name)
|
|
||||||
.and_then(|child| child.text())
|
|
||||||
.map(|text| text.trim().to_string())
|
|
||||||
.filter(|text| !text.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_webhook_configuration() {
|
|
||||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
||||||
<WebhookConfiguration>
|
|
||||||
<Id>upload</Id>
|
|
||||||
<Event>s3:ObjectCreated:*</Event>
|
|
||||||
<Destination><Url>https://example.com/hook</Url></Destination>
|
|
||||||
<Filter>
|
|
||||||
<S3Key>
|
|
||||||
<FilterRule><Name>prefix</Name><Value>logs/</Value></FilterRule>
|
|
||||||
<FilterRule><Name>suffix</Name><Value>.txt</Value></FilterRule>
|
|
||||||
</S3Key>
|
|
||||||
</Filter>
|
|
||||||
</WebhookConfiguration>
|
|
||||||
</NotificationConfiguration>"#;
|
|
||||||
let configs = parse_notification_configurations(xml).unwrap();
|
|
||||||
assert_eq!(configs.len(), 1);
|
|
||||||
assert!(configs[0].matches_event("s3:ObjectCreated:Put", "logs/test.txt"));
|
|
||||||
assert!(!configs[0].matches_event("s3:ObjectRemoved:Delete", "logs/test.txt"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user