added multi-key support for optional auth logic

This commit is contained in:
Luxferre
2026-07-11 10:02:48 +03:00
parent 2439257729
commit 1c25a98208
4 changed files with 144 additions and 33 deletions
+5 -4
View File
@@ -76,9 +76,10 @@ ollama-llama3,-,http://localhost:11434/v1,
``` ```
### 3. Setup gateway key (optional) ### 3. Setup gateway key (optional)
To restrict access to your gateway, write a single plaintext key to a file (e.g. `gatekey.txt`): To restrict access to your gateway, write one or more plaintext keys (one per line) to a file (e.g. `gatekey.txt`):
```text ```text
my-secure-gateway-token my-secure-gateway-token-1
my-secure-gateway-token-2
``` ```
### 4. Run the server ### 4. Run the server
@@ -94,7 +95,7 @@ my-secure-gateway-token
| ---- | ---- | ------- | ----------- | | ---- | ---- | ------- | ----------- |
| `-port` | int | `8080` | Port the gateway listens on. | | `-port` | int | `8080` | Port the gateway listens on. |
| `-csv` | string | `models.csv` | Path to the model configuration CSV. | | `-csv` | string | `models.csv` | Path to the model configuration CSV. |
| `-key` | string | `""` | Path to a file containing the gateway's access token. If blank, authentication is disabled. | | `-key` | string | `""` | Path to a file containing the gateway's access tokens (one per line). If blank, authentication is disabled. |
| `-csv-updater` | string | `""` | Command/script to run periodically to update `models.csv`. | | `-csv-updater` | string | `""` | Command/script to run periodically to update `models.csv`. |
| `-csv-update-interval`| int | `10` | Frequency in minutes to invoke `-csv-updater`. | | `-csv-update-interval`| int | `10` | Frequency in minutes to invoke `-csv-updater`. |
@@ -152,7 +153,7 @@ curl -i -X POST http://localhost:8080/v1/chat/completions \
### 401 Unauthorized errors ### 401 Unauthorized errors
- **Symptom**: Requests return `{"error": {"code": "invalid_api_key", ...}}`. - **Symptom**: Requests return `{"error": {"code": "invalid_api_key", ...}}`.
- **Solution**: Check that you are passing the correct Bearer token configured in the `-key` token file. Trim any whitespace, and verify it starts with `Bearer `. - **Solution**: Check that you are passing one of the correct Bearer tokens configured in the `-key` token file. Trim any whitespace, and verify it starts with `Bearer `.
## FAQ ## FAQ
+108 -14
View File
@@ -211,7 +211,7 @@ func TestGatewayCompletionsFailover(t *testing.T) {
uniqueModels: []string{"model-1", "model-2"}, uniqueModels: []string{"model-1", "model-2"},
} }
handler := handleChatCompletions(cm, "") handler := handleChatCompletions(cm, nil)
// 1. Test non-streaming failover request starting with model-1 // 1. Test non-streaming failover request starting with model-1
reqBodyObj := map[string]any{ reqBodyObj := map[string]any{
@@ -284,7 +284,7 @@ func TestGatewayCompletionsStreamingFailover(t *testing.T) {
uniqueModels: []string{"model-1", "model-2"}, uniqueModels: []string{"model-1", "model-2"},
} }
handler := handleChatCompletions(cm, "") handler := handleChatCompletions(cm, nil)
reqBodyObj := map[string]any{ reqBodyObj := map[string]any{
"model": "model-1", "model": "model-1",
@@ -328,7 +328,7 @@ func TestModelsEndpoint(t *testing.T) {
uniqueModels: []string{"model-1", "model-2"}, uniqueModels: []string{"model-1", "model-2"},
} }
handler := handleModels(cm, "") handler := handleModels(cm, nil)
req := httptest.NewRequest("GET", "/v1/models", nil) req := httptest.NewRequest("GET", "/v1/models", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -367,9 +367,10 @@ func TestGatewayAuthentication(t *testing.T) {
uniqueModels: []string{"model-1"}, uniqueModels: []string{"model-1"},
} }
// Set up handlers expecting "secret-token" // Set up handlers expecting multiple secret tokens
modelsHandler := handleModels(cm, "secret-token") expectedTokens := []string{"secret-token-1", "secret-token-2"}
completionsHandler := handleChatCompletions(cm, "secret-token") modelsHandler := handleModels(cm, expectedTokens)
completionsHandler := handleChatCompletions(cm, expectedTokens)
// Case 1: No Authorization header // Case 1: No Authorization header
{ {
@@ -392,14 +393,25 @@ func TestGatewayAuthentication(t *testing.T) {
} }
} }
// Case 3: Correct Authorization header // Case 3: Correct Authorization header (first token)
{ {
req := httptest.NewRequest("GET", "/v1/models", nil) req := httptest.NewRequest("GET", "/v1/models", nil)
req.Header.Set("Authorization", "Bearer secret-token") req.Header.Set("Authorization", "Bearer secret-token-1")
w := httptest.NewRecorder() w := httptest.NewRecorder()
modelsHandler.ServeHTTP(w, req) modelsHandler.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusOK { if w.Result().StatusCode != http.StatusOK {
t.Errorf("Expected 200 OK for correct token, got %d", w.Result().StatusCode) t.Errorf("Expected 200 OK for correct token 1, got %d", w.Result().StatusCode)
}
}
// Case 3b: Correct Authorization header (second token)
{
req := httptest.NewRequest("GET", "/v1/models", nil)
req.Header.Set("Authorization", "Bearer secret-token-2")
w := httptest.NewRecorder()
modelsHandler.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusOK {
t.Errorf("Expected 200 OK for correct token 2, got %d", w.Result().StatusCode)
} }
} }
@@ -410,7 +422,7 @@ func TestGatewayAuthentication(t *testing.T) {
} }
reqBytes, _ := json.Marshal(reqBodyObj) reqBytes, _ := json.Marshal(reqBodyObj)
req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(reqBytes)) req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(reqBytes))
req.Header.Set("Authorization", "Bearer secret-token") req.Header.Set("Authorization", "Bearer secret-token-2")
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
completionsHandler.ServeHTTP(w, req) completionsHandler.ServeHTTP(w, req)
@@ -492,7 +504,7 @@ func TestGatewayImageGenerations(t *testing.T) {
uniqueModels: []string{"dall-e-3"}, uniqueModels: []string{"dall-e-3"},
} }
handler := handleImageGenerations(cm, "") handler := handleImageGenerations(cm, nil)
reqBodyObj := map[string]any{ reqBodyObj := map[string]any{
"prompt": "a beautiful kitten", "prompt": "a beautiful kitten",
@@ -551,7 +563,7 @@ func TestGatewayNoAuthHeaderOnEmptyKey(t *testing.T) {
uniqueModels: []string{"model-no-key", "model-no-key-2"}, uniqueModels: []string{"model-no-key", "model-no-key-2"},
} }
handler := handleChatCompletions(cm, "") handler := handleChatCompletions(cm, nil)
// Case 1: Empty Key // Case 1: Empty Key
{ {
@@ -615,7 +627,7 @@ func TestGatewayBlankKey(t *testing.T) {
uniqueModels: []string{"model-blank-key"}, uniqueModels: []string{"model-blank-key"},
} }
handler := handleChatCompletions(cm, "") handler := handleChatCompletions(cm, nil)
reqBodyObj := map[string]any{ reqBodyObj := map[string]any{
"model": "model-blank-key", "model": "model-blank-key",
@@ -664,7 +676,7 @@ func TestGatewayExtraHeaders(t *testing.T) {
uniqueModels: []string{"model-extra-headers"}, uniqueModels: []string{"model-extra-headers"},
} }
handler := handleChatCompletions(cm, "") handler := handleChatCompletions(cm, nil)
reqBodyObj := map[string]any{ reqBodyObj := map[string]any{
"model": "model-extra-headers", "model": "model-extra-headers",
@@ -685,5 +697,87 @@ func TestGatewayExtraHeaders(t *testing.T) {
} }
} }
func TestMultipleTokensKeyFile(t *testing.T) {
// Create a temporary key file with multiple tokens, some empty lines, and comments/spaces
tmpDir, err := os.MkdirTemp("", "dynagate-keyfile-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
keyFilePath := filepath.Join(tmpDir, "keys.txt")
keyFileContent := "\n token-a \n\ntoken-b\n \ntoken-c\n"
if err := os.WriteFile(keyFilePath, []byte(keyFileContent), 0644); err != nil {
t.Fatalf("failed to write key file: %v", err)
}
// Parse using the same logic as main.go
content, err := os.ReadFile(keyFilePath)
if err != nil {
t.Fatalf("failed to read key file: %v", err)
}
var authTokens []string
for _, line := range strings.Split(string(content), "\n") {
token := strings.TrimSpace(line)
if token != "" {
authTokens = append(authTokens, token)
}
}
expected := []string{"token-a", "token-b", "token-c"}
if !reflect.DeepEqual(authTokens, expected) {
t.Errorf("Expected parsed tokens to be %v, got %v", expected, authTokens)
}
// Verify that the helper checkAuth works with these parsed tokens
req := httptest.NewRequest("GET", "/v1/models", nil)
req.Header.Set("Authorization", "Bearer token-b")
if !checkAuth(authTokens, req) {
t.Errorf("Expected token-b to authenticate successfully")
}
reqWrong := httptest.NewRequest("GET", "/v1/models", nil)
reqWrong.Header.Set("Authorization", "Bearer token-wrong")
if checkAuth(authTokens, reqWrong) {
t.Errorf("Expected token-wrong to fail authentication")
}
// Test empty key file content behavior
emptyKeyFilePath := filepath.Join(tmpDir, "empty_keys.txt")
emptyKeyFileContent := "\n \n\n \n"
if err := os.WriteFile(emptyKeyFilePath, []byte(emptyKeyFileContent), 0644); err != nil {
t.Fatalf("failed to write empty key file: %v", err)
}
// Parse using empty key file logic as in main.go
emptyContent, err := os.ReadFile(emptyKeyFilePath)
if err != nil {
t.Fatalf("failed to read empty key file: %v", err)
}
var emptyAuthTokens []string
var tokens []string
for _, line := range strings.Split(string(emptyContent), "\n") {
token := strings.TrimSpace(line)
if token != "" {
tokens = append(tokens, token)
}
}
if len(tokens) > 0 {
emptyAuthTokens = tokens
} else {
emptyAuthTokens = nil
}
if emptyAuthTokens != nil {
t.Errorf("Expected emptyAuthTokens to be nil, got %v", emptyAuthTokens)
}
// Verify that checkAuth returns true when expectedTokens is nil
reqNoAuth := httptest.NewRequest("GET", "/v1/models", nil)
if !checkAuth(emptyAuthTokens, reqNoAuth) {
t.Errorf("Expected checkAuth to return true for nil expectedTokens")
}
}
+14 -9
View File
@@ -14,8 +14,8 @@ import (
var httpClient = &http.Client{} var httpClient = &http.Client{}
func checkAuth(expectedToken string, r *http.Request) bool { func checkAuth(expectedTokens []string, r *http.Request) bool {
if expectedToken == "" { if expectedTokens == nil {
return true return true
} }
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
@@ -23,7 +23,12 @@ func checkAuth(expectedToken string, r *http.Request) bool {
return false return false
} }
token := strings.TrimPrefix(authHeader, "Bearer ") token := strings.TrimPrefix(authHeader, "Bearer ")
return token == expectedToken for _, expected := range expectedTokens {
if token == expected {
return true
}
}
return false
} }
func sendUnauthorized(w http.ResponseWriter) { func sendUnauthorized(w http.ResponseWriter) {
@@ -39,7 +44,7 @@ func sendUnauthorized(w http.ResponseWriter) {
}) })
} }
func handleModels(cm *ConfigManager, expectedToken string) http.HandlerFunc { func handleModels(cm *ConfigManager, expectedTokens []string) http.HandlerFunc {
type ModelData struct { type ModelData struct {
ID string `json:"id"` ID string `json:"id"`
Object string `json:"object"` Object string `json:"object"`
@@ -53,7 +58,7 @@ func handleModels(cm *ConfigManager, expectedToken string) http.HandlerFunc {
} }
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if !checkAuth(expectedToken, r) { if !checkAuth(expectedTokens, r) {
sendUnauthorized(w) sendUnauthorized(w)
return return
} }
@@ -91,9 +96,9 @@ func handleModels(cm *ConfigManager, expectedToken string) http.HandlerFunc {
} }
} }
func handleChatCompletions(cm *ConfigManager, expectedToken string) http.HandlerFunc { func handleChatCompletions(cm *ConfigManager, expectedTokens []string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if !checkAuth(expectedToken, r) { if !checkAuth(expectedTokens, r) {
sendUnauthorized(w) sendUnauthorized(w)
return return
} }
@@ -324,9 +329,9 @@ func handleChatCompletions(cm *ConfigManager, expectedToken string) http.Handler
} }
} }
func handleImageGenerations(cm *ConfigManager, expectedToken string) http.HandlerFunc { func handleImageGenerations(cm *ConfigManager, expectedTokens []string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if !checkAuth(expectedToken, r) { if !checkAuth(expectedTokens, r) {
sendUnauthorized(w) sendUnauthorized(w)
return return
} }
+17 -6
View File
@@ -27,14 +27,25 @@ func main() {
csvUpdateInterval := flag.Int("csv-update-interval", 10, "Interval in minutes with which to run the CSV updater command") csvUpdateInterval := flag.Int("csv-update-interval", 10, "Interval in minutes with which to run the CSV updater command")
flag.Parse() flag.Parse()
var authToken string var authTokens []string
if *keyPath != "" { if *keyPath != "" {
content, err := os.ReadFile(*keyPath) content, err := os.ReadFile(*keyPath)
if err != nil { if err != nil {
log.Fatalf("Failed to read auth token file: %v", err) log.Fatalf("Failed to read auth token file: %v", err)
} }
authToken = strings.TrimSpace(string(content)) var tokens []string
log.Printf("Starting Dynagate LLM Gateway. Config: %s, Port: %d, Auth: Required", *csvPath, *port) for _, line := range strings.Split(string(content), "\n") {
token := strings.TrimSpace(line)
if token != "" {
tokens = append(tokens, token)
}
}
if len(tokens) > 0 {
authTokens = tokens
log.Printf("Starting Dynagate LLM Gateway. Config: %s, Port: %d, Auth: Required (%d tokens loaded)", *csvPath, *port, len(authTokens))
} else {
log.Printf("Starting Dynagate LLM Gateway. Config: %s, Port: %d, Auth: None (empty key file)", *csvPath, *port)
}
} else { } else {
log.Printf("Starting Dynagate LLM Gateway. Config: %s, Port: %d, Auth: None", *csvPath, *port) log.Printf("Starting Dynagate LLM Gateway. Config: %s, Port: %d, Auth: None", *csvPath, *port)
} }
@@ -62,9 +73,9 @@ func main() {
// Register routes // Register routes
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/v1/models", handleModels(cm, authToken)) mux.HandleFunc("/v1/models", handleModels(cm, authTokens))
mux.HandleFunc("/v1/chat/completions", handleChatCompletions(cm, authToken)) mux.HandleFunc("/v1/chat/completions", handleChatCompletions(cm, authTokens))
mux.HandleFunc("/v1/images/generations", handleImageGenerations(cm, authToken)) mux.HandleFunc("/v1/images/generations", handleImageGenerations(cm, authTokens))
server := &http.Server{ server := &http.Server{
Addr: fmt.Sprintf(":%d", *port), Addr: fmt.Sprintf(":%d", *port),