diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..47b9842 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/bin +**/obj +.git +.next +.vinext +dist +node_modules +.pnpm-store +*.tar.gz diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5449069 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + packages: write + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - run: dotnet restore IpCheck.slnx + - run: dotnet build IpCheck.slnx -c Release --no-restore + - run: dotnet run --project tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj -c Release --no-build + - run: docker compose -f deploy/docker-compose.yml config + env: + POSTGRES_PASSWORD: ci-only + IPCHECK_LOCATION_ID: 11111111-1111-1111-1111-111111111111 + + containers: + if: github.event_name == 'push' + needs: build-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.server + push: true + tags: ghcr.io/${{ github.repository_owner }}/ipcheck-server:${{ github.sha }} + - uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.agent + push: true + tags: ghcr.io/${{ github.repository_owner }}/ipcheck-agent:${{ github.sha }} diff --git a/.gitignore b/.gitignore index 6d38aba..54aefca 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ yarn-error.log* /.wrangler/ /outputs/ /work/ +**/bin/ +**/obj/ +.env +*.tar.gz +/.docker-build-config/ diff --git a/Dockerfile.agent b/Dockerfile.agent new file mode 100644 index 0000000..84c3dbc --- /dev/null +++ b/Dockerfile.agent @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore src/IpCheck.Agent/IpCheck.Agent.fsproj +RUN dotnet publish src/IpCheck.Agent/IpCheck.Agent.fsproj -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime +WORKDIR /app +COPY --from=build /app . +USER 10001:10001 +ENTRYPOINT ["dotnet", "IpCheck.Agent.dll"] diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..c638b75 --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore src/IpCheck.Server/IpCheck.Server.fsproj +RUN dotnet publish src/IpCheck.Server/IpCheck.Server.fsproj -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY --from=build /app . +COPY src/IpCheck.Server/migrations ./migrations +USER 10001:10001 +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD curl --fail --silent http://localhost:8080/healthz || exit 1 +ENTRYPOINT ["dotnet", "IpCheck.Server.dll"] diff --git a/IpCheck.slnx b/IpCheck.slnx new file mode 100644 index 0000000..c91fdea --- /dev/null +++ b/IpCheck.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/README.md b/README.md index bd9b9e0..ef8c055 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,42 @@ -# vinext-starter +# IpCheck -A clean full-stack starter running on -[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and -Drizzle support. +Распределённая система мониторинга IP-адресов на F#. Каждый агент представляет отдельную локацию, синхронизирует общий список IP с центральным сервером, выполняет Ping и TCP-проверки SSH/RDP и отправляет результаты обратно. -## Prerequisites +## Состав первого среза -- Node.js `>=22.13.0` +- F# ASP.NET Core API; +- F# Worker Service для агентов локаций; +- общие контракты и доменная модель; +- схема PostgreSQL; +- Docker-образы сервера и агента; +- Docker Compose для локального окружения; +- CI для сборки, тестов и публикации контейнеров; +- существующий интерфейс в `app/` сохранён как визуальный прототип до переноса на Fable. -## Quick Start +## Локальная проверка ```bash -npm install -npm run dev -npm run build +dotnet restore IpCheck.slnx +dotnet build IpCheck.slnx -c Release +dotnet run --project tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj -c Release ``` -This starter does not use `wrangler.jsonc`. +Запуск сервера: -## Included Shape - -- edit site code under `app/` -- `.openai/hosting.json` declares optional Sites D1 and R2 bindings -- `vite.config.ts` simulates declared bindings for local development -- `db/schema.ts` starts intentionally empty -- `examples/d1/` contains an optional D1 example surface -- `drizzle.config.ts` supports local migration generation when needed - -## Workspace Auth Headers - -Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present. - -The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes. - -SIWC-authenticated workspace sites may also receive -`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty -`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by -`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`. - -Treat the full name as optional and fall back to email when it is absent: - -```tsx -import { headers } from "next/headers"; - -export default async function Home() { - const requestHeaders = await headers(); - const userId = requestHeaders.get("oai-authenticated-user-id"); - const email = requestHeaders.get("oai-authenticated-user-email"); - const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name"); - const fullName = - encodedFullName && - requestHeaders.get("oai-authenticated-user-full-name-encoding") === - "percent-encoded-utf-8" - ? decodeURIComponent(encodedFullName) - : null; - - const displayName = fullName ?? email; - // ... -} +```bash +dotnet run --project src/IpCheck.Server/IpCheck.Server.fsproj ``` -## Optional Dispatch-Owned ChatGPT Sign-In +API будет доступен по адресу, указанному ASP.NET Core в консоли. Проверка состояния: `GET /healthz`. -Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs -optional or required ChatGPT sign-in: +## Docker Compose -- Use `getChatGPTUser()` for optional signed-in UI. -- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send - anonymous visitors through Sign in with ChatGPT. -- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for - browser links or actions. -- Pass a same-origin relative `returnTo` path for the destination after sign-in - or sign-out. The helper validates and safely encodes it. -- Mark protected pages with `export const dynamic = "force-dynamic"` because - they depend on per-request identity headers. +Скопируйте `deploy/.env.example` в `deploy/.env`, замените пароль и запустите: -Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the -OAuth cookies, and identity header injection. Do not implement app routes for -those reserved paths. Routes that do not import and call the helper remain -anonymous-compatible. +```bash +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --build +``` -SIWC establishes identity only; it does not prove workspace membership. Use the -Sites hosting platform's access policy controls for workspace-wide restrictions, -or enforce explicit server-side membership or allowlist checks. +## Следующий этап -Use SIWC for account pages, user-specific dashboards, saved records, and write -actions tied to the current ChatGPT user. Leave public content anonymous. - -## Useful Commands - -- `npm run dev`: start local development -- `npm run build`: verify the vinext build output -- `npm test`: build the starter and verify its rendered loading skeleton -- `npm run db:generate`: generate Drizzle migrations after schema changes - -## Learn More - -- [vinext Documentation](https://github.com/cloudflare/vinext) -- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new) +Перевести репозитории IP, локаций и результатов с временного in-memory хранилища на PostgreSQL, добавить регистрацию агентов по одноразовому токену и начать Fable-админку. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..dcf7380 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,48 @@ +services: + postgres: + image: postgres:18-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-ipcheck} + POSTGRES_USER: ${POSTGRES_USER:-ipcheck} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ipcheck} -d ${POSTGRES_DB:-ipcheck}"] + interval: 10s + timeout: 5s + retries: 5 + + server: + image: ${IPCHECK_SERVER_IMAGE:-ipcheck/server:local} + build: + context: .. + dockerfile: Dockerfile.server + restart: unless-stopped + ports: + - "${IPCHECK_HTTP_PORT:-8080}:8080" + environment: + ConnectionStrings__Postgres: Host=postgres;Database=${POSTGRES_DB:-ipcheck};Username=${POSTGRES_USER:-ipcheck};Password=${POSTGRES_PASSWORD} + depends_on: + postgres: + condition: service_healthy + + agent: + image: ${IPCHECK_AGENT_IMAGE:-ipcheck/agent:local} + build: + context: .. + dockerfile: Dockerfile.agent + restart: unless-stopped + cap_add: + - NET_RAW + environment: + IpCheck__ServerUrl: http://server:8080 + IpCheck__LocationId: ${IPCHECK_LOCATION_ID} + IpCheck__SyncIntervalSeconds: ${IPCHECK_SYNC_INTERVAL_SECONDS:-30} + depends_on: + server: + condition: service_healthy + +volumes: + postgres-data: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d34808e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,14 @@ +# Архитектура IpCheck + +Центральный F#-сервер хранит IP-адреса, локации, расписание и результаты. Агент в каждой локации синхронизирует общий список целей, выполняет ICMP Ping и TCP-проверки SSH/RDP и возвращает результаты. + +## Первый вертикальный срез + +- создание IP через `POST /api/targets`; +- создание локации через `POST /api/locations`; +- получение агентом полного списка через `GET /api/agent/sync/{locationId}`; +- отправка результатов через `POST /api/agent/results`; +- просмотр результатов через `GET /api/results`; +- проверка работоспособности через `GET /healthz`. + +Текущее оперативное хранилище — in-memory. PostgreSQL-схема создаётся при наличии строки подключения; следующим этапом репозитории переводятся на постоянное хранение. diff --git a/global.json b/global.json new file mode 100644 index 0000000..5f5c8e8 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestPatch" + } +} diff --git a/src/IpCheck.Agent/IpCheck.Agent.fsproj b/src/IpCheck.Agent/IpCheck.Agent.fsproj new file mode 100644 index 0000000..0c38429 --- /dev/null +++ b/src/IpCheck.Agent/IpCheck.Agent.fsproj @@ -0,0 +1,9 @@ + + net10.0 + + + + + + + diff --git a/src/IpCheck.Agent/Program.fs b/src/IpCheck.Agent/Program.fs new file mode 100644 index 0000000..d957a4c --- /dev/null +++ b/src/IpCheck.Agent/Program.fs @@ -0,0 +1,12 @@ +namespace IpCheck.Agent + +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting + +module Program = + [] + let main args = + let builder = Host.CreateApplicationBuilder(args) + builder.Services.AddHostedService() |> ignore + builder.Build().Run() + 0 diff --git a/src/IpCheck.Agent/Worker.fs b/src/IpCheck.Agent/Worker.fs new file mode 100644 index 0000000..0c6b02f --- /dev/null +++ b/src/IpCheck.Agent/Worker.fs @@ -0,0 +1,84 @@ +namespace IpCheck.Agent + +open System +open System.Diagnostics +open System.Net.NetworkInformation +open System.Net.Sockets +open System.Net.Http +open System.Net.Http.Json +open System.Threading +open System.Threading.Tasks +open IpCheck.Contracts +open Microsoft.Extensions.Configuration +open Microsoft.Extensions.Hosting +open Microsoft.Extensions.Logging + +module private Checks = + let ping (address: string) (targetId: Guid) (locationId: Guid) (_cancellationToken: CancellationToken) = task { + let stopwatch = Stopwatch.StartNew() + try + use ping = new Ping() + let! (reply: PingReply) = ping.SendPingAsync(address, 3000) + stopwatch.Stop() + return { + TargetId = targetId; LocationId = locationId; CheckType = "ping"; Port = Nullable() + Status = if reply.Status = IPStatus.Success then "available" else "unavailable" + LatencyMs = if reply.Status = IPStatus.Success then Nullable reply.RoundtripTime else Nullable() + Error = if reply.Status = IPStatus.Success then "" else string reply.Status + CheckedAt = DateTimeOffset.UtcNow + } + with ex -> + return { TargetId = targetId; LocationId = locationId; CheckType = "ping"; Port = Nullable(); Status = "error"; LatencyMs = Nullable(); Error = ex.Message; CheckedAt = DateTimeOffset.UtcNow } + } + + let tcp (name: string) (address: string) (port: int) (targetId: Guid) (locationId: Guid) (cancellationToken: CancellationToken) = task { + let stopwatch = Stopwatch.StartNew() + try + use client = new TcpClient() + use timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + timeout.CancelAfter(TimeSpan.FromSeconds 5.0) + do! client.ConnectAsync(address, port, timeout.Token) + stopwatch.Stop() + return { TargetId = targetId; LocationId = locationId; CheckType = name; Port = Nullable port; Status = "available"; LatencyMs = Nullable stopwatch.ElapsedMilliseconds; Error = ""; CheckedAt = DateTimeOffset.UtcNow } + with + | :? OperationCanceledException -> + return { TargetId = targetId; LocationId = locationId; CheckType = name; Port = Nullable port; Status = "timeout"; LatencyMs = Nullable(); Error = "Тайм-аут подключения"; CheckedAt = DateTimeOffset.UtcNow } + | ex -> + return { TargetId = targetId; LocationId = locationId; CheckType = name; Port = Nullable port; Status = "unavailable"; LatencyMs = Nullable(); Error = ex.Message; CheckedAt = DateTimeOffset.UtcNow } + } + +type Worker(configuration: IConfiguration, logger: ILogger) = + inherit BackgroundService() + + let serverUrl = configuration["IpCheck:ServerUrl"] |> Option.ofObj |> Option.defaultValue "http://localhost:8080" + let locationId = configuration["IpCheck:LocationId"] |> Guid.Parse + let pollInterval = configuration.GetValue("IpCheck:SyncIntervalSeconds", 30) |> int64 |> TimeSpan.FromSeconds + let http = new HttpClient(BaseAddress = Uri serverUrl) + + override _.ExecuteAsync(stoppingToken: CancellationToken) = task { + logger.LogInformation("Агент локации {LocationId} запущен", locationId) + while not stoppingToken.IsCancellationRequested do + try + let! sync = http.GetFromJsonAsync($"/api/agent/sync/{locationId}", stoppingToken) + if not (isNull (box sync)) then + let results = ResizeArray() + for target in sync.Targets do + if target.PingEnabled then + let! result = Checks.ping target.IpAddress target.Id locationId stoppingToken + results.Add result + if target.SshPort.HasValue then + let! result = Checks.tcp "ssh" target.IpAddress target.SshPort.Value target.Id locationId stoppingToken + results.Add result + if target.RdpPort.HasValue then + let! result = Checks.tcp "rdp" target.IpAddress target.RdpPort.Value target.Id locationId stoppingToken + results.Add result + if results.Count > 0 then + use! response = http.PostAsJsonAsync("/api/agent/results", results.ToArray(), stoppingToken) + response.EnsureSuccessStatusCode() |> ignore + with ex -> logger.LogError(ex, "Ошибка синхронизации с сервером") + do! Task.Delay(pollInterval, stoppingToken) + } + + override _.Dispose() = + http.Dispose() + base.Dispose() diff --git a/src/IpCheck.Agent/appsettings.json b/src/IpCheck.Agent/appsettings.json new file mode 100644 index 0000000..5a8d772 --- /dev/null +++ b/src/IpCheck.Agent/appsettings.json @@ -0,0 +1,7 @@ +{ + "IpCheck": { + "ServerUrl": "http://localhost:8080", + "LocationId": "11111111-1111-1111-1111-111111111111", + "SyncIntervalSeconds": 30 + } +} diff --git a/src/IpCheck.Contracts/Contracts.fs b/src/IpCheck.Contracts/Contracts.fs new file mode 100644 index 0000000..0d9a520 --- /dev/null +++ b/src/IpCheck.Contracts/Contracts.fs @@ -0,0 +1,61 @@ +namespace IpCheck.Contracts + +open System + +[] +type TargetDto = { + Id: Guid + IpAddress: string + Description: string + PingEnabled: bool + SshPort: Nullable + RdpPort: Nullable + IntervalSeconds: int + Enabled: bool + CreatedAt: DateTimeOffset +} + +[] +type CreateTargetRequest = { + IpAddress: string + Description: string + PingEnabled: bool + SshPort: Nullable + RdpPort: Nullable + IntervalSeconds: int +} + +[] +type LocationDto = { + Id: Guid + Name: string + Description: string + AgentStatus: string + LastSeenAt: Nullable + CreatedAt: DateTimeOffset +} + +[] +type CreateLocationRequest = { + Name: string + Description: string +} + +[] +type CheckResultDto = { + TargetId: Guid + LocationId: Guid + CheckType: string + Port: Nullable + Status: string + LatencyMs: Nullable + Error: string + CheckedAt: DateTimeOffset +} + +[] +type AgentSyncResponse = { + LocationId: Guid + ServerTime: DateTimeOffset + Targets: TargetDto array +} diff --git a/src/IpCheck.Contracts/IpCheck.Contracts.fsproj b/src/IpCheck.Contracts/IpCheck.Contracts.fsproj new file mode 100644 index 0000000..a15997f --- /dev/null +++ b/src/IpCheck.Contracts/IpCheck.Contracts.fsproj @@ -0,0 +1,4 @@ + + net10.0 + + diff --git a/src/IpCheck.Domain/Domain.fs b/src/IpCheck.Domain/Domain.fs new file mode 100644 index 0000000..04800db --- /dev/null +++ b/src/IpCheck.Domain/Domain.fs @@ -0,0 +1,47 @@ +namespace IpCheck.Domain + +open System +open System.Net + +[] +type TargetId = TargetId of Guid + +[] +type LocationId = LocationId of Guid + +type ServiceCheck = + | Ping + | TcpPort of name: string * port: uint16 + +type CheckOutcome = + | Available of latency: TimeSpan + | Unavailable of reason: string + | TimedOut + +type Target = { + Id: TargetId + Address: IPAddress + Description: string option + Checks: ServiceCheck list + Interval: TimeSpan + Enabled: bool +} + +module Target = + let create (address: string) (description: string option) (checks: ServiceCheck list) (interval: TimeSpan) = + if interval < TimeSpan.FromSeconds 10.0 then + Error "Интервал проверки не может быть меньше 10 секунд" + elif List.isEmpty checks then + Error "Необходимо выбрать хотя бы одну проверку" + else + match IPAddress.TryParse address with + | true, ip -> + Ok { + Id = TargetId(Guid.NewGuid()) + Address = ip + Description = description + Checks = checks + Interval = interval + Enabled = true + } + | _ -> Error "Некорректный IP-адрес" diff --git a/src/IpCheck.Domain/IpCheck.Domain.fsproj b/src/IpCheck.Domain/IpCheck.Domain.fsproj new file mode 100644 index 0000000..7feb821 --- /dev/null +++ b/src/IpCheck.Domain/IpCheck.Domain.fsproj @@ -0,0 +1,9 @@ + + + net10.0 + true + + + + + diff --git a/src/IpCheck.Persistence/IpCheck.Persistence.fsproj b/src/IpCheck.Persistence/IpCheck.Persistence.fsproj new file mode 100644 index 0000000..20a0bd7 --- /dev/null +++ b/src/IpCheck.Persistence/IpCheck.Persistence.fsproj @@ -0,0 +1,7 @@ + + net10.0 + + + + + diff --git a/src/IpCheck.Persistence/Postgres.fs b/src/IpCheck.Persistence/Postgres.fs new file mode 100644 index 0000000..4d8f5d6 --- /dev/null +++ b/src/IpCheck.Persistence/Postgres.fs @@ -0,0 +1,15 @@ +namespace IpCheck.Persistence + +open System.IO +open System.Threading +open System.Threading.Tasks +open Npgsql + +type PostgresSchema(connectionString: string, migrationPath: string) = + member _.EnsureCreated(cancellationToken: CancellationToken) = task { + let sql = File.ReadAllText migrationPath + use dataSource = NpgsqlDataSource.Create connectionString + use command = dataSource.CreateCommand(sql) + let! _ = command.ExecuteNonQueryAsync(cancellationToken) + return () + } diff --git a/src/IpCheck.Server/IpCheck.Server.fsproj b/src/IpCheck.Server/IpCheck.Server.fsproj new file mode 100644 index 0000000..0c23749 --- /dev/null +++ b/src/IpCheck.Server/IpCheck.Server.fsproj @@ -0,0 +1,10 @@ + + net10.0 + + + + + + + + diff --git a/src/IpCheck.Server/Program.fs b/src/IpCheck.Server/Program.fs new file mode 100644 index 0000000..6a893c4 --- /dev/null +++ b/src/IpCheck.Server/Program.fs @@ -0,0 +1,47 @@ +namespace IpCheck.Server + +open System +open System.IO +open IpCheck.Contracts +open IpCheck.Persistence +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting + +module Program = + [] + let main args = + let builder = WebApplication.CreateBuilder(args) + builder.Services.AddSingleton() |> ignore + builder.Services.AddHealthChecks() |> ignore + let app = builder.Build() + let store = app.Services.GetRequiredService() + store.Seed() + + let connectionString = builder.Configuration["ConnectionStrings:Postgres"] + if not (String.IsNullOrWhiteSpace connectionString) then + let migration = Path.Combine(app.Environment.ContentRootPath, "migrations", "001_initial.sql") + PostgresSchema(connectionString, migration).EnsureCreated(app.Lifetime.ApplicationStopping).GetAwaiter().GetResult() + + app.MapHealthChecks("/healthz") |> ignore + app.MapGet("/api/targets", Func(fun () -> Results.Ok(store.ListTargets()))) |> ignore + app.MapPost("/api/targets", Func(fun request -> + match store.AddTarget request with + | Ok target -> Results.Created($"/api/targets/{target.Id}", target) + | Error message -> Results.BadRequest {| error = message |})) |> ignore + app.MapGet("/api/locations", Func(fun () -> Results.Ok(store.ListLocations()))) |> ignore + app.MapPost("/api/locations", Func(fun request -> + match store.AddLocation request with + | Ok location -> Results.Created($"/api/locations/{location.Id}", location) + | Error message -> Results.BadRequest {| error = message |})) |> ignore + app.MapGet("/api/results", Func(fun limit -> Results.Ok(store.ListResults(if limit <= 0 then 200 else min limit 2000)))) |> ignore + app.MapGet("/api/agent/sync/{locationId:guid}", Func(fun locationId -> + match store.Sync locationId with + | Some response -> Results.Ok response + | None -> Results.NotFound {| error = "Локация не найдена" |})) |> ignore + app.MapPost("/api/agent/results", Func(fun items -> + Results.Accepted(value = {| accepted = store.AddResults items |}))) |> ignore + + app.Run() + 0 diff --git a/src/IpCheck.Server/Store.fs b/src/IpCheck.Server/Store.fs new file mode 100644 index 0000000..f934939 --- /dev/null +++ b/src/IpCheck.Server/Store.fs @@ -0,0 +1,75 @@ +namespace IpCheck.Server + +open System +open System.Collections.Concurrent +open System.Net +open IpCheck.Contracts + +type MemoryStore() = + let targets = ConcurrentDictionary() + let locations = ConcurrentDictionary() + let results = ConcurrentQueue() + + member _.ListTargets() = targets.Values |> Seq.sortBy _.IpAddress |> Seq.toArray + + member _.AddTarget(request: CreateTargetRequest) = + match IPAddress.TryParse request.IpAddress with + | false, _ -> Error "Некорректный IP-адрес" + | true, _ when not request.PingEnabled && not request.SshPort.HasValue && not request.RdpPort.HasValue -> + Error "Выберите хотя бы одну проверку" + | true, _ when request.IntervalSeconds < 10 -> Error "Минимальный интервал — 10 секунд" + | true, _ -> + let target = { + Id = Guid.NewGuid() + IpAddress = request.IpAddress + Description = request.Description + PingEnabled = request.PingEnabled + SshPort = request.SshPort + RdpPort = request.RdpPort + IntervalSeconds = request.IntervalSeconds + Enabled = true + CreatedAt = DateTimeOffset.UtcNow + } + targets[target.Id] <- target + Ok target + + member _.ListLocations() = locations.Values |> Seq.sortBy _.Name |> Seq.toArray + + member _.AddLocation(request: CreateLocationRequest) = + if String.IsNullOrWhiteSpace request.Name then Error "Название локации обязательно" + else + let location = { + Id = Guid.NewGuid() + Name = request.Name.Trim() + Description = request.Description + AgentStatus = "awaiting-agent" + LastSeenAt = Nullable() + CreatedAt = DateTimeOffset.UtcNow + } + locations[location.Id] <- location + Ok location + + member _.Sync(locationId: Guid) = + match locations.TryGetValue locationId with + | true, location -> + locations[locationId] <- { location with AgentStatus = "online"; LastSeenAt = Nullable DateTimeOffset.UtcNow } + Some { LocationId = locationId; ServerTime = DateTimeOffset.UtcNow; Targets = targets.Values |> Seq.filter _.Enabled |> Seq.toArray } + | _ -> None + + member _.AddResults(items: CheckResultDto array) = + items |> Array.iter results.Enqueue + items.Length + + member _.ListResults(limit: int) = results.ToArray() |> Array.sortByDescending _.CheckedAt |> Array.truncate limit + + member this.Seed() = + if locations.IsEmpty then + let location = { + Id = Guid.Parse "11111111-1111-1111-1111-111111111111" + Name = "Локальная локация" + Description = "Агент разработки" + AgentStatus = "awaiting-agent" + LastSeenAt = Nullable() + CreatedAt = DateTimeOffset.UtcNow + } + locations[location.Id] <- location diff --git a/src/IpCheck.Server/appsettings.json b/src/IpCheck.Server/appsettings.json new file mode 100644 index 0000000..c9cdada --- /dev/null +++ b/src/IpCheck.Server/appsettings.json @@ -0,0 +1,5 @@ +{ + "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, + "AllowedHosts": "*", + "ConnectionStrings": { "Postgres": "" } +} diff --git a/src/IpCheck.Server/migrations/001_initial.sql b/src/IpCheck.Server/migrations/001_initial.sql new file mode 100644 index 0000000..18bbef1 --- /dev/null +++ b/src/IpCheck.Server/migrations/001_initial.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS locations ( + id uuid PRIMARY KEY, + name text NOT NULL, + description text NOT NULL DEFAULT '', + agent_status text NOT NULL, + last_seen_at timestamptz, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS targets ( + id uuid PRIMARY KEY, + ip_address inet NOT NULL UNIQUE, + description text NOT NULL DEFAULT '', + ping_enabled boolean NOT NULL, + ssh_port integer, + rdp_port integer, + interval_seconds integer NOT NULL CHECK (interval_seconds >= 10), + enabled boolean NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS check_results ( + id bigserial PRIMARY KEY, + target_id uuid NOT NULL REFERENCES targets(id), + location_id uuid NOT NULL REFERENCES locations(id), + check_type text NOT NULL, + port integer, + status text NOT NULL, + latency_ms bigint, + error text NOT NULL DEFAULT '', + checked_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_check_results_target_location_time + ON check_results(target_id, location_id, checked_at DESC); diff --git a/tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj b/tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj new file mode 100644 index 0000000..6ea1fd0 --- /dev/null +++ b/tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj @@ -0,0 +1,7 @@ + + Exenet10.0 + + + + + diff --git a/tests/IpCheck.UnitTests/Program.fs b/tests/IpCheck.UnitTests/Program.fs new file mode 100644 index 0000000..f1f0477 --- /dev/null +++ b/tests/IpCheck.UnitTests/Program.fs @@ -0,0 +1,15 @@ +open System +open IpCheck.Domain + +let assertTrue message condition = if not condition then failwith message + +[] +let main _ = + let valid = Target.create "192.168.1.10" None [ Ping; TcpPort("ssh", 22us) ] (TimeSpan.FromSeconds 60.0) + let invalidAddress = Target.create "not-an-ip" None [ Ping ] (TimeSpan.FromSeconds 60.0) + let invalidInterval = Target.create "127.0.0.1" None [ Ping ] (TimeSpan.FromSeconds 5.0) + assertTrue "Корректный адрес должен создавать цель" (Result.isOk valid) + assertTrue "Некорректный адрес должен отклоняться" (Result.isError invalidAddress) + assertTrue "Слишком короткий интервал должен отклоняться" (Result.isError invalidInterval) + printfn "Все доменные тесты пройдены" + 0