Add distributed F# monitoring platform
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
.git
|
||||||
|
.next
|
||||||
|
.vinext
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
*.tar.gz
|
||||||
51
.github/workflows/ci.yml
vendored
Normal file
51
.github/workflows/ci.yml
vendored
Normal file
@@ -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 }}
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -41,3 +41,8 @@ yarn-error.log*
|
|||||||
/.wrangler/
|
/.wrangler/
|
||||||
/outputs/
|
/outputs/
|
||||||
/work/
|
/work/
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
.env
|
||||||
|
*.tar.gz
|
||||||
|
/.docker-build-config/
|
||||||
|
|||||||
11
Dockerfile.agent
Normal file
11
Dockerfile.agent
Normal file
@@ -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"]
|
||||||
16
Dockerfile.server
Normal file
16
Dockerfile.server
Normal file
@@ -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"]
|
||||||
12
IpCheck.slnx
Normal file
12
IpCheck.slnx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/IpCheck.Domain/IpCheck.Domain.fsproj" />
|
||||||
|
<Project Path="src/IpCheck.Contracts/IpCheck.Contracts.fsproj" />
|
||||||
|
<Project Path="src/IpCheck.Persistence/IpCheck.Persistence.fsproj" />
|
||||||
|
<Project Path="src/IpCheck.Server/IpCheck.Server.fsproj" />
|
||||||
|
<Project Path="src/IpCheck.Agent/IpCheck.Agent.fsproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
110
README.md
110
README.md
@@ -1,100 +1,42 @@
|
|||||||
# vinext-starter
|
# IpCheck
|
||||||
|
|
||||||
A clean full-stack starter running on
|
Распределённая система мониторинга IP-адресов на F#. Каждый агент представляет отдельную локацию, синхронизирует общий список IP с центральным сервером, выполняет Ping и TCP-проверки SSH/RDP и отправляет результаты обратно.
|
||||||
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
|
|
||||||
Drizzle support.
|
|
||||||
|
|
||||||
## Prerequisites
|
## Состав первого среза
|
||||||
|
|
||||||
- Node.js `>=22.13.0`
|
- F# ASP.NET Core API;
|
||||||
|
- F# Worker Service для агентов локаций;
|
||||||
|
- общие контракты и доменная модель;
|
||||||
|
- схема PostgreSQL;
|
||||||
|
- Docker-образы сервера и агента;
|
||||||
|
- Docker Compose для локального окружения;
|
||||||
|
- CI для сборки, тестов и публикации контейнеров;
|
||||||
|
- существующий интерфейс в `app/` сохранён как визуальный прототип до переноса на Fable.
|
||||||
|
|
||||||
## Quick Start
|
## Локальная проверка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
dotnet restore IpCheck.slnx
|
||||||
npm run dev
|
dotnet build IpCheck.slnx -c Release
|
||||||
npm run build
|
dotnet run --project tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj -c Release
|
||||||
```
|
```
|
||||||
|
|
||||||
This starter does not use `wrangler.jsonc`.
|
Запуск сервера:
|
||||||
|
|
||||||
## Included Shape
|
```bash
|
||||||
|
dotnet run --project src/IpCheck.Server/IpCheck.Server.fsproj
|
||||||
- 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;
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
## Docker Compose
|
||||||
optional or required ChatGPT sign-in:
|
|
||||||
|
|
||||||
- Use `getChatGPTUser()` for optional signed-in UI.
|
Скопируйте `deploy/.env.example` в `deploy/.env`, замените пароль и запустите:
|
||||||
- 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.
|
|
||||||
|
|
||||||
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
|
```bash
|
||||||
OAuth cookies, and identity header injection. Do not implement app routes for
|
docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --build
|
||||||
those reserved paths. Routes that do not import and call the helper remain
|
```
|
||||||
anonymous-compatible.
|
|
||||||
|
|
||||||
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
|
Перевести репозитории IP, локаций и результатов с временного in-memory хранилища на PostgreSQL, добавить регистрацию агентов по одноразовому токену и начать Fable-админку.
|
||||||
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)
|
|
||||||
|
|||||||
48
deploy/docker-compose.yml
Normal file
48
deploy/docker-compose.yml
Normal file
@@ -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:
|
||||||
14
docs/architecture.md
Normal file
14
docs/architecture.md
Normal file
@@ -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-схема создаётся при наличии строки подключения; следующим этапом репозитории переводятся на постоянное хранение.
|
||||||
6
global.json
Normal file
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.302",
|
||||||
|
"rollForward": "latestPatch"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/IpCheck.Agent/IpCheck.Agent.fsproj
Normal file
9
src/IpCheck.Agent/IpCheck.Agent.fsproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||||
|
<ProjectReference Include="../IpCheck.Contracts/IpCheck.Contracts.fsproj" />
|
||||||
|
<Compile Include="Worker.fs" />
|
||||||
|
<Compile Include="Program.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
12
src/IpCheck.Agent/Program.fs
Normal file
12
src/IpCheck.Agent/Program.fs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace IpCheck.Agent
|
||||||
|
|
||||||
|
open Microsoft.Extensions.DependencyInjection
|
||||||
|
open Microsoft.Extensions.Hosting
|
||||||
|
|
||||||
|
module Program =
|
||||||
|
[<EntryPoint>]
|
||||||
|
let main args =
|
||||||
|
let builder = Host.CreateApplicationBuilder(args)
|
||||||
|
builder.Services.AddHostedService<Worker>() |> ignore
|
||||||
|
builder.Build().Run()
|
||||||
|
0
|
||||||
84
src/IpCheck.Agent/Worker.fs
Normal file
84
src/IpCheck.Agent/Worker.fs
Normal file
@@ -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<Worker>) =
|
||||||
|
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<int>("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<AgentSyncResponse>($"/api/agent/sync/{locationId}", stoppingToken)
|
||||||
|
if not (isNull (box sync)) then
|
||||||
|
let results = ResizeArray<CheckResultDto>()
|
||||||
|
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()
|
||||||
7
src/IpCheck.Agent/appsettings.json
Normal file
7
src/IpCheck.Agent/appsettings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"IpCheck": {
|
||||||
|
"ServerUrl": "http://localhost:8080",
|
||||||
|
"LocationId": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"SyncIntervalSeconds": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/IpCheck.Contracts/Contracts.fs
Normal file
61
src/IpCheck.Contracts/Contracts.fs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
namespace IpCheck.Contracts
|
||||||
|
|
||||||
|
open System
|
||||||
|
|
||||||
|
[<CLIMutable>]
|
||||||
|
type TargetDto = {
|
||||||
|
Id: Guid
|
||||||
|
IpAddress: string
|
||||||
|
Description: string
|
||||||
|
PingEnabled: bool
|
||||||
|
SshPort: Nullable<int>
|
||||||
|
RdpPort: Nullable<int>
|
||||||
|
IntervalSeconds: int
|
||||||
|
Enabled: bool
|
||||||
|
CreatedAt: DateTimeOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
[<CLIMutable>]
|
||||||
|
type CreateTargetRequest = {
|
||||||
|
IpAddress: string
|
||||||
|
Description: string
|
||||||
|
PingEnabled: bool
|
||||||
|
SshPort: Nullable<int>
|
||||||
|
RdpPort: Nullable<int>
|
||||||
|
IntervalSeconds: int
|
||||||
|
}
|
||||||
|
|
||||||
|
[<CLIMutable>]
|
||||||
|
type LocationDto = {
|
||||||
|
Id: Guid
|
||||||
|
Name: string
|
||||||
|
Description: string
|
||||||
|
AgentStatus: string
|
||||||
|
LastSeenAt: Nullable<DateTimeOffset>
|
||||||
|
CreatedAt: DateTimeOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
[<CLIMutable>]
|
||||||
|
type CreateLocationRequest = {
|
||||||
|
Name: string
|
||||||
|
Description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
[<CLIMutable>]
|
||||||
|
type CheckResultDto = {
|
||||||
|
TargetId: Guid
|
||||||
|
LocationId: Guid
|
||||||
|
CheckType: string
|
||||||
|
Port: Nullable<int>
|
||||||
|
Status: string
|
||||||
|
LatencyMs: Nullable<int64>
|
||||||
|
Error: string
|
||||||
|
CheckedAt: DateTimeOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
[<CLIMutable>]
|
||||||
|
type AgentSyncResponse = {
|
||||||
|
LocationId: Guid
|
||||||
|
ServerTime: DateTimeOffset
|
||||||
|
Targets: TargetDto array
|
||||||
|
}
|
||||||
4
src/IpCheck.Contracts/IpCheck.Contracts.fsproj
Normal file
4
src/IpCheck.Contracts/IpCheck.Contracts.fsproj
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||||
|
<ItemGroup><Compile Include="Contracts.fs" /></ItemGroup>
|
||||||
|
</Project>
|
||||||
47
src/IpCheck.Domain/Domain.fs
Normal file
47
src/IpCheck.Domain/Domain.fs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
namespace IpCheck.Domain
|
||||||
|
|
||||||
|
open System
|
||||||
|
open System.Net
|
||||||
|
|
||||||
|
[<Struct>]
|
||||||
|
type TargetId = TargetId of Guid
|
||||||
|
|
||||||
|
[<Struct>]
|
||||||
|
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-адрес"
|
||||||
9
src/IpCheck.Domain/IpCheck.Domain.fsproj
Normal file
9
src/IpCheck.Domain/IpCheck.Domain.fsproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="Domain.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
7
src/IpCheck.Persistence/IpCheck.Persistence.fsproj
Normal file
7
src/IpCheck.Persistence/IpCheck.Persistence.fsproj
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Npgsql" Version="10.0.3" />
|
||||||
|
<Compile Include="Postgres.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
15
src/IpCheck.Persistence/Postgres.fs
Normal file
15
src/IpCheck.Persistence/Postgres.fs
Normal file
@@ -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 ()
|
||||||
|
}
|
||||||
10
src/IpCheck.Server/IpCheck.Server.fsproj
Normal file
10
src/IpCheck.Server/IpCheck.Server.fsproj
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../IpCheck.Contracts/IpCheck.Contracts.fsproj" />
|
||||||
|
<ProjectReference Include="../IpCheck.Domain/IpCheck.Domain.fsproj" />
|
||||||
|
<ProjectReference Include="../IpCheck.Persistence/IpCheck.Persistence.fsproj" />
|
||||||
|
<Compile Include="Store.fs" />
|
||||||
|
<Compile Include="Program.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
47
src/IpCheck.Server/Program.fs
Normal file
47
src/IpCheck.Server/Program.fs
Normal file
@@ -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 =
|
||||||
|
[<EntryPoint>]
|
||||||
|
let main args =
|
||||||
|
let builder = WebApplication.CreateBuilder(args)
|
||||||
|
builder.Services.AddSingleton<MemoryStore>() |> ignore
|
||||||
|
builder.Services.AddHealthChecks() |> ignore
|
||||||
|
let app = builder.Build()
|
||||||
|
let store = app.Services.GetRequiredService<MemoryStore>()
|
||||||
|
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<IResult>(fun () -> Results.Ok(store.ListTargets()))) |> ignore
|
||||||
|
app.MapPost("/api/targets", Func<CreateTargetRequest, IResult>(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<IResult>(fun () -> Results.Ok(store.ListLocations()))) |> ignore
|
||||||
|
app.MapPost("/api/locations", Func<CreateLocationRequest, IResult>(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<int, IResult>(fun limit -> Results.Ok(store.ListResults(if limit <= 0 then 200 else min limit 2000)))) |> ignore
|
||||||
|
app.MapGet("/api/agent/sync/{locationId:guid}", Func<Guid, IResult>(fun locationId ->
|
||||||
|
match store.Sync locationId with
|
||||||
|
| Some response -> Results.Ok response
|
||||||
|
| None -> Results.NotFound {| error = "Локация не найдена" |})) |> ignore
|
||||||
|
app.MapPost("/api/agent/results", Func<CheckResultDto array, IResult>(fun items ->
|
||||||
|
Results.Accepted(value = {| accepted = store.AddResults items |}))) |> ignore
|
||||||
|
|
||||||
|
app.Run()
|
||||||
|
0
|
||||||
75
src/IpCheck.Server/Store.fs
Normal file
75
src/IpCheck.Server/Store.fs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
namespace IpCheck.Server
|
||||||
|
|
||||||
|
open System
|
||||||
|
open System.Collections.Concurrent
|
||||||
|
open System.Net
|
||||||
|
open IpCheck.Contracts
|
||||||
|
|
||||||
|
type MemoryStore() =
|
||||||
|
let targets = ConcurrentDictionary<Guid, TargetDto>()
|
||||||
|
let locations = ConcurrentDictionary<Guid, LocationDto>()
|
||||||
|
let results = ConcurrentQueue<CheckResultDto>()
|
||||||
|
|
||||||
|
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
|
||||||
5
src/IpCheck.Server/appsettings.json
Normal file
5
src/IpCheck.Server/appsettings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } },
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": { "Postgres": "" }
|
||||||
|
}
|
||||||
35
src/IpCheck.Server/migrations/001_initial.sql
Normal file
35
src/IpCheck.Server/migrations/001_initial.sql
Normal file
@@ -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);
|
||||||
7
tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj
Normal file
7
tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/IpCheck.Domain/IpCheck.Domain.fsproj" />
|
||||||
|
<Compile Include="Program.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
15
tests/IpCheck.UnitTests/Program.fs
Normal file
15
tests/IpCheck.UnitTests/Program.fs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
open System
|
||||||
|
open IpCheck.Domain
|
||||||
|
|
||||||
|
let assertTrue message condition = if not condition then failwith message
|
||||||
|
|
||||||
|
[<EntryPoint>]
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user