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