feat: GitHub Key Scanner mit automatischem Intervall und manueller UI
- KeyScannerService als BackgroundService (Timer + manueller Trigger) - GitHub Code Search: sucht nach sk-ant-oat01-* Keys in Dotenv-Dateien - AnthropicKeyChecker: shared Key-Check-Logik (ersetzt Duplikat in ApiKeysEndpoint) - ScannedKeyRecord Model mit Soft-Delete und Adopt-Verknüpfung - Scan-Endpoints: trigger, status, list, recheck, adopt, delete - API-Keys-Page: Scanner-UI mit Status-Indikator und Scanned-Keys-Liste - GitHub-URL pro gefundenem Key (html_url aus Search API) - Server-Settings: GitHubToken + ScanIntervalHours konfigurierbar - DB-Migration: scanned_keys Tabelle + api_keys Meta-Spalten (status, plan, usage) - ApiKeyRecord erweitert um Status/Plan/Usage-Felder mit UpdateApiKeyMeta()
This commit is contained in:
1 parent
db1a7c7f64
commit
006ef223bf
15 files changed
+996
-107
No files matched your search
+166
-102
@@ -1,8 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Gott.Extensions;
|
||||
@@ -24,6 +22,14 @@ public static class ApiKeysEndpoint
|
||||
app.MapPut("/api/admin/api-keys/{keyId}/deactivate", HandleDeactivate);
|
||||
app.MapDelete("/api/admin/api-keys/{keyId}", HandleDelete);
|
||||
app.MapPost("/api/admin/api-keys/{keyId}/check", HandleCheck);
|
||||
|
||||
// Scan-Endpoints
|
||||
app.MapPost("/api/admin/api-keys/scan", HandleScanTrigger);
|
||||
app.MapGet("/api/admin/api-keys/scan/status", HandleScanStatus);
|
||||
app.MapGet("/api/admin/api-keys/scanned", HandleGetScanned);
|
||||
app.MapPost("/api/admin/api-keys/scanned/{id}/recheck", HandleRecheck);
|
||||
app.MapPost("/api/admin/api-keys/scanned/{id}/adopt", HandleAdopt);
|
||||
app.MapDelete("/api/admin/api-keys/scanned/{id}", HandleDeleteScanned);
|
||||
}
|
||||
|
||||
private static async Task HandleGetAll(HttpContext ctx, DatabaseService db)
|
||||
@@ -38,6 +44,11 @@ public static class ApiKeysEndpoint
|
||||
k.Name,
|
||||
k.ApiKey,
|
||||
k.IsActive,
|
||||
k.Status,
|
||||
k.Plan,
|
||||
k.Usage7d,
|
||||
k.Usage5h,
|
||||
k.CheckedAt,
|
||||
k.CreatedAt,
|
||||
k.UpdatedAt
|
||||
});
|
||||
@@ -193,106 +204,159 @@ public static class ApiKeysEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
var result = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
|
||||
|
||||
// Metadaten in DB speichern
|
||||
await db.UpdateApiKeyMeta(keyId, result.Status, result.Plan, result.Usage7d, result.Usage5h);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
var client = httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
model = "claude-sonnet-4-20250514",
|
||||
max_tokens = 1,
|
||||
messages = new[] { new { role = "user", content = "hi" } }
|
||||
});
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages");
|
||||
request.Headers.Add("x-api-key", existing.ApiKey);
|
||||
request.Headers.Add("anthropic-version", "2023-06-01");
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
var resp = await client.SendAsync(request);
|
||||
var headers = resp.Headers;
|
||||
|
||||
string GetHeader(string name)
|
||||
{
|
||||
if (headers.TryGetValues("anthropic-ratelimit-" + name, out var v1))
|
||||
return string.Join("", v1);
|
||||
if (headers.TryGetValues(name, out var v2))
|
||||
return string.Join("", v2);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "Ungültig oder abgelaufen (401)" });
|
||||
return;
|
||||
}
|
||||
if (resp.StatusCode == (System.Net.HttpStatusCode)429)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "Rate Limited (429)" });
|
||||
return;
|
||||
}
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "Insufficient Quota (403)" });
|
||||
return;
|
||||
}
|
||||
if ((int)resp.StatusCode >= 400)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "API-Fehler (" + (int)resp.StatusCode + ")" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Key ist gültig – Header auslesen
|
||||
var d7Util = GetHeader("unified-7d-utilization");
|
||||
var d5hUtil = GetHeader("unified-5h-utilization");
|
||||
var overageUtil = GetHeader("unified-overage-utilization");
|
||||
var d7Status = GetHeader("unified-7d-status");
|
||||
var d5hStatus = GetHeader("unified-5h-status");
|
||||
var overageStatus = GetHeader("unified-overage-status");
|
||||
|
||||
// Subscription-Keys haben unified-* Headers, API-Keys nicht
|
||||
var isSubscription = d7Util != null || d5hUtil != null;
|
||||
var fallbackPct = GetHeader("unified-fallback-percentage");
|
||||
|
||||
// Plan-Erkennung:
|
||||
// - overage > 0 → definitiv MAX (Extra Usage aktiv, nur bei Max sinnvoll nutzbar)
|
||||
// - kein fallback-percentage Header → PRO
|
||||
// - sonst → Pro/Max (nicht unterscheidbar)
|
||||
string keyType;
|
||||
if (!isSubscription)
|
||||
keyType = "API Console";
|
||||
else if (overageUtil != null && double.TryParse(overageUtil, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var ovCheck) && ovCheck > 0)
|
||||
keyType = "Max";
|
||||
else if (fallbackPct == null)
|
||||
keyType = "Pro";
|
||||
else
|
||||
keyType = "Pro/Max";
|
||||
|
||||
double? usage7d = null, usage5h = null, overage = null;
|
||||
if (d7Util != null && double.TryParse(d7Util, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var d7))
|
||||
usage7d = Math.Round(d7 * 100, 1);
|
||||
if (d5hUtil != null && double.TryParse(d5hUtil, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var d5))
|
||||
usage5h = Math.Round(d5 * 100, 1);
|
||||
if (overageUtil != null && double.TryParse(overageUtil, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var ov))
|
||||
overage = Math.Round(ov * 100, 1);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
status = "ok",
|
||||
keyType,
|
||||
isSubscription,
|
||||
usage7d,
|
||||
usage5h,
|
||||
overage,
|
||||
status7d = d7Status,
|
||||
status5h = d5hStatus,
|
||||
statusOverage = overageStatus
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = "error", message = ex.Message });
|
||||
}
|
||||
status = result.Status == "ok" ? "ok" : "error",
|
||||
message = result.Message,
|
||||
keyType = result.Plan,
|
||||
result.IsSubscription,
|
||||
result.Usage7d,
|
||||
result.Usage5h,
|
||||
result.Overage,
|
||||
status7d = result.Status7d,
|
||||
status5h = result.Status5h,
|
||||
statusOverage = result.StatusOverage
|
||||
});
|
||||
}
|
||||
|
||||
// ── Scan-Endpoints ──
|
||||
|
||||
private static Task HandleScanTrigger(HttpContext ctx, KeyScannerService scanner)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return Task.CompletedTask; }
|
||||
|
||||
if (scanner.IsRunning)
|
||||
{
|
||||
ctx.Response.StatusCode = 409;
|
||||
return ctx.Response.WriteAsJsonAsync(new { error = "Scan läuft bereits" });
|
||||
}
|
||||
|
||||
scanner.TriggerScan();
|
||||
return ctx.Response.WriteAsJsonAsync(new { ok = true, message = "Scan gestartet" });
|
||||
}
|
||||
|
||||
private static Task HandleScanStatus(HttpContext ctx, KeyScannerService scanner)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return Task.CompletedTask; }
|
||||
|
||||
return ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
running = scanner.IsRunning,
|
||||
lastRun = scanner.LastRun,
|
||||
lastResult = scanner.LastResult
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task HandleGetScanned(HttpContext ctx, DatabaseService db)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var keys = await db.GetScannedKeys();
|
||||
var result = keys.Select(k => new
|
||||
{
|
||||
k.ScannedKeyId,
|
||||
k.SourceUrl,
|
||||
k.Status,
|
||||
k.Plan,
|
||||
k.Usage7d,
|
||||
k.Usage5h,
|
||||
k.ScannedAt,
|
||||
k.CheckedAt,
|
||||
k.AdoptedApiKeyId
|
||||
});
|
||||
await ctx.Response.WriteAsJsonAsync(result);
|
||||
}
|
||||
|
||||
private static async Task HandleRecheck(HttpContext ctx, DatabaseService db, KeyScannerService scanner, string id)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var sk = await db.GetScannedKey(id);
|
||||
if (sk == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" });
|
||||
return;
|
||||
}
|
||||
|
||||
await scanner.CheckAndUpdateKey(sk);
|
||||
var updated = await db.GetScannedKey(id);
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
ok = true,
|
||||
updated.Status,
|
||||
updated.Plan,
|
||||
updated.Usage7d,
|
||||
updated.Usage5h,
|
||||
updated.CheckedAt
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task HandleAdopt(HttpContext ctx, DatabaseService db, string id)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var sk = await db.GetScannedKey(id);
|
||||
if (sk == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sk.AdoptedApiKeyId != null)
|
||||
{
|
||||
ctx.Response.StatusCode = 409;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key wurde bereits übernommen" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfen ob Key schon als API-Key existiert
|
||||
if (await db.ApiKeyExistsInKeys(sk.ApiKey))
|
||||
{
|
||||
ctx.Response.StatusCode = 409;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key existiert bereits als API-Key" });
|
||||
return;
|
||||
}
|
||||
|
||||
// URL als Titel verwenden, Metadaten übertragen
|
||||
var title = !string.IsNullOrWhiteSpace(sk.SourceUrl)
|
||||
? sk.SourceUrl.Replace("https://github.com/", "")
|
||||
: $"Scan: {sk.Plan}";
|
||||
|
||||
var now = DateTime.UtcNow.ToString("o");
|
||||
var record = new ApiKeyRecord
|
||||
{
|
||||
KeyId = Guid.NewGuid().ToString("N"),
|
||||
Provider = "anthropic",
|
||||
Name = title,
|
||||
ApiKey = sk.ApiKey,
|
||||
IsActive = false,
|
||||
Status = sk.Status,
|
||||
Plan = sk.Plan,
|
||||
Usage7d = sk.Usage7d,
|
||||
Usage5h = sk.Usage5h,
|
||||
CheckedAt = sk.CheckedAt,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
var apiKeyId = await db.CreateApiKey(record);
|
||||
await db.SetScannedKeyAdopted(id, apiKeyId);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new { ok = true, record.KeyId, record.Name });
|
||||
}
|
||||
|
||||
private static async Task HandleDeleteScanned(HttpContext ctx, DatabaseService db, string id)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
await db.DeleteScannedKey(id);
|
||||
await ctx.Response.WriteAsJsonAsync(new { ok = true });
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user