Update HF Spaces deployment with latest changes
Browse files- Refactored middleware (removed correlation, rate_limit)
- Added security and request_id middleware
- Updated error handling and logging
- Added new plans directory structure
- Various executor and translator improvements
Co-Authored-By: Claude <noreply@anthropic.com>
This view is limited to 50 files because it contains too many changes. See raw diff
- .factory/settings.json +3 -3
- .github/workflows/lint.yml +25 -0
- .github/workflows/security.yml +67 -0
- .gitignore +1 -0
- .golangci.yml +101 -0
- .kilocode/mcp.json +21 -0
- HF_SPACES_DEPLOYMENT.md +129 -0
- Makefile +20 -0
- internal/access/config_access/provider.go +1 -1
- internal/api/handlers/management/auth_files.go +3 -3
- internal/api/handlers/management/config_basic.go +1 -1
- internal/api/handlers/management/handler.go +1 -1
- internal/api/middleware/correlation.go +0 -54
- internal/api/middleware/error_handler.go +55 -83
- internal/api/middleware/middleware_test.go +31 -30
- internal/api/middleware/rate_limit.go +0 -148
- internal/api/middleware/recovery.go +10 -14
- internal/api/middleware/request_id.go +18 -0
- internal/api/middleware/security.go +56 -0
- internal/api/modules/amp/amp.go +0 -1
- internal/api/modules/amp/amp_test.go +4 -3
- internal/api/modules/amp/gemini_bridge_test.go +2 -2
- internal/api/modules/amp/proxy_test.go +1 -1
- internal/api/modules/amp/response_rewriter_test.go +17 -17
- internal/api/modules/amp/routes_test.go +10 -10
- internal/api/server.go +3 -0
- internal/api/server_test.go +1 -1
- internal/application/dto/config_dto.go +23 -23
- internal/application/dto/error.go +21 -0
- internal/application/mapper/config_mapper.go +1 -1
- internal/application/usecase/config_usecase.go +1 -1
- internal/auth/antigravity/auth.go +1 -1
- internal/auth/codex/token.go +0 -1
- internal/auth/gemini/gemini_auth.go +1 -1
- internal/auth/iflow/iflow_auth.go +2 -2
- internal/cmd/login.go +2 -2
- internal/domain/errors/errors.go +93 -8
- internal/domain/ports/rate_limit.go +0 -43
- internal/domain/ports/repositories.go +59 -59
- internal/domain/ports/services.go +64 -64
- internal/domain/services/auth_service.go +1 -1
- internal/domain/services/config_service.go +2 -2
- internal/domain/services/log_service.go +1 -1
- internal/domain/services/rate_limit_service.go +0 -153
- internal/domain/services/rate_limit_service_test.go +0 -200
- internal/infrastructure/logging/structured.go +8 -3
- internal/infrastructure/persistence/auth_repository.go +15 -15
- internal/infrastructure/persistence/config_repository.go +2 -2
- internal/infrastructure/persistence/log_index.go +13 -13
- internal/infrastructure/persistence/log_repository.go +5 -5
.factory/settings.json
CHANGED
|
@@ -51,9 +51,9 @@
|
|
| 51 |
},
|
| 52 |
{
|
| 53 |
"model": "gemini-3-pro-preview",
|
| 54 |
-
"displayName": "
|
| 55 |
-
"baseUrl": "https://
|
| 56 |
-
"apiKey": "
|
| 57 |
"provider": "generic-chat-completion-api"
|
| 58 |
},
|
| 59 |
{
|
|
|
|
| 51 |
},
|
| 52 |
{
|
| 53 |
"model": "gemini-3-pro-preview",
|
| 54 |
+
"displayName": "Gemini 3 Pro [Google]",
|
| 55 |
+
"baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
|
| 56 |
+
"apiKey": "AIzaSyCc9zZOS82GvtbQQFItyHNbQwc7zzxFkN0",
|
| 57 |
"provider": "generic-chat-completion-api"
|
| 58 |
},
|
| 59 |
{
|
.github/workflows/lint.yml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Lint
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main, develop]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [main, develop]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
golangci:
|
| 11 |
+
name: lint
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
steps:
|
| 14 |
+
- uses: actions/checkout@v4
|
| 15 |
+
|
| 16 |
+
- uses: actions/setup-go@v5
|
| 17 |
+
with:
|
| 18 |
+
go-version: '1.24'
|
| 19 |
+
|
| 20 |
+
- name: golangci-lint
|
| 21 |
+
uses: golangci/golangci-lint-action@v6
|
| 22 |
+
with:
|
| 23 |
+
version: latest
|
| 24 |
+
args: --timeout=5m
|
| 25 |
+
only-new-issues: true
|
.github/workflows/security.yml
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Security
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main, develop]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [main, develop]
|
| 8 |
+
schedule:
|
| 9 |
+
- cron: '0 0 * * 0' # Weekly
|
| 10 |
+
|
| 11 |
+
jobs:
|
| 12 |
+
gosec:
|
| 13 |
+
runs-on: ubuntu-latest
|
| 14 |
+
steps:
|
| 15 |
+
- uses: actions/checkout@v4
|
| 16 |
+
|
| 17 |
+
- name: Run Gosec
|
| 18 |
+
uses: securego/gosec@master
|
| 19 |
+
with:
|
| 20 |
+
args: '-fmt sarif -out results.sarif ./...'
|
| 21 |
+
|
| 22 |
+
- name: Upload SARIF
|
| 23 |
+
uses: github/codeql-action/upload-sarif@v2
|
| 24 |
+
with:
|
| 25 |
+
sarif_file: results.sarif
|
| 26 |
+
|
| 27 |
+
govulncheck:
|
| 28 |
+
runs-on: ubuntu-latest
|
| 29 |
+
steps:
|
| 30 |
+
- uses: actions/checkout@v4
|
| 31 |
+
|
| 32 |
+
- uses: actions/setup-go@v5
|
| 33 |
+
with:
|
| 34 |
+
go-version: '1.24'
|
| 35 |
+
|
| 36 |
+
- name: Install govulncheck
|
| 37 |
+
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
| 38 |
+
|
| 39 |
+
- name: Run govulncheck
|
| 40 |
+
run: govulncheck ./...
|
| 41 |
+
|
| 42 |
+
trivy:
|
| 43 |
+
runs-on: ubuntu-latest
|
| 44 |
+
steps:
|
| 45 |
+
- uses: actions/checkout@v4
|
| 46 |
+
|
| 47 |
+
- name: Build image
|
| 48 |
+
run: docker build -t cliproxy:test .
|
| 49 |
+
|
| 50 |
+
- name: Run Trivy
|
| 51 |
+
uses: aquasecurity/trivy-action@master
|
| 52 |
+
with:
|
| 53 |
+
image-ref: 'cliproxy:test'
|
| 54 |
+
format: 'sarif'
|
| 55 |
+
output: 'trivy-results.sarif'
|
| 56 |
+
|
| 57 |
+
- name: Upload SARIF
|
| 58 |
+
uses: github/codeql-action/upload-sarif@v2
|
| 59 |
+
with:
|
| 60 |
+
sarif_file: trivy-results.sarif
|
| 61 |
+
|
| 62 |
+
dependency-review:
|
| 63 |
+
runs-on: ubuntu-latest
|
| 64 |
+
if: github.event_name == 'pull_request'
|
| 65 |
+
steps:
|
| 66 |
+
- uses: actions/checkout@v4
|
| 67 |
+
- uses: actions/dependency-review-action@v3
|
.gitignore
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
# Binaries
|
| 2 |
cli-proxy-api
|
| 3 |
*.exe
|
|
|
|
| 4 |
|
| 5 |
# Configuration
|
| 6 |
config.yaml
|
|
|
|
| 1 |
# Binaries
|
| 2 |
cli-proxy-api
|
| 3 |
*.exe
|
| 4 |
+
main
|
| 5 |
|
| 6 |
# Configuration
|
| 7 |
config.yaml
|
.golangci.yml
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
run:
|
| 2 |
+
timeout: 5m
|
| 3 |
+
go: '1.24'
|
| 4 |
+
|
| 5 |
+
issues:
|
| 6 |
+
exclude-dirs:
|
| 7 |
+
- management-center
|
| 8 |
+
- kiro-gateway
|
| 9 |
+
exclude-rules:
|
| 10 |
+
# Exclude init() function warnings in translator registrations
|
| 11 |
+
- path: internal/translator/.*/init\.go
|
| 12 |
+
linters:
|
| 13 |
+
- gochecknoinits
|
| 14 |
+
|
| 15 |
+
# Exclude underscore variable warnings in test files
|
| 16 |
+
- path: _test\.go
|
| 17 |
+
linters:
|
| 18 |
+
- errcheck
|
| 19 |
+
|
| 20 |
+
# Exclude long function warnings in handlers (will refactor separately)
|
| 21 |
+
- path: internal/api/handlers/
|
| 22 |
+
linters:
|
| 23 |
+
- funlen
|
| 24 |
+
- gocognit
|
| 25 |
+
|
| 26 |
+
exclude-use-default: false
|
| 27 |
+
max-issues-per-linter: 0
|
| 28 |
+
max-same-issues: 0
|
| 29 |
+
|
| 30 |
+
linters:
|
| 31 |
+
enable:
|
| 32 |
+
# Default
|
| 33 |
+
- errcheck
|
| 34 |
+
- gosimple
|
| 35 |
+
- govet
|
| 36 |
+
- ineffassign
|
| 37 |
+
- staticcheck
|
| 38 |
+
- unused
|
| 39 |
+
# Additional
|
| 40 |
+
- bodyclose
|
| 41 |
+
- dogsled
|
| 42 |
+
- dupl
|
| 43 |
+
- exhaustive
|
| 44 |
+
- goconst
|
| 45 |
+
- gocritic
|
| 46 |
+
- gofmt
|
| 47 |
+
- goimports
|
| 48 |
+
- mnd
|
| 49 |
+
- goprintffuncname
|
| 50 |
+
- gosec
|
| 51 |
+
- misspell
|
| 52 |
+
- nakedret
|
| 53 |
+
- noctx
|
| 54 |
+
- nolintlint
|
| 55 |
+
- prealloc
|
| 56 |
+
- revive
|
| 57 |
+
- stylecheck
|
| 58 |
+
- unconvert
|
| 59 |
+
- unparam
|
| 60 |
+
- whitespace
|
| 61 |
+
|
| 62 |
+
linters-settings:
|
| 63 |
+
gocritic:
|
| 64 |
+
enabled-tags:
|
| 65 |
+
- performance
|
| 66 |
+
- style
|
| 67 |
+
- experimental
|
| 68 |
+
disabled-checks:
|
| 69 |
+
- wrapperFunc
|
| 70 |
+
- dupImport
|
| 71 |
+
|
| 72 |
+
revive:
|
| 73 |
+
rules:
|
| 74 |
+
- name: unexported-return
|
| 75 |
+
disabled: false
|
| 76 |
+
- name: exported
|
| 77 |
+
disabled: false
|
| 78 |
+
- name: package-comments
|
| 79 |
+
disabled: true
|
| 80 |
+
|
| 81 |
+
mnd:
|
| 82 |
+
checks:
|
| 83 |
+
- argument
|
| 84 |
+
- case
|
| 85 |
+
- condition
|
| 86 |
+
- operation
|
| 87 |
+
- return
|
| 88 |
+
ignored-numbers:
|
| 89 |
+
- '0'
|
| 90 |
+
- '1'
|
| 91 |
+
- '2'
|
| 92 |
+
- '10'
|
| 93 |
+
- '60'
|
| 94 |
+
- '100'
|
| 95 |
+
|
| 96 |
+
dupl:
|
| 97 |
+
threshold: 100
|
| 98 |
+
|
| 99 |
+
gosec:
|
| 100 |
+
excludes:
|
| 101 |
+
- G104 # Audit errors not checked (handled by errcheck)
|
.kilocode/mcp.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"mcpServers": {
|
| 3 |
+
"context7": {
|
| 4 |
+
"command": "npx",
|
| 5 |
+
"args": [
|
| 6 |
+
"-y",
|
| 7 |
+
"@upstash/context7-mcp"
|
| 8 |
+
],
|
| 9 |
+
"env": {
|
| 10 |
+
"DEFAULT_MINIMUM_TOKENS": ""
|
| 11 |
+
}
|
| 12 |
+
},
|
| 13 |
+
"sequentialthinking": {
|
| 14 |
+
"command": "npx",
|
| 15 |
+
"args": [
|
| 16 |
+
"-y",
|
| 17 |
+
"@modelcontextprotocol/server-sequential-thinking"
|
| 18 |
+
]
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
}
|
HF_SPACES_DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HF Spaces Deployment - Claude Multi-API Key Feature
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
This version of CLIProxyAPI includes support for multiple Claude API keys with round-robin load balancing, accessible through both the Management UI and API endpoints.
|
| 5 |
+
|
| 6 |
+
## What's New
|
| 7 |
+
|
| 8 |
+
### 1. Multiple Claude API Keys
|
| 9 |
+
- Add multiple API keys per Claude configuration
|
| 10 |
+
- Each key can have its own proxy URL
|
| 11 |
+
- Automatic round-robin load balancing across all keys
|
| 12 |
+
- Backward compatible with single-key configurations
|
| 13 |
+
|
| 14 |
+
### 2. Updated Management UI
|
| 15 |
+
The management UI (`/management.html`) now includes:
|
| 16 |
+
- **AI Providers** → **Claude API Configuration** section
|
| 17 |
+
- Add/remove multiple API keys with individual proxy settings
|
| 18 |
+
- Visual interface for managing key entries
|
| 19 |
+
|
| 20 |
+
### 3. API Endpoints
|
| 21 |
+
The following endpoints support the new `api-key-entries` field:
|
| 22 |
+
|
| 23 |
+
- `GET /api/config/claude-api-key` - Returns configurations with `api-key-entries`
|
| 24 |
+
- `PUT /api/config/claude-api-key` - Create/update with multiple keys
|
| 25 |
+
- `PATCH /api/config/claude-api-key` - Update specific fields including `api-key-entries`
|
| 26 |
+
- `DELETE /api/config/claude-api-key` - Delete by API key or index
|
| 27 |
+
|
| 28 |
+
## Configuration Examples
|
| 29 |
+
|
| 30 |
+
### Via Management UI
|
| 31 |
+
1. Access `https://your-space.hf.space/management.html`
|
| 32 |
+
2. Navigate to **AI Providers** → **Claude API Configuration**
|
| 33 |
+
3. Click **Add Configuration**
|
| 34 |
+
4. Add multiple API keys in the **API Keys** section
|
| 35 |
+
5. Save
|
| 36 |
+
|
| 37 |
+
### Via Config File (config.yaml)
|
| 38 |
+
|
| 39 |
+
```yaml
|
| 40 |
+
claude-api-key:
|
| 41 |
+
- api-key-entries:
|
| 42 |
+
- api-key: "sk-ant-api03-key1..."
|
| 43 |
+
proxy-url: "http://proxy1:8080"
|
| 44 |
+
- api-key: "sk-ant-api03-key2..."
|
| 45 |
+
proxy-url: "http://proxy2:8080"
|
| 46 |
+
- api-key: "sk-ant-api03-key3..."
|
| 47 |
+
base-url: "https://api.anthropic.com"
|
| 48 |
+
priority: 10
|
| 49 |
+
prefix: "team-a/"
|
| 50 |
+
models:
|
| 51 |
+
- name: claude-3-5-sonnet-20241022
|
| 52 |
+
alias: claude-sonnet
|
| 53 |
+
- name: claude-3-opus-20240229
|
| 54 |
+
alias: claude-opus
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
### Via API
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
# Add Claude configuration with multiple keys
|
| 61 |
+
curl -X PUT https://your-space.hf.space/api/config/claude-api-key \
|
| 62 |
+
-H "Content-Type: application/json" \
|
| 63 |
+
-H "Authorization: Bearer your-api-key" \
|
| 64 |
+
-d '[{
|
| 65 |
+
"api-key-entries": [
|
| 66 |
+
{"api-key": "sk-ant-api03-key1...", "proxy-url": "http://proxy1:8080"},
|
| 67 |
+
{"api-key": "sk-ant-api03-key2...", "proxy-url": "http://proxy2:8080"}
|
| 68 |
+
],
|
| 69 |
+
"base-url": "https://api.anthropic.com",
|
| 70 |
+
"priority": 10,
|
| 71 |
+
"models": [
|
| 72 |
+
{"name": "claude-3-5-sonnet-20241022", "alias": "claude-sonnet"}
|
| 73 |
+
]
|
| 74 |
+
}]'
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
## How It Works
|
| 78 |
+
|
| 79 |
+
1. **Load Balancing**: Each API key becomes a separate Auth entry. The system uses round-robin selection to distribute requests across all available keys.
|
| 80 |
+
|
| 81 |
+
2. **Per-Key Proxy**: Each API key entry can specify its own `proxy-url`. If not specified, it falls back to the key-level `proxy-url` or no proxy.
|
| 82 |
+
|
| 83 |
+
3. **Backward Compatibility**: The old single `api-key` field still works. If `api-key-entries` is empty, the system uses the single `api-key`.
|
| 84 |
+
|
| 85 |
+
4. **Failover**: If one API key fails (e.g., rate limited), the system automatically tries the next key in the rotation.
|
| 86 |
+
|
| 87 |
+
## Files Modified
|
| 88 |
+
|
| 89 |
+
### Backend (Go)
|
| 90 |
+
- `internal/config/config.go` - Added `ClaudeAPIKeyEntry` type and updated `ClaudeKey`
|
| 91 |
+
- `internal/watcher/synthesizer/config.go` - Updated `synthesizeClaudeKeys()` for multi-key support
|
| 92 |
+
- `internal/watcher/diff/config_diff.go` - Added diff detection for `api-key-entries`
|
| 93 |
+
- `internal/api/handlers/management/config_lists.go` - Updated API handlers
|
| 94 |
+
- `internal/managementasset/management.html` - Updated React UI build
|
| 95 |
+
|
| 96 |
+
### Frontend (React/TypeScript)
|
| 97 |
+
- `management-center/src/types/provider.ts` - Added `apiKeyEntries` to types
|
| 98 |
+
- `management-center/src/components/providers/ClaudeSection/ClaudeModal.tsx` - New multi-key UI
|
| 99 |
+
- `management-center/src/components/providers/types.ts` - Updated form state types
|
| 100 |
+
- `management-center/src/i18n/locales/*.json` - Added translation keys
|
| 101 |
+
|
| 102 |
+
## Testing
|
| 103 |
+
|
| 104 |
+
All tests pass for the modified components:
|
| 105 |
+
```bash
|
| 106 |
+
go test ./internal/config/... ./internal/watcher/... ./internal/api/handlers/management/... -v
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
## Deployment Notes
|
| 110 |
+
|
| 111 |
+
1. The `management.html` file is embedded in the binary at `/home/internal/managementasset/management.html`
|
| 112 |
+
2. The Dockerfile copies the entire project, so the management UI is included automatically
|
| 113 |
+
3. No additional environment variables are required for the multi-key feature
|
| 114 |
+
4. The feature works out of the box once deployed
|
| 115 |
+
|
| 116 |
+
## Troubleshooting
|
| 117 |
+
|
| 118 |
+
### Management UI Not Loading
|
| 119 |
+
- Ensure the `management.html` file exists in the Docker image
|
| 120 |
+
- Check that `RemoteManagement.DisableControlPanel` is not set to `true` in config
|
| 121 |
+
|
| 122 |
+
### API Keys Not Rotating
|
| 123 |
+
- Verify that `api-key-entries` is properly formatted in the config
|
| 124 |
+
- Check logs for any synthesis errors
|
| 125 |
+
- Ensure at least one API key has a non-empty value
|
| 126 |
+
|
| 127 |
+
### Per-Key Proxy Not Working
|
| 128 |
+
- Verify the proxy URL format (e.g., `http://proxy.example.com:8080`)
|
| 129 |
+
- Check that the proxy is accessible from the HF Spaces environment
|
Makefile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GOPATH=$(shell go env GOPATH)
|
| 2 |
+
GOLANGCI_LINT=$(GOPATH)/bin/golangci-lint
|
| 3 |
+
|
| 4 |
+
.PHONY: lint lint-fix lint-install lint-precommit
|
| 5 |
+
|
| 6 |
+
lint-install:
|
| 7 |
+
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
| 8 |
+
|
| 9 |
+
lint:
|
| 10 |
+
$(GOLANGCI_LINT) run ./...
|
| 11 |
+
|
| 12 |
+
lint-fix:
|
| 13 |
+
$(GOLANGCI_LINT) run --fix ./...
|
| 14 |
+
|
| 15 |
+
# Pre-commit hook
|
| 16 |
+
lint-precommit:
|
| 17 |
+
@echo "#!/bin/sh" > .git/hooks/pre-commit
|
| 18 |
+
@echo '$(GOLANGCI_LINT) run --fast ./...' >> .git/hooks/pre-commit
|
| 19 |
+
@chmod +x .git/hooks/pre-commit
|
| 20 |
+
@echo "Pre-commit hook installed"
|
internal/access/config_access/provider.go
CHANGED
|
@@ -105,7 +105,7 @@ func extractBearerToken(header string) string {
|
|
| 105 |
if len(parts) != 2 {
|
| 106 |
return header
|
| 107 |
}
|
| 108 |
-
if strings.
|
| 109 |
return header
|
| 110 |
}
|
| 111 |
return strings.TrimSpace(parts[1])
|
|
|
|
| 105 |
if len(parts) != 2 {
|
| 106 |
return header
|
| 107 |
}
|
| 108 |
+
if !strings.EqualFold(parts[0], "bearer") {
|
| 109 |
return header
|
| 110 |
}
|
| 111 |
return strings.TrimSpace(parts[1])
|
internal/api/handlers/management/auth_files.go
CHANGED
|
@@ -1103,7 +1103,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
|
| 1103 |
|
| 1104 |
// Create token storage (mirrors internal/auth/gemini createTokenStorage)
|
| 1105 |
authHTTPClient := conf.Client(ctx, token)
|
| 1106 |
-
req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
| 1107 |
if errNewRequest != nil {
|
| 1108 |
log.Errorf("Could not get user info: %v", errNewRequest)
|
| 1109 |
SetOAuthSessionError(state, "Could not get user info")
|
|
@@ -2079,7 +2079,7 @@ func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string
|
|
| 2079 |
}
|
| 2080 |
|
| 2081 |
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
|
| 2082 |
-
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects",
|
| 2083 |
if errRequest != nil {
|
| 2084 |
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
|
| 2085 |
}
|
|
@@ -2114,7 +2114,7 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
|
|
| 2114 |
}
|
| 2115 |
for _, service := range requiredServices {
|
| 2116 |
checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
|
| 2117 |
-
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL,
|
| 2118 |
if errRequest != nil {
|
| 2119 |
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
| 2120 |
}
|
|
|
|
| 1103 |
|
| 1104 |
// Create token storage (mirrors internal/auth/gemini createTokenStorage)
|
| 1105 |
authHTTPClient := conf.Client(ctx, token)
|
| 1106 |
+
req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", http.NoBody)
|
| 1107 |
if errNewRequest != nil {
|
| 1108 |
log.Errorf("Could not get user info: %v", errNewRequest)
|
| 1109 |
SetOAuthSessionError(state, "Could not get user info")
|
|
|
|
| 2079 |
}
|
| 2080 |
|
| 2081 |
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
|
| 2082 |
+
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", http.NoBody)
|
| 2083 |
if errRequest != nil {
|
| 2084 |
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
|
| 2085 |
}
|
|
|
|
| 2114 |
}
|
| 2115 |
for _, service := range requiredServices {
|
| 2116 |
checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
|
| 2117 |
+
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, http.NoBody)
|
| 2118 |
if errRequest != nil {
|
| 2119 |
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
| 2120 |
}
|
internal/api/handlers/management/config_basic.go
CHANGED
|
@@ -49,7 +49,7 @@ func (h *Handler) GetLatestVersion(c *gin.Context) {
|
|
| 49 |
util.SetProxy(sdkCfg, client)
|
| 50 |
}
|
| 51 |
|
| 52 |
-
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL,
|
| 53 |
if err != nil {
|
| 54 |
c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
|
| 55 |
return
|
|
|
|
| 49 |
util.SetProxy(sdkCfg, client)
|
| 50 |
}
|
| 51 |
|
| 52 |
+
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, http.NoBody)
|
| 53 |
if err != nil {
|
| 54 |
c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
|
| 55 |
return
|
internal/api/handlers/management/handler.go
CHANGED
|
@@ -205,7 +205,7 @@ func (h *Handler) Middleware() gin.HandlerFunc {
|
|
| 205 |
var provided string
|
| 206 |
if ah := c.GetHeader("Authorization"); ah != "" {
|
| 207 |
parts := strings.SplitN(ah, " ", 2)
|
| 208 |
-
if len(parts) == 2 && strings.
|
| 209 |
provided = parts[1]
|
| 210 |
} else {
|
| 211 |
provided = ah
|
|
|
|
| 205 |
var provided string
|
| 206 |
if ah := c.GetHeader("Authorization"); ah != "" {
|
| 207 |
parts := strings.SplitN(ah, " ", 2)
|
| 208 |
+
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
|
| 209 |
provided = parts[1]
|
| 210 |
} else {
|
| 211 |
provided = ah
|
internal/api/middleware/correlation.go
DELETED
|
@@ -1,54 +0,0 @@
|
|
| 1 |
-
// Package middleware provides HTTP middleware components for the CLI Proxy API server.
|
| 2 |
-
// This file contains correlation ID middleware for request tracing.
|
| 3 |
-
package middleware
|
| 4 |
-
|
| 5 |
-
import (
|
| 6 |
-
"github.com/gin-gonic/gin"
|
| 7 |
-
"github.com/google/uuid"
|
| 8 |
-
)
|
| 9 |
-
|
| 10 |
-
const (
|
| 11 |
-
// CorrelationIDHeader is the HTTP header name for correlation IDs
|
| 12 |
-
CorrelationIDHeader = "X-Correlation-ID"
|
| 13 |
-
// CorrelationIDContextKey is the context key for correlation IDs
|
| 14 |
-
CorrelationIDContextKey = "correlation_id"
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
// CorrelationIDMiddleware creates a Gin middleware that ensures every request
|
| 18 |
-
// has a correlation ID for distributed tracing. It checks for an existing ID
|
| 19 |
-
// in the request headers and generates a new one if not present.
|
| 20 |
-
func CorrelationIDMiddleware() gin.HandlerFunc {
|
| 21 |
-
return func(c *gin.Context) {
|
| 22 |
-
// Check for existing correlation ID in header
|
| 23 |
-
correlationID := c.GetHeader(CorrelationIDHeader)
|
| 24 |
-
|
| 25 |
-
// Generate new ID if not present
|
| 26 |
-
if correlationID == "" {
|
| 27 |
-
correlationID = generateCorrelationID()
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
// Store in context
|
| 31 |
-
c.Set(CorrelationIDContextKey, correlationID)
|
| 32 |
-
|
| 33 |
-
// Add to response headers
|
| 34 |
-
c.Header(CorrelationIDHeader, correlationID)
|
| 35 |
-
|
| 36 |
-
c.Next()
|
| 37 |
-
}
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
// GetCorrelationID retrieves the correlation ID from the Gin context.
|
| 41 |
-
// Returns empty string if no correlation ID is found.
|
| 42 |
-
func GetCorrelationID(c *gin.Context) string {
|
| 43 |
-
if id, exists := c.Get(CorrelationIDContextKey); exists {
|
| 44 |
-
if str, ok := id.(string); ok {
|
| 45 |
-
return str
|
| 46 |
-
}
|
| 47 |
-
}
|
| 48 |
-
return ""
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
// generateCorrelationID generates a new unique correlation ID.
|
| 52 |
-
func generateCorrelationID() string {
|
| 53 |
-
return uuid.New().String()
|
| 54 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
internal/api/middleware/error_handler.go
CHANGED
|
@@ -1,108 +1,80 @@
|
|
| 1 |
-
// Package middleware provides HTTP middleware components for the CLI Proxy API server.
|
| 2 |
-
// This file contains centralized error handling middleware for consistent API responses.
|
| 3 |
package middleware
|
| 4 |
|
| 5 |
import (
|
|
|
|
| 6 |
"net/http"
|
| 7 |
|
| 8 |
"github.com/gin-gonic/gin"
|
| 9 |
-
"github.com/
|
| 10 |
-
)
|
| 11 |
-
|
| 12 |
-
// ErrorResponse represents a standardized error response
|
| 13 |
-
type ErrorResponse struct {
|
| 14 |
-
Success bool `json:"success"`
|
| 15 |
-
Error *APIError `json:"error,omitempty"`
|
| 16 |
-
RequestID string `json:"request_id,omitempty"`
|
| 17 |
-
}
|
| 18 |
|
| 19 |
-
//
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
Message string `json:"message"`
|
| 23 |
-
Field string `json:"field,omitempty"`
|
| 24 |
-
}
|
| 25 |
|
| 26 |
-
//
|
| 27 |
-
|
| 28 |
-
// standardized error responses.
|
| 29 |
-
func ErrorHandlerMiddleware() gin.HandlerFunc {
|
| 30 |
return func(c *gin.Context) {
|
| 31 |
c.Next()
|
| 32 |
|
| 33 |
// Check if there are any errors
|
| 34 |
-
if len(c.Errors)
|
| 35 |
-
|
| 36 |
-
handleError(c, err)
|
| 37 |
}
|
| 38 |
-
}
|
| 39 |
-
}
|
| 40 |
|
| 41 |
-
//
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
Code: string(domainErr.Code),
|
| 50 |
-
Message: domainErr.Message,
|
| 51 |
-
}
|
| 52 |
-
// Extract field from details if available
|
| 53 |
-
if domainErr.Details != nil {
|
| 54 |
-
if field, ok := domainErr.Details["field"].(string); ok {
|
| 55 |
-
apiErr.Field = field
|
| 56 |
-
}
|
| 57 |
}
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
-
c.JSON(statusCode, response)
|
| 64 |
-
return
|
| 65 |
-
}
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
Code: "INVALID_INPUT",
|
| 75 |
-
Message: "Invalid request format: " + err.Err.Error(),
|
| 76 |
-
},
|
| 77 |
-
})
|
| 78 |
-
case gin.ErrorTypeRender:
|
| 79 |
-
c.JSON(http.StatusInternalServerError, ErrorResponse{
|
| 80 |
-
Success: false,
|
| 81 |
-
RequestID: requestID,
|
| 82 |
-
Error: &APIError{
|
| 83 |
-
Code: "RENDER_ERROR",
|
| 84 |
-
Message: "Failed to render response",
|
| 85 |
-
},
|
| 86 |
-
})
|
| 87 |
-
default:
|
| 88 |
-
c.JSON(http.StatusInternalServerError, ErrorResponse{
|
| 89 |
Success: false,
|
|
|
|
| 90 |
RequestID: requestID,
|
| 91 |
-
Error: &APIError{
|
| 92 |
-
Code: "INTERNAL_ERROR",
|
| 93 |
-
Message: "An internal error occurred",
|
| 94 |
-
},
|
| 95 |
})
|
| 96 |
}
|
| 97 |
}
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
-
//
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
}
|
|
|
|
|
|
|
|
|
|
| 1 |
package middleware
|
| 2 |
|
| 3 |
import (
|
| 4 |
+
"errors"
|
| 5 |
"net/http"
|
| 6 |
|
| 7 |
"github.com/gin-gonic/gin"
|
| 8 |
+
"github.com/sirupsen/logrus"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/application/dto"
|
| 11 |
+
domainerrors "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/errors"
|
| 12 |
+
)
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
// ErrorHandler returns a middleware that handles domain errors
|
| 15 |
+
func ErrorHandler(logger *logrus.Logger) gin.HandlerFunc {
|
|
|
|
|
|
|
| 16 |
return func(c *gin.Context) {
|
| 17 |
c.Next()
|
| 18 |
|
| 19 |
// Check if there are any errors
|
| 20 |
+
if len(c.Errors) == 0 {
|
| 21 |
+
return
|
|
|
|
| 22 |
}
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
// Get the last error
|
| 25 |
+
err := c.Errors.Last().Err
|
| 26 |
+
requestID := c.GetString("request_id")
|
| 27 |
+
|
| 28 |
+
// Handle domain errors
|
| 29 |
+
if domainErr, ok := err.(*domainerrors.DomainError); ok {
|
| 30 |
+
handleDomainError(c, domainErr, requestID, logger)
|
| 31 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
+
|
| 34 |
+
// Handle wrapped domain errors
|
| 35 |
+
var domainErr *domainerrors.DomainError
|
| 36 |
+
if errors.As(err, &domainErr) {
|
| 37 |
+
handleDomainError(c, domainErr, requestID, logger)
|
| 38 |
+
return
|
| 39 |
}
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
// Unknown error - log full details but return generic message
|
| 42 |
+
logger.WithError(err).
|
| 43 |
+
WithField("request_id", requestID).
|
| 44 |
+
WithField("path", c.Request.URL.Path).
|
| 45 |
+
Error("Unhandled error")
|
| 46 |
+
|
| 47 |
+
c.JSON(http.StatusInternalServerError, dto.InternalErrorResponse{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
Success: false,
|
| 49 |
+
Error: "An unexpected error occurred",
|
| 50 |
RequestID: requestID,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
})
|
| 52 |
}
|
| 53 |
}
|
| 54 |
|
| 55 |
+
func handleDomainError(c *gin.Context, err *domainerrors.DomainError, requestID string, logger *logrus.Logger) {
|
| 56 |
+
// Log with appropriate level based on status code
|
| 57 |
+
entry := logger.WithError(err).
|
| 58 |
+
WithField("request_id", requestID).
|
| 59 |
+
WithField("error_code", err.Code).
|
| 60 |
+
WithField("path", c.Request.URL.Path)
|
| 61 |
+
|
| 62 |
+
statusCode := err.HTTPStatusCode()
|
| 63 |
+
|
| 64 |
+
if statusCode >= 500 {
|
| 65 |
+
entry.Error("Server error")
|
| 66 |
+
} else if statusCode >= 400 {
|
| 67 |
+
entry.Warn("Client error")
|
| 68 |
+
}
|
| 69 |
|
| 70 |
+
// Return structured error response
|
| 71 |
+
c.JSON(statusCode, dto.ErrorResponse{
|
| 72 |
+
Success: false,
|
| 73 |
+
Error: dto.ErrorInfo{
|
| 74 |
+
Code: string(err.Code),
|
| 75 |
+
Message: err.Message,
|
| 76 |
+
Details: err.Details,
|
| 77 |
+
},
|
| 78 |
+
RequestID: requestID,
|
| 79 |
+
})
|
| 80 |
}
|
internal/api/middleware/middleware_test.go
CHANGED
|
@@ -9,6 +9,7 @@ import (
|
|
| 9 |
"testing"
|
| 10 |
|
| 11 |
"github.com/gin-gonic/gin"
|
|
|
|
| 12 |
"github.com/stretchr/testify/assert"
|
| 13 |
)
|
| 14 |
|
|
@@ -17,42 +18,42 @@ func setupTestRouter() *gin.Engine {
|
|
| 17 |
return gin.New()
|
| 18 |
}
|
| 19 |
|
| 20 |
-
func
|
| 21 |
router := setupTestRouter()
|
| 22 |
-
router.Use(
|
| 23 |
router.GET("/test", func(c *gin.Context) {
|
| 24 |
-
id :=
|
| 25 |
-
c.JSON(200, gin.H{"
|
| 26 |
})
|
| 27 |
|
| 28 |
-
t.Run("generates
|
| 29 |
w := httptest.NewRecorder()
|
| 30 |
-
req, _ := http.NewRequest("GET", "/test",
|
| 31 |
router.ServeHTTP(w, req)
|
| 32 |
|
| 33 |
assert.Equal(t, 200, w.Code)
|
| 34 |
-
// Check that response contains a
|
| 35 |
-
assert.Contains(t, w.Body.String(), "
|
| 36 |
-
// Check that response header contains the
|
| 37 |
-
assert.NotEmpty(t, w.Header().Get(
|
| 38 |
})
|
| 39 |
|
| 40 |
-
t.Run("uses existing
|
| 41 |
w := httptest.NewRecorder()
|
| 42 |
-
req, _ := http.NewRequest("GET", "/test",
|
| 43 |
-
req.Header.Set(
|
| 44 |
router.ServeHTTP(w, req)
|
| 45 |
|
| 46 |
assert.Equal(t, 200, w.Code)
|
| 47 |
-
assert.Contains(t, w.Body.String(), "test-
|
| 48 |
-
assert.Equal(t, "test-
|
| 49 |
})
|
| 50 |
}
|
| 51 |
|
| 52 |
-
func
|
| 53 |
router := setupTestRouter()
|
| 54 |
-
router.Use(
|
| 55 |
-
router.Use(
|
| 56 |
|
| 57 |
router.GET("/error", func(c *gin.Context) {
|
| 58 |
c.Error(errors.New("test error"))
|
|
@@ -65,16 +66,16 @@ func TestErrorHandlerMiddleware(t *testing.T) {
|
|
| 65 |
|
| 66 |
t.Run("handles errors gracefully", func(t *testing.T) {
|
| 67 |
w := httptest.NewRecorder()
|
| 68 |
-
req, _ := http.NewRequest("GET", "/error",
|
| 69 |
router.ServeHTTP(w, req)
|
| 70 |
|
| 71 |
assert.Equal(t, 500, w.Code)
|
| 72 |
-
assert.Contains(t, w.Body.String(), "
|
| 73 |
})
|
| 74 |
|
| 75 |
t.Run("passes through successful requests", func(t *testing.T) {
|
| 76 |
w := httptest.NewRecorder()
|
| 77 |
-
req, _ := http.NewRequest("GET", "/success",
|
| 78 |
router.ServeHTTP(w, req)
|
| 79 |
|
| 80 |
assert.Equal(t, 200, w.Code)
|
|
@@ -85,8 +86,8 @@ func TestErrorHandlerMiddleware(t *testing.T) {
|
|
| 85 |
func TestRecoveryMiddleware(t *testing.T) {
|
| 86 |
router := setupTestRouter()
|
| 87 |
router.Use(RecoveryMiddleware(nil))
|
| 88 |
-
router.Use(
|
| 89 |
-
router.Use(
|
| 90 |
|
| 91 |
router.GET("/panic", func(c *gin.Context) {
|
| 92 |
panic("test panic")
|
|
@@ -98,16 +99,16 @@ func TestRecoveryMiddleware(t *testing.T) {
|
|
| 98 |
|
| 99 |
t.Run("recovers from panic", func(t *testing.T) {
|
| 100 |
w := httptest.NewRecorder()
|
| 101 |
-
req, _ := http.NewRequest("GET", "/panic",
|
| 102 |
router.ServeHTTP(w, req)
|
| 103 |
|
| 104 |
assert.Equal(t, 500, w.Code)
|
| 105 |
-
assert.Contains(t, w.Body.String(), "
|
| 106 |
})
|
| 107 |
|
| 108 |
t.Run("normal requests work after panic recovery", func(t *testing.T) {
|
| 109 |
w := httptest.NewRecorder()
|
| 110 |
-
req, _ := http.NewRequest("GET", "/normal",
|
| 111 |
router.ServeHTTP(w, req)
|
| 112 |
|
| 113 |
assert.Equal(t, 200, w.Code)
|
|
@@ -117,8 +118,8 @@ func TestRecoveryMiddleware(t *testing.T) {
|
|
| 117 |
|
| 118 |
func TestSafeHandler(t *testing.T) {
|
| 119 |
router := setupTestRouter()
|
| 120 |
-
router.Use(
|
| 121 |
-
router.Use(
|
| 122 |
|
| 123 |
router.GET("/safe-panic", SafeHandler(func(c *gin.Context) {
|
| 124 |
panic("safe handler panic")
|
|
@@ -126,10 +127,10 @@ func TestSafeHandler(t *testing.T) {
|
|
| 126 |
|
| 127 |
t.Run("safe handler recovers from panic", func(t *testing.T) {
|
| 128 |
w := httptest.NewRecorder()
|
| 129 |
-
req, _ := http.NewRequest("GET", "/safe-panic",
|
| 130 |
router.ServeHTTP(w, req)
|
| 131 |
|
| 132 |
assert.Equal(t, 500, w.Code)
|
| 133 |
-
assert.Contains(t, w.Body.String(), "
|
| 134 |
})
|
| 135 |
}
|
|
|
|
| 9 |
"testing"
|
| 10 |
|
| 11 |
"github.com/gin-gonic/gin"
|
| 12 |
+
"github.com/sirupsen/logrus"
|
| 13 |
"github.com/stretchr/testify/assert"
|
| 14 |
)
|
| 15 |
|
|
|
|
| 18 |
return gin.New()
|
| 19 |
}
|
| 20 |
|
| 21 |
+
func TestRequestIDMiddleware(t *testing.T) {
|
| 22 |
router := setupTestRouter()
|
| 23 |
+
router.Use(RequestID())
|
| 24 |
router.GET("/test", func(c *gin.Context) {
|
| 25 |
+
id := c.GetString("request_id")
|
| 26 |
+
c.JSON(200, gin.H{"request_id": id})
|
| 27 |
})
|
| 28 |
|
| 29 |
+
t.Run("generates request ID when not provided", func(t *testing.T) {
|
| 30 |
w := httptest.NewRecorder()
|
| 31 |
+
req, _ := http.NewRequest("GET", "/test", http.NoBody)
|
| 32 |
router.ServeHTTP(w, req)
|
| 33 |
|
| 34 |
assert.Equal(t, 200, w.Code)
|
| 35 |
+
// Check that response contains a request ID
|
| 36 |
+
assert.Contains(t, w.Body.String(), "request_id")
|
| 37 |
+
// Check that response header contains the request ID
|
| 38 |
+
assert.NotEmpty(t, w.Header().Get("X-Request-ID"))
|
| 39 |
})
|
| 40 |
|
| 41 |
+
t.Run("uses existing request ID from header", func(t *testing.T) {
|
| 42 |
w := httptest.NewRecorder()
|
| 43 |
+
req, _ := http.NewRequest("GET", "/test", http.NoBody)
|
| 44 |
+
req.Header.Set("X-Request-ID", "test-request-id-123")
|
| 45 |
router.ServeHTTP(w, req)
|
| 46 |
|
| 47 |
assert.Equal(t, 200, w.Code)
|
| 48 |
+
assert.Contains(t, w.Body.String(), "test-request-id-123")
|
| 49 |
+
assert.Equal(t, "test-request-id-123", w.Header().Get("X-Request-ID"))
|
| 50 |
})
|
| 51 |
}
|
| 52 |
|
| 53 |
+
func TestErrorHandler(t *testing.T) {
|
| 54 |
router := setupTestRouter()
|
| 55 |
+
router.Use(RequestID())
|
| 56 |
+
router.Use(ErrorHandler(logrus.New()))
|
| 57 |
|
| 58 |
router.GET("/error", func(c *gin.Context) {
|
| 59 |
c.Error(errors.New("test error"))
|
|
|
|
| 66 |
|
| 67 |
t.Run("handles errors gracefully", func(t *testing.T) {
|
| 68 |
w := httptest.NewRecorder()
|
| 69 |
+
req, _ := http.NewRequest("GET", "/error", http.NoBody)
|
| 70 |
router.ServeHTTP(w, req)
|
| 71 |
|
| 72 |
assert.Equal(t, 500, w.Code)
|
| 73 |
+
assert.Contains(t, w.Body.String(), "An unexpected error occurred")
|
| 74 |
})
|
| 75 |
|
| 76 |
t.Run("passes through successful requests", func(t *testing.T) {
|
| 77 |
w := httptest.NewRecorder()
|
| 78 |
+
req, _ := http.NewRequest("GET", "/success", http.NoBody)
|
| 79 |
router.ServeHTTP(w, req)
|
| 80 |
|
| 81 |
assert.Equal(t, 200, w.Code)
|
|
|
|
| 86 |
func TestRecoveryMiddleware(t *testing.T) {
|
| 87 |
router := setupTestRouter()
|
| 88 |
router.Use(RecoveryMiddleware(nil))
|
| 89 |
+
router.Use(RequestID())
|
| 90 |
+
router.Use(ErrorHandler(logrus.New()))
|
| 91 |
|
| 92 |
router.GET("/panic", func(c *gin.Context) {
|
| 93 |
panic("test panic")
|
|
|
|
| 99 |
|
| 100 |
t.Run("recovers from panic", func(t *testing.T) {
|
| 101 |
w := httptest.NewRecorder()
|
| 102 |
+
req, _ := http.NewRequest("GET", "/panic", http.NoBody)
|
| 103 |
router.ServeHTTP(w, req)
|
| 104 |
|
| 105 |
assert.Equal(t, 500, w.Code)
|
| 106 |
+
assert.Contains(t, w.Body.String(), "An internal server error occurred")
|
| 107 |
})
|
| 108 |
|
| 109 |
t.Run("normal requests work after panic recovery", func(t *testing.T) {
|
| 110 |
w := httptest.NewRecorder()
|
| 111 |
+
req, _ := http.NewRequest("GET", "/normal", http.NoBody)
|
| 112 |
router.ServeHTTP(w, req)
|
| 113 |
|
| 114 |
assert.Equal(t, 200, w.Code)
|
|
|
|
| 118 |
|
| 119 |
func TestSafeHandler(t *testing.T) {
|
| 120 |
router := setupTestRouter()
|
| 121 |
+
router.Use(RequestID())
|
| 122 |
+
router.Use(ErrorHandler(logrus.New()))
|
| 123 |
|
| 124 |
router.GET("/safe-panic", SafeHandler(func(c *gin.Context) {
|
| 125 |
panic("safe handler panic")
|
|
|
|
| 127 |
|
| 128 |
t.Run("safe handler recovers from panic", func(t *testing.T) {
|
| 129 |
w := httptest.NewRecorder()
|
| 130 |
+
req, _ := http.NewRequest("GET", "/safe-panic", http.NoBody)
|
| 131 |
router.ServeHTTP(w, req)
|
| 132 |
|
| 133 |
assert.Equal(t, 500, w.Code)
|
| 134 |
+
assert.Contains(t, w.Body.String(), "An internal server error occurred")
|
| 135 |
})
|
| 136 |
}
|
internal/api/middleware/rate_limit.go
DELETED
|
@@ -1,148 +0,0 @@
|
|
| 1 |
-
// Package middleware provides HTTP middleware components for the CLI Proxy API server.
|
| 2 |
-
// This file contains the rate limiting middleware that integrates with the domain
|
| 3 |
-
// rate limiting service to enforce request limits and block abusive clients.
|
| 4 |
-
package middleware
|
| 5 |
-
|
| 6 |
-
import (
|
| 7 |
-
"net/http"
|
| 8 |
-
"time"
|
| 9 |
-
|
| 10 |
-
"github.com/gin-gonic/gin"
|
| 11 |
-
"github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports"
|
| 12 |
-
)
|
| 13 |
-
|
| 14 |
-
// RateLimitMiddleware creates a Gin middleware that enforces rate limiting
|
| 15 |
-
// using the provided RateLimitService. It checks if the client IP is blocked
|
| 16 |
-
// and records failed authentication attempts.
|
| 17 |
-
type RateLimitMiddleware struct {
|
| 18 |
-
service ports.RateLimitService
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
// NewRateLimitMiddleware creates a new rate limiting middleware instance.
|
| 22 |
-
func NewRateLimitMiddleware(service ports.RateLimitService) *RateLimitMiddleware {
|
| 23 |
-
return &RateLimitMiddleware{
|
| 24 |
-
service: service,
|
| 25 |
-
}
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
// Middleware returns the Gin middleware function that enforces rate limiting.
|
| 29 |
-
// It should be used for management endpoints that require authentication.
|
| 30 |
-
func (m *RateLimitMiddleware) Middleware() gin.HandlerFunc {
|
| 31 |
-
return func(c *gin.Context) {
|
| 32 |
-
if m.service == nil {
|
| 33 |
-
c.Next()
|
| 34 |
-
return
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
clientIP := c.ClientIP()
|
| 38 |
-
ctx := c.Request.Context()
|
| 39 |
-
|
| 40 |
-
// Check if client is blocked
|
| 41 |
-
blocked, blockedUntil, err := m.service.IsBlocked(ctx, clientIP)
|
| 42 |
-
if err != nil {
|
| 43 |
-
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
|
| 44 |
-
"error": "rate limit check failed",
|
| 45 |
-
})
|
| 46 |
-
return
|
| 47 |
-
}
|
| 48 |
-
if blocked {
|
| 49 |
-
remaining := time.Until(blockedUntil)
|
| 50 |
-
if remaining < 0 {
|
| 51 |
-
remaining = 0
|
| 52 |
-
}
|
| 53 |
-
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
| 54 |
-
"error": "IP banned due to too many failed attempts",
|
| 55 |
-
"retry_after": remaining.String(),
|
| 56 |
-
})
|
| 57 |
-
return
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
// Check if request is allowed
|
| 61 |
-
allowed, err := m.service.Allow(ctx, clientIP)
|
| 62 |
-
if err != nil {
|
| 63 |
-
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
|
| 64 |
-
"error": "rate limit check failed",
|
| 65 |
-
})
|
| 66 |
-
return
|
| 67 |
-
}
|
| 68 |
-
if !allowed {
|
| 69 |
-
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
| 70 |
-
"error": "rate limit exceeded",
|
| 71 |
-
})
|
| 72 |
-
return
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
c.Next()
|
| 76 |
-
}
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
// AuthMiddleware wraps the rate limiting middleware with authentication logic.
|
| 80 |
-
// It records failed attempts when authentication fails.
|
| 81 |
-
type AuthMiddleware struct {
|
| 82 |
-
rateLimitService ports.RateLimitService
|
| 83 |
-
getSecretHash func() string
|
| 84 |
-
getEnvSecret func() string
|
| 85 |
-
allowRemote func() bool
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
// NewAuthMiddleware creates a new authentication middleware with rate limiting.
|
| 89 |
-
func NewAuthMiddleware(
|
| 90 |
-
service ports.RateLimitService,
|
| 91 |
-
getSecretHash func() string,
|
| 92 |
-
getEnvSecret func() string,
|
| 93 |
-
allowRemote func() bool,
|
| 94 |
-
) *AuthMiddleware {
|
| 95 |
-
return &AuthMiddleware{
|
| 96 |
-
rateLimitService: service,
|
| 97 |
-
getSecretHash: getSecretHash,
|
| 98 |
-
getEnvSecret: getEnvSecret,
|
| 99 |
-
allowRemote: allowRemote,
|
| 100 |
-
}
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
// OnAuthFailure should be called when authentication fails to record the attempt.
|
| 104 |
-
func (m *AuthMiddleware) OnAuthFailure(c *gin.Context) {
|
| 105 |
-
if m.rateLimitService == nil {
|
| 106 |
-
return
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
clientIP := c.ClientIP()
|
| 110 |
-
ctx := c.Request.Context()
|
| 111 |
-
|
| 112 |
-
m.rateLimitService.RecordAttempt(ctx, clientIP, false)
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
// OnAuthSuccess should be called when authentication succeeds to reset attempts.
|
| 116 |
-
func (m *AuthMiddleware) OnAuthSuccess(c *gin.Context) {
|
| 117 |
-
if m.rateLimitService == nil {
|
| 118 |
-
return
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
clientIP := c.ClientIP()
|
| 122 |
-
ctx := c.Request.Context()
|
| 123 |
-
|
| 124 |
-
m.rateLimitService.RecordAttempt(ctx, clientIP, true)
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
// GetRetryAfter returns the duration until the client can retry after being blocked.
|
| 128 |
-
func (m *AuthMiddleware) GetRetryAfter(c *gin.Context) time.Duration {
|
| 129 |
-
if m.rateLimitService == nil {
|
| 130 |
-
return 0
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
clientIP := c.ClientIP()
|
| 134 |
-
ctx := c.Request.Context()
|
| 135 |
-
|
| 136 |
-
blocked, blockedUntil, err := m.rateLimitService.IsBlocked(ctx, clientIP)
|
| 137 |
-
if err != nil {
|
| 138 |
-
return 0
|
| 139 |
-
}
|
| 140 |
-
if blocked {
|
| 141 |
-
remaining := time.Until(blockedUntil)
|
| 142 |
-
if remaining > 0 {
|
| 143 |
-
return remaining
|
| 144 |
-
}
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
return 0
|
| 148 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
internal/api/middleware/recovery.go
CHANGED
|
@@ -9,6 +9,8 @@ import (
|
|
| 9 |
|
| 10 |
"github.com/gin-gonic/gin"
|
| 11 |
"github.com/sirupsen/logrus"
|
|
|
|
|
|
|
| 12 |
)
|
| 13 |
|
| 14 |
// RecoveryMiddleware creates a Gin middleware that recovers from panics
|
|
@@ -18,6 +20,7 @@ func RecoveryMiddleware(logger *logrus.Logger) gin.HandlerFunc {
|
|
| 18 |
return func(c *gin.Context) {
|
| 19 |
defer func() {
|
| 20 |
if r := recover(); r != nil {
|
|
|
|
| 21 |
// Log the panic with stack trace if logger is available
|
| 22 |
if logger != nil {
|
| 23 |
logger.WithFields(logrus.Fields{
|
|
@@ -26,19 +29,15 @@ func RecoveryMiddleware(logger *logrus.Logger) gin.HandlerFunc {
|
|
| 26 |
"path": c.Request.URL.Path,
|
| 27 |
"method": c.Request.Method,
|
| 28 |
"client_ip": c.ClientIP(),
|
| 29 |
-
"request_id":
|
| 30 |
}).Error("Panic recovered in HTTP handler")
|
| 31 |
}
|
| 32 |
|
| 33 |
// Return graceful error response
|
| 34 |
-
|
| 35 |
-
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{
|
| 36 |
Success: false,
|
| 37 |
RequestID: requestID,
|
| 38 |
-
Error:
|
| 39 |
-
Code: "INTERNAL_ERROR",
|
| 40 |
-
Message: "An internal server error occurred",
|
| 41 |
-
},
|
| 42 |
})
|
| 43 |
}
|
| 44 |
}()
|
|
@@ -53,22 +52,19 @@ func SafeHandler(handler gin.HandlerFunc) gin.HandlerFunc {
|
|
| 53 |
return func(c *gin.Context) {
|
| 54 |
defer func() {
|
| 55 |
if r := recover(); r != nil {
|
|
|
|
| 56 |
// Log the panic
|
| 57 |
logrus.WithFields(logrus.Fields{
|
| 58 |
"panic": fmt.Sprintf("%v", r),
|
| 59 |
"path": c.Request.URL.Path,
|
| 60 |
"method": c.Request.Method,
|
| 61 |
-
"request_id":
|
| 62 |
}).Error("Panic recovered in handler")
|
| 63 |
|
| 64 |
-
|
| 65 |
-
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{
|
| 66 |
Success: false,
|
| 67 |
RequestID: requestID,
|
| 68 |
-
Error:
|
| 69 |
-
Code: "INTERNAL_ERROR",
|
| 70 |
-
Message: "An internal server error occurred",
|
| 71 |
-
},
|
| 72 |
})
|
| 73 |
}
|
| 74 |
}()
|
|
|
|
| 9 |
|
| 10 |
"github.com/gin-gonic/gin"
|
| 11 |
"github.com/sirupsen/logrus"
|
| 12 |
+
|
| 13 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/application/dto"
|
| 14 |
)
|
| 15 |
|
| 16 |
// RecoveryMiddleware creates a Gin middleware that recovers from panics
|
|
|
|
| 20 |
return func(c *gin.Context) {
|
| 21 |
defer func() {
|
| 22 |
if r := recover(); r != nil {
|
| 23 |
+
requestID := c.GetString("request_id")
|
| 24 |
// Log the panic with stack trace if logger is available
|
| 25 |
if logger != nil {
|
| 26 |
logger.WithFields(logrus.Fields{
|
|
|
|
| 29 |
"path": c.Request.URL.Path,
|
| 30 |
"method": c.Request.Method,
|
| 31 |
"client_ip": c.ClientIP(),
|
| 32 |
+
"request_id": requestID,
|
| 33 |
}).Error("Panic recovered in HTTP handler")
|
| 34 |
}
|
| 35 |
|
| 36 |
// Return graceful error response
|
| 37 |
+
c.AbortWithStatusJSON(http.StatusInternalServerError, dto.InternalErrorResponse{
|
|
|
|
| 38 |
Success: false,
|
| 39 |
RequestID: requestID,
|
| 40 |
+
Error: "An internal server error occurred",
|
|
|
|
|
|
|
|
|
|
| 41 |
})
|
| 42 |
}
|
| 43 |
}()
|
|
|
|
| 52 |
return func(c *gin.Context) {
|
| 53 |
defer func() {
|
| 54 |
if r := recover(); r != nil {
|
| 55 |
+
requestID := c.GetString("request_id")
|
| 56 |
// Log the panic
|
| 57 |
logrus.WithFields(logrus.Fields{
|
| 58 |
"panic": fmt.Sprintf("%v", r),
|
| 59 |
"path": c.Request.URL.Path,
|
| 60 |
"method": c.Request.Method,
|
| 61 |
+
"request_id": requestID,
|
| 62 |
}).Error("Panic recovered in handler")
|
| 63 |
|
| 64 |
+
c.AbortWithStatusJSON(http.StatusInternalServerError, dto.InternalErrorResponse{
|
|
|
|
| 65 |
Success: false,
|
| 66 |
RequestID: requestID,
|
| 67 |
+
Error: "An internal server error occurred",
|
|
|
|
|
|
|
|
|
|
| 68 |
})
|
| 69 |
}
|
| 70 |
}()
|
internal/api/middleware/request_id.go
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package middleware
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"github.com/gin-gonic/gin"
|
| 5 |
+
"github.com/google/uuid"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
func RequestID() gin.HandlerFunc {
|
| 9 |
+
return func(c *gin.Context) {
|
| 10 |
+
requestID := c.GetHeader("X-Request-ID")
|
| 11 |
+
if requestID == "" {
|
| 12 |
+
requestID = uuid.New().String()
|
| 13 |
+
}
|
| 14 |
+
c.Set("request_id", requestID)
|
| 15 |
+
c.Header("X-Request-ID", requestID)
|
| 16 |
+
c.Next()
|
| 17 |
+
}
|
| 18 |
+
}
|
internal/api/middleware/security.go
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package middleware
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"strings"
|
| 5 |
+
|
| 6 |
+
"github.com/gin-gonic/gin"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func SecurityHeaders() gin.HandlerFunc {
|
| 10 |
+
return func(c *gin.Context) {
|
| 11 |
+
// Prevent clickjacking
|
| 12 |
+
c.Header("X-Frame-Options", "DENY")
|
| 13 |
+
|
| 14 |
+
// Prevent MIME type sniffing
|
| 15 |
+
c.Header("X-Content-Type-Options", "nosniff")
|
| 16 |
+
|
| 17 |
+
// XSS Protection
|
| 18 |
+
c.Header("X-XSS-Protection", "1; mode=block")
|
| 19 |
+
|
| 20 |
+
// Referrer Policy
|
| 21 |
+
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
| 22 |
+
|
| 23 |
+
// Permissions Policy
|
| 24 |
+
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
| 25 |
+
|
| 26 |
+
// HSTS (HTTPS only)
|
| 27 |
+
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
| 28 |
+
|
| 29 |
+
// CSP for API (restrictive)
|
| 30 |
+
if !strings.HasPrefix(c.Request.URL.Path, "/management") {
|
| 31 |
+
c.Header("Content-Security-Policy", "default-src 'none'")
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
c.Next()
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// CSP for Management UI
|
| 39 |
+
func ManagementCSP() gin.HandlerFunc {
|
| 40 |
+
return func(c *gin.Context) {
|
| 41 |
+
csp := strings.Join([]string{
|
| 42 |
+
"default-src 'self'",
|
| 43 |
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
| 44 |
+
"style-src 'self' 'unsafe-inline'",
|
| 45 |
+
"img-src 'self' data: https:",
|
| 46 |
+
"font-src 'self'",
|
| 47 |
+
"connect-src 'self'",
|
| 48 |
+
"frame-ancestors 'none'",
|
| 49 |
+
"base-uri 'self'",
|
| 50 |
+
"form-action 'self'",
|
| 51 |
+
}, "; ")
|
| 52 |
+
|
| 53 |
+
c.Header("Content-Security-Policy", csp)
|
| 54 |
+
c.Next()
|
| 55 |
+
}
|
| 56 |
+
}
|
internal/api/modules/amp/amp.go
CHANGED
|
@@ -246,7 +246,6 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
|
|
| 246 |
}
|
| 247 |
}
|
| 248 |
}
|
| 249 |
-
|
| 250 |
}
|
| 251 |
|
| 252 |
// Store current config for next comparison
|
|
|
|
| 246 |
}
|
| 247 |
}
|
| 248 |
}
|
|
|
|
| 249 |
}
|
| 250 |
|
| 251 |
// Store current config for next comparison
|
internal/api/modules/amp/amp_test.go
CHANGED
|
@@ -2,6 +2,7 @@ package amp
|
|
| 2 |
|
| 3 |
import (
|
| 4 |
"context"
|
|
|
|
| 5 |
"net/http/httptest"
|
| 6 |
"os"
|
| 7 |
"path/filepath"
|
|
@@ -106,7 +107,7 @@ func TestAmpModule_Register_WithoutUpstream(t *testing.T) {
|
|
| 106 |
}
|
| 107 |
|
| 108 |
// But provider aliases should still be registered
|
| 109 |
-
req := httptest.NewRequest("GET", "/api/provider/openai/models",
|
| 110 |
w := httptest.NewRecorder()
|
| 111 |
r.ServeHTTP(w, req)
|
| 112 |
|
|
@@ -226,7 +227,7 @@ func TestAmpModule_AuthMiddleware_Fallback(t *testing.T) {
|
|
| 226 |
c.String(200, "ok")
|
| 227 |
})
|
| 228 |
|
| 229 |
-
req := httptest.NewRequest("GET", "/test",
|
| 230 |
w := httptest.NewRecorder()
|
| 231 |
r.ServeHTTP(w, req)
|
| 232 |
|
|
@@ -302,7 +303,7 @@ func TestAmpModule_ProviderAliasesAlwaysRegistered(t *testing.T) {
|
|
| 302 |
}
|
| 303 |
|
| 304 |
// Provider aliases should always be available
|
| 305 |
-
req := httptest.NewRequest("GET", "/api/provider/openai/models",
|
| 306 |
w := httptest.NewRecorder()
|
| 307 |
r.ServeHTTP(w, req)
|
| 308 |
|
|
|
|
| 2 |
|
| 3 |
import (
|
| 4 |
"context"
|
| 5 |
+
"net/http"
|
| 6 |
"net/http/httptest"
|
| 7 |
"os"
|
| 8 |
"path/filepath"
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
// But provider aliases should still be registered
|
| 110 |
+
req := httptest.NewRequest("GET", "/api/provider/openai/models", http.NoBody)
|
| 111 |
w := httptest.NewRecorder()
|
| 112 |
r.ServeHTTP(w, req)
|
| 113 |
|
|
|
|
| 227 |
c.String(200, "ok")
|
| 228 |
})
|
| 229 |
|
| 230 |
+
req := httptest.NewRequest("GET", "/test", http.NoBody)
|
| 231 |
w := httptest.NewRecorder()
|
| 232 |
r.ServeHTTP(w, req)
|
| 233 |
|
|
|
|
| 303 |
}
|
| 304 |
|
| 305 |
// Provider aliases should always be available
|
| 306 |
+
req := httptest.NewRequest("GET", "/api/provider/openai/models", http.NoBody)
|
| 307 |
w := httptest.NewRecorder()
|
| 308 |
r.ServeHTTP(w, req)
|
| 309 |
|
internal/api/modules/amp/gemini_bridge_test.go
CHANGED
|
@@ -58,7 +58,7 @@ func TestCreateGeminiBridgeHandler_ActionParameterExtraction(t *testing.T) {
|
|
| 58 |
}
|
| 59 |
r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
|
| 60 |
|
| 61 |
-
req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1"+tt.path,
|
| 62 |
w := httptest.NewRecorder()
|
| 63 |
r.ServeHTTP(w, req)
|
| 64 |
|
|
@@ -83,7 +83,7 @@ func TestCreateGeminiBridgeHandler_InvalidPath(t *testing.T) {
|
|
| 83 |
r := gin.New()
|
| 84 |
r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
|
| 85 |
|
| 86 |
-
req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1/invalid/path",
|
| 87 |
w := httptest.NewRecorder()
|
| 88 |
r.ServeHTTP(w, req)
|
| 89 |
|
|
|
|
| 58 |
}
|
| 59 |
r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
|
| 60 |
|
| 61 |
+
req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1"+tt.path, http.NoBody)
|
| 62 |
w := httptest.NewRecorder()
|
| 63 |
r.ServeHTTP(w, req)
|
| 64 |
|
|
|
|
| 83 |
r := gin.New()
|
| 84 |
r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
|
| 85 |
|
| 86 |
+
req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1/invalid/path", http.NoBody)
|
| 87 |
w := httptest.NewRecorder()
|
| 88 |
r.ServeHTTP(w, req)
|
| 89 |
|
internal/api/modules/amp/proxy_test.go
CHANGED
|
@@ -335,7 +335,7 @@ func TestReverseProxy_StripsClientCredentialsFromHeadersAndQuery(t *testing.T) {
|
|
| 335 |
}))
|
| 336 |
defer srv.Close()
|
| 337 |
|
| 338 |
-
req, err := http.NewRequest(http.MethodGet, srv.URL+"/test?key=client-key&key=keep&auth_token=client-key&foo=bar",
|
| 339 |
if err != nil {
|
| 340 |
t.Fatal(err)
|
| 341 |
}
|
|
|
|
| 335 |
}))
|
| 336 |
defer srv.Close()
|
| 337 |
|
| 338 |
+
req, err := http.NewRequest(http.MethodGet, srv.URL+"/test?key=client-key&key=keep&auth_token=client-key&foo=bar", http.NoBody)
|
| 339 |
if err != nil {
|
| 340 |
t.Fatal(err)
|
| 341 |
}
|
internal/api/modules/amp/response_rewriter_test.go
CHANGED
|
@@ -106,7 +106,7 @@ func TestResponseRewriter_SplitJSONTokensAcrossChunks(t *testing.T) {
|
|
| 106 |
|
| 107 |
// Simulate streaming response
|
| 108 |
for _, chunk := range tt.chunks {
|
| 109 |
-
rw.
|
| 110 |
}
|
| 111 |
rw.Flush()
|
| 112 |
|
|
@@ -176,7 +176,7 @@ func TestResponseRewriter_InvalidMalformedJSON(t *testing.T) {
|
|
| 176 |
rw := NewResponseRewriter(mock, tt.originalModel)
|
| 177 |
|
| 178 |
// For non-streaming, we buffer and flush
|
| 179 |
-
rw.
|
| 180 |
rw.Flush()
|
| 181 |
|
| 182 |
result := mock.body.String()
|
|
@@ -256,7 +256,7 @@ func TestResponseRewriter_MixedContentTypes(t *testing.T) {
|
|
| 256 |
rw := NewResponseRewriter(mock, tt.originalModel)
|
| 257 |
|
| 258 |
// First write triggers streaming detection
|
| 259 |
-
rw.
|
| 260 |
|
| 261 |
if rw.isStreaming != tt.isStreaming {
|
| 262 |
t.Errorf("isStreaming = %v, want %v", rw.isStreaming, tt.isStreaming)
|
|
@@ -320,7 +320,7 @@ func TestResponseRewriter_FallbackStrategy(t *testing.T) {
|
|
| 320 |
|
| 321 |
// Write all chunks
|
| 322 |
for _, chunk := range tt.input {
|
| 323 |
-
_, err := rw.
|
| 324 |
if err != nil && !tt.expectError {
|
| 325 |
t.Errorf("unexpected error: %v", err)
|
| 326 |
}
|
|
@@ -403,7 +403,7 @@ func TestResponseRewriter_SSEEdgeCases(t *testing.T) {
|
|
| 403 |
rw := NewResponseRewriter(mock, tt.originalModel)
|
| 404 |
|
| 405 |
for _, chunk := range tt.chunks {
|
| 406 |
-
rw.
|
| 407 |
}
|
| 408 |
|
| 409 |
result := mock.body.String()
|
|
@@ -456,7 +456,7 @@ func TestResponseRewriter_ThinkingBlockSuppression(t *testing.T) {
|
|
| 456 |
mock := newMockResponseWriter()
|
| 457 |
rw := NewResponseRewriter(mock, tt.originalModel)
|
| 458 |
|
| 459 |
-
rw.
|
| 460 |
rw.Flush()
|
| 461 |
|
| 462 |
result := mock.body.String()
|
|
@@ -495,7 +495,7 @@ func TestResponseRewriter_ConcurrentWrites(t *testing.T) {
|
|
| 495 |
}
|
| 496 |
|
| 497 |
for _, chunk := range chunks {
|
| 498 |
-
rw.
|
| 499 |
}
|
| 500 |
rw.Flush()
|
| 501 |
|
|
@@ -515,7 +515,7 @@ func TestResponseRewriter_LargeResponse(t *testing.T) {
|
|
| 515 |
largeContent := strings.Repeat("a", 100000)
|
| 516 |
input := `{"model": "mapped-model", "content": "` + largeContent + `"}`
|
| 517 |
|
| 518 |
-
rw.
|
| 519 |
rw.Flush()
|
| 520 |
|
| 521 |
result := mock.body.String()
|
|
@@ -594,27 +594,27 @@ func TestNewResponseRewriter(t *testing.T) {
|
|
| 594 |
// TestResponseRewriter_FlushBehavior tests Flush method behavior
|
| 595 |
func TestResponseRewriter_FlushBehavior(t *testing.T) {
|
| 596 |
tests := []struct {
|
| 597 |
-
name
|
| 598 |
isStreaming bool
|
| 599 |
-
writeData
|
| 600 |
expectFlush bool
|
| 601 |
}{
|
| 602 |
{
|
| 603 |
-
name:
|
| 604 |
isStreaming: false,
|
| 605 |
-
writeData:
|
| 606 |
expectFlush: true,
|
| 607 |
},
|
| 608 |
{
|
| 609 |
-
name:
|
| 610 |
isStreaming: true,
|
| 611 |
-
writeData:
|
| 612 |
expectFlush: true,
|
| 613 |
},
|
| 614 |
{
|
| 615 |
-
name:
|
| 616 |
isStreaming: false,
|
| 617 |
-
writeData:
|
| 618 |
expectFlush: false,
|
| 619 |
},
|
| 620 |
}
|
|
@@ -628,7 +628,7 @@ func TestResponseRewriter_FlushBehavior(t *testing.T) {
|
|
| 628 |
rw := NewResponseRewriter(mock, "original")
|
| 629 |
|
| 630 |
if tt.writeData != "" {
|
| 631 |
-
rw.
|
| 632 |
}
|
| 633 |
|
| 634 |
// Reset flushed flag
|
|
|
|
| 106 |
|
| 107 |
// Simulate streaming response
|
| 108 |
for _, chunk := range tt.chunks {
|
| 109 |
+
rw.WriteString(chunk)
|
| 110 |
}
|
| 111 |
rw.Flush()
|
| 112 |
|
|
|
|
| 176 |
rw := NewResponseRewriter(mock, tt.originalModel)
|
| 177 |
|
| 178 |
// For non-streaming, we buffer and flush
|
| 179 |
+
rw.WriteString(tt.input)
|
| 180 |
rw.Flush()
|
| 181 |
|
| 182 |
result := mock.body.String()
|
|
|
|
| 256 |
rw := NewResponseRewriter(mock, tt.originalModel)
|
| 257 |
|
| 258 |
// First write triggers streaming detection
|
| 259 |
+
rw.WriteString(tt.input)
|
| 260 |
|
| 261 |
if rw.isStreaming != tt.isStreaming {
|
| 262 |
t.Errorf("isStreaming = %v, want %v", rw.isStreaming, tt.isStreaming)
|
|
|
|
| 320 |
|
| 321 |
// Write all chunks
|
| 322 |
for _, chunk := range tt.input {
|
| 323 |
+
_, err := rw.WriteString(chunk)
|
| 324 |
if err != nil && !tt.expectError {
|
| 325 |
t.Errorf("unexpected error: %v", err)
|
| 326 |
}
|
|
|
|
| 403 |
rw := NewResponseRewriter(mock, tt.originalModel)
|
| 404 |
|
| 405 |
for _, chunk := range tt.chunks {
|
| 406 |
+
rw.WriteString(chunk)
|
| 407 |
}
|
| 408 |
|
| 409 |
result := mock.body.String()
|
|
|
|
| 456 |
mock := newMockResponseWriter()
|
| 457 |
rw := NewResponseRewriter(mock, tt.originalModel)
|
| 458 |
|
| 459 |
+
rw.WriteString(tt.input)
|
| 460 |
rw.Flush()
|
| 461 |
|
| 462 |
result := mock.body.String()
|
|
|
|
| 495 |
}
|
| 496 |
|
| 497 |
for _, chunk := range chunks {
|
| 498 |
+
rw.WriteString(chunk)
|
| 499 |
}
|
| 500 |
rw.Flush()
|
| 501 |
|
|
|
|
| 515 |
largeContent := strings.Repeat("a", 100000)
|
| 516 |
input := `{"model": "mapped-model", "content": "` + largeContent + `"}`
|
| 517 |
|
| 518 |
+
rw.WriteString(input)
|
| 519 |
rw.Flush()
|
| 520 |
|
| 521 |
result := mock.body.String()
|
|
|
|
| 594 |
// TestResponseRewriter_FlushBehavior tests Flush method behavior
|
| 595 |
func TestResponseRewriter_FlushBehavior(t *testing.T) {
|
| 596 |
tests := []struct {
|
| 597 |
+
name string
|
| 598 |
isStreaming bool
|
| 599 |
+
writeData string
|
| 600 |
expectFlush bool
|
| 601 |
}{
|
| 602 |
{
|
| 603 |
+
name: "flush non-streaming",
|
| 604 |
isStreaming: false,
|
| 605 |
+
writeData: `{"model": "mapped"}`,
|
| 606 |
expectFlush: true,
|
| 607 |
},
|
| 608 |
{
|
| 609 |
+
name: "flush streaming",
|
| 610 |
isStreaming: true,
|
| 611 |
+
writeData: `data: {"model": "mapped"}`,
|
| 612 |
expectFlush: true,
|
| 613 |
},
|
| 614 |
{
|
| 615 |
+
name: "flush empty body",
|
| 616 |
isStreaming: false,
|
| 617 |
+
writeData: "",
|
| 618 |
expectFlush: false,
|
| 619 |
},
|
| 620 |
}
|
|
|
|
| 628 |
rw := NewResponseRewriter(mock, "original")
|
| 629 |
|
| 630 |
if tt.writeData != "" {
|
| 631 |
+
rw.WriteString(tt.writeData)
|
| 632 |
}
|
| 633 |
|
| 634 |
// Reset flushed flag
|
internal/api/modules/amp/routes_test.go
CHANGED
|
@@ -65,7 +65,7 @@ func TestRegisterManagementRoutes(t *testing.T) {
|
|
| 65 |
for _, path := range managementPaths {
|
| 66 |
t.Run(path.path, func(t *testing.T) {
|
| 67 |
proxyCalled = false
|
| 68 |
-
req, err := http.NewRequest(path.method, srv.URL+path.path,
|
| 69 |
if err != nil {
|
| 70 |
t.Fatalf("failed to build request: %v", err)
|
| 71 |
}
|
|
@@ -120,7 +120,7 @@ func TestRegisterProviderAliases_AllProvidersRegistered(t *testing.T) {
|
|
| 120 |
for _, tc := range paths {
|
| 121 |
t.Run(tc.path, func(t *testing.T) {
|
| 122 |
authCalled = false
|
| 123 |
-
req := httptest.NewRequest(tc.method, tc.path,
|
| 124 |
w := httptest.NewRecorder()
|
| 125 |
r.ServeHTTP(w, req)
|
| 126 |
|
|
@@ -151,7 +151,7 @@ func TestRegisterProviderAliases_DynamicModelsHandler(t *testing.T) {
|
|
| 151 |
for _, provider := range providers {
|
| 152 |
t.Run(provider, func(t *testing.T) {
|
| 153 |
path := "/api/provider/" + provider + "/models"
|
| 154 |
-
req := httptest.NewRequest(http.MethodGet, path,
|
| 155 |
w := httptest.NewRecorder()
|
| 156 |
r.ServeHTTP(w, req)
|
| 157 |
|
|
@@ -185,7 +185,7 @@ func TestRegisterProviderAliases_V1Routes(t *testing.T) {
|
|
| 185 |
|
| 186 |
for _, tc := range v1Paths {
|
| 187 |
t.Run(tc.path, func(t *testing.T) {
|
| 188 |
-
req := httptest.NewRequest(tc.method, tc.path,
|
| 189 |
w := httptest.NewRecorder()
|
| 190 |
r.ServeHTTP(w, req)
|
| 191 |
|
|
@@ -215,7 +215,7 @@ func TestRegisterProviderAliases_V1BetaRoutes(t *testing.T) {
|
|
| 215 |
|
| 216 |
for _, tc := range v1betaPaths {
|
| 217 |
t.Run(tc.path, func(t *testing.T) {
|
| 218 |
-
req := httptest.NewRequest(tc.method, tc.path,
|
| 219 |
w := httptest.NewRecorder()
|
| 220 |
r.ServeHTTP(w, req)
|
| 221 |
|
|
@@ -236,7 +236,7 @@ func TestRegisterProviderAliases_NoAuthMiddleware(t *testing.T) {
|
|
| 236 |
m := &AmpModule{authMiddleware_: nil} // No auth middleware
|
| 237 |
m.registerProviderAliases(r, base, func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) })
|
| 238 |
|
| 239 |
-
req := httptest.NewRequest(http.MethodGet, "/api/provider/openai/models",
|
| 240 |
w := httptest.NewRecorder()
|
| 241 |
r.ServeHTTP(w, req)
|
| 242 |
|
|
@@ -314,7 +314,7 @@ func TestLocalhostOnlyMiddleware_PreventsSpoofing(t *testing.T) {
|
|
| 314 |
|
| 315 |
for _, tt := range tests {
|
| 316 |
t.Run(tt.name, func(t *testing.T) {
|
| 317 |
-
req := httptest.NewRequest(http.MethodGet, "/test",
|
| 318 |
req.RemoteAddr = tt.remoteAddr
|
| 319 |
if tt.forwardedFor != "" {
|
| 320 |
req.Header.Set("X-Forwarded-For", tt.forwardedFor)
|
|
@@ -346,7 +346,7 @@ func TestLocalhostOnlyMiddleware_HotReload(t *testing.T) {
|
|
| 346 |
})
|
| 347 |
|
| 348 |
// Test 1: Remote IP should be blocked when restriction is enabled
|
| 349 |
-
req := httptest.NewRequest(http.MethodGet, "/test",
|
| 350 |
req.RemoteAddr = "192.168.1.100:12345"
|
| 351 |
w := httptest.NewRecorder()
|
| 352 |
r.ServeHTTP(w, req)
|
|
@@ -358,7 +358,7 @@ func TestLocalhostOnlyMiddleware_HotReload(t *testing.T) {
|
|
| 358 |
// Test 2: Hot-reload - disable restriction
|
| 359 |
m.setRestrictToLocalhost(false)
|
| 360 |
|
| 361 |
-
req = httptest.NewRequest(http.MethodGet, "/test",
|
| 362 |
req.RemoteAddr = "192.168.1.100:12345"
|
| 363 |
w = httptest.NewRecorder()
|
| 364 |
r.ServeHTTP(w, req)
|
|
@@ -370,7 +370,7 @@ func TestLocalhostOnlyMiddleware_HotReload(t *testing.T) {
|
|
| 370 |
// Test 3: Hot-reload - re-enable restriction
|
| 371 |
m.setRestrictToLocalhost(true)
|
| 372 |
|
| 373 |
-
req = httptest.NewRequest(http.MethodGet, "/test",
|
| 374 |
req.RemoteAddr = "192.168.1.100:12345"
|
| 375 |
w = httptest.NewRecorder()
|
| 376 |
r.ServeHTTP(w, req)
|
|
|
|
| 65 |
for _, path := range managementPaths {
|
| 66 |
t.Run(path.path, func(t *testing.T) {
|
| 67 |
proxyCalled = false
|
| 68 |
+
req, err := http.NewRequest(path.method, srv.URL+path.path, http.NoBody)
|
| 69 |
if err != nil {
|
| 70 |
t.Fatalf("failed to build request: %v", err)
|
| 71 |
}
|
|
|
|
| 120 |
for _, tc := range paths {
|
| 121 |
t.Run(tc.path, func(t *testing.T) {
|
| 122 |
authCalled = false
|
| 123 |
+
req := httptest.NewRequest(tc.method, tc.path, http.NoBody)
|
| 124 |
w := httptest.NewRecorder()
|
| 125 |
r.ServeHTTP(w, req)
|
| 126 |
|
|
|
|
| 151 |
for _, provider := range providers {
|
| 152 |
t.Run(provider, func(t *testing.T) {
|
| 153 |
path := "/api/provider/" + provider + "/models"
|
| 154 |
+
req := httptest.NewRequest(http.MethodGet, path, http.NoBody)
|
| 155 |
w := httptest.NewRecorder()
|
| 156 |
r.ServeHTTP(w, req)
|
| 157 |
|
|
|
|
| 185 |
|
| 186 |
for _, tc := range v1Paths {
|
| 187 |
t.Run(tc.path, func(t *testing.T) {
|
| 188 |
+
req := httptest.NewRequest(tc.method, tc.path, http.NoBody)
|
| 189 |
w := httptest.NewRecorder()
|
| 190 |
r.ServeHTTP(w, req)
|
| 191 |
|
|
|
|
| 215 |
|
| 216 |
for _, tc := range v1betaPaths {
|
| 217 |
t.Run(tc.path, func(t *testing.T) {
|
| 218 |
+
req := httptest.NewRequest(tc.method, tc.path, http.NoBody)
|
| 219 |
w := httptest.NewRecorder()
|
| 220 |
r.ServeHTTP(w, req)
|
| 221 |
|
|
|
|
| 236 |
m := &AmpModule{authMiddleware_: nil} // No auth middleware
|
| 237 |
m.registerProviderAliases(r, base, func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) })
|
| 238 |
|
| 239 |
+
req := httptest.NewRequest(http.MethodGet, "/api/provider/openai/models", http.NoBody)
|
| 240 |
w := httptest.NewRecorder()
|
| 241 |
r.ServeHTTP(w, req)
|
| 242 |
|
|
|
|
| 314 |
|
| 315 |
for _, tt := range tests {
|
| 316 |
t.Run(tt.name, func(t *testing.T) {
|
| 317 |
+
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
| 318 |
req.RemoteAddr = tt.remoteAddr
|
| 319 |
if tt.forwardedFor != "" {
|
| 320 |
req.Header.Set("X-Forwarded-For", tt.forwardedFor)
|
|
|
|
| 346 |
})
|
| 347 |
|
| 348 |
// Test 1: Remote IP should be blocked when restriction is enabled
|
| 349 |
+
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
| 350 |
req.RemoteAddr = "192.168.1.100:12345"
|
| 351 |
w := httptest.NewRecorder()
|
| 352 |
r.ServeHTTP(w, req)
|
|
|
|
| 358 |
// Test 2: Hot-reload - disable restriction
|
| 359 |
m.setRestrictToLocalhost(false)
|
| 360 |
|
| 361 |
+
req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
| 362 |
req.RemoteAddr = "192.168.1.100:12345"
|
| 363 |
w = httptest.NewRecorder()
|
| 364 |
r.ServeHTTP(w, req)
|
|
|
|
| 370 |
// Test 3: Hot-reload - re-enable restriction
|
| 371 |
m.setRestrictToLocalhost(true)
|
| 372 |
|
| 373 |
+
req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
| 374 |
req.RemoteAddr = "192.168.1.100:12345"
|
| 375 |
w = httptest.NewRecorder()
|
| 376 |
r.ServeHTTP(w, req)
|
internal/api/server.go
CHANGED
|
@@ -201,6 +201,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
|
| 201 |
}
|
| 202 |
|
| 203 |
// Add middleware
|
|
|
|
|
|
|
|
|
|
| 204 |
engine.Use(logging.GinLogrusLogger())
|
| 205 |
engine.Use(logging.GinLogrusRecovery())
|
| 206 |
for _, mw := range optionState.extraMiddleware {
|
|
|
|
| 201 |
}
|
| 202 |
|
| 203 |
// Add middleware
|
| 204 |
+
engine.Use(middleware.RequestID())
|
| 205 |
+
engine.Use(middleware.SecurityHeaders())
|
| 206 |
+
engine.Use(middleware.ErrorHandler(log.StandardLogger()))
|
| 207 |
engine.Use(logging.GinLogrusLogger())
|
| 208 |
engine.Use(logging.GinLogrusRecovery())
|
| 209 |
for _, mw := range optionState.extraMiddleware {
|
internal/api/server_test.go
CHANGED
|
@@ -94,7 +94,7 @@ func TestAmpProviderModelRoutes(t *testing.T) {
|
|
| 94 |
t.Run(tc.name, func(t *testing.T) {
|
| 95 |
server := newTestServer(t)
|
| 96 |
|
| 97 |
-
req := httptest.NewRequest(http.MethodGet, tc.path,
|
| 98 |
req.Header.Set("Authorization", "Bearer test-key")
|
| 99 |
|
| 100 |
rr := httptest.NewRecorder()
|
|
|
|
| 94 |
t.Run(tc.name, func(t *testing.T) {
|
| 95 |
server := newTestServer(t)
|
| 96 |
|
| 97 |
+
req := httptest.NewRequest(http.MethodGet, tc.path, http.NoBody)
|
| 98 |
req.Header.Set("Authorization", "Bearer test-key")
|
| 99 |
|
| 100 |
rr := httptest.NewRecorder()
|
internal/application/dto/config_dto.go
CHANGED
|
@@ -7,29 +7,29 @@ import (
|
|
| 7 |
|
| 8 |
// ConfigResponse represents a configuration response
|
| 9 |
type ConfigResponse struct {
|
| 10 |
-
Debug bool
|
| 11 |
-
UsageStatisticsEnabled bool
|
| 12 |
-
LoggingToFile bool
|
| 13 |
-
LogsMaxTotalSizeMB int
|
| 14 |
-
RequestLog bool
|
| 15 |
-
WebsocketAuth bool
|
| 16 |
-
RequestRetry int
|
| 17 |
-
MaxRetryInterval int
|
| 18 |
-
ForceModelPrefix bool
|
| 19 |
-
ProxyURL string
|
| 20 |
-
Routing RoutingConfig
|
| 21 |
-
RemoteManagement RemoteManagementConfig
|
| 22 |
-
QuotaExceeded QuotaExceededConfig
|
| 23 |
-
APIKeys []string
|
| 24 |
-
GeminiKey []config.GeminiKey
|
| 25 |
-
ClaudeKey []config.ClaudeKey
|
| 26 |
-
CodexKey []config.CodexKey
|
| 27 |
-
OpenAICompatibility []config.OpenAICompatibility
|
| 28 |
-
VertexCompatAPIKey []config.VertexCompatKey
|
| 29 |
-
KiroKey []config.KiroKey
|
| 30 |
-
OAuthExcludedModels map[string][]string
|
| 31 |
OAuthModelAlias map[string][]config.OAuthModelAlias `json:"oauth_model_alias,omitempty"`
|
| 32 |
-
AmpCode config.AmpCode
|
| 33 |
}
|
| 34 |
|
| 35 |
// RoutingConfig represents routing configuration
|
|
@@ -149,4 +149,4 @@ type BoolFieldUpdateRequest struct {
|
|
| 149 |
// IntFieldUpdateRequest represents a request to update an int field
|
| 150 |
type IntFieldUpdateRequest struct {
|
| 151 |
Value *int `json:"value"`
|
| 152 |
-
}
|
|
|
|
| 7 |
|
| 8 |
// ConfigResponse represents a configuration response
|
| 9 |
type ConfigResponse struct {
|
| 10 |
+
Debug bool `json:"debug"`
|
| 11 |
+
UsageStatisticsEnabled bool `json:"usage_statistics_enabled"`
|
| 12 |
+
LoggingToFile bool `json:"logging_to_file"`
|
| 13 |
+
LogsMaxTotalSizeMB int `json:"logs_max_total_size_mb"`
|
| 14 |
+
RequestLog bool `json:"request_log"`
|
| 15 |
+
WebsocketAuth bool `json:"websocket_auth"`
|
| 16 |
+
RequestRetry int `json:"request_retry"`
|
| 17 |
+
MaxRetryInterval int `json:"max_retry_interval"`
|
| 18 |
+
ForceModelPrefix bool `json:"force_model_prefix"`
|
| 19 |
+
ProxyURL string `json:"proxy_url,omitempty"`
|
| 20 |
+
Routing RoutingConfig `json:"routing"`
|
| 21 |
+
RemoteManagement RemoteManagementConfig `json:"remote_management"`
|
| 22 |
+
QuotaExceeded QuotaExceededConfig `json:"quota_exceeded"`
|
| 23 |
+
APIKeys []string `json:"api_keys,omitempty"`
|
| 24 |
+
GeminiKey []config.GeminiKey `json:"gemini_key,omitempty"`
|
| 25 |
+
ClaudeKey []config.ClaudeKey `json:"claude_key,omitempty"`
|
| 26 |
+
CodexKey []config.CodexKey `json:"codex_key,omitempty"`
|
| 27 |
+
OpenAICompatibility []config.OpenAICompatibility `json:"openai_compatibility,omitempty"`
|
| 28 |
+
VertexCompatAPIKey []config.VertexCompatKey `json:"vertex_compat_api_key,omitempty"`
|
| 29 |
+
KiroKey []config.KiroKey `json:"kiro_key,omitempty"`
|
| 30 |
+
OAuthExcludedModels map[string][]string `json:"oauth_excluded_models,omitempty"`
|
| 31 |
OAuthModelAlias map[string][]config.OAuthModelAlias `json:"oauth_model_alias,omitempty"`
|
| 32 |
+
AmpCode config.AmpCode `json:"amp_code"`
|
| 33 |
}
|
| 34 |
|
| 35 |
// RoutingConfig represents routing configuration
|
|
|
|
| 149 |
// IntFieldUpdateRequest represents a request to update an int field
|
| 150 |
type IntFieldUpdateRequest struct {
|
| 151 |
Value *int `json:"value"`
|
| 152 |
+
}
|
internal/application/dto/error.go
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package dto
|
| 2 |
+
|
| 3 |
+
// ErrorResponse is the standardized error response for clients
|
| 4 |
+
type ErrorResponse struct {
|
| 5 |
+
Success bool `json:"success" example:"false"`
|
| 6 |
+
Error ErrorInfo `json:"error"`
|
| 7 |
+
RequestID string `json:"request_id,omitempty"`
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
type ErrorInfo struct {
|
| 11 |
+
Code string `json:"code" example:"AUTH_INVALID_CREDENTIALS"`
|
| 12 |
+
Message string `json:"message" example:"Invalid credentials provided"`
|
| 13 |
+
Details map[string]interface{} `json:"details,omitempty"`
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// InternalErrorResponse is returned for 500 errors (no sensitive info)
|
| 17 |
+
type InternalErrorResponse struct {
|
| 18 |
+
Success bool `json:"success"`
|
| 19 |
+
Error string `json:"error"`
|
| 20 |
+
RequestID string `json:"request_id"`
|
| 21 |
+
}
|
internal/application/mapper/config_mapper.go
CHANGED
|
@@ -103,4 +103,4 @@ func ToConfigFromResponse(resp *dto.ConfigResponse) *config.Config {
|
|
| 103 |
cfg.SDKConfig.APIKeys = resp.APIKeys
|
| 104 |
|
| 105 |
return cfg
|
| 106 |
-
}
|
|
|
|
| 103 |
cfg.SDKConfig.APIKeys = resp.APIKeys
|
| 104 |
|
| 105 |
return cfg
|
| 106 |
+
}
|
internal/application/usecase/config_usecase.go
CHANGED
|
@@ -537,4 +537,4 @@ func (uc *ConfigUseCase) GetLatestVersion(ctx context.Context) (*dto.VersionResp
|
|
| 537 |
return &dto.VersionResponse{
|
| 538 |
LatestVersion: version,
|
| 539 |
}, nil
|
| 540 |
-
}
|
|
|
|
| 537 |
return &dto.VersionResponse{
|
| 538 |
LatestVersion: version,
|
| 539 |
}, nil
|
| 540 |
+
}
|
internal/auth/antigravity/auth.go
CHANGED
|
@@ -113,7 +113,7 @@ func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string)
|
|
| 113 |
if accessToken == "" {
|
| 114 |
return "", fmt.Errorf("antigravity userinfo: missing access token")
|
| 115 |
}
|
| 116 |
-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoEndpoint,
|
| 117 |
if err != nil {
|
| 118 |
return "", fmt.Errorf("antigravity userinfo: create request: %w", err)
|
| 119 |
}
|
|
|
|
| 113 |
if accessToken == "" {
|
| 114 |
return "", fmt.Errorf("antigravity userinfo: missing access token")
|
| 115 |
}
|
| 116 |
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoEndpoint, http.NoBody)
|
| 117 |
if err != nil {
|
| 118 |
return "", fmt.Errorf("antigravity userinfo: create request: %w", err)
|
| 119 |
}
|
internal/auth/codex/token.go
CHANGED
|
@@ -62,5 +62,4 @@ func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
|
|
| 62 |
return fmt.Errorf("failed to write token to file: %w", err)
|
| 63 |
}
|
| 64 |
return nil
|
| 65 |
-
|
| 66 |
}
|
|
|
|
| 62 |
return fmt.Errorf("failed to write token to file: %w", err)
|
| 63 |
}
|
| 64 |
return nil
|
|
|
|
| 65 |
}
|
internal/auth/gemini/gemini_auth.go
CHANGED
|
@@ -161,7 +161,7 @@ func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiToken
|
|
| 161 |
// - error: An error if the token storage creation fails, nil otherwise
|
| 162 |
func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*GeminiTokenStorage, error) {
|
| 163 |
httpClient := config.Client(ctx, token)
|
| 164 |
-
req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
| 165 |
if err != nil {
|
| 166 |
return nil, fmt.Errorf("could not get user info: %v", err)
|
| 167 |
}
|
|
|
|
| 161 |
// - error: An error if the token storage creation fails, nil otherwise
|
| 162 |
func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*GeminiTokenStorage, error) {
|
| 163 |
httpClient := config.Client(ctx, token)
|
| 164 |
+
req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", http.NoBody)
|
| 165 |
if err != nil {
|
| 166 |
return nil, fmt.Errorf("could not get user info: %v", err)
|
| 167 |
}
|
internal/auth/iflow/iflow_auth.go
CHANGED
|
@@ -173,7 +173,7 @@ func (ia *IFlowAuth) FetchUserInfo(ctx context.Context, accessToken string) (*us
|
|
| 173 |
}
|
| 174 |
|
| 175 |
endpoint := fmt.Sprintf("%s?accessToken=%s", iFlowUserInfoEndpoint, url.QueryEscape(accessToken))
|
| 176 |
-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint,
|
| 177 |
if err != nil {
|
| 178 |
return nil, fmt.Errorf("iflow api key: create request failed: %w", err)
|
| 179 |
}
|
|
@@ -334,7 +334,7 @@ func (ia *IFlowAuth) AuthenticateWithCookie(ctx context.Context, cookie string)
|
|
| 334 |
|
| 335 |
// fetchAPIKeyInfo retrieves API key information using GET request with cookie
|
| 336 |
func (ia *IFlowAuth) fetchAPIKeyInfo(ctx context.Context, cookie string) (*iFlowKeyData, error) {
|
| 337 |
-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, iFlowAPIKeyEndpoint,
|
| 338 |
if err != nil {
|
| 339 |
return nil, fmt.Errorf("iflow cookie: create GET request failed: %w", err)
|
| 340 |
}
|
|
|
|
| 173 |
}
|
| 174 |
|
| 175 |
endpoint := fmt.Sprintf("%s?accessToken=%s", iFlowUserInfoEndpoint, url.QueryEscape(accessToken))
|
| 176 |
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
| 177 |
if err != nil {
|
| 178 |
return nil, fmt.Errorf("iflow api key: create request failed: %w", err)
|
| 179 |
}
|
|
|
|
| 334 |
|
| 335 |
// fetchAPIKeyInfo retrieves API key information using GET request with cookie
|
| 336 |
func (ia *IFlowAuth) fetchAPIKeyInfo(ctx context.Context, cookie string) (*iFlowKeyData, error) {
|
| 337 |
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, iFlowAPIKeyEndpoint, http.NoBody)
|
| 338 |
if err != nil {
|
| 339 |
return nil, fmt.Errorf("iflow cookie: create GET request failed: %w", err)
|
| 340 |
}
|
internal/cmd/login.go
CHANGED
|
@@ -375,7 +375,7 @@ func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string
|
|
| 375 |
}
|
| 376 |
|
| 377 |
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
|
| 378 |
-
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects",
|
| 379 |
if errRequest != nil {
|
| 380 |
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
|
| 381 |
}
|
|
@@ -559,7 +559,7 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
|
|
| 559 |
}
|
| 560 |
for _, service := range requiredServices {
|
| 561 |
checkUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
|
| 562 |
-
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl,
|
| 563 |
if errRequest != nil {
|
| 564 |
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
| 565 |
}
|
|
|
|
| 375 |
}
|
| 376 |
|
| 377 |
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
|
| 378 |
+
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", http.NoBody)
|
| 379 |
if errRequest != nil {
|
| 380 |
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
|
| 381 |
}
|
|
|
|
| 559 |
}
|
| 560 |
for _, service := range requiredServices {
|
| 561 |
checkUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
|
| 562 |
+
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl, http.NoBody)
|
| 563 |
if errRequest != nil {
|
| 564 |
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
| 565 |
}
|
internal/domain/errors/errors.go
CHANGED
|
@@ -32,6 +32,37 @@ const (
|
|
| 32 |
ValidationFailed ErrorCode = "VALIDATION_FAILED"
|
| 33 |
// AlreadyExists indicates a resource already exists
|
| 34 |
AlreadyExists ErrorCode = "ALREADY_EXISTS"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
)
|
| 36 |
|
| 37 |
// DomainError is the base error type for all domain errors.
|
|
@@ -40,8 +71,9 @@ const (
|
|
| 40 |
type DomainError struct {
|
| 41 |
Code ErrorCode
|
| 42 |
Message string
|
| 43 |
-
Cause error
|
| 44 |
Details map[string]interface{}
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
// Error implements the error interface
|
|
@@ -184,6 +216,9 @@ func NewTimeoutError(operation string) *DomainError {
|
|
| 184 |
|
| 185 |
// HTTPStatusCode returns the appropriate HTTP status code for the error
|
| 186 |
func (e *DomainError) HTTPStatusCode() int {
|
|
|
|
|
|
|
|
|
|
| 187 |
switch e.Code {
|
| 188 |
case NotFound:
|
| 189 |
return 404
|
|
@@ -216,26 +251,76 @@ func (e *DomainError) ToResponse() map[string]interface{} {
|
|
| 216 |
return response
|
| 217 |
}
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
// Common error instances for reuse
|
| 220 |
var (
|
| 221 |
// ErrConfigNotFound is returned when configuration is not found
|
| 222 |
ErrConfigNotFound = New(NotFound, "configuration not found")
|
| 223 |
-
|
| 224 |
// ErrAuthFileNotFound is returned when an auth file is not found
|
| 225 |
ErrAuthFileNotFound = New(NotFound, "auth file not found")
|
| 226 |
-
|
| 227 |
// ErrInvalidConfig is returned when configuration is invalid
|
| 228 |
ErrInvalidConfig = New(ValidationFailed, "invalid configuration")
|
| 229 |
-
|
| 230 |
// ErrAuthManagerUnavailable is returned when the auth manager is not available
|
| 231 |
ErrAuthManagerUnavailable = New(ServiceUnavailable, "auth manager unavailable")
|
| 232 |
-
|
| 233 |
// ErrTokenStoreUnavailable is returned when the token store is not available
|
| 234 |
ErrTokenStoreUnavailable = New(ServiceUnavailable, "token store unavailable")
|
| 235 |
-
|
| 236 |
// ErrLogDirectoryNotConfigured is returned when log directory is not set
|
| 237 |
ErrLogDirectoryNotConfigured = New(InternalError, "log directory not configured")
|
| 238 |
-
|
| 239 |
// ErrLoggingDisabled is returned when logging to file is disabled
|
| 240 |
ErrLoggingDisabled = New(ServiceUnavailable, "logging to file disabled")
|
| 241 |
-
)
|
|
|
|
| 32 |
ValidationFailed ErrorCode = "VALIDATION_FAILED"
|
| 33 |
// AlreadyExists indicates a resource already exists
|
| 34 |
AlreadyExists ErrorCode = "ALREADY_EXISTS"
|
| 35 |
+
|
| 36 |
+
// New Error Codes (Standardization Plan)
|
| 37 |
+
// Authentication errors
|
| 38 |
+
ErrCodeInvalidCredentials ErrorCode = "AUTH_INVALID_CREDENTIALS"
|
| 39 |
+
ErrCodeTokenExpired ErrorCode = "AUTH_TOKEN_EXPIRED"
|
| 40 |
+
ErrCodeTokenInvalid ErrorCode = "AUTH_TOKEN_INVALID"
|
| 41 |
+
ErrCodeUnauthorized ErrorCode = "AUTH_UNAUTHORIZED"
|
| 42 |
+
|
| 43 |
+
// Configuration errors
|
| 44 |
+
ErrCodeConfigNotFound ErrorCode = "CONFIG_NOT_FOUND"
|
| 45 |
+
ErrCodeConfigInvalid ErrorCode = "CONFIG_INVALID"
|
| 46 |
+
ErrCodeConfigValidation ErrorCode = "CONFIG_VALIDATION_FAILED"
|
| 47 |
+
|
| 48 |
+
// Provider errors
|
| 49 |
+
ErrCodeProviderNotFound ErrorCode = "PROVIDER_NOT_FOUND"
|
| 50 |
+
ErrCodeProviderUnavailable ErrorCode = "PROVIDER_UNAVAILABLE"
|
| 51 |
+
ErrCodeProviderRateLimited ErrorCode = "PROVIDER_RATE_LIMITED"
|
| 52 |
+
|
| 53 |
+
// Request errors
|
| 54 |
+
ErrCodeInvalidRequest ErrorCode = "REQUEST_INVALID"
|
| 55 |
+
ErrCodeMissingField ErrorCode = "REQUEST_MISSING_FIELD"
|
| 56 |
+
ErrCodeInvalidFormat ErrorCode = "REQUEST_INVALID_FORMAT"
|
| 57 |
+
|
| 58 |
+
// Storage errors
|
| 59 |
+
ErrCodeStorageFailure ErrorCode = "STORAGE_FAILURE"
|
| 60 |
+
ErrCodeNotFound ErrorCode = "RESOURCE_NOT_FOUND"
|
| 61 |
+
ErrCodeConflict ErrorCode = "RESOURCE_CONFLICT"
|
| 62 |
+
|
| 63 |
+
// Internal errors
|
| 64 |
+
ErrCodeInternal ErrorCode = "INTERNAL_ERROR"
|
| 65 |
+
ErrCodeNotImplemented ErrorCode = "NOT_IMPLEMENTED"
|
| 66 |
)
|
| 67 |
|
| 68 |
// DomainError is the base error type for all domain errors.
|
|
|
|
| 71 |
type DomainError struct {
|
| 72 |
Code ErrorCode
|
| 73 |
Message string
|
|
|
|
| 74 |
Details map[string]interface{}
|
| 75 |
+
Cause error
|
| 76 |
+
HTTPStatus int
|
| 77 |
}
|
| 78 |
|
| 79 |
// Error implements the error interface
|
|
|
|
| 216 |
|
| 217 |
// HTTPStatusCode returns the appropriate HTTP status code for the error
|
| 218 |
func (e *DomainError) HTTPStatusCode() int {
|
| 219 |
+
if e.HTTPStatus > 0 {
|
| 220 |
+
return e.HTTPStatus
|
| 221 |
+
}
|
| 222 |
switch e.Code {
|
| 223 |
case NotFound:
|
| 224 |
return 404
|
|
|
|
| 251 |
return response
|
| 252 |
}
|
| 253 |
|
| 254 |
+
// NewInvalidCredentials creates an AUTH_INVALID_CREDENTIALS error
|
| 255 |
+
func NewInvalidCredentials(msg string) *DomainError {
|
| 256 |
+
return &DomainError{
|
| 257 |
+
Code: ErrCodeInvalidCredentials,
|
| 258 |
+
Message: msg,
|
| 259 |
+
HTTPStatus: 401,
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// NewConfigNotFound creates a CONFIG_NOT_FOUND error
|
| 264 |
+
func NewConfigNotFound(resource string) *DomainError {
|
| 265 |
+
return &DomainError{
|
| 266 |
+
Code: ErrCodeConfigNotFound,
|
| 267 |
+
Message: fmt.Sprintf("configuration not found: %s", resource),
|
| 268 |
+
HTTPStatus: 404,
|
| 269 |
+
Details: map[string]interface{}{"resource": resource},
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
// NewProviderUnavailable creates a PROVIDER_UNAVAILABLE error
|
| 274 |
+
func NewProviderUnavailable(provider string, cause error) *DomainError {
|
| 275 |
+
return &DomainError{
|
| 276 |
+
Code: ErrCodeProviderUnavailable,
|
| 277 |
+
Message: fmt.Sprintf("provider %s is unavailable", provider),
|
| 278 |
+
HTTPStatus: 503,
|
| 279 |
+
Cause: cause,
|
| 280 |
+
Details: map[string]interface{}{"provider": provider},
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
// NewConfigValidationError creates a CONFIG_VALIDATION_FAILED error
|
| 285 |
+
func NewConfigValidationError(field string, msg string) *DomainError {
|
| 286 |
+
return &DomainError{
|
| 287 |
+
Code: ErrCodeConfigValidation,
|
| 288 |
+
Message: fmt.Sprintf("validation failed for %s: %s", field, msg),
|
| 289 |
+
HTTPStatus: 400,
|
| 290 |
+
Details: map[string]interface{}{"field": field},
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// NewInternalErrorWrapped creates an INTERNAL_ERROR
|
| 295 |
+
func NewInternalErrorWrapped(cause error) *DomainError {
|
| 296 |
+
return &DomainError{
|
| 297 |
+
Code: ErrCodeInternal,
|
| 298 |
+
Message: "an internal error occurred",
|
| 299 |
+
HTTPStatus: 500,
|
| 300 |
+
Cause: cause,
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
// Common error instances for reuse
|
| 305 |
var (
|
| 306 |
// ErrConfigNotFound is returned when configuration is not found
|
| 307 |
ErrConfigNotFound = New(NotFound, "configuration not found")
|
| 308 |
+
|
| 309 |
// ErrAuthFileNotFound is returned when an auth file is not found
|
| 310 |
ErrAuthFileNotFound = New(NotFound, "auth file not found")
|
| 311 |
+
|
| 312 |
// ErrInvalidConfig is returned when configuration is invalid
|
| 313 |
ErrInvalidConfig = New(ValidationFailed, "invalid configuration")
|
| 314 |
+
|
| 315 |
// ErrAuthManagerUnavailable is returned when the auth manager is not available
|
| 316 |
ErrAuthManagerUnavailable = New(ServiceUnavailable, "auth manager unavailable")
|
| 317 |
+
|
| 318 |
// ErrTokenStoreUnavailable is returned when the token store is not available
|
| 319 |
ErrTokenStoreUnavailable = New(ServiceUnavailable, "token store unavailable")
|
| 320 |
+
|
| 321 |
// ErrLogDirectoryNotConfigured is returned when log directory is not set
|
| 322 |
ErrLogDirectoryNotConfigured = New(InternalError, "log directory not configured")
|
| 323 |
+
|
| 324 |
// ErrLoggingDisabled is returned when logging to file is disabled
|
| 325 |
ErrLoggingDisabled = New(ServiceUnavailable, "logging to file disabled")
|
| 326 |
+
)
|
internal/domain/ports/rate_limit.go
DELETED
|
@@ -1,43 +0,0 @@
|
|
| 1 |
-
package ports
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"context"
|
| 5 |
-
"time"
|
| 6 |
-
)
|
| 7 |
-
|
| 8 |
-
// RateLimitEntry represents the state of a rate limit for a key (e.g., IP address).
|
| 9 |
-
type RateLimitEntry struct {
|
| 10 |
-
Key string
|
| 11 |
-
Count int // Current count of attempts or tokens used
|
| 12 |
-
LastAttempt time.Time // Timestamp of the last attempt
|
| 13 |
-
BlockedUntil time.Time // Time until which the key is blocked (zero time if not blocked)
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
// RateLimitRepository defines the interface for persisting rate limit data.
|
| 17 |
-
type RateLimitRepository interface {
|
| 18 |
-
// Get retrieves the rate limit entry for a given key.
|
| 19 |
-
Get(ctx context.Context, key string) (*RateLimitEntry, error)
|
| 20 |
-
|
| 21 |
-
// Set saves the rate limit entry for a given key with an expiration.
|
| 22 |
-
Set(ctx context.Context, key string, entry *RateLimitEntry, expiration time.Duration) error
|
| 23 |
-
|
| 24 |
-
// Cleanup removes entries older than the specified time.
|
| 25 |
-
Cleanup(ctx context.Context, olderThan time.Time) error
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
// RateLimitService defines the interface for the rate limiting logic.
|
| 29 |
-
type RateLimitService interface {
|
| 30 |
-
// Allow checks if a request from the given key is allowed based on the rate limit policy.
|
| 31 |
-
// It basically checks if the key is currently blocked.
|
| 32 |
-
Allow(ctx context.Context, key string) (bool, error)
|
| 33 |
-
|
| 34 |
-
// RecordAttempt records a request or action attempt for the given key.
|
| 35 |
-
// success: indicates if the attempt was successful.
|
| 36 |
-
// If success is true, it might reset the failure count.
|
| 37 |
-
// If success is false, it increments the failure count and might block the key.
|
| 38 |
-
RecordAttempt(ctx context.Context, key string, success bool) error
|
| 39 |
-
|
| 40 |
-
// IsBlocked checks if the key is currently blocked and returns the blockage details.
|
| 41 |
-
// Returns true if blocked, the time until it's blocked, and any error.
|
| 42 |
-
IsBlocked(ctx context.Context, key string) (bool, time.Time, error)
|
| 43 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
internal/domain/ports/repositories.go
CHANGED
|
@@ -15,67 +15,67 @@ import (
|
|
| 15 |
type ConfigRepository interface {
|
| 16 |
// Load retrieves the current configuration
|
| 17 |
Load(ctx context.Context) (*config.Config, error)
|
| 18 |
-
|
| 19 |
// Save persists the configuration
|
| 20 |
Save(ctx context.Context, cfg *config.Config) error
|
| 21 |
-
|
| 22 |
// SaveWithPath persists the configuration to a specific path
|
| 23 |
SaveWithPath(ctx context.Context, path string, cfg *config.Config) error
|
| 24 |
-
|
| 25 |
// Validate validates the configuration without saving
|
| 26 |
Validate(ctx context.Context, cfg *config.Config) error
|
| 27 |
-
|
| 28 |
// GetConfigPath returns the current configuration file path
|
| 29 |
GetConfigPath() string
|
| 30 |
}
|
| 31 |
|
| 32 |
// AuthFile represents an authentication file in the domain
|
| 33 |
type AuthFile struct {
|
| 34 |
-
ID
|
| 35 |
-
Provider
|
| 36 |
-
FileName
|
| 37 |
-
Label
|
| 38 |
-
Email
|
| 39 |
-
Status
|
| 40 |
-
StatusMessage
|
| 41 |
-
Disabled
|
| 42 |
-
Unavailable
|
| 43 |
-
RuntimeOnly
|
| 44 |
-
Path
|
| 45 |
-
Size
|
| 46 |
-
CreatedAt
|
| 47 |
-
UpdatedAt
|
| 48 |
LastRefreshedAt time.Time
|
| 49 |
-
Metadata
|
| 50 |
-
Attributes
|
| 51 |
}
|
| 52 |
|
| 53 |
// AuthRepository defines the interface for authentication file persistence
|
| 54 |
type AuthRepository interface {
|
| 55 |
// List retrieves all authentication files
|
| 56 |
List(ctx context.Context) ([]*AuthFile, error)
|
| 57 |
-
|
| 58 |
// GetByID retrieves an authentication file by its ID
|
| 59 |
GetByID(ctx context.Context, id string) (*AuthFile, error)
|
| 60 |
-
|
| 61 |
// GetByName retrieves an authentication file by its filename
|
| 62 |
GetByName(ctx context.Context, name string) (*AuthFile, error)
|
| 63 |
-
|
| 64 |
// Save persists an authentication file
|
| 65 |
Save(ctx context.Context, file *AuthFile) error
|
| 66 |
-
|
| 67 |
// Delete removes an authentication file
|
| 68 |
Delete(ctx context.Context, id string) error
|
| 69 |
-
|
| 70 |
// DeleteAll removes all authentication files
|
| 71 |
DeleteAll(ctx context.Context) (int, error)
|
| 72 |
-
|
| 73 |
// Disable marks an authentication file as disabled
|
| 74 |
Disable(ctx context.Context, id string, reason string) error
|
| 75 |
-
|
| 76 |
// Enable marks an authentication file as enabled
|
| 77 |
Enable(ctx context.Context, id string) error
|
| 78 |
-
|
| 79 |
// GetAuthDir returns the authentication directory path
|
| 80 |
GetAuthDir() string
|
| 81 |
}
|
|
@@ -94,25 +94,25 @@ type LogEntry struct {
|
|
| 94 |
type LogRepository interface {
|
| 95 |
// ListLogFiles retrieves all log files
|
| 96 |
ListLogFiles(ctx context.Context) ([]*LogFileInfo, error)
|
| 97 |
-
|
| 98 |
// ReadLogFile reads a log file with optional filtering
|
| 99 |
ReadLogFile(ctx context.Context, filename string, after int64, limit int) (*LogContent, error)
|
| 100 |
-
|
| 101 |
// DeleteLogFiles removes all log files and truncates the active log
|
| 102 |
DeleteLogFiles(ctx context.Context) (*DeleteLogResult, error)
|
| 103 |
-
|
| 104 |
// GetRequestErrorLogs retrieves error request log files
|
| 105 |
GetRequestErrorLogs(ctx context.Context) ([]*LogFileInfo, error)
|
| 106 |
-
|
| 107 |
// GetRequestLogByID retrieves a specific request log by ID
|
| 108 |
GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error)
|
| 109 |
-
|
| 110 |
// DownloadRequestErrorLog downloads a specific error log file
|
| 111 |
DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error)
|
| 112 |
-
|
| 113 |
// GetLogDirectory returns the log directory path
|
| 114 |
GetLogDirectory() string
|
| 115 |
-
|
| 116 |
// IsLoggingEnabled returns whether logging to file is enabled
|
| 117 |
IsLoggingEnabled() bool
|
| 118 |
}
|
|
@@ -155,35 +155,35 @@ type TokenRecord struct {
|
|
| 155 |
type TokenStore interface {
|
| 156 |
// Save persists a token record
|
| 157 |
Save(ctx context.Context, record *TokenRecord) (string, error)
|
| 158 |
-
|
| 159 |
// Delete removes a token record
|
| 160 |
Delete(ctx context.Context, path string) error
|
| 161 |
-
|
| 162 |
// Get retrieves a token record by path
|
| 163 |
Get(ctx context.Context, path string) (*TokenRecord, error)
|
| 164 |
-
|
| 165 |
// List retrieves all token records
|
| 166 |
List(ctx context.Context) ([]*TokenRecord, error)
|
| 167 |
}
|
| 168 |
|
| 169 |
// UsageStatistics represents usage statistics data
|
| 170 |
type UsageStatistics struct {
|
| 171 |
-
TotalRequests
|
| 172 |
-
FailureCount
|
| 173 |
-
RequestCount
|
| 174 |
-
TokenCount
|
| 175 |
-
LastUpdated
|
| 176 |
-
Data
|
| 177 |
}
|
| 178 |
|
| 179 |
// UsageRepository defines the interface for usage statistics persistence
|
| 180 |
type UsageRepository interface {
|
| 181 |
// GetStatistics retrieves current usage statistics
|
| 182 |
GetStatistics(ctx context.Context) (*UsageStatistics, error)
|
| 183 |
-
|
| 184 |
// ExportStatistics exports statistics for backup
|
| 185 |
ExportStatistics(ctx context.Context) (*UsageStatistics, error)
|
| 186 |
-
|
| 187 |
// ImportStatistics imports statistics from backup
|
| 188 |
ImportStatistics(ctx context.Context, stats *UsageStatistics) (*ImportResult, error)
|
| 189 |
}
|
|
@@ -197,34 +197,34 @@ type ImportResult struct {
|
|
| 197 |
|
| 198 |
// OAuthSession represents an OAuth session
|
| 199 |
type OAuthSession struct {
|
| 200 |
-
State
|
| 201 |
-
Provider
|
| 202 |
-
Status
|
| 203 |
-
Error
|
| 204 |
-
CreatedAt
|
| 205 |
-
ExpiresAt
|
| 206 |
}
|
| 207 |
|
| 208 |
// OAuthSessionRepository defines the interface for OAuth session management
|
| 209 |
type OAuthSessionRepository interface {
|
| 210 |
// Create creates a new OAuth session
|
| 211 |
Create(ctx context.Context, session *OAuthSession) error
|
| 212 |
-
|
| 213 |
// Get retrieves an OAuth session by state
|
| 214 |
Get(ctx context.Context, state string) (*OAuthSession, error)
|
| 215 |
-
|
| 216 |
// Update updates an OAuth session
|
| 217 |
Update(ctx context.Context, session *OAuthSession) error
|
| 218 |
-
|
| 219 |
// Complete marks an OAuth session as complete
|
| 220 |
Complete(ctx context.Context, state string) error
|
| 221 |
-
|
| 222 |
// SetError sets an error on an OAuth session
|
| 223 |
SetError(ctx context.Context, state string, err string) error
|
| 224 |
-
|
| 225 |
// IsPending checks if a session is pending
|
| 226 |
IsPending(ctx context.Context, state string) bool
|
| 227 |
-
|
| 228 |
// Cleanup removes expired sessions
|
| 229 |
Cleanup(ctx context.Context) error
|
| 230 |
-
}
|
|
|
|
| 15 |
type ConfigRepository interface {
|
| 16 |
// Load retrieves the current configuration
|
| 17 |
Load(ctx context.Context) (*config.Config, error)
|
| 18 |
+
|
| 19 |
// Save persists the configuration
|
| 20 |
Save(ctx context.Context, cfg *config.Config) error
|
| 21 |
+
|
| 22 |
// SaveWithPath persists the configuration to a specific path
|
| 23 |
SaveWithPath(ctx context.Context, path string, cfg *config.Config) error
|
| 24 |
+
|
| 25 |
// Validate validates the configuration without saving
|
| 26 |
Validate(ctx context.Context, cfg *config.Config) error
|
| 27 |
+
|
| 28 |
// GetConfigPath returns the current configuration file path
|
| 29 |
GetConfigPath() string
|
| 30 |
}
|
| 31 |
|
| 32 |
// AuthFile represents an authentication file in the domain
|
| 33 |
type AuthFile struct {
|
| 34 |
+
ID string
|
| 35 |
+
Provider string
|
| 36 |
+
FileName string
|
| 37 |
+
Label string
|
| 38 |
+
Email string
|
| 39 |
+
Status string
|
| 40 |
+
StatusMessage string
|
| 41 |
+
Disabled bool
|
| 42 |
+
Unavailable bool
|
| 43 |
+
RuntimeOnly bool
|
| 44 |
+
Path string
|
| 45 |
+
Size int64
|
| 46 |
+
CreatedAt time.Time
|
| 47 |
+
UpdatedAt time.Time
|
| 48 |
LastRefreshedAt time.Time
|
| 49 |
+
Metadata map[string]interface{}
|
| 50 |
+
Attributes map[string]string
|
| 51 |
}
|
| 52 |
|
| 53 |
// AuthRepository defines the interface for authentication file persistence
|
| 54 |
type AuthRepository interface {
|
| 55 |
// List retrieves all authentication files
|
| 56 |
List(ctx context.Context) ([]*AuthFile, error)
|
| 57 |
+
|
| 58 |
// GetByID retrieves an authentication file by its ID
|
| 59 |
GetByID(ctx context.Context, id string) (*AuthFile, error)
|
| 60 |
+
|
| 61 |
// GetByName retrieves an authentication file by its filename
|
| 62 |
GetByName(ctx context.Context, name string) (*AuthFile, error)
|
| 63 |
+
|
| 64 |
// Save persists an authentication file
|
| 65 |
Save(ctx context.Context, file *AuthFile) error
|
| 66 |
+
|
| 67 |
// Delete removes an authentication file
|
| 68 |
Delete(ctx context.Context, id string) error
|
| 69 |
+
|
| 70 |
// DeleteAll removes all authentication files
|
| 71 |
DeleteAll(ctx context.Context) (int, error)
|
| 72 |
+
|
| 73 |
// Disable marks an authentication file as disabled
|
| 74 |
Disable(ctx context.Context, id string, reason string) error
|
| 75 |
+
|
| 76 |
// Enable marks an authentication file as enabled
|
| 77 |
Enable(ctx context.Context, id string) error
|
| 78 |
+
|
| 79 |
// GetAuthDir returns the authentication directory path
|
| 80 |
GetAuthDir() string
|
| 81 |
}
|
|
|
|
| 94 |
type LogRepository interface {
|
| 95 |
// ListLogFiles retrieves all log files
|
| 96 |
ListLogFiles(ctx context.Context) ([]*LogFileInfo, error)
|
| 97 |
+
|
| 98 |
// ReadLogFile reads a log file with optional filtering
|
| 99 |
ReadLogFile(ctx context.Context, filename string, after int64, limit int) (*LogContent, error)
|
| 100 |
+
|
| 101 |
// DeleteLogFiles removes all log files and truncates the active log
|
| 102 |
DeleteLogFiles(ctx context.Context) (*DeleteLogResult, error)
|
| 103 |
+
|
| 104 |
// GetRequestErrorLogs retrieves error request log files
|
| 105 |
GetRequestErrorLogs(ctx context.Context) ([]*LogFileInfo, error)
|
| 106 |
+
|
| 107 |
// GetRequestLogByID retrieves a specific request log by ID
|
| 108 |
GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error)
|
| 109 |
+
|
| 110 |
// DownloadRequestErrorLog downloads a specific error log file
|
| 111 |
DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error)
|
| 112 |
+
|
| 113 |
// GetLogDirectory returns the log directory path
|
| 114 |
GetLogDirectory() string
|
| 115 |
+
|
| 116 |
// IsLoggingEnabled returns whether logging to file is enabled
|
| 117 |
IsLoggingEnabled() bool
|
| 118 |
}
|
|
|
|
| 155 |
type TokenStore interface {
|
| 156 |
// Save persists a token record
|
| 157 |
Save(ctx context.Context, record *TokenRecord) (string, error)
|
| 158 |
+
|
| 159 |
// Delete removes a token record
|
| 160 |
Delete(ctx context.Context, path string) error
|
| 161 |
+
|
| 162 |
// Get retrieves a token record by path
|
| 163 |
Get(ctx context.Context, path string) (*TokenRecord, error)
|
| 164 |
+
|
| 165 |
// List retrieves all token records
|
| 166 |
List(ctx context.Context) ([]*TokenRecord, error)
|
| 167 |
}
|
| 168 |
|
| 169 |
// UsageStatistics represents usage statistics data
|
| 170 |
type UsageStatistics struct {
|
| 171 |
+
TotalRequests int64
|
| 172 |
+
FailureCount int64
|
| 173 |
+
RequestCount int64
|
| 174 |
+
TokenCount int64
|
| 175 |
+
LastUpdated time.Time
|
| 176 |
+
Data map[string]interface{}
|
| 177 |
}
|
| 178 |
|
| 179 |
// UsageRepository defines the interface for usage statistics persistence
|
| 180 |
type UsageRepository interface {
|
| 181 |
// GetStatistics retrieves current usage statistics
|
| 182 |
GetStatistics(ctx context.Context) (*UsageStatistics, error)
|
| 183 |
+
|
| 184 |
// ExportStatistics exports statistics for backup
|
| 185 |
ExportStatistics(ctx context.Context) (*UsageStatistics, error)
|
| 186 |
+
|
| 187 |
// ImportStatistics imports statistics from backup
|
| 188 |
ImportStatistics(ctx context.Context, stats *UsageStatistics) (*ImportResult, error)
|
| 189 |
}
|
|
|
|
| 197 |
|
| 198 |
// OAuthSession represents an OAuth session
|
| 199 |
type OAuthSession struct {
|
| 200 |
+
State string
|
| 201 |
+
Provider string
|
| 202 |
+
Status string
|
| 203 |
+
Error string
|
| 204 |
+
CreatedAt time.Time
|
| 205 |
+
ExpiresAt time.Time
|
| 206 |
}
|
| 207 |
|
| 208 |
// OAuthSessionRepository defines the interface for OAuth session management
|
| 209 |
type OAuthSessionRepository interface {
|
| 210 |
// Create creates a new OAuth session
|
| 211 |
Create(ctx context.Context, session *OAuthSession) error
|
| 212 |
+
|
| 213 |
// Get retrieves an OAuth session by state
|
| 214 |
Get(ctx context.Context, state string) (*OAuthSession, error)
|
| 215 |
+
|
| 216 |
// Update updates an OAuth session
|
| 217 |
Update(ctx context.Context, session *OAuthSession) error
|
| 218 |
+
|
| 219 |
// Complete marks an OAuth session as complete
|
| 220 |
Complete(ctx context.Context, state string) error
|
| 221 |
+
|
| 222 |
// SetError sets an error on an OAuth session
|
| 223 |
SetError(ctx context.Context, state string, err string) error
|
| 224 |
+
|
| 225 |
// IsPending checks if a session is pending
|
| 226 |
IsPending(ctx context.Context, state string) bool
|
| 227 |
+
|
| 228 |
// Cleanup removes expired sessions
|
| 229 |
Cleanup(ctx context.Context) error
|
| 230 |
+
}
|
internal/domain/ports/services.go
CHANGED
|
@@ -13,97 +13,97 @@ import (
|
|
| 13 |
type ConfigService interface {
|
| 14 |
// GetConfig retrieves the current configuration
|
| 15 |
GetConfig(ctx context.Context) (*config.Config, error)
|
| 16 |
-
|
| 17 |
// UpdateConfig updates the entire configuration
|
| 18 |
UpdateConfig(ctx context.Context, cfg *config.Config) error
|
| 19 |
-
|
| 20 |
// UpdateField updates a single configuration field
|
| 21 |
UpdateField(ctx context.Context, field string, value interface{}) error
|
| 22 |
-
|
| 23 |
// UpdateAPIKeys updates the API keys list
|
| 24 |
UpdateAPIKeys(ctx context.Context, keys []string) error
|
| 25 |
-
|
| 26 |
// UpdateGeminiKeys updates the Gemini keys list
|
| 27 |
UpdateGeminiKeys(ctx context.Context, keys []config.GeminiKey) error
|
| 28 |
-
|
| 29 |
// UpdateClaudeKeys updates the Claude keys list
|
| 30 |
UpdateClaudeKeys(ctx context.Context, keys []config.ClaudeKey) error
|
| 31 |
-
|
| 32 |
// UpdateCodexKeys updates the Codex keys list
|
| 33 |
UpdateCodexKeys(ctx context.Context, keys []config.CodexKey) error
|
| 34 |
-
|
| 35 |
// UpdateOpenAICompatibility updates the OpenAI compatibility entries
|
| 36 |
UpdateOpenAICompatibility(ctx context.Context, entries []config.OpenAICompatibility) error
|
| 37 |
-
|
| 38 |
// UpdateVertexCompatKeys updates the Vertex compatibility keys
|
| 39 |
UpdateVertexCompatKeys(ctx context.Context, keys []config.VertexCompatKey) error
|
| 40 |
-
|
| 41 |
// UpdateKiroKeys updates the Kiro keys list
|
| 42 |
UpdateKiroKeys(ctx context.Context, keys []config.KiroKey) error
|
| 43 |
-
|
| 44 |
// UpdateOAuthExcludedModels updates OAuth excluded models
|
| 45 |
UpdateOAuthExcludedModels(ctx context.Context, models map[string][]string) error
|
| 46 |
-
|
| 47 |
// UpdateOAuthModelAlias updates OAuth model aliases
|
| 48 |
UpdateOAuthModelAlias(ctx context.Context, aliases map[string][]config.OAuthModelAlias) error
|
| 49 |
-
|
| 50 |
// UpdateAmpCode updates the AmpCode configuration
|
| 51 |
UpdateAmpCode(ctx context.Context, ampCode config.AmpCode) error
|
| 52 |
-
|
| 53 |
// UpdateAmpUpstreamURL updates the Amp upstream URL
|
| 54 |
UpdateAmpUpstreamURL(ctx context.Context, url string) error
|
| 55 |
-
|
| 56 |
// UpdateAmpModelMappings updates Amp model mappings
|
| 57 |
UpdateAmpModelMappings(ctx context.Context, mappings []config.AmpModelMapping) error
|
| 58 |
-
|
| 59 |
// UpdateAmpUpstreamAPIKeys updates Amp upstream API keys
|
| 60 |
UpdateAmpUpstreamAPIKeys(ctx context.Context, keys []config.AmpUpstreamAPIKeyEntry) error
|
| 61 |
-
|
| 62 |
// UpdateDebug updates the debug setting
|
| 63 |
UpdateDebug(ctx context.Context, enabled bool) error
|
| 64 |
-
|
| 65 |
// UpdateUsageStatisticsEnabled updates the usage statistics enabled setting
|
| 66 |
UpdateUsageStatisticsEnabled(ctx context.Context, enabled bool) error
|
| 67 |
-
|
| 68 |
// UpdateLoggingToFile updates the logging to file setting
|
| 69 |
UpdateLoggingToFile(ctx context.Context, enabled bool) error
|
| 70 |
-
|
| 71 |
// UpdateLogsMaxTotalSizeMB updates the max log size
|
| 72 |
UpdateLogsMaxTotalSizeMB(ctx context.Context, sizeMB int) error
|
| 73 |
-
|
| 74 |
// UpdateRequestLog updates the request log setting
|
| 75 |
UpdateRequestLog(ctx context.Context, enabled bool) error
|
| 76 |
-
|
| 77 |
// UpdateWebsocketAuth updates the websocket auth setting
|
| 78 |
UpdateWebsocketAuth(ctx context.Context, enabled bool) error
|
| 79 |
-
|
| 80 |
// UpdateRequestRetry updates the request retry count
|
| 81 |
UpdateRequestRetry(ctx context.Context, retry int) error
|
| 82 |
-
|
| 83 |
// UpdateMaxRetryInterval updates the max retry interval
|
| 84 |
UpdateMaxRetryInterval(ctx context.Context, interval int) error
|
| 85 |
-
|
| 86 |
// UpdateForceModelPrefix updates the force model prefix setting
|
| 87 |
UpdateForceModelPrefix(ctx context.Context, enabled bool) error
|
| 88 |
-
|
| 89 |
// UpdateRoutingStrategy updates the routing strategy
|
| 90 |
UpdateRoutingStrategy(ctx context.Context, strategy string) error
|
| 91 |
-
|
| 92 |
// UpdateProxyURL updates the proxy URL
|
| 93 |
UpdateProxyURL(ctx context.Context, url string) error
|
| 94 |
-
|
| 95 |
// UpdateRemoteManagement updates the remote management settings
|
| 96 |
UpdateRemoteManagement(ctx context.Context, allowRemote bool, secretHash string) error
|
| 97 |
-
|
| 98 |
// UpdateQuotaExceeded updates the quota exceeded settings
|
| 99 |
UpdateQuotaExceeded(ctx context.Context, switchProject, switchPreviewModel bool) error
|
| 100 |
-
|
| 101 |
// Validate validates the current configuration
|
| 102 |
Validate(ctx context.Context) error
|
| 103 |
-
|
| 104 |
// ValidateConfig validates a specific configuration
|
| 105 |
ValidateConfig(ctx context.Context, cfg *config.Config) error
|
| 106 |
-
|
| 107 |
// GetLatestVersion retrieves the latest version from GitHub
|
| 108 |
GetLatestVersion(ctx context.Context) (string, error)
|
| 109 |
}
|
|
@@ -112,31 +112,31 @@ type ConfigService interface {
|
|
| 112 |
type AuthFileService interface {
|
| 113 |
// ListAuthFiles retrieves all authentication files
|
| 114 |
ListAuthFiles(ctx context.Context) ([]*AuthFile, error)
|
| 115 |
-
|
| 116 |
// GetAuthFile retrieves a single authentication file by ID
|
| 117 |
GetAuthFile(ctx context.Context, id string) (*AuthFile, error)
|
| 118 |
-
|
| 119 |
// GetAuthFileModels retrieves models supported by an auth file
|
| 120 |
GetAuthFileModels(ctx context.Context, id string) ([]*AuthFileModel, error)
|
| 121 |
-
|
| 122 |
// UploadAuthFile uploads a new authentication file
|
| 123 |
UploadAuthFile(ctx context.Context, filename string, data []byte) (*AuthFile, error)
|
| 124 |
-
|
| 125 |
// DownloadAuthFile retrieves the raw content of an auth file
|
| 126 |
DownloadAuthFile(ctx context.Context, id string) ([]byte, error)
|
| 127 |
-
|
| 128 |
// DeleteAuthFile deletes an authentication file
|
| 129 |
DeleteAuthFile(ctx context.Context, id string) error
|
| 130 |
-
|
| 131 |
// DeleteAllAuthFiles deletes all authentication files
|
| 132 |
DeleteAllAuthFiles(ctx context.Context) (int, error)
|
| 133 |
-
|
| 134 |
// DisableAuthFile disables an authentication file
|
| 135 |
DisableAuthFile(ctx context.Context, id string) error
|
| 136 |
-
|
| 137 |
// EnableAuthFile enables an authentication file
|
| 138 |
EnableAuthFile(ctx context.Context, id string) error
|
| 139 |
-
|
| 140 |
// RefreshAuthToken refreshes the token for an auth file
|
| 141 |
RefreshAuthToken(ctx context.Context, id string) error
|
| 142 |
}
|
|
@@ -153,16 +153,16 @@ type AuthFileModel struct {
|
|
| 153 |
type LogService interface {
|
| 154 |
// GetLogs retrieves log entries with optional filtering
|
| 155 |
GetLogs(ctx context.Context, after int64, limit int) (*LogContent, error)
|
| 156 |
-
|
| 157 |
// DeleteLogs removes all log files
|
| 158 |
DeleteLogs(ctx context.Context) (*DeleteLogResult, error)
|
| 159 |
-
|
| 160 |
// GetRequestErrorLogs retrieves error request log files
|
| 161 |
GetRequestErrorLogs(ctx context.Context) ([]*LogFileInfo, error)
|
| 162 |
-
|
| 163 |
// GetRequestLogByID retrieves a specific request log by ID
|
| 164 |
GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error)
|
| 165 |
-
|
| 166 |
// DownloadRequestErrorLog downloads a specific error log file
|
| 167 |
DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error)
|
| 168 |
}
|
|
@@ -171,10 +171,10 @@ type LogService interface {
|
|
| 171 |
type UsageService interface {
|
| 172 |
// GetUsageStatistics retrieves current usage statistics
|
| 173 |
GetUsageStatistics(ctx context.Context) (*UsageStatistics, error)
|
| 174 |
-
|
| 175 |
// ExportUsageStatistics exports statistics for backup
|
| 176 |
ExportUsageStatistics(ctx context.Context) (*UsageStatistics, error)
|
| 177 |
-
|
| 178 |
// ImportUsageStatistics imports statistics from backup
|
| 179 |
ImportUsageStatistics(ctx context.Context, stats *UsageStatistics) (*ImportResult, error)
|
| 180 |
}
|
|
@@ -183,13 +183,13 @@ type UsageService interface {
|
|
| 183 |
type OAuthService interface {
|
| 184 |
// InitiateAuth initiates OAuth authentication for a provider
|
| 185 |
InitiateAuth(ctx context.Context, provider string, options *OAuthOptions) (*OAuthInitResult, error)
|
| 186 |
-
|
| 187 |
// CompleteAuth completes OAuth authentication with a code
|
| 188 |
CompleteAuth(ctx context.Context, state string, code string) (*AuthFile, error)
|
| 189 |
-
|
| 190 |
// GetAuthStatus retrieves the status of an OAuth session
|
| 191 |
GetAuthStatus(ctx context.Context, state string) (*OAuthSessionStatus, error)
|
| 192 |
-
|
| 193 |
// CancelAuth cancels an ongoing OAuth session
|
| 194 |
CancelAuth(ctx context.Context, state string) error
|
| 195 |
}
|
|
@@ -209,26 +209,26 @@ type OAuthInitResult struct {
|
|
| 209 |
|
| 210 |
// OAuthSessionStatus represents the status of an OAuth session
|
| 211 |
type OAuthSessionStatus struct {
|
| 212 |
-
State
|
| 213 |
-
Status
|
| 214 |
-
Error
|
| 215 |
-
Provider
|
| 216 |
}
|
| 217 |
|
| 218 |
// ManagementService defines operations for management functionality
|
| 219 |
type ManagementService interface {
|
| 220 |
// VerifyManagementKey verifies a management key
|
| 221 |
VerifyManagementKey(ctx context.Context, key string, clientIP string) error
|
| 222 |
-
|
| 223 |
// IsRemoteAllowed checks if remote management is allowed for a client
|
| 224 |
IsRemoteAllowed(ctx context.Context, clientIP string) bool
|
| 225 |
-
|
| 226 |
// RecordFailedAttempt records a failed authentication attempt
|
| 227 |
RecordFailedAttempt(ctx context.Context, clientIP string)
|
| 228 |
-
|
| 229 |
// IsBlocked checks if a client IP is blocked
|
| 230 |
IsBlocked(ctx context.Context, clientIP string) (bool, string)
|
| 231 |
-
|
| 232 |
// GetVersionInfo retrieves version information
|
| 233 |
GetVersionInfo(ctx context.Context) (*VersionInfo, error)
|
| 234 |
}
|
|
@@ -244,18 +244,18 @@ type VersionInfo struct {
|
|
| 244 |
type APICallService interface {
|
| 245 |
// MakeAPICall makes a generic HTTP API call
|
| 246 |
MakeAPICall(ctx context.Context, req *APICallRequest) (*APICallResponse, error)
|
| 247 |
-
|
| 248 |
// ResolveToken resolves a token for an auth index
|
| 249 |
ResolveToken(ctx context.Context, authIndex string) (string, error)
|
| 250 |
}
|
| 251 |
|
| 252 |
// APICallRequest contains parameters for an API call
|
| 253 |
type APICallRequest struct {
|
| 254 |
-
AuthIndex
|
| 255 |
-
Method
|
| 256 |
-
URL
|
| 257 |
-
Headers
|
| 258 |
-
Body
|
| 259 |
}
|
| 260 |
|
| 261 |
// APICallResponse contains the response from an API call
|
|
@@ -263,4 +263,4 @@ type APICallResponse struct {
|
|
| 263 |
StatusCode int
|
| 264 |
Headers map[string][]string
|
| 265 |
Body string
|
| 266 |
-
}
|
|
|
|
| 13 |
type ConfigService interface {
|
| 14 |
// GetConfig retrieves the current configuration
|
| 15 |
GetConfig(ctx context.Context) (*config.Config, error)
|
| 16 |
+
|
| 17 |
// UpdateConfig updates the entire configuration
|
| 18 |
UpdateConfig(ctx context.Context, cfg *config.Config) error
|
| 19 |
+
|
| 20 |
// UpdateField updates a single configuration field
|
| 21 |
UpdateField(ctx context.Context, field string, value interface{}) error
|
| 22 |
+
|
| 23 |
// UpdateAPIKeys updates the API keys list
|
| 24 |
UpdateAPIKeys(ctx context.Context, keys []string) error
|
| 25 |
+
|
| 26 |
// UpdateGeminiKeys updates the Gemini keys list
|
| 27 |
UpdateGeminiKeys(ctx context.Context, keys []config.GeminiKey) error
|
| 28 |
+
|
| 29 |
// UpdateClaudeKeys updates the Claude keys list
|
| 30 |
UpdateClaudeKeys(ctx context.Context, keys []config.ClaudeKey) error
|
| 31 |
+
|
| 32 |
// UpdateCodexKeys updates the Codex keys list
|
| 33 |
UpdateCodexKeys(ctx context.Context, keys []config.CodexKey) error
|
| 34 |
+
|
| 35 |
// UpdateOpenAICompatibility updates the OpenAI compatibility entries
|
| 36 |
UpdateOpenAICompatibility(ctx context.Context, entries []config.OpenAICompatibility) error
|
| 37 |
+
|
| 38 |
// UpdateVertexCompatKeys updates the Vertex compatibility keys
|
| 39 |
UpdateVertexCompatKeys(ctx context.Context, keys []config.VertexCompatKey) error
|
| 40 |
+
|
| 41 |
// UpdateKiroKeys updates the Kiro keys list
|
| 42 |
UpdateKiroKeys(ctx context.Context, keys []config.KiroKey) error
|
| 43 |
+
|
| 44 |
// UpdateOAuthExcludedModels updates OAuth excluded models
|
| 45 |
UpdateOAuthExcludedModels(ctx context.Context, models map[string][]string) error
|
| 46 |
+
|
| 47 |
// UpdateOAuthModelAlias updates OAuth model aliases
|
| 48 |
UpdateOAuthModelAlias(ctx context.Context, aliases map[string][]config.OAuthModelAlias) error
|
| 49 |
+
|
| 50 |
// UpdateAmpCode updates the AmpCode configuration
|
| 51 |
UpdateAmpCode(ctx context.Context, ampCode config.AmpCode) error
|
| 52 |
+
|
| 53 |
// UpdateAmpUpstreamURL updates the Amp upstream URL
|
| 54 |
UpdateAmpUpstreamURL(ctx context.Context, url string) error
|
| 55 |
+
|
| 56 |
// UpdateAmpModelMappings updates Amp model mappings
|
| 57 |
UpdateAmpModelMappings(ctx context.Context, mappings []config.AmpModelMapping) error
|
| 58 |
+
|
| 59 |
// UpdateAmpUpstreamAPIKeys updates Amp upstream API keys
|
| 60 |
UpdateAmpUpstreamAPIKeys(ctx context.Context, keys []config.AmpUpstreamAPIKeyEntry) error
|
| 61 |
+
|
| 62 |
// UpdateDebug updates the debug setting
|
| 63 |
UpdateDebug(ctx context.Context, enabled bool) error
|
| 64 |
+
|
| 65 |
// UpdateUsageStatisticsEnabled updates the usage statistics enabled setting
|
| 66 |
UpdateUsageStatisticsEnabled(ctx context.Context, enabled bool) error
|
| 67 |
+
|
| 68 |
// UpdateLoggingToFile updates the logging to file setting
|
| 69 |
UpdateLoggingToFile(ctx context.Context, enabled bool) error
|
| 70 |
+
|
| 71 |
// UpdateLogsMaxTotalSizeMB updates the max log size
|
| 72 |
UpdateLogsMaxTotalSizeMB(ctx context.Context, sizeMB int) error
|
| 73 |
+
|
| 74 |
// UpdateRequestLog updates the request log setting
|
| 75 |
UpdateRequestLog(ctx context.Context, enabled bool) error
|
| 76 |
+
|
| 77 |
// UpdateWebsocketAuth updates the websocket auth setting
|
| 78 |
UpdateWebsocketAuth(ctx context.Context, enabled bool) error
|
| 79 |
+
|
| 80 |
// UpdateRequestRetry updates the request retry count
|
| 81 |
UpdateRequestRetry(ctx context.Context, retry int) error
|
| 82 |
+
|
| 83 |
// UpdateMaxRetryInterval updates the max retry interval
|
| 84 |
UpdateMaxRetryInterval(ctx context.Context, interval int) error
|
| 85 |
+
|
| 86 |
// UpdateForceModelPrefix updates the force model prefix setting
|
| 87 |
UpdateForceModelPrefix(ctx context.Context, enabled bool) error
|
| 88 |
+
|
| 89 |
// UpdateRoutingStrategy updates the routing strategy
|
| 90 |
UpdateRoutingStrategy(ctx context.Context, strategy string) error
|
| 91 |
+
|
| 92 |
// UpdateProxyURL updates the proxy URL
|
| 93 |
UpdateProxyURL(ctx context.Context, url string) error
|
| 94 |
+
|
| 95 |
// UpdateRemoteManagement updates the remote management settings
|
| 96 |
UpdateRemoteManagement(ctx context.Context, allowRemote bool, secretHash string) error
|
| 97 |
+
|
| 98 |
// UpdateQuotaExceeded updates the quota exceeded settings
|
| 99 |
UpdateQuotaExceeded(ctx context.Context, switchProject, switchPreviewModel bool) error
|
| 100 |
+
|
| 101 |
// Validate validates the current configuration
|
| 102 |
Validate(ctx context.Context) error
|
| 103 |
+
|
| 104 |
// ValidateConfig validates a specific configuration
|
| 105 |
ValidateConfig(ctx context.Context, cfg *config.Config) error
|
| 106 |
+
|
| 107 |
// GetLatestVersion retrieves the latest version from GitHub
|
| 108 |
GetLatestVersion(ctx context.Context) (string, error)
|
| 109 |
}
|
|
|
|
| 112 |
type AuthFileService interface {
|
| 113 |
// ListAuthFiles retrieves all authentication files
|
| 114 |
ListAuthFiles(ctx context.Context) ([]*AuthFile, error)
|
| 115 |
+
|
| 116 |
// GetAuthFile retrieves a single authentication file by ID
|
| 117 |
GetAuthFile(ctx context.Context, id string) (*AuthFile, error)
|
| 118 |
+
|
| 119 |
// GetAuthFileModels retrieves models supported by an auth file
|
| 120 |
GetAuthFileModels(ctx context.Context, id string) ([]*AuthFileModel, error)
|
| 121 |
+
|
| 122 |
// UploadAuthFile uploads a new authentication file
|
| 123 |
UploadAuthFile(ctx context.Context, filename string, data []byte) (*AuthFile, error)
|
| 124 |
+
|
| 125 |
// DownloadAuthFile retrieves the raw content of an auth file
|
| 126 |
DownloadAuthFile(ctx context.Context, id string) ([]byte, error)
|
| 127 |
+
|
| 128 |
// DeleteAuthFile deletes an authentication file
|
| 129 |
DeleteAuthFile(ctx context.Context, id string) error
|
| 130 |
+
|
| 131 |
// DeleteAllAuthFiles deletes all authentication files
|
| 132 |
DeleteAllAuthFiles(ctx context.Context) (int, error)
|
| 133 |
+
|
| 134 |
// DisableAuthFile disables an authentication file
|
| 135 |
DisableAuthFile(ctx context.Context, id string) error
|
| 136 |
+
|
| 137 |
// EnableAuthFile enables an authentication file
|
| 138 |
EnableAuthFile(ctx context.Context, id string) error
|
| 139 |
+
|
| 140 |
// RefreshAuthToken refreshes the token for an auth file
|
| 141 |
RefreshAuthToken(ctx context.Context, id string) error
|
| 142 |
}
|
|
|
|
| 153 |
type LogService interface {
|
| 154 |
// GetLogs retrieves log entries with optional filtering
|
| 155 |
GetLogs(ctx context.Context, after int64, limit int) (*LogContent, error)
|
| 156 |
+
|
| 157 |
// DeleteLogs removes all log files
|
| 158 |
DeleteLogs(ctx context.Context) (*DeleteLogResult, error)
|
| 159 |
+
|
| 160 |
// GetRequestErrorLogs retrieves error request log files
|
| 161 |
GetRequestErrorLogs(ctx context.Context) ([]*LogFileInfo, error)
|
| 162 |
+
|
| 163 |
// GetRequestLogByID retrieves a specific request log by ID
|
| 164 |
GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error)
|
| 165 |
+
|
| 166 |
// DownloadRequestErrorLog downloads a specific error log file
|
| 167 |
DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error)
|
| 168 |
}
|
|
|
|
| 171 |
type UsageService interface {
|
| 172 |
// GetUsageStatistics retrieves current usage statistics
|
| 173 |
GetUsageStatistics(ctx context.Context) (*UsageStatistics, error)
|
| 174 |
+
|
| 175 |
// ExportUsageStatistics exports statistics for backup
|
| 176 |
ExportUsageStatistics(ctx context.Context) (*UsageStatistics, error)
|
| 177 |
+
|
| 178 |
// ImportUsageStatistics imports statistics from backup
|
| 179 |
ImportUsageStatistics(ctx context.Context, stats *UsageStatistics) (*ImportResult, error)
|
| 180 |
}
|
|
|
|
| 183 |
type OAuthService interface {
|
| 184 |
// InitiateAuth initiates OAuth authentication for a provider
|
| 185 |
InitiateAuth(ctx context.Context, provider string, options *OAuthOptions) (*OAuthInitResult, error)
|
| 186 |
+
|
| 187 |
// CompleteAuth completes OAuth authentication with a code
|
| 188 |
CompleteAuth(ctx context.Context, state string, code string) (*AuthFile, error)
|
| 189 |
+
|
| 190 |
// GetAuthStatus retrieves the status of an OAuth session
|
| 191 |
GetAuthStatus(ctx context.Context, state string) (*OAuthSessionStatus, error)
|
| 192 |
+
|
| 193 |
// CancelAuth cancels an ongoing OAuth session
|
| 194 |
CancelAuth(ctx context.Context, state string) error
|
| 195 |
}
|
|
|
|
| 209 |
|
| 210 |
// OAuthSessionStatus represents the status of an OAuth session
|
| 211 |
type OAuthSessionStatus struct {
|
| 212 |
+
State string
|
| 213 |
+
Status string // "pending", "complete", "error"
|
| 214 |
+
Error string
|
| 215 |
+
Provider string
|
| 216 |
}
|
| 217 |
|
| 218 |
// ManagementService defines operations for management functionality
|
| 219 |
type ManagementService interface {
|
| 220 |
// VerifyManagementKey verifies a management key
|
| 221 |
VerifyManagementKey(ctx context.Context, key string, clientIP string) error
|
| 222 |
+
|
| 223 |
// IsRemoteAllowed checks if remote management is allowed for a client
|
| 224 |
IsRemoteAllowed(ctx context.Context, clientIP string) bool
|
| 225 |
+
|
| 226 |
// RecordFailedAttempt records a failed authentication attempt
|
| 227 |
RecordFailedAttempt(ctx context.Context, clientIP string)
|
| 228 |
+
|
| 229 |
// IsBlocked checks if a client IP is blocked
|
| 230 |
IsBlocked(ctx context.Context, clientIP string) (bool, string)
|
| 231 |
+
|
| 232 |
// GetVersionInfo retrieves version information
|
| 233 |
GetVersionInfo(ctx context.Context) (*VersionInfo, error)
|
| 234 |
}
|
|
|
|
| 244 |
type APICallService interface {
|
| 245 |
// MakeAPICall makes a generic HTTP API call
|
| 246 |
MakeAPICall(ctx context.Context, req *APICallRequest) (*APICallResponse, error)
|
| 247 |
+
|
| 248 |
// ResolveToken resolves a token for an auth index
|
| 249 |
ResolveToken(ctx context.Context, authIndex string) (string, error)
|
| 250 |
}
|
| 251 |
|
| 252 |
// APICallRequest contains parameters for an API call
|
| 253 |
type APICallRequest struct {
|
| 254 |
+
AuthIndex string
|
| 255 |
+
Method string
|
| 256 |
+
URL string
|
| 257 |
+
Headers map[string]string
|
| 258 |
+
Body string
|
| 259 |
}
|
| 260 |
|
| 261 |
// APICallResponse contains the response from an API call
|
|
|
|
| 263 |
StatusCode int
|
| 264 |
Headers map[string][]string
|
| 265 |
Body string
|
| 266 |
+
}
|
internal/domain/services/auth_service.go
CHANGED
|
@@ -319,4 +319,4 @@ func (s *AuthService) RefreshAuthToken(ctx context.Context, id string) error {
|
|
| 319 |
}
|
| 320 |
|
| 321 |
// Ensure AuthService implements the interface
|
| 322 |
-
var _ ports.AuthFileService = (*AuthService)(nil)
|
|
|
|
| 319 |
}
|
| 320 |
|
| 321 |
// Ensure AuthService implements the interface
|
| 322 |
+
var _ ports.AuthFileService = (*AuthService)(nil)
|
internal/domain/services/config_service.go
CHANGED
|
@@ -508,7 +508,7 @@ func (s *ConfigService) GetLatestVersion(ctx context.Context) (string, error) {
|
|
| 508 |
util.SetProxy(sdkCfg, client)
|
| 509 |
}
|
| 510 |
|
| 511 |
-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL,
|
| 512 |
if err != nil {
|
| 513 |
return "", errors.Wrap(errors.InternalError, "failed to create request", err)
|
| 514 |
}
|
|
@@ -674,4 +674,4 @@ func toInt(v interface{}) (int, bool) {
|
|
| 674 |
}
|
| 675 |
|
| 676 |
// Ensure ConfigService implements the interface
|
| 677 |
-
var _ ports.ConfigService = (*ConfigService)(nil)
|
|
|
|
| 508 |
util.SetProxy(sdkCfg, client)
|
| 509 |
}
|
| 510 |
|
| 511 |
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, http.NoBody)
|
| 512 |
if err != nil {
|
| 513 |
return "", errors.Wrap(errors.InternalError, "failed to create request", err)
|
| 514 |
}
|
|
|
|
| 674 |
}
|
| 675 |
|
| 676 |
// Ensure ConfigService implements the interface
|
| 677 |
+
var _ ports.ConfigService = (*ConfigService)(nil)
|
internal/domain/services/log_service.go
CHANGED
|
@@ -128,4 +128,4 @@ func (s *LogService) DownloadRequestErrorLog(ctx context.Context, filename strin
|
|
| 128 |
}
|
| 129 |
|
| 130 |
// Ensure LogService implements the interface
|
| 131 |
-
var _ ports.LogService = (*LogService)(nil)
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
// Ensure LogService implements the interface
|
| 131 |
+
var _ ports.LogService = (*LogService)(nil)
|
internal/domain/services/rate_limit_service.go
DELETED
|
@@ -1,153 +0,0 @@
|
|
| 1 |
-
package services
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"context"
|
| 5 |
-
"time"
|
| 6 |
-
|
| 7 |
-
"github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports"
|
| 8 |
-
)
|
| 9 |
-
|
| 10 |
-
// RateLimitConfig holds the configuration for the RateLimitService.
|
| 11 |
-
type RateLimitConfig struct {
|
| 12 |
-
MaxFailures int // Maximum number of failures allowed before blocking
|
| 13 |
-
FailureDecayInterval time.Duration // Time duration to forgive one failure (leak rate)
|
| 14 |
-
BlockDuration time.Duration // Duration to block the key after MaxFailures is reached
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
// RateLimitService implements the RateLimitService interface.
|
| 18 |
-
type RateLimitService struct {
|
| 19 |
-
repo ports.RateLimitRepository
|
| 20 |
-
config RateLimitConfig
|
| 21 |
-
// now returns the current time. It is a field to allow mocking in tests.
|
| 22 |
-
now func() time.Time
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
// NewRateLimitService creates a new instance of RateLimitService.
|
| 26 |
-
func NewRateLimitService(repo ports.RateLimitRepository, config RateLimitConfig) *RateLimitService {
|
| 27 |
-
return &RateLimitService{
|
| 28 |
-
repo: repo,
|
| 29 |
-
config: config,
|
| 30 |
-
now: time.Now,
|
| 31 |
-
}
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
// Allow checks if the request is allowed.
|
| 35 |
-
func (s *RateLimitService) Allow(ctx context.Context, key string) (bool, error) {
|
| 36 |
-
blocked, _, err := s.IsBlocked(ctx, key)
|
| 37 |
-
if err != nil {
|
| 38 |
-
return false, err
|
| 39 |
-
}
|
| 40 |
-
return !blocked, nil
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
// IsBlocked checks if the key is currently blocked.
|
| 44 |
-
func (s *RateLimitService) IsBlocked(ctx context.Context, key string) (bool, time.Time, error) {
|
| 45 |
-
entry, err := s.repo.Get(ctx, key)
|
| 46 |
-
if err != nil {
|
| 47 |
-
return false, time.Time{}, err
|
| 48 |
-
}
|
| 49 |
-
if entry == nil {
|
| 50 |
-
return false, time.Time{}, nil
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
// Check if blocked
|
| 54 |
-
if !entry.BlockedUntil.IsZero() {
|
| 55 |
-
if s.now().After(entry.BlockedUntil) {
|
| 56 |
-
// Block has expired.
|
| 57 |
-
// Ideally, we should clear the block state in the repo, but "Get" is read-only.
|
| 58 |
-
// The next RecordAttempt will clean it up or we can lazily accept it as allowed.
|
| 59 |
-
return false, time.Time{}, nil
|
| 60 |
-
}
|
| 61 |
-
return true, entry.BlockedUntil, nil
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
return false, time.Time{}, nil
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
// RecordAttempt records the result of an action.
|
| 68 |
-
func (s *RateLimitService) RecordAttempt(ctx context.Context, key string, success bool) error {
|
| 69 |
-
entry, err := s.repo.Get(ctx, key)
|
| 70 |
-
if err != nil {
|
| 71 |
-
return err
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
now := s.now()
|
| 75 |
-
|
| 76 |
-
if entry == nil {
|
| 77 |
-
entry = &ports.RateLimitEntry{
|
| 78 |
-
Key: key,
|
| 79 |
-
LastAttempt: now,
|
| 80 |
-
}
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
// If the block has expired, reset the state
|
| 84 |
-
if !entry.BlockedUntil.IsZero() && now.After(entry.BlockedUntil) {
|
| 85 |
-
entry.BlockedUntil = time.Time{}
|
| 86 |
-
entry.Count = 0 // Reset count after block expiry
|
| 87 |
-
entry.LastAttempt = now
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
// If currently blocked, we might choose to extend or just return.
|
| 91 |
-
// For now, if blocked, we don't count further failures (or we could).
|
| 92 |
-
// Let's assume we don't process attempts while blocked (caller should have checked Allow).
|
| 93 |
-
if !entry.BlockedUntil.IsZero() && now.Before(entry.BlockedUntil) {
|
| 94 |
-
// Still blocked, nothing to update?
|
| 95 |
-
// Or should we extend? Let's just keep the existing block.
|
| 96 |
-
return nil
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
// Apply Leaky Bucket Logic (Decay)
|
| 100 |
-
if s.config.FailureDecayInterval > 0 {
|
| 101 |
-
elapsed := now.Sub(entry.LastAttempt)
|
| 102 |
-
decay := int(elapsed / s.config.FailureDecayInterval)
|
| 103 |
-
if decay > 0 {
|
| 104 |
-
entry.Count -= decay
|
| 105 |
-
if entry.Count < 0 {
|
| 106 |
-
entry.Count = 0
|
| 107 |
-
}
|
| 108 |
-
// We effectively used up the time for these decays.
|
| 109 |
-
// To be precise with remaining time, we could adjust LastAttempt,
|
| 110 |
-
// but for simplicity, we'll just set LastAttempt to now at the end if we update.
|
| 111 |
-
}
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
if success {
|
| 115 |
-
// On success, we generally don't increase failure count.
|
| 116 |
-
// We could decrease it (reward) or just let time decay it.
|
| 117 |
-
// Let's just update LastAttempt to keep the record alive and accurate for decay calculation.
|
| 118 |
-
// Actually, if we update LastAttempt without reducing count (via decay), we stop the decay from happening?
|
| 119 |
-
// Wait.
|
| 120 |
-
// T0: Count=5. Last=T0.
|
| 121 |
-
// T10 (Decay=10s): Success. Elapsed=10s. Decay=1. Count=4. Last=T10.
|
| 122 |
-
// This works. We applied the decay that happened during the interval.
|
| 123 |
-
// So yes, we should run the decay logic and update LastAttempt even on success.
|
| 124 |
-
entry.LastAttempt = now
|
| 125 |
-
} else {
|
| 126 |
-
// Failure
|
| 127 |
-
entry.Count++
|
| 128 |
-
entry.LastAttempt = now
|
| 129 |
-
|
| 130 |
-
if entry.Count >= s.config.MaxFailures {
|
| 131 |
-
entry.BlockedUntil = now.Add(s.config.BlockDuration)
|
| 132 |
-
// Reset count or keep it at max?
|
| 133 |
-
// Often helpful to keep it at max or 0.
|
| 134 |
-
// If we keep it at max, future failures after unblock will immediately reblock?
|
| 135 |
-
// That depends on if we reset on unblock (handled above).
|
| 136 |
-
}
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
// Calculate TTL for the storage entry
|
| 141 |
-
// It should survive at least until block expires OR until count decays to 0.
|
| 142 |
-
ttl := s.config.BlockDuration
|
| 143 |
-
if s.config.FailureDecayInterval > 0 {
|
| 144 |
-
decayTTL := time.Duration(entry.Count) * s.config.FailureDecayInterval
|
| 145 |
-
if decayTTL > ttl {
|
| 146 |
-
ttl = decayTTL
|
| 147 |
-
}
|
| 148 |
-
}
|
| 149 |
-
// Add a buffer to TTL
|
| 150 |
-
ttl += time.Minute
|
| 151 |
-
|
| 152 |
-
return s.repo.Set(ctx, key, entry, ttl)
|
| 153 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
internal/domain/services/rate_limit_service_test.go
DELETED
|
@@ -1,200 +0,0 @@
|
|
| 1 |
-
package services
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"context"
|
| 5 |
-
"testing"
|
| 6 |
-
"time"
|
| 7 |
-
|
| 8 |
-
"github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports"
|
| 9 |
-
"github.com/router-for-me/CLIProxyAPI/v6/internal/infrastructure/persistence"
|
| 10 |
-
"github.com/stretchr/testify/assert"
|
| 11 |
-
)
|
| 12 |
-
|
| 13 |
-
func TestRateLimitService(t *testing.T) {
|
| 14 |
-
repo := persistence.NewInMemoryRateLimitRepository()
|
| 15 |
-
config := RateLimitConfig{
|
| 16 |
-
MaxFailures: 3,
|
| 17 |
-
FailureDecayInterval: time.Minute,
|
| 18 |
-
BlockDuration: 10 * time.Minute,
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
service := NewRateLimitService(repo, config)
|
| 22 |
-
|
| 23 |
-
// Mock time
|
| 24 |
-
currentTime := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)
|
| 25 |
-
service.now = func() time.Time {
|
| 26 |
-
return currentTime
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
ctx := context.Background()
|
| 30 |
-
key := "127.0.0.1"
|
| 31 |
-
|
| 32 |
-
// 1. Initial State: Allowed
|
| 33 |
-
allowed, err := service.Allow(ctx, key)
|
| 34 |
-
assert.NoError(t, err)
|
| 35 |
-
assert.True(t, allowed, "Initial state should be allowed")
|
| 36 |
-
|
| 37 |
-
// 2. Record 2 Failures
|
| 38 |
-
err = service.RecordAttempt(ctx, key, false)
|
| 39 |
-
assert.NoError(t, err)
|
| 40 |
-
err = service.RecordAttempt(ctx, key, false)
|
| 41 |
-
assert.NoError(t, err)
|
| 42 |
-
|
| 43 |
-
// Still allowed
|
| 44 |
-
allowed, err = service.Allow(ctx, key)
|
| 45 |
-
assert.NoError(t, err)
|
| 46 |
-
assert.True(t, allowed, "Should be allowed after 2 failures (max 3)")
|
| 47 |
-
|
| 48 |
-
// Check underlying state (optional, white-box testing)
|
| 49 |
-
entry, _ := repo.Get(ctx, key)
|
| 50 |
-
assert.Equal(t, 2, entry.Count)
|
| 51 |
-
|
| 52 |
-
// 3. Record 3rd Failure -> Blocked
|
| 53 |
-
err = service.RecordAttempt(ctx, key, false)
|
| 54 |
-
assert.NoError(t, err)
|
| 55 |
-
|
| 56 |
-
allowed, err = service.Allow(ctx, key)
|
| 57 |
-
assert.NoError(t, err)
|
| 58 |
-
assert.False(t, allowed, "Should be blocked after 3 failures")
|
| 59 |
-
|
| 60 |
-
isBlocked, until, err := service.IsBlocked(ctx, key)
|
| 61 |
-
assert.NoError(t, err)
|
| 62 |
-
assert.True(t, isBlocked)
|
| 63 |
-
assert.Equal(t, currentTime.Add(config.BlockDuration), until)
|
| 64 |
-
|
| 65 |
-
// 4. Advance time past block duration
|
| 66 |
-
currentTime = currentTime.Add(config.BlockDuration).Add(time.Second)
|
| 67 |
-
|
| 68 |
-
allowed, err = service.Allow(ctx, key)
|
| 69 |
-
assert.NoError(t, err)
|
| 70 |
-
assert.True(t, allowed, "Should be allowed after block expires")
|
| 71 |
-
|
| 72 |
-
// Record an attempt after expiry - should reset logic
|
| 73 |
-
err = service.RecordAttempt(ctx, key, true) // Success attempt
|
| 74 |
-
assert.NoError(t, err)
|
| 75 |
-
|
| 76 |
-
entry, _ = repo.Get(ctx, key)
|
| 77 |
-
assert.Equal(t, 0, entry.Count, "Count should be reset after block expiry")
|
| 78 |
-
|
| 79 |
-
// 5. Test Decay
|
| 80 |
-
// Reset
|
| 81 |
-
currentTime = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)
|
| 82 |
-
repo = persistence.NewInMemoryRateLimitRepository()
|
| 83 |
-
service = NewRateLimitService(repo, config)
|
| 84 |
-
service.now = func() time.Time { return currentTime }
|
| 85 |
-
|
| 86 |
-
// 2 failures
|
| 87 |
-
service.RecordAttempt(ctx, key, false)
|
| 88 |
-
service.RecordAttempt(ctx, key, false)
|
| 89 |
-
|
| 90 |
-
entry, _ = repo.Get(ctx, key)
|
| 91 |
-
assert.Equal(t, 2, entry.Count)
|
| 92 |
-
|
| 93 |
-
// Advance time by 1 minute (1 decay interval)
|
| 94 |
-
currentTime = currentTime.Add(time.Minute)
|
| 95 |
-
|
| 96 |
-
// Record another failure.
|
| 97 |
-
// Before record: elapsed=1m, decay=1. Count becomes 1.
|
| 98 |
-
// After record: Count becomes 2.
|
| 99 |
-
service.RecordAttempt(ctx, key, false)
|
| 100 |
-
|
| 101 |
-
entry, _ = repo.Get(ctx, key)
|
| 102 |
-
assert.Equal(t, 2, entry.Count, "Count should be 2 (2 decayed to 1, then +1)")
|
| 103 |
-
|
| 104 |
-
allowed, err = service.Allow(ctx, key)
|
| 105 |
-
assert.True(t, allowed, "Should still be allowed")
|
| 106 |
-
|
| 107 |
-
// 6. Test Success resets nothing but updates time (Leaky Bucket Standard)
|
| 108 |
-
// Reset
|
| 109 |
-
currentTime = time.Date(2023, 1, 1, 13, 0, 0, 0, time.UTC)
|
| 110 |
-
repo = persistence.NewInMemoryRateLimitRepository()
|
| 111 |
-
service = NewRateLimitService(repo, config)
|
| 112 |
-
service.now = func() time.Time { return currentTime }
|
| 113 |
-
|
| 114 |
-
service.RecordAttempt(ctx, key, false) // Count 1
|
| 115 |
-
currentTime = currentTime.Add(30 * time.Second) // 0.5 decay
|
| 116 |
-
service.RecordAttempt(ctx, key, true) // Success. Should update LastAttempt.
|
| 117 |
-
|
| 118 |
-
entry, _ = repo.Get(ctx, key)
|
| 119 |
-
assert.Equal(t, 1, entry.Count)
|
| 120 |
-
assert.Equal(t, currentTime, entry.LastAttempt)
|
| 121 |
-
|
| 122 |
-
currentTime = currentTime.Add(30 * time.Second) // Another 0.5 decay. Total 1 min since start.
|
| 123 |
-
// But LastAttempt was updated at 30s. So elapsed is 30s. Decay = 0.
|
| 124 |
-
// This is the "Leaky Bucket" behavior where consistent activity keeps it full?
|
| 125 |
-
// Wait. If I update LastAttempt on success without reducing count, I am resetting the decay timer.
|
| 126 |
-
// If I have 0.9 decay pending, and I succeed, I reset timer to 0 decay pending.
|
| 127 |
-
// This penalizes frequent successful requests if they happen faster than decay rate?
|
| 128 |
-
// No, because success doesn't add to count.
|
| 129 |
-
// But it does delay the decay of existing failures.
|
| 130 |
-
// If I fail once, then spam successes every second, the failure will never decay because elapsed < interval always.
|
| 131 |
-
// This might be unintended.
|
| 132 |
-
// FIX: We should accumulate partial decay or NOT update LastAttempt on success if we want purely time-based decay regardless of activity.
|
| 133 |
-
// However, usually Rate Limiters *do* care about activity.
|
| 134 |
-
// But for "Failure Rate Limiting", success shouldn't prevent failure decay.
|
| 135 |
-
// Implementation choice:
|
| 136 |
-
// A) Update LastAttempt on success: Active users keep their "failure score" longer. (Strict)
|
| 137 |
-
// B) Don't update LastAttempt on success: Failures decay based on absolute time since last failure (or last check).
|
| 138 |
-
// My implementation does (A).
|
| 139 |
-
|
| 140 |
-
// Let's verify behavior A is what we have.
|
| 141 |
-
service.RecordAttempt(ctx, key, false) // Count 1 + 0 (decay) = 2.
|
| 142 |
-
// If behavior B (don't update on success), elapsed would be 30s from last failure check? No, LastAttempt was updated on success.
|
| 143 |
-
// So we expect Count to be 2.
|
| 144 |
-
// If we hadn't updated on success, elapsed would be 60s from first failure. Decay 1. Count would be 1.
|
| 145 |
-
|
| 146 |
-
assert.Equal(t, 2, entry.Count + 1) // Logic check, wait.
|
| 147 |
-
|
| 148 |
-
// Let's not assert on implementation detail of success-decay interaction unless specified.
|
| 149 |
-
// I'll stick to asserting the "failures trigger block" and "time decays failures" basics.
|
| 150 |
-
}
|
| 151 |
-
|
| 152 |
-
func TestRateLimitService_Cleanup(t *testing.T) {
|
| 153 |
-
repo := persistence.NewInMemoryRateLimitRepository()
|
| 154 |
-
config := RateLimitConfig{
|
| 155 |
-
MaxFailures: 3,
|
| 156 |
-
BlockDuration: time.Minute,
|
| 157 |
-
}
|
| 158 |
-
service := NewRateLimitService(repo, config)
|
| 159 |
-
|
| 160 |
-
// Mock time
|
| 161 |
-
currentTime := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)
|
| 162 |
-
service.now = func() time.Time { return currentTime }
|
| 163 |
-
|
| 164 |
-
ctx := context.Background()
|
| 165 |
-
|
| 166 |
-
// Add entry
|
| 167 |
-
service.RecordAttempt(ctx, "key1", false)
|
| 168 |
-
|
| 169 |
-
// Verify exists
|
| 170 |
-
entry, _ := repo.Get(ctx, "key1")
|
| 171 |
-
assert.NotNil(t, entry)
|
| 172 |
-
|
| 173 |
-
// Advance time past expiration (Repo sets TTL = BlockDuration + buffer)
|
| 174 |
-
// TTL logic: BlockDuration (1m) + 1m buffer = 2m.
|
| 175 |
-
// Wait, code says `ttl += time.Minute`.
|
| 176 |
-
|
| 177 |
-
// Manually invoke cleanup on repo?
|
| 178 |
-
// Persistence layer relies on expiration check in Get() or manual Cleanup().
|
| 179 |
-
// Let's test manual cleanup.
|
| 180 |
-
|
| 181 |
-
// Add old entry directly to repo to test Cleanup logic
|
| 182 |
-
oldTime := currentTime.Add(-24 * time.Hour)
|
| 183 |
-
repo.Set(ctx, "old_key", &ports.RateLimitEntry{
|
| 184 |
-
Key: "old_key",
|
| 185 |
-
LastAttempt: oldTime,
|
| 186 |
-
}, time.Hour)
|
| 187 |
-
|
| 188 |
-
// We need to advance "real" time for `Get` to see it as expired?
|
| 189 |
-
// `Get` uses `time.Now()`, not the service mocked time.
|
| 190 |
-
// Ah, the repository implementation uses `time.Now()` directly!
|
| 191 |
-
// My mock only affects the Service.
|
| 192 |
-
// Testing expiration in `Get` relies on system time.
|
| 193 |
-
|
| 194 |
-
// To test Cleanup properly, we should probably mock time in Repo too, or just test logic that doesn't rely on `time.Now()` inside Repo for this specific unit test,
|
| 195 |
-
// OR just trust the logic I wrote:
|
| 196 |
-
// `if now.After(item.expiresAt) { delete }`
|
| 197 |
-
|
| 198 |
-
// Since I cannot mock time in the Repo (it uses `time.Now`), I will skip strict expiration tests that rely on waiting,
|
| 199 |
-
// or assume `Cleanup` works as implemented.
|
| 200 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
internal/infrastructure/logging/structured.go
CHANGED
|
@@ -20,7 +20,7 @@ type contextKey string
|
|
| 20 |
|
| 21 |
const (
|
| 22 |
// CorrelationIDKey is the context key for correlation IDs
|
| 23 |
-
CorrelationIDKey contextKey = "
|
| 24 |
// ServiceKey is the context key for service name
|
| 25 |
ServiceKey contextKey = "service_name"
|
| 26 |
// OperationKey is the context key for operation name
|
|
@@ -79,7 +79,7 @@ func (l *StructuredLogger) Configure(cfg *config.Config) error {
|
|
| 79 |
}
|
| 80 |
|
| 81 |
logPath := filepath.Join(logDir, "main.log")
|
| 82 |
-
|
| 83 |
if l.logWriter != nil {
|
| 84 |
_ = l.logWriter.Close()
|
| 85 |
}
|
|
@@ -232,6 +232,11 @@ func GetCorrelationID(ctx context.Context) string {
|
|
| 232 |
if ctx == nil {
|
| 233 |
return ""
|
| 234 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
if id, ok := ctx.Value(CorrelationIDKey).(string); ok {
|
| 236 |
return id
|
| 237 |
}
|
|
@@ -292,4 +297,4 @@ func GetLogger() *StructuredLogger {
|
|
| 292 |
// SetLogger sets the global structured logger instance
|
| 293 |
func SetLogger(logger *StructuredLogger) {
|
| 294 |
globalLogger = logger
|
| 295 |
-
}
|
|
|
|
| 20 |
|
| 21 |
const (
|
| 22 |
// CorrelationIDKey is the context key for correlation IDs
|
| 23 |
+
CorrelationIDKey contextKey = "request_id"
|
| 24 |
// ServiceKey is the context key for service name
|
| 25 |
ServiceKey contextKey = "service_name"
|
| 26 |
// OperationKey is the context key for operation name
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
logPath := filepath.Join(logDir, "main.log")
|
| 82 |
+
|
| 83 |
if l.logWriter != nil {
|
| 84 |
_ = l.logWriter.Close()
|
| 85 |
}
|
|
|
|
| 232 |
if ctx == nil {
|
| 233 |
return ""
|
| 234 |
}
|
| 235 |
+
// Try string key first (from Gin middleware)
|
| 236 |
+
if id, ok := ctx.Value("request_id").(string); ok {
|
| 237 |
+
return id
|
| 238 |
+
}
|
| 239 |
+
// Try typed key
|
| 240 |
if id, ok := ctx.Value(CorrelationIDKey).(string); ok {
|
| 241 |
return id
|
| 242 |
}
|
|
|
|
| 297 |
// SetLogger sets the global structured logger instance
|
| 298 |
func SetLogger(logger *StructuredLogger) {
|
| 299 |
globalLogger = logger
|
| 300 |
+
}
|
internal/infrastructure/persistence/auth_repository.go
CHANGED
|
@@ -17,9 +17,9 @@ import (
|
|
| 17 |
|
| 18 |
// AuthRepository implements the ports.AuthRepository interface
|
| 19 |
type AuthRepository struct {
|
| 20 |
-
authDir
|
| 21 |
authManager *coreauth.Manager
|
| 22 |
-
mu
|
| 23 |
}
|
| 24 |
|
| 25 |
// NewAuthRepository creates a new AuthRepository
|
|
@@ -207,7 +207,7 @@ func (r *AuthRepository) Delete(ctx context.Context, id string) error {
|
|
| 207 |
defer r.mu.Unlock()
|
| 208 |
|
| 209 |
fullPath := filepath.Join(r.authDir, id)
|
| 210 |
-
|
| 211 |
if !strings.HasSuffix(fullPath, ".json") {
|
| 212 |
fullPath += ".json"
|
| 213 |
}
|
|
@@ -260,7 +260,7 @@ func (r *AuthRepository) DeleteAll(ctx context.Context) (int, error) {
|
|
| 260 |
fullPath := filepath.Join(r.authDir, name)
|
| 261 |
if err := os.Remove(fullPath); err == nil {
|
| 262 |
deleted++
|
| 263 |
-
|
| 264 |
// Disable in auth manager
|
| 265 |
if r.authManager != nil {
|
| 266 |
if auth, ok := r.authManager.GetByID(name); ok {
|
|
@@ -407,17 +407,17 @@ func (r *AuthRepository) mapFileToAuth(file *ports.AuthFile) *coreauth.Auth {
|
|
| 407 |
}
|
| 408 |
|
| 409 |
return &coreauth.Auth{
|
| 410 |
-
ID:
|
| 411 |
-
Provider:
|
| 412 |
-
FileName:
|
| 413 |
-
Label:
|
| 414 |
-
Status:
|
| 415 |
-
Disabled:
|
| 416 |
Unavailable: file.Unavailable,
|
| 417 |
-
Metadata:
|
| 418 |
-
Attributes:
|
| 419 |
-
CreatedAt:
|
| 420 |
-
UpdatedAt:
|
| 421 |
}
|
| 422 |
}
|
| 423 |
|
|
@@ -445,4 +445,4 @@ func (r *AuthRepository) SetAuthManager(manager *coreauth.Manager) {
|
|
| 445 |
}
|
| 446 |
|
| 447 |
// Ensure AuthRepository implements the interface
|
| 448 |
-
var _ ports.AuthRepository = (*AuthRepository)(nil)
|
|
|
|
| 17 |
|
| 18 |
// AuthRepository implements the ports.AuthRepository interface
|
| 19 |
type AuthRepository struct {
|
| 20 |
+
authDir string
|
| 21 |
authManager *coreauth.Manager
|
| 22 |
+
mu sync.RWMutex
|
| 23 |
}
|
| 24 |
|
| 25 |
// NewAuthRepository creates a new AuthRepository
|
|
|
|
| 207 |
defer r.mu.Unlock()
|
| 208 |
|
| 209 |
fullPath := filepath.Join(r.authDir, id)
|
| 210 |
+
|
| 211 |
if !strings.HasSuffix(fullPath, ".json") {
|
| 212 |
fullPath += ".json"
|
| 213 |
}
|
|
|
|
| 260 |
fullPath := filepath.Join(r.authDir, name)
|
| 261 |
if err := os.Remove(fullPath); err == nil {
|
| 262 |
deleted++
|
| 263 |
+
|
| 264 |
// Disable in auth manager
|
| 265 |
if r.authManager != nil {
|
| 266 |
if auth, ok := r.authManager.GetByID(name); ok {
|
|
|
|
| 407 |
}
|
| 408 |
|
| 409 |
return &coreauth.Auth{
|
| 410 |
+
ID: file.ID,
|
| 411 |
+
Provider: file.Provider,
|
| 412 |
+
FileName: file.FileName,
|
| 413 |
+
Label: file.Label,
|
| 414 |
+
Status: coreauth.Status(file.Status),
|
| 415 |
+
Disabled: file.Disabled,
|
| 416 |
Unavailable: file.Unavailable,
|
| 417 |
+
Metadata: file.Metadata,
|
| 418 |
+
Attributes: file.Attributes,
|
| 419 |
+
CreatedAt: file.CreatedAt,
|
| 420 |
+
UpdatedAt: file.UpdatedAt,
|
| 421 |
}
|
| 422 |
}
|
| 423 |
|
|
|
|
| 445 |
}
|
| 446 |
|
| 447 |
// Ensure AuthRepository implements the interface
|
| 448 |
+
var _ ports.AuthRepository = (*AuthRepository)(nil)
|
internal/infrastructure/persistence/config_repository.go
CHANGED
|
@@ -100,7 +100,7 @@ func (r *ConfigRepository) Validate(ctx context.Context, cfg *config.Config) err
|
|
| 100 |
return errors.Wrap(errors.InternalError, "failed to create temp file for validation", err)
|
| 101 |
}
|
| 102 |
tmpPath := tmpFile.Name()
|
| 103 |
-
|
| 104 |
// Cleanup
|
| 105 |
_ = tmpFile.Close()
|
| 106 |
defer os.Remove(tmpPath)
|
|
@@ -129,4 +129,4 @@ func (r *ConfigRepository) SetConfigPath(path string) {
|
|
| 129 |
}
|
| 130 |
|
| 131 |
// Ensure ConfigRepository implements the interface
|
| 132 |
-
var _ ports.ConfigRepository = (*ConfigRepository)(nil)
|
|
|
|
| 100 |
return errors.Wrap(errors.InternalError, "failed to create temp file for validation", err)
|
| 101 |
}
|
| 102 |
tmpPath := tmpFile.Name()
|
| 103 |
+
|
| 104 |
// Cleanup
|
| 105 |
_ = tmpFile.Close()
|
| 106 |
defer os.Remove(tmpPath)
|
|
|
|
| 129 |
}
|
| 130 |
|
| 131 |
// Ensure ConfigRepository implements the interface
|
| 132 |
+
var _ ports.ConfigRepository = (*ConfigRepository)(nil)
|
internal/infrastructure/persistence/log_index.go
CHANGED
|
@@ -21,15 +21,15 @@ import (
|
|
| 21 |
|
| 22 |
// LogIndexEntry represents a single entry in the log index
|
| 23 |
type LogIndexEntry struct {
|
| 24 |
-
RequestID
|
| 25 |
-
Filename
|
| 26 |
-
Timestamp
|
| 27 |
-
Method
|
| 28 |
-
URL
|
| 29 |
-
StatusCode
|
| 30 |
-
Size
|
| 31 |
-
Offset
|
| 32 |
-
Tags
|
| 33 |
}
|
| 34 |
|
| 35 |
// LogIndex provides O(1) lookup for log entries by various criteria
|
|
@@ -557,10 +557,10 @@ func (r *IndexedLogRepository) GetIndexStats() map[string]interface{} {
|
|
| 557 |
defer r.mu.RUnlock()
|
| 558 |
|
| 559 |
return map[string]interface{}{
|
| 560 |
-
"total_entries":
|
| 561 |
-
"is_dirty":
|
| 562 |
-
"index_path":
|
| 563 |
-
"last_persisted":
|
| 564 |
}
|
| 565 |
}
|
| 566 |
|
|
|
|
| 21 |
|
| 22 |
// LogIndexEntry represents a single entry in the log index
|
| 23 |
type LogIndexEntry struct {
|
| 24 |
+
RequestID string `json:"request_id"`
|
| 25 |
+
Filename string `json:"filename"`
|
| 26 |
+
Timestamp time.Time `json:"timestamp"`
|
| 27 |
+
Method string `json:"method"`
|
| 28 |
+
URL string `json:"url"`
|
| 29 |
+
StatusCode int `json:"status_code"`
|
| 30 |
+
Size int64 `json:"size"`
|
| 31 |
+
Offset int64 `json:"offset"` // Byte offset in file for O(1) access
|
| 32 |
+
Tags map[string]string `json:"tags"` // Optional tags for filtering
|
| 33 |
}
|
| 34 |
|
| 35 |
// LogIndex provides O(1) lookup for log entries by various criteria
|
|
|
|
| 557 |
defer r.mu.RUnlock()
|
| 558 |
|
| 559 |
return map[string]interface{}{
|
| 560 |
+
"total_entries": r.index.Size(),
|
| 561 |
+
"is_dirty": r.index.IsDirty(),
|
| 562 |
+
"index_path": r.indexPath,
|
| 563 |
+
"last_persisted": r.index.lastPersisted,
|
| 564 |
}
|
| 565 |
}
|
| 566 |
|
internal/infrastructure/persistence/log_repository.go
CHANGED
|
@@ -377,15 +377,15 @@ func (r *LogRepository) DownloadRequestErrorLog(ctx context.Context, filename st
|
|
| 377 |
func (r *LogRepository) GetLogDirectory() string {
|
| 378 |
r.mu.RLock()
|
| 379 |
defer r.mu.RUnlock()
|
| 380 |
-
|
| 381 |
if r.logDir != "" {
|
| 382 |
return r.logDir
|
| 383 |
}
|
| 384 |
-
|
| 385 |
if r.cfg != nil {
|
| 386 |
return logging.ResolveLogDirectory(r.cfg)
|
| 387 |
}
|
| 388 |
-
|
| 389 |
return ""
|
| 390 |
}
|
| 391 |
|
|
@@ -393,7 +393,7 @@ func (r *LogRepository) GetLogDirectory() string {
|
|
| 393 |
func (r *LogRepository) IsLoggingEnabled() bool {
|
| 394 |
r.mu.RLock()
|
| 395 |
defer r.mu.RUnlock()
|
| 396 |
-
|
| 397 |
if r.cfg == nil {
|
| 398 |
return false
|
| 399 |
}
|
|
@@ -631,4 +631,4 @@ func parseTimestamp(line string) int64 {
|
|
| 631 |
}
|
| 632 |
|
| 633 |
// Ensure LogRepository implements the interface
|
| 634 |
-
var _ ports.LogRepository = (*LogRepository)(nil)
|
|
|
|
| 377 |
func (r *LogRepository) GetLogDirectory() string {
|
| 378 |
r.mu.RLock()
|
| 379 |
defer r.mu.RUnlock()
|
| 380 |
+
|
| 381 |
if r.logDir != "" {
|
| 382 |
return r.logDir
|
| 383 |
}
|
| 384 |
+
|
| 385 |
if r.cfg != nil {
|
| 386 |
return logging.ResolveLogDirectory(r.cfg)
|
| 387 |
}
|
| 388 |
+
|
| 389 |
return ""
|
| 390 |
}
|
| 391 |
|
|
|
|
| 393 |
func (r *LogRepository) IsLoggingEnabled() bool {
|
| 394 |
r.mu.RLock()
|
| 395 |
defer r.mu.RUnlock()
|
| 396 |
+
|
| 397 |
if r.cfg == nil {
|
| 398 |
return false
|
| 399 |
}
|
|
|
|
| 631 |
}
|
| 632 |
|
| 633 |
// Ensure LogRepository implements the interface
|
| 634 |
+
var _ ports.LogRepository = (*LogRepository)(nil)
|