feat(api, web): enhance agent installation process with invited status and policy support
- Updated the agent enrollment process to include an 'invited' status, allowing for better tracking of agent states. - Implemented support for install links that can now include an `install_link_id`, facilitating the transition from invited to pending status upon enrollment. - Enhanced the MikroTik installation script to include the `EvofwInstallLinkId` for better tracking and management. - Added new API endpoints for fetching agent policies and serving MikroTik-specific installation scripts. - Improved the web UI to reflect the new agent statuses and provide copyable installation commands for agents. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -43,8 +43,13 @@ CLIENT_TOKEN="$(gen_token)"
|
|||||||
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
|
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
|
||||||
CP_URL="${EVOFW_CP_URL%/}"
|
CP_URL="${EVOFW_CP_URL%/}"
|
||||||
|
|
||||||
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1"}' \
|
if [[ -n "${EVOFW_INSTALL_LINK_ID:-}" ]]; then
|
||||||
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN")
|
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1","install_link_id":"%s"}' \
|
||||||
|
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN" "$EVOFW_INSTALL_LINK_ID")
|
||||||
|
else
|
||||||
|
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1"}' \
|
||||||
|
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN")
|
||||||
|
fi
|
||||||
|
|
||||||
ENROLL_TMP=$(mktemp)
|
ENROLL_TMP=$(mktemp)
|
||||||
trap 'rm -f "$ENROLL_TMP"' EXIT
|
trap 'rm -f "$ENROLL_TMP"' EXIT
|
||||||
|
|||||||
@@ -1,46 +1,84 @@
|
|||||||
# EvoFirewall MikroTik install (RouterOS 7+)
|
# EvoFirewall MikroTik install (RouterOS 7.21+)
|
||||||
# Usage: import after setting globals, or paste into terminal.
|
# Short-link sets EvofwCpUrl / EvofwSeed / EvofwName / EvofwInstallLinkId before body.
|
||||||
# Required globals before import (or edit below):
|
# Legacy: set globals, then /import file-name=mikrotik-install.rsc
|
||||||
# :global EvofwCpUrl "https://fw.example.com"
|
#
|
||||||
# :global EvofwSeed "YOUR_SEED"
|
# Blacklist: drop EVOFW_DENY on input+forward
|
||||||
# :global EvofwName "mt-01"
|
# Whitelist: accept EVOFW_ALLOW + drop others on forward only (input stays open for Winbox/SSH)
|
||||||
|
|
||||||
:global EvofwCpUrl
|
:global EvofwCpUrl
|
||||||
:global EvofwSeed
|
:global EvofwSeed
|
||||||
:global EvofwName
|
:global EvofwName
|
||||||
|
:global EvofwInstallLinkId
|
||||||
|
|
||||||
:if ([:typeof $EvofwCpUrl] = "nothing") do={ :error "EvofwCpUrl required" }
|
:if ([:typeof $EvofwCpUrl] = "nothing" || [:len $EvofwCpUrl] = 0) do={ :error "EvofwCpUrl required" }
|
||||||
:if ([:typeof $EvofwSeed] = "nothing") do={ :error "EvofwSeed required" }
|
:if ([:typeof $EvofwSeed] = "nothing" || [:len $EvofwSeed] = 0) do={ :error "EvofwSeed required" }
|
||||||
:if ([:typeof $EvofwName] = "nothing") do={ :set EvofwName [/system identity get name] }
|
:if ([:typeof $EvofwName] = "nothing" || [:len $EvofwName] = 0) do={ :set EvofwName [/system identity get name] }
|
||||||
|
|
||||||
:local token ("evofw_" . [/certificate scep-server nonce generate])
|
:local token ("evofw_" . [/certificate scep-server nonce generate])
|
||||||
:if ([:len $token] < 20) do={
|
:if ([:len $token] < 20) do={
|
||||||
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]])
|
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]] . [:tostr [/system resource get free-memory]])
|
||||||
}
|
}
|
||||||
|
|
||||||
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/1\"}")
|
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/1\"")
|
||||||
|
:if ([:typeof $EvofwInstallLinkId] != "nothing" && [:len $EvofwInstallLinkId] > 0) do={
|
||||||
|
:set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"")
|
||||||
|
}
|
||||||
|
:set body ($body . "}")
|
||||||
|
|
||||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Seed: " . $EvofwSeed) http-data=$body keep-result=no
|
:do {
|
||||||
|
/tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Seed: " . $EvofwSeed) http-data=$body keep-result=no
|
||||||
|
} on-error={
|
||||||
|
:error "evofw: enroll failed — check EvofwCpUrl / EvofwSeed / connectivity"
|
||||||
|
}
|
||||||
|
|
||||||
# Persist credentials for scheduler script
|
# Persist credentials
|
||||||
/system script remove [find name="evofw-env"]
|
:do { /system script remove [find name="evofw-env"] } on-error={}
|
||||||
/system script add name=evofw-env source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $token . "\" ")
|
/system script add name=evofw-env policy=read,write,policy,test source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $token . "\" ")
|
||||||
|
|
||||||
/system script remove [find name="evofw-sync"]
|
# Filter rules (idempotent by comment)
|
||||||
|
:do { /ip firewall filter remove [find comment~"^evofw-"] } on-error={}
|
||||||
|
|
||||||
|
/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-bl-drop-input disabled=no
|
||||||
|
/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-bl-drop-forward disabled=no
|
||||||
|
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-wl-accept-forward disabled=yes
|
||||||
|
/ip firewall filter add chain=forward action=drop comment=evofw-wl-drop-forward disabled=yes
|
||||||
|
|
||||||
|
# Sync: fetch policy.rsc → import address-lists + toggle mode
|
||||||
|
:do { /system script remove [find name="evofw-sync"] } on-error={}
|
||||||
/system script add name=evofw-sync policy=read,write,policy,test source={
|
/system script add name=evofw-sync policy=read,write,policy,test source={
|
||||||
:global EvofwCpUrl
|
:global EvofwCpUrl
|
||||||
:global EvofwToken
|
:global EvofwToken
|
||||||
:if ([:typeof $EvofwCpUrl] = "nothing" || [:typeof $EvofwToken] = "nothing") do={ /system script run evofw-env }
|
:if ([:typeof $EvofwCpUrl] = "nothing" || [:typeof $EvofwToken] = "nothing") do={
|
||||||
:local tmp [/file get [find name="evofw-policy.json"] name]
|
/system script run evofw-env
|
||||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/policy") http-header-field=("Authorization: Bearer " . $EvofwToken) dst-path=evofw-policy.json
|
}
|
||||||
# Address-lists: EVOFW_DENY / EVOFW_ALLOW — operator should map filter rules once:
|
:if ([:typeof $EvofwCpUrl] = "nothing" || [:typeof $EvofwToken] = "nothing") do={
|
||||||
# /ip firewall filter add chain=input src-address-list=EVOFW_DENY action=drop comment=evofw
|
:log error "evofw: missing EvofwCpUrl/EvofwToken"
|
||||||
# whitelist: policy drop + accept EVOFW_ALLOW
|
:error "evofw env missing"
|
||||||
:log info "evofw: policy fetched — apply address-lists via controller export or manual parse"
|
}
|
||||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/heartbeat") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data="{\"source\":\"mikrotik\"}" keep-result=no
|
:do {
|
||||||
|
/tool fetch url=($EvofwCpUrl . "/v1/agent/policy.rsc") http-header-field=("Authorization: Bearer " . $EvofwToken) dst-path=evofw-policy.rsc
|
||||||
|
/import file-name=evofw-policy.rsc
|
||||||
|
} on-error={
|
||||||
|
:log warning "evofw: policy sync failed (pending approval or network)"
|
||||||
|
}
|
||||||
|
:local denyCnt [:len [/ip firewall address-list find list=EVOFW_DENY]]
|
||||||
|
:local allowCnt [:len [/ip firewall address-list find list=EVOFW_ALLOW]]
|
||||||
|
:local cnt ($denyCnt + $allowCnt)
|
||||||
|
:local report ("{\"status\":\"ok\",\"prefix_count\":" . $cnt . ",\"kernel_method\":\"address-list\",\"source\":\"mikrotik\"}")
|
||||||
|
:do {
|
||||||
|
/tool fetch url=($EvofwCpUrl . "/v1/agent/apply-report") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data=$report keep-result=no
|
||||||
|
} on-error={}
|
||||||
|
:do {
|
||||||
|
/tool fetch url=($EvofwCpUrl . "/v1/agent/heartbeat") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data="{\"source\":\"mikrotik\"}" keep-result=no
|
||||||
|
} on-error={}
|
||||||
|
:log info ("evofw: sync done deny=" . $denyCnt . " allow=" . $allowCnt)
|
||||||
}
|
}
|
||||||
|
|
||||||
/system scheduler remove [find name="evofw-sync"]
|
:do { /system scheduler remove [find name="evofw-sync"] } on-error={}
|
||||||
/system scheduler add name=evofw-sync interval=1m on-event=evofw-sync
|
/system scheduler add name=evofw-sync interval=1m on-event=evofw-sync policy=read,write,policy,test
|
||||||
|
|
||||||
:put ("EvoFirewall enrolled as " . $EvofwName . " — approve in UI, ensure filter rules for EVOFW_* lists")
|
:do { /system script run evofw-sync } on-error={
|
||||||
|
:log info "evofw: initial sync skipped (approve agent in UI)"
|
||||||
|
}
|
||||||
|
|
||||||
|
:put ("EvoFirewall enrolled as " . $EvofwName . " — approve in UI; scheduler evofw-sync every 1m")
|
||||||
|
|||||||
+3
-3
@@ -19,7 +19,7 @@ import { refreshAllLists } from './services/lists/refresh.js'
|
|||||||
import { repos } from '@evofw/db'
|
import { repos } from '@evofw/db'
|
||||||
import {
|
import {
|
||||||
isValidInstallSlug,
|
isValidInstallSlug,
|
||||||
resolveAndRenderInstallScript,
|
resolveAndRenderInstall,
|
||||||
} from './services/install-links.js'
|
} from './services/install-links.js'
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
@@ -85,13 +85,13 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
) {
|
) {
|
||||||
const link = repos.getInstallLinkBySlug(app.db, segment)
|
const link = repos.getInstallLinkBySlug(app.db, segment)
|
||||||
if (link) {
|
if (link) {
|
||||||
const script = resolveAndRenderInstallScript(
|
const { body, contentType } = resolveAndRenderInstall(
|
||||||
app.db,
|
app.db,
|
||||||
link,
|
link,
|
||||||
config.publicBaseUrl,
|
config.publicBaseUrl,
|
||||||
config.enrollSeed,
|
config.enrollSeed,
|
||||||
)
|
)
|
||||||
return reply.type('text/x-shellscript').send(script)
|
return reply.type(contentType).send(body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function isAgentPath(url: string): boolean {
|
|||||||
const path = url.split('?')[0] ?? url
|
const path = url.split('?')[0] ?? url
|
||||||
return (
|
return (
|
||||||
path === '/v1/agent/policy' ||
|
path === '/v1/agent/policy' ||
|
||||||
|
path === '/v1/agent/policy.rsc' ||
|
||||||
path === '/v1/agent/apply-report' ||
|
path === '/v1/agent/apply-report' ||
|
||||||
path === '/v1/agent/heartbeat'
|
path === '/v1/agent/heartbeat'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { enrollBodySchema, applyReportBodySchema } from '@evofw/shared'
|
|||||||
import type { AppConfig } from '../config.js'
|
import type { AppConfig } from '../config.js'
|
||||||
import { hashToken } from '../plugins/auth.js'
|
import { hashToken } from '../plugins/auth.js'
|
||||||
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
||||||
|
import { renderMikrotikPolicyRsc } from '../services/policy/mikrotik-rsc.js'
|
||||||
import { AppError } from '../plugins/error-handler.js'
|
import { AppError } from '../plugins/error-handler.js'
|
||||||
import { resolveAndRenderInstallScript } from '../services/install-links.js'
|
import { resolveAndRenderInstall } from '../services/install-links.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const scriptsDir = join(__dirname, '../agent-scripts')
|
const scriptsDir = join(__dirname, '../agent-scripts')
|
||||||
@@ -29,13 +30,13 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const link = repos.getInstallLink(app.db, req.params.id)
|
const link = repos.getInstallLink(app.db, req.params.id)
|
||||||
if (!link) throw new AppError('NOT_FOUND', 'Install link not found', 404)
|
if (!link) throw new AppError('NOT_FOUND', 'Install link not found', 404)
|
||||||
const script = resolveAndRenderInstallScript(
|
const { body, contentType } = resolveAndRenderInstall(
|
||||||
app.db,
|
app.db,
|
||||||
link,
|
link,
|
||||||
config.publicBaseUrl,
|
config.publicBaseUrl,
|
||||||
config.enrollSeed,
|
config.enrollSeed,
|
||||||
)
|
)
|
||||||
return reply.type('text/x-shellscript').send(script)
|
return reply.type(contentType).send(body)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,12 +61,52 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
|
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
|
||||||
}
|
}
|
||||||
const body = enrollBodySchema.parse(req.body)
|
const body = enrollBodySchema.parse(req.body)
|
||||||
const id = crypto.randomUUID()
|
|
||||||
const tokenHash = hashToken(body.token)
|
const tokenHash = hashToken(body.token)
|
||||||
const existing = repos.getAgentByTokenHash(app.db, tokenHash)
|
const existingByToken = repos.getAgentByTokenHash(app.db, tokenHash)
|
||||||
if (existing) {
|
if (existingByToken) {
|
||||||
throw new AppError('CONFLICT', 'Token already enrolled', 409)
|
throw new AppError('CONFLICT', 'Token already enrolled', 409)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.install_link_id) {
|
||||||
|
const link = repos.getInstallLink(app.db, body.install_link_id)
|
||||||
|
if (!link) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Install link not found', 404)
|
||||||
|
}
|
||||||
|
if (link.revokedAt) {
|
||||||
|
throw new AppError('GONE', 'Install link revoked', 410)
|
||||||
|
}
|
||||||
|
if (!link.agentId) {
|
||||||
|
throw new AppError('CONFLICT', 'Install link has no agent', 409)
|
||||||
|
}
|
||||||
|
const invited = repos.getAgent(app.db, link.agentId)
|
||||||
|
if (!invited) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||||
|
}
|
||||||
|
if (invited.status !== 'invited' && invited.status !== 'pending') {
|
||||||
|
throw new AppError(
|
||||||
|
'CONFLICT',
|
||||||
|
`Agent status is ${invited.status}`,
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const agent = repos.updateAgent(app.db, invited.id, {
|
||||||
|
name: body.name,
|
||||||
|
hostname: body.hostname ?? null,
|
||||||
|
platform: body.platform ?? invited.platform,
|
||||||
|
tokenPrefix: body.token.slice(0, 12),
|
||||||
|
tokenHash,
|
||||||
|
status: 'pending',
|
||||||
|
clientVersion: body.client_version ?? null,
|
||||||
|
})
|
||||||
|
return reply.code(201).send({
|
||||||
|
client_id: agent!.id,
|
||||||
|
id: agent!.id,
|
||||||
|
status: agent!.status,
|
||||||
|
name: agent!.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID()
|
||||||
const agent = repos.insertAgent(app.db, {
|
const agent = repos.insertAgent(app.db, {
|
||||||
id,
|
id,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
@@ -114,6 +155,16 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/v1/agent/policy.rsc', async (req, reply) => {
|
||||||
|
const agentId = req.agentId!
|
||||||
|
const policy = evaluateAgentPolicy(app.db, agentId)
|
||||||
|
repos.updateAgent(app.db, agentId, {
|
||||||
|
lastSeenAt: new Date().toISOString(),
|
||||||
|
lastSeenIp: req.ip,
|
||||||
|
})
|
||||||
|
return reply.type('text/plain').send(renderMikrotikPolicyRsc(policy))
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/v1/agent/apply-report', async (req) => {
|
app.post('/v1/agent/apply-report', async (req) => {
|
||||||
const agentId = req.agentId!
|
const agentId = req.agentId!
|
||||||
const body = applyReportBodySchema.parse(req.body)
|
const body = applyReportBodySchema.parse(req.body)
|
||||||
|
|||||||
@@ -29,10 +29,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
mapInstallLink,
|
mapInstallLink,
|
||||||
randomToken,
|
randomToken,
|
||||||
|
buildInstallUrls,
|
||||||
} from '../services/install-links.js'
|
} from '../services/install-links.js'
|
||||||
|
import { hashToken } from '../plugins/auth.js'
|
||||||
import type { AppConfig } from '../config.js'
|
import type { AppConfig } from '../config.js'
|
||||||
|
|
||||||
function mapAgent(a: NonNullable<ReturnType<typeof repos.getAgent>>) {
|
function mapAgent(
|
||||||
|
a: NonNullable<ReturnType<typeof repos.getAgent>>,
|
||||||
|
opts?: { installCurl?: string | null; installLinkId?: string | null },
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
@@ -55,6 +60,8 @@ function mapAgent(a: NonNullable<ReturnType<typeof repos.getAgent>>) {
|
|||||||
created_at: a.createdAt,
|
created_at: a.createdAt,
|
||||||
approved_at: a.approvedAt,
|
approved_at: a.approvedAt,
|
||||||
revoked_at: a.revokedAt,
|
revoked_at: a.revokedAt,
|
||||||
|
install_curl: opts?.installCurl ?? null,
|
||||||
|
install_link_id: opts?.installLinkId ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,20 +155,49 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
|
|
||||||
app.post('/install-links', async (req, reply) => {
|
app.post('/install-links', async (req, reply) => {
|
||||||
const body = createInstallLinkBodySchema.parse(req.body)
|
const body = createInstallLinkBodySchema.parse(req.body)
|
||||||
const id = randomToken(10)
|
const linkId = randomToken(10)
|
||||||
const slug = randomToken(16)
|
const slug = randomToken(16)
|
||||||
if (repos.getInstallLink(app.db, id) || repos.getInstallLinkBySlug(app.db, slug)) {
|
if (
|
||||||
|
repos.getInstallLink(app.db, linkId) ||
|
||||||
|
repos.getInstallLinkBySlug(app.db, slug)
|
||||||
|
) {
|
||||||
throw new AppError('CONFLICT', 'Retry create (id collision)', 409)
|
throw new AppError('CONFLICT', 'Retry create (id collision)', 409)
|
||||||
}
|
}
|
||||||
const row = repos.insertInstallLink(app.db, {
|
|
||||||
id,
|
const agentId = crypto.randomUUID()
|
||||||
slug,
|
const inviteToken = `invite:${agentId}`
|
||||||
clientName: body.name.trim(),
|
const now = new Date().toISOString()
|
||||||
platform: body.platform ?? 'linux',
|
const name = body.name.trim()
|
||||||
createdAt: new Date().toISOString(),
|
const platform = body.platform ?? 'linux'
|
||||||
useCount: 0,
|
|
||||||
})
|
app.sqlite.transaction(() => {
|
||||||
return reply.code(201).send(mapInstallLink(row!, config.publicBaseUrl))
|
repos.insertAgent(app.db, {
|
||||||
|
id: agentId,
|
||||||
|
name,
|
||||||
|
hostname: null,
|
||||||
|
platform,
|
||||||
|
tokenPrefix: inviteToken.slice(0, 12),
|
||||||
|
tokenHash: hashToken(inviteToken),
|
||||||
|
status: 'invited',
|
||||||
|
policyMode: 'blacklist',
|
||||||
|
policyGeneration: 1,
|
||||||
|
clientVersion: null,
|
||||||
|
settingsJson: '{}',
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
repos.insertInstallLink(app.db, {
|
||||||
|
id: linkId,
|
||||||
|
slug,
|
||||||
|
clientName: name,
|
||||||
|
platform,
|
||||||
|
agentId,
|
||||||
|
createdAt: now,
|
||||||
|
useCount: 0,
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
const row = repos.getInstallLink(app.db, linkId)!
|
||||||
|
return reply.code(201).send(mapInstallLink(row, config.publicBaseUrl))
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>(
|
app.delete<{ Params: { id: string } }>(
|
||||||
@@ -175,9 +211,27 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
app.get('/agents', async () => ({
|
app.get('/agents', async () => {
|
||||||
items: repos.listAgents(app.db).map(mapAgent),
|
const all = repos.listAgents(app.db)
|
||||||
}))
|
return {
|
||||||
|
items: all.map((a) => {
|
||||||
|
const link = repos.getInstallLinkByAgentId(app.db, a.id)
|
||||||
|
if (!link || link.revokedAt) {
|
||||||
|
return mapAgent(a)
|
||||||
|
}
|
||||||
|
const urls = buildInstallUrls(
|
||||||
|
config.publicBaseUrl,
|
||||||
|
link.id,
|
||||||
|
link.slug,
|
||||||
|
link.platform === 'mikrotik' ? 'mikrotik' : 'linux',
|
||||||
|
)
|
||||||
|
return mapAgent(a, {
|
||||||
|
installCurl: urls.curl.by_slug,
|
||||||
|
installLinkId: link.id,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
|
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
|
||||||
const a = repos.getAgent(app.db, req.params.id)
|
const a = repos.getAgent(app.db, req.params.id)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe('install-links', () => {
|
|||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates link and serves scripts by id and slug', async () => {
|
it('creates invited agent and serves scripts by id and slug', async () => {
|
||||||
const app = await appPromise
|
const app = await appPromise
|
||||||
await app.ready()
|
await app.ready()
|
||||||
|
|
||||||
@@ -37,12 +37,27 @@ describe('install-links', () => {
|
|||||||
const body = created.json() as {
|
const body = created.json() as {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
|
agent_id: string
|
||||||
curl: { by_id: string; by_slug: string }
|
curl: { by_id: string; by_slug: string }
|
||||||
}
|
}
|
||||||
expect(body.id).toBeTruthy()
|
expect(body.id).toBeTruthy()
|
||||||
expect(body.slug).toBeTruthy()
|
expect(body.slug).toBeTruthy()
|
||||||
|
expect(body.agent_id).toBeTruthy()
|
||||||
expect(body.curl.by_id).toContain(`/agent-install/${body.id}`)
|
expect(body.curl.by_id).toContain(`/agent-install/${body.id}`)
|
||||||
|
|
||||||
|
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
|
||||||
|
expect(agents.statusCode).toBe(200)
|
||||||
|
const list = agents.json() as {
|
||||||
|
items: {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
install_curl?: string | null
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
const invited = list.items.find((a) => a.id === body.agent_id)
|
||||||
|
expect(invited?.status).toBe('invited')
|
||||||
|
expect(invited?.install_curl).toContain(body.slug)
|
||||||
|
|
||||||
const byId = await app.inject({
|
const byId = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: `/agent-install/${body.id}`,
|
url: `/agent-install/${body.id}`,
|
||||||
@@ -50,7 +65,7 @@ describe('install-links', () => {
|
|||||||
expect(byId.statusCode).toBe(200)
|
expect(byId.statusCode).toBe(200)
|
||||||
expect(byId.headers['content-type']).toContain('text/x-shellscript')
|
expect(byId.headers['content-type']).toContain('text/x-shellscript')
|
||||||
expect(byId.body).toContain("EVOFW_CLIENT_NAME='web-01'")
|
expect(byId.body).toContain("EVOFW_CLIENT_NAME='web-01'")
|
||||||
expect(byId.body).toContain("EVOFW_SEED='test-seed'")
|
expect(byId.body).toContain(`EVOFW_INSTALL_LINK_ID='${body.id}'`)
|
||||||
|
|
||||||
const bySlug = await app.inject({
|
const bySlug = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -59,4 +74,125 @@ describe('install-links', () => {
|
|||||||
expect(bySlug.statusCode).toBe(200)
|
expect(bySlug.statusCode).toBe(200)
|
||||||
expect(bySlug.body).toContain("EVOFW_CP_URL='https://fw.example.com'")
|
expect(bySlug.body).toContain("EVOFW_CP_URL='https://fw.example.com'")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('enroll with install_link_id updates invited agent to pending', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name: 'web-02', platform: 'linux' },
|
||||||
|
})
|
||||||
|
const link = created.json() as { id: string; agent_id: string }
|
||||||
|
|
||||||
|
const enroll = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/agent/enroll',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-evofw-seed': 'test-seed',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
name: 'web-02',
|
||||||
|
hostname: 'host-02',
|
||||||
|
platform: 'linux',
|
||||||
|
token: 'evofw_test_token_1234567890abcd',
|
||||||
|
install_link_id: link.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(enroll.statusCode).toBe(201)
|
||||||
|
const enrolled = enroll.json() as { id: string; status: string }
|
||||||
|
expect(enrolled.id).toBe(link.agent_id)
|
||||||
|
expect(enrolled.status).toBe('pending')
|
||||||
|
|
||||||
|
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
|
||||||
|
const list = agents.json() as { items: { id: string; status: string }[] }
|
||||||
|
const row = list.items.find((a) => a.id === link.agent_id)
|
||||||
|
expect(row?.status).toBe('pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mikrotik install link serves RSC and fetch/import one-liner', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name: 'mt-01', platform: 'mikrotik' },
|
||||||
|
})
|
||||||
|
expect(created.statusCode).toBe(201)
|
||||||
|
const body = created.json() as {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
agent_id: string
|
||||||
|
curl: { by_id: string; by_slug: string }
|
||||||
|
}
|
||||||
|
expect(body.curl.by_id).toContain('/tool fetch url=')
|
||||||
|
expect(body.curl.by_id).toContain('/import file-name=evofw-install.rsc')
|
||||||
|
expect(body.curl.by_id).not.toContain('| bash')
|
||||||
|
|
||||||
|
const byId = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/agent-install/${body.id}`,
|
||||||
|
})
|
||||||
|
expect(byId.statusCode).toBe(200)
|
||||||
|
expect(byId.headers['content-type']).toContain('text/plain')
|
||||||
|
expect(byId.body).toContain(':global EvofwCpUrl "https://fw.example.com"')
|
||||||
|
expect(byId.body).toContain(`:global EvofwInstallLinkId "${body.id}"`)
|
||||||
|
expect(byId.body).toContain('evofw-bl-drop-input')
|
||||||
|
expect(byId.body).toContain('/v1/agent/policy.rsc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('approved agent can fetch policy.rsc with address-list commands', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name: 'mt-policy', platform: 'mikrotik' },
|
||||||
|
})
|
||||||
|
const link = created.json() as { id: string; agent_id: string }
|
||||||
|
const token = 'evofw_mt_policy_token_abcdefghij'
|
||||||
|
|
||||||
|
const enroll = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/agent/enroll',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-evofw-seed': 'test-seed',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
name: 'mt-policy',
|
||||||
|
platform: 'mikrotik',
|
||||||
|
token,
|
||||||
|
install_link_id: link.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(enroll.statusCode).toBe(201)
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/agents/${link.agent_id}/approve`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// add a deny override so policy has a CIDR
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/agents/${link.agent_id}/overrides`,
|
||||||
|
payload: { action: 'deny', cidr: '203.0.113.0/24' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const rsc = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/v1/agent/policy.rsc',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(rsc.statusCode).toBe(200)
|
||||||
|
expect(rsc.headers['content-type']).toContain('text/plain')
|
||||||
|
expect(rsc.body).toContain('address-list')
|
||||||
|
expect(rsc.body).toContain('EVOFW_DENY')
|
||||||
|
expect(rsc.body).toContain('203.0.113.0/24')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,10 +42,31 @@ export function isValidInstallSlug(segment: string): boolean {
|
|||||||
return SLUG_RE.test(segment)
|
return SLUG_RE.test(segment)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildInstallUrls(baseUrl: string, id: string, slug: string) {
|
export type InstallPlatform = 'linux' | 'mikrotik'
|
||||||
|
|
||||||
|
function mikrotikFetchImport(url: string): string {
|
||||||
|
return `/tool fetch url="${url}" dst-path=evofw-install.rsc; /import file-name=evofw-install.rsc`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInstallUrls(
|
||||||
|
baseUrl: string,
|
||||||
|
id: string,
|
||||||
|
slug: string,
|
||||||
|
platform: InstallPlatform = 'linux',
|
||||||
|
) {
|
||||||
const base = baseUrl.replace(/\/$/, '')
|
const base = baseUrl.replace(/\/$/, '')
|
||||||
const byId = `${base}/agent-install/${id}`
|
const byId = `${base}/agent-install/${id}`
|
||||||
const bySlug = `${base}/${slug}`
|
const bySlug = `${base}/${slug}`
|
||||||
|
if (platform === 'mikrotik') {
|
||||||
|
return {
|
||||||
|
by_id: byId,
|
||||||
|
by_slug: bySlug,
|
||||||
|
curl: {
|
||||||
|
by_id: mikrotikFetchImport(byId),
|
||||||
|
by_slug: mikrotikFetchImport(bySlug),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
by_id: byId,
|
by_id: byId,
|
||||||
by_slug: bySlug,
|
by_slug: bySlug,
|
||||||
@@ -60,12 +81,14 @@ export function mapInstallLink(
|
|||||||
row: NonNullable<ReturnType<typeof repos.getInstallLink>>,
|
row: NonNullable<ReturnType<typeof repos.getInstallLink>>,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
) {
|
) {
|
||||||
const urls = buildInstallUrls(baseUrl, row.id, row.slug)
|
const platform = (row.platform === 'mikrotik' ? 'mikrotik' : 'linux') as InstallPlatform
|
||||||
|
const urls = buildInstallUrls(baseUrl, row.id, row.slug, platform)
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
slug: row.slug,
|
slug: row.slug,
|
||||||
client_name: row.clientName,
|
client_name: row.clientName,
|
||||||
platform: row.platform as 'linux' | 'mikrotik',
|
platform,
|
||||||
|
agent_id: row.agentId ?? null,
|
||||||
created_at: row.createdAt,
|
created_at: row.createdAt,
|
||||||
revoked_at: row.revokedAt,
|
revoked_at: row.revokedAt,
|
||||||
last_used_at: row.lastUsedAt,
|
last_used_at: row.lastUsedAt,
|
||||||
@@ -79,6 +102,14 @@ function loadInstallSh(): string {
|
|||||||
return readFileSync(join(scriptsDir, 'install.sh'), 'utf-8')
|
return readFileSync(join(scriptsDir, 'install.sh'), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadMikrotikInstallRsc(): string {
|
||||||
|
return readFileSync(join(scriptsDir, 'mikrotik-install.rsc'), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRosString(s: string): string {
|
||||||
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-contained install script: env exports + full install.sh body.
|
* Self-contained install script: env exports + full install.sh body.
|
||||||
*/
|
*/
|
||||||
@@ -87,6 +118,7 @@ export function renderInstallScript(opts: {
|
|||||||
seed: string
|
seed: string
|
||||||
clientName: string
|
clientName: string
|
||||||
platform: string
|
platform: string
|
||||||
|
installLinkId: string
|
||||||
}): string {
|
}): string {
|
||||||
const cp = opts.cpUrl.replace(/\/$/, '')
|
const cp = opts.cpUrl.replace(/\/$/, '')
|
||||||
const escape = (s: string) => s.replace(/'/g, `'\\''`)
|
const escape = (s: string) => s.replace(/'/g, `'\\''`)
|
||||||
@@ -98,6 +130,7 @@ export function renderInstallScript(opts: {
|
|||||||
`export EVOFW_SEED='${escape(opts.seed)}'`,
|
`export EVOFW_SEED='${escape(opts.seed)}'`,
|
||||||
`export EVOFW_CLIENT_NAME='${escape(opts.clientName)}'`,
|
`export EVOFW_CLIENT_NAME='${escape(opts.clientName)}'`,
|
||||||
`export EVOFW_PLATFORM='${escape(opts.platform)}'`,
|
`export EVOFW_PLATFORM='${escape(opts.platform)}'`,
|
||||||
|
`export EVOFW_INSTALL_LINK_ID='${escape(opts.installLinkId)}'`,
|
||||||
'',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
@@ -106,22 +139,73 @@ export function renderInstallScript(opts: {
|
|||||||
return `${header}${body}`
|
return `${header}${body}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personalized MikroTik RSC: globals + mikrotik-install.rsc body.
|
||||||
|
*/
|
||||||
|
export function renderMikrotikInstallScript(opts: {
|
||||||
|
cpUrl: string
|
||||||
|
seed: string
|
||||||
|
clientName: string
|
||||||
|
installLinkId: string
|
||||||
|
}): string {
|
||||||
|
const cp = opts.cpUrl.replace(/\/$/, '')
|
||||||
|
const header = [
|
||||||
|
'# EvoFirewall short install link — globals pre-set (RouterOS 7.21+)',
|
||||||
|
`:global EvofwCpUrl "${escapeRosString(cp)}"`,
|
||||||
|
`:global EvofwSeed "${escapeRosString(opts.seed)}"`,
|
||||||
|
`:global EvofwName "${escapeRosString(opts.clientName)}"`,
|
||||||
|
`:global EvofwInstallLinkId "${escapeRosString(opts.installLinkId)}"`,
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
return `${header}${loadMikrotikInstallRsc()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedInstall = {
|
||||||
|
body: string
|
||||||
|
contentType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use resolveAndRenderInstall */
|
||||||
export function resolveAndRenderInstallScript(
|
export function resolveAndRenderInstallScript(
|
||||||
db: Db,
|
db: Db,
|
||||||
link: NonNullable<ReturnType<typeof repos.getInstallLink>>,
|
link: NonNullable<ReturnType<typeof repos.getInstallLink>>,
|
||||||
publicBaseUrl: string,
|
publicBaseUrl: string,
|
||||||
enrollSeedFallback: string,
|
enrollSeedFallback: string,
|
||||||
): string {
|
): string {
|
||||||
|
return resolveAndRenderInstall(db, link, publicBaseUrl, enrollSeedFallback)
|
||||||
|
.body
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAndRenderInstall(
|
||||||
|
db: Db,
|
||||||
|
link: NonNullable<ReturnType<typeof repos.getInstallLink>>,
|
||||||
|
publicBaseUrl: string,
|
||||||
|
enrollSeedFallback: string,
|
||||||
|
): ResolvedInstall {
|
||||||
if (link.revokedAt) {
|
if (link.revokedAt) {
|
||||||
throw new AppError('GONE', 'Install link revoked', 410)
|
throw new AppError('GONE', 'Install link revoked', 410)
|
||||||
}
|
}
|
||||||
const seed =
|
const seed = repos.getSetting(db, 'enroll_seed') || enrollSeedFallback
|
||||||
repos.getSetting(db, 'enroll_seed') || enrollSeedFallback
|
|
||||||
repos.touchInstallLink(db, link.id)
|
repos.touchInstallLink(db, link.id)
|
||||||
return renderInstallScript({
|
if (link.platform === 'mikrotik') {
|
||||||
cpUrl: publicBaseUrl,
|
return {
|
||||||
seed,
|
body: renderMikrotikInstallScript({
|
||||||
clientName: link.clientName,
|
cpUrl: publicBaseUrl,
|
||||||
platform: link.platform,
|
seed,
|
||||||
})
|
clientName: link.clientName,
|
||||||
|
installLinkId: link.id,
|
||||||
|
}),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
body: renderInstallScript({
|
||||||
|
cpUrl: publicBaseUrl,
|
||||||
|
seed,
|
||||||
|
clientName: link.clientName,
|
||||||
|
platform: link.platform,
|
||||||
|
installLinkId: link.id,
|
||||||
|
}),
|
||||||
|
contentType: 'text/x-shellscript',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { renderMikrotikPolicyRsc, isIpv4Cidr } from './mikrotik-rsc.js'
|
||||||
|
import type { EvaluatedPolicy } from './evaluate.js'
|
||||||
|
|
||||||
|
function basePolicy(
|
||||||
|
overrides: Partial<EvaluatedPolicy> = {},
|
||||||
|
): EvaluatedPolicy {
|
||||||
|
return {
|
||||||
|
generation: 3,
|
||||||
|
hash: 'sha256:abc',
|
||||||
|
policyMode: 'blacklist',
|
||||||
|
denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'],
|
||||||
|
allowCidrs: ['8.8.8.8/32', 'fe80::1/128'],
|
||||||
|
syncIntervalSec: 60,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mikrotik-rsc', () => {
|
||||||
|
it('isIpv4Cidr skips IPv6', () => {
|
||||||
|
expect(isIpv4Cidr('1.2.3.0/24')).toBe(true)
|
||||||
|
expect(isIpv4Cidr('2001:db8::/32')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders blacklist: lists + BL enabled / WL disabled', () => {
|
||||||
|
const rsc = renderMikrotikPolicyRsc(basePolicy())
|
||||||
|
expect(rsc).toContain('# evofw hash=sha256:abc mode=blacklist gen=3')
|
||||||
|
expect(rsc).toContain(
|
||||||
|
'/ip firewall address-list remove [find list=EVOFW_DENY]',
|
||||||
|
)
|
||||||
|
expect(rsc).toContain(
|
||||||
|
'/ip firewall address-list remove [find list=EVOFW_ALLOW]',
|
||||||
|
)
|
||||||
|
expect(rsc).toContain(
|
||||||
|
'add list=EVOFW_DENY address=1.2.3.0/24 comment=evofw',
|
||||||
|
)
|
||||||
|
expect(rsc).toContain(
|
||||||
|
'add list=EVOFW_DENY address=10.0.0.1/32 comment=evofw',
|
||||||
|
)
|
||||||
|
expect(rsc).not.toContain('2001:db8')
|
||||||
|
expect(rsc).toContain(
|
||||||
|
'add list=EVOFW_ALLOW address=8.8.8.8/32 comment=evofw',
|
||||||
|
)
|
||||||
|
expect(rsc).toContain(
|
||||||
|
'set [find comment=evofw-bl-drop-input] disabled=no',
|
||||||
|
)
|
||||||
|
expect(rsc).toContain(
|
||||||
|
'set [find comment=evofw-wl-accept-forward] disabled=yes',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders whitelist: WL enabled / BL disabled', () => {
|
||||||
|
const rsc = renderMikrotikPolicyRsc(
|
||||||
|
basePolicy({ policyMode: 'whitelist' }),
|
||||||
|
)
|
||||||
|
expect(rsc).toContain('mode=whitelist')
|
||||||
|
expect(rsc).toContain(
|
||||||
|
'set [find comment=evofw-bl-drop-forward] disabled=yes',
|
||||||
|
)
|
||||||
|
expect(rsc).toContain(
|
||||||
|
'set [find comment=evofw-wl-drop-forward] disabled=no',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { EvaluatedPolicy } from './evaluate.js'
|
||||||
|
|
||||||
|
/** Skip IPv6 (contains ':') — same as Linux agent. */
|
||||||
|
export function isIpv4Cidr(cidr: string): boolean {
|
||||||
|
const t = cidr.trim()
|
||||||
|
return t.length > 0 && !t.includes(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escAddress(cidr: string): string {
|
||||||
|
// CIDRs are alphanumeric + . / - ; quote if anything odd
|
||||||
|
const t = cidr.trim()
|
||||||
|
if (/^[0-9./-]+$/.test(t)) return t
|
||||||
|
return `"${t.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RouterOS 7.x script: rebuild EVOFW_* address-lists and toggle filter mode.
|
||||||
|
* Device: /tool fetch → /import (no JSON parse on router).
|
||||||
|
*/
|
||||||
|
export function renderMikrotikPolicyRsc(policy: EvaluatedPolicy): string {
|
||||||
|
const isBl = policy.policyMode === 'blacklist'
|
||||||
|
const blDisabled = isBl ? 'no' : 'yes'
|
||||||
|
const wlDisabled = isBl ? 'yes' : 'no'
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`# evofw hash=${policy.hash} mode=${policy.policyMode} gen=${policy.generation}`,
|
||||||
|
'/ip firewall address-list remove [find list=EVOFW_DENY]',
|
||||||
|
'/ip firewall address-list remove [find list=EVOFW_ALLOW]',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const c of policy.denyCidrs) {
|
||||||
|
if (!isIpv4Cidr(c)) continue
|
||||||
|
lines.push(
|
||||||
|
`/ip firewall address-list add list=EVOFW_DENY address=${escAddress(c)} comment=evofw`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const c of policy.allowCidrs) {
|
||||||
|
if (!isIpv4Cidr(c)) continue
|
||||||
|
lines.push(
|
||||||
|
`/ip firewall address-list add list=EVOFW_ALLOW address=${escAddress(c)} comment=evofw`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`:do { /ip firewall filter set [find comment=evofw-bl-drop-input] disabled=${blDisabled} } on-error={}`,
|
||||||
|
`:do { /ip firewall filter set [find comment=evofw-bl-drop-forward] disabled=${blDisabled} } on-error={}`,
|
||||||
|
`:do { /ip firewall filter set [find comment=evofw-wl-accept-forward] disabled=${wlDisabled} } on-error={}`,
|
||||||
|
`:do { /ip firewall filter set [find comment=evofw-wl-drop-forward] disabled=${wlDisabled} } on-error={}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Copy } from 'lucide-react'
|
import { Copy } from 'lucide-react'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@evofw/ui/components/sheet'
|
} from '@evofw/ui/components/sheet'
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create agent install invite — Sheet.
|
* Create agent install invite — Sheet.
|
||||||
@@ -44,12 +45,9 @@ interface AddAgentSheetProps {
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText(text: string) {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
toast.success('Скопировано')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
const [name, setName] = useState('web-01')
|
const [name, setName] = useState('web-01')
|
||||||
const [platform, setPlatform] = useState<Platform>('linux')
|
const [platform, setPlatform] = useState<Platform>('linux')
|
||||||
const [created, setCreated] = useState<InstallLink | null>(null)
|
const [created, setCreated] = useState<InstallLink | null>(null)
|
||||||
@@ -70,13 +68,20 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
|||||||
}),
|
}),
|
||||||
onSuccess: (link) => {
|
onSuccess: (link) => {
|
||||||
setCreated(link)
|
setCreated(link)
|
||||||
toast.success('Ссылка создана')
|
toast.success('Агент создан')
|
||||||
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const canCreate = Boolean(name.trim()) && !create.isPending
|
const canCreate = Boolean(name.trim()) && !create.isPending
|
||||||
|
|
||||||
|
function handleCopy(text: string) {
|
||||||
|
if (!text) return
|
||||||
|
copyToClipboard(text)
|
||||||
|
toast.success('Скопировано')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
@@ -86,8 +91,8 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
|||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
{created
|
{created
|
||||||
? 'Скопируйте one-liner и выполните на хосте. Затем одобрите агента в списке.'
|
? 'Агент уже в списке (Invited). Скопируйте one-liner и выполните на хосте.'
|
||||||
: 'Создайте короткую install-ссылку с именем клиента.'}
|
: 'Создайте агента и короткую install-ссылку.'}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -139,9 +144,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="self-start"
|
className="self-start"
|
||||||
onClick={() =>
|
onClick={() => handleCopy(created.curl?.by_id ?? '')}
|
||||||
void copyText(created.curl?.by_id ?? '')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Copy data-icon="inline-start" />
|
<Copy data-icon="inline-start" />
|
||||||
Копировать
|
Копировать
|
||||||
@@ -159,9 +162,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="self-start"
|
className="self-start"
|
||||||
onClick={() =>
|
onClick={() => handleCopy(created.curl?.by_slug ?? '')}
|
||||||
void copyText(created.curl?.by_slug ?? '')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Copy data-icon="inline-start" />
|
<Copy data-icon="inline-start" />
|
||||||
Копировать
|
Копировать
|
||||||
@@ -182,7 +183,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
|||||||
setCreated(null)
|
setCreated(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Ещё ссылка
|
Ещё агент
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => onOpenChange(false)}>Готово</Button>
|
<Button onClick={() => onOpenChange(false)}>Готово</Button>
|
||||||
</>
|
</>
|
||||||
@@ -191,11 +192,8 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
|||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button disabled={!canCreate} onClick={() => create.mutate()}>
|
||||||
disabled={!canCreate}
|
Создать
|
||||||
onClick={() => create.mutate()}
|
|
||||||
>
|
|
||||||
Создать ссылку
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
|||||||
allow: 'success-light',
|
allow: 'success-light',
|
||||||
pending_push: 'secondary',
|
pending_push: 'secondary',
|
||||||
pending: 'warning-light',
|
pending: 'warning-light',
|
||||||
|
invited: 'info-light',
|
||||||
warning: 'warning-light',
|
warning: 'warning-light',
|
||||||
degraded: 'warning-light',
|
degraded: 'warning-light',
|
||||||
conflict: 'destructive-light',
|
conflict: 'destructive-light',
|
||||||
@@ -54,6 +55,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
synced: 'Синхронизировано',
|
synced: 'Синхронизировано',
|
||||||
pending_push: 'Ожидает отправки',
|
pending_push: 'Ожидает отправки',
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
|
invited: 'Invited',
|
||||||
approved: 'Approved',
|
approved: 'Approved',
|
||||||
revoked: 'Revoked',
|
revoked: 'Revoked',
|
||||||
enabled: 'Включён',
|
enabled: 'Включён',
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
export function useCopyToClipboard({
|
||||||
|
timeout = 2000,
|
||||||
|
onCopy,
|
||||||
|
}: {
|
||||||
|
timeout?: number
|
||||||
|
onCopy?: () => void
|
||||||
|
} = {}) {
|
||||||
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
|
|
||||||
|
const copyToClipboard = (value: string) => {
|
||||||
|
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(value).then(() => {
|
||||||
|
setIsCopied(true)
|
||||||
|
|
||||||
|
if (onCopy) {
|
||||||
|
onCopy()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeout !== 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsCopied(false)
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
}, console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isCopied, copyToClipboard }
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Check, Plus, Trash2 } from 'lucide-react'
|
import { Check, Copy, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
@@ -20,15 +20,21 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
|||||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||||
import { agentsQueryOptions } from '@/queries'
|
import { agentsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@evofw/ui/components/tooltip'
|
||||||
import type { Agent } from '@evofw/shared'
|
import type { Agent } from '@evofw/shared'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* Copyable install: https://reui.io/preview/base/settings-14
|
||||||
* Empty: https://reui.io/preview/base/empty-state-7
|
* Empty: https://reui.io/preview/base/empty-state-7
|
||||||
* Create Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
|
* Create Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
|
||||||
* Docs: https://reui.io/blocks · https://reui.io/components/sheet
|
|
||||||
*/
|
*/
|
||||||
export const Route = createFileRoute('/_auth/agents/')({
|
export const Route = createFileRoute('/_auth/agents/')({
|
||||||
component: AgentsPage,
|
component: AgentsPage,
|
||||||
@@ -37,6 +43,7 @@ export const Route = createFileRoute('/_auth/agents/')({
|
|||||||
function AgentsPage() {
|
function AgentsPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const agentsQ = useQuery(agentsQueryOptions())
|
const agentsQ = useQuery(agentsQueryOptions())
|
||||||
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
@@ -88,6 +95,7 @@ function AgentsPage() {
|
|||||||
label: 'Статус',
|
label: 'Статус',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
|
{ value: 'invited', label: 'invited' },
|
||||||
{ value: 'approved', label: 'approved' },
|
{ value: 'approved', label: 'approved' },
|
||||||
{ value: 'pending', label: 'pending' },
|
{ value: 'pending', label: 'pending' },
|
||||||
{ value: 'revoked', label: 'revoked' },
|
{ value: 'revoked', label: 'revoked' },
|
||||||
@@ -118,6 +126,15 @@ function AgentsPage() {
|
|||||||
return item.status === tabId
|
return item.status === tabId
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleCopyCurl = useCallback(
|
||||||
|
(curl: string) => {
|
||||||
|
if (!curl) return
|
||||||
|
copyToClipboard(curl)
|
||||||
|
toast.success('Скопировано')
|
||||||
|
},
|
||||||
|
[copyToClipboard],
|
||||||
|
)
|
||||||
|
|
||||||
const columns: ColumnDef<Agent>[] = useMemo(
|
const columns: ColumnDef<Agent>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -152,6 +169,39 @@ function AgentsPage() {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'install',
|
||||||
|
enableSorting: false,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Install" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const curl = row.original.install_curl
|
||||||
|
if (!curl) {
|
||||||
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="max-w-[14rem] font-mono text-xs"
|
||||||
|
onClick={() => handleCopyCurl(curl)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy data-icon="inline-start" className="size-3.5" />
|
||||||
|
<span className="truncate">{curl}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-sm break-all font-mono text-xs">
|
||||||
|
{curl}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'policy_mode',
|
accessorKey: 'policy_mode',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -217,7 +267,7 @@ function AgentsPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[approve, revoke],
|
[approve, revoke, handleCopyCurl],
|
||||||
)
|
)
|
||||||
|
|
||||||
const addButton = (
|
const addButton = (
|
||||||
@@ -231,7 +281,7 @@ function AgentsPage() {
|
|||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Агенты"
|
title="Агенты"
|
||||||
description="Linux / MikroTik — short install, approve, policy mode"
|
description="Linux / MikroTik — invite, install, approve"
|
||||||
actions={addButton}
|
actions={addButton}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -248,8 +298,9 @@ function AgentsPage() {
|
|||||||
getFilterFieldValue={getFilterFieldValue}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
{ id: 'approved', label: 'Approved' },
|
{ id: 'invited', label: 'Invited' },
|
||||||
{ id: 'pending', label: 'Pending' },
|
{ id: 'pending', label: 'Pending' },
|
||||||
|
{ id: 'approved', label: 'Approved' },
|
||||||
{ id: 'revoked', label: 'Revoked' },
|
{ id: 'revoked', label: 'Revoked' },
|
||||||
]}
|
]}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
@@ -262,7 +313,7 @@ function AgentsPage() {
|
|||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет агентов',
|
title: 'Нет агентов',
|
||||||
description:
|
description:
|
||||||
'Создайте install-ссылку, выполните curl на хосте и одобрите запрос.',
|
'Создайте агента — он появится в списке как Invited с командой установки.',
|
||||||
action: addButton,
|
action: addButton,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -275,7 +326,7 @@ function AgentsPage() {
|
|||||||
if (!open) setDeleteId(null)
|
if (!open) setDeleteId(null)
|
||||||
}}
|
}}
|
||||||
title="Удалить агента?"
|
title="Удалить агента?"
|
||||||
description="Агент и связанные назначения будут удалены."
|
description="Агент, install-ссылка и связанные назначения будут удалены."
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (deleteId) remove.mutate(deleteId)
|
if (deleteId) remove.mutate(deleteId)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": ["./src/*"],
|
||||||
"@evofw/ui/*": ["../../packages/ui/src/*"],
|
"@evofw/ui/*": ["../../packages/ui/src/*"],
|
||||||
|
|||||||
+36
-7
@@ -2,17 +2,25 @@
|
|||||||
|
|
||||||
## Short install (рекомендуется)
|
## Short install (рекомендуется)
|
||||||
|
|
||||||
В UI `/agents` → **Добавить агента** создаёт install-ссылку. На хосте:
|
В UI `/agents` → **Добавить агента**:
|
||||||
|
|
||||||
|
1. Создаётся агент со статусом **Invited** (сразу виден в таблице) + install-ссылка.
|
||||||
|
2. Скопируйте one-liner (колонка Install или Sheet):
|
||||||
|
|
||||||
|
**Linux:**
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://<cp>/agent-install/<id> | bash
|
curl -fsSL https://<cp>/agent-install/<id> | bash
|
||||||
# или короткий slug:
|
|
||||||
curl -fsSL https://<cp>/<slug> | bash
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт уже содержит `EVOFW_CP_URL`, `EVOFW_SEED`, `EVOFW_CLIENT_NAME`. После enroll одобрите агента во вкладке Pending.
|
**MikroTik:**
|
||||||
|
```
|
||||||
|
/tool fetch url="https://<cp>/agent-install/<id>" dst-path=evofw-install.rsc; /import file-name=evofw-install.rsc
|
||||||
|
```
|
||||||
|
|
||||||
API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" }`.
|
3. После enroll статус станет **Pending** — одобрите агента (Approve).
|
||||||
|
4. **Approved** — агент синхронизирует политику.
|
||||||
|
|
||||||
|
API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" | "mikrotik" }`.
|
||||||
|
|
||||||
## Linux (legacy one-liner)
|
## Linux (legacy one-liner)
|
||||||
|
|
||||||
@@ -24,15 +32,36 @@ curl -fsSL https://<cp>/v1/agent/install.sh | \
|
|||||||
bash
|
bash
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Создаёт нового агента со статусом Pending (без Invited).
|
||||||
|
|
||||||
Файлы: `/etc/evofw/agent.conf`, `/usr/local/sbin/evofw-firewall.sh`, timer `evofw-firewall.timer` (default 1min).
|
Файлы: `/etc/evofw/agent.conf`, `/usr/local/sbin/evofw-firewall.sh`, timer `evofw-firewall.timer` (default 1min).
|
||||||
|
|
||||||
Backend auto-detect: nft → ipset → iptables.
|
Backend auto-detect: nft → ipset → iptables.
|
||||||
|
|
||||||
Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny set.
|
Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny set.
|
||||||
|
|
||||||
## MikroTik
|
## MikroTik (RouterOS 7.21+)
|
||||||
|
|
||||||
Скачайте `/v1/agent/mikrotik-install.rsc`, задайте globals `EvofwCpUrl`, `EvofwSeed`, `EvofwName`, import. Scheduler каждую минуту тянет policy. Настройте filter на address-list `EVOFW_DENY` / `EVOFW_ALLOW`.
|
В UI `/agents` → **Добавить агента** → platform **MikroTik**. Скопируйте one-liner:
|
||||||
|
|
||||||
|
```
|
||||||
|
/tool fetch url="https://<cp>/agent-install/<id>" dst-path=evofw-install.rsc; /import file-name=evofw-install.rsc
|
||||||
|
```
|
||||||
|
|
||||||
|
Или короткий slug: `https://<cp>/<slug>`.
|
||||||
|
|
||||||
|
Install RSC:
|
||||||
|
|
||||||
|
1. Enroll (с `install_link_id` → агент Invited → Pending).
|
||||||
|
2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`.
|
||||||
|
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy.rsc` → `/import` (списки + режим).
|
||||||
|
|
||||||
|
**Blacklist:** `drop` по `EVOFW_DENY` в `input` и `forward`.
|
||||||
|
**Whitelist:** `accept` по `EVOFW_ALLOW` + catch-all `drop` только в `forward` (input не закрывается — Winbox/SSH).
|
||||||
|
|
||||||
|
Legacy: скачайте `/v1/agent/mikrotik-install.rsc`, задайте globals `EvofwCpUrl`, `EvofwSeed`, `EvofwName`, опционально `EvofwInstallLinkId`, затем `/import`.
|
||||||
|
|
||||||
|
Одобрите агента в UI — после Approve sync начнёт применять политику.
|
||||||
|
|
||||||
## Force sync
|
## Force sync
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- Allow invited agents (created in UI before enroll) + link install invites to agents.
|
||||||
|
-- Rebuild agents to widen status CHECK (FK temporarily off).
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
CREATE TABLE agents_v2 (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
hostname TEXT,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'linux',
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
policy_mode TEXT NOT NULL DEFAULT 'blacklist',
|
||||||
|
policy_generation INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
last_seen_ip TEXT,
|
||||||
|
last_apply_at TEXT,
|
||||||
|
last_apply_status TEXT,
|
||||||
|
last_apply_error TEXT,
|
||||||
|
last_apply_prefix_count INTEGER DEFAULT 0,
|
||||||
|
last_apply_packets_dropped INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_apply_packets_accepted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_apply_kernel_method TEXT,
|
||||||
|
client_version TEXT,
|
||||||
|
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_by_user_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
approved_at TEXT,
|
||||||
|
revoked_at TEXT,
|
||||||
|
CHECK (status IN ('invited', 'pending', 'approved', 'revoked')),
|
||||||
|
CHECK (platform IN ('linux', 'mikrotik')),
|
||||||
|
CHECK (policy_mode IN ('blacklist', 'whitelist')),
|
||||||
|
CHECK (length(trim(name)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO agents_v2 (
|
||||||
|
id, name, hostname, platform, token_prefix, token_hash, status, policy_mode,
|
||||||
|
policy_generation, last_seen_at, last_seen_ip, last_apply_at, last_apply_status,
|
||||||
|
last_apply_error, last_apply_prefix_count, last_apply_packets_dropped,
|
||||||
|
last_apply_packets_accepted, last_apply_kernel_method, client_version,
|
||||||
|
settings_json, created_by_user_id, created_at, approved_at, revoked_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, name, hostname, platform, token_prefix, token_hash, status, policy_mode,
|
||||||
|
policy_generation, last_seen_at, last_seen_ip, last_apply_at, last_apply_status,
|
||||||
|
last_apply_error, last_apply_prefix_count, last_apply_packets_dropped,
|
||||||
|
last_apply_packets_accepted, last_apply_kernel_method, client_version,
|
||||||
|
settings_json, created_by_user_id, created_at, approved_at, revoked_at
|
||||||
|
FROM agents;
|
||||||
|
|
||||||
|
DROP TABLE agents;
|
||||||
|
ALTER TABLE agents_v2 RENAME TO agents;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_token_hash ON agents (token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents (status);
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
-- Bind install links to agents
|
||||||
|
ALTER TABLE agent_install_links ADD COLUMN agent_id TEXT REFERENCES agents (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_install_links_agent
|
||||||
|
ON agent_install_links (agent_id);
|
||||||
@@ -459,6 +459,20 @@ export function getInstallLinkBySlug(db: Db, slug: string) {
|
|||||||
.get()
|
.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInstallLinkByAgentId(db: Db, agentId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agentInstallLinks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(agentInstallLinks.agentId, agentId),
|
||||||
|
sql`${agentInstallLinks.revokedAt} IS NULL`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(agentInstallLinks.createdAt))
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
|
||||||
export function insertInstallLink(
|
export function insertInstallLink(
|
||||||
db: Db,
|
db: Db,
|
||||||
row: typeof agentInstallLinks.$inferInsert,
|
row: typeof agentInstallLinks.$inferInsert,
|
||||||
@@ -538,6 +552,7 @@ export const repos = {
|
|||||||
listInstallLinks,
|
listInstallLinks,
|
||||||
getInstallLink,
|
getInstallLink,
|
||||||
getInstallLinkBySlug,
|
getInstallLinkBySlug,
|
||||||
|
getInstallLinkByAgentId,
|
||||||
insertInstallLink,
|
insertInstallLink,
|
||||||
revokeInstallLink,
|
revokeInstallLink,
|
||||||
touchInstallLink,
|
touchInstallLink,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const agents = sqliteTable(
|
|||||||
platform: text('platform').notNull().default('linux'), // linux | mikrotik
|
platform: text('platform').notNull().default('linux'), // linux | mikrotik
|
||||||
tokenPrefix: text('token_prefix').notNull(),
|
tokenPrefix: text('token_prefix').notNull(),
|
||||||
tokenHash: text('token_hash').notNull(),
|
tokenHash: text('token_hash').notNull(),
|
||||||
status: text('status').notNull().default('pending'), // pending | approved | revoked
|
status: text('status').notNull().default('pending'), // invited | pending | approved | revoked
|
||||||
policyMode: text('policy_mode').notNull().default('blacklist'), // blacklist | whitelist
|
policyMode: text('policy_mode').notNull().default('blacklist'), // blacklist | whitelist
|
||||||
policyGeneration: integer('policy_generation').notNull().default(1),
|
policyGeneration: integer('policy_generation').notNull().default(1),
|
||||||
lastSeenAt: text('last_seen_at'),
|
lastSeenAt: text('last_seen_at'),
|
||||||
@@ -202,6 +202,7 @@ export const agentInstallLinks = sqliteTable(
|
|||||||
slug: text('slug').notNull(),
|
slug: text('slug').notNull(),
|
||||||
clientName: text('client_name').notNull(),
|
clientName: text('client_name').notNull(),
|
||||||
platform: text('platform').notNull().default('linux'), // linux | mikrotik
|
platform: text('platform').notNull().default('linux'), // linux | mikrotik
|
||||||
|
agentId: text('agent_id').references(() => agents.id, { onDelete: 'cascade' }),
|
||||||
createdAt: text('created_at')
|
createdAt: text('created_at')
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
@@ -211,6 +212,7 @@ export const agentInstallLinks = sqliteTable(
|
|||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
slugIdx: uniqueIndex('idx_agent_install_links_slug').on(t.slug),
|
slugIdx: uniqueIndex('idx_agent_install_links_slug').on(t.slug),
|
||||||
|
agentIdx: index('idx_agent_install_links_agent').on(t.agentId),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const agentPlatformSchema = z.enum(['linux', 'mikrotik'])
|
export const agentPlatformSchema = z.enum(['linux', 'mikrotik'])
|
||||||
export const agentStatusSchema = z.enum(['pending', 'approved', 'revoked'])
|
export const agentStatusSchema = z.enum([
|
||||||
|
'invited',
|
||||||
|
'pending',
|
||||||
|
'approved',
|
||||||
|
'revoked',
|
||||||
|
])
|
||||||
export const policyModeSchema = z.enum(['blacklist', 'whitelist'])
|
export const policyModeSchema = z.enum(['blacklist', 'whitelist'])
|
||||||
export const policyActionSchema = z.enum(['allow', 'deny'])
|
export const policyActionSchema = z.enum(['allow', 'deny'])
|
||||||
export const ipListTypeSchema = z.enum([
|
export const ipListTypeSchema = z.enum([
|
||||||
@@ -34,6 +39,8 @@ export const agentSchema = z.object({
|
|||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
approved_at: z.string().nullable().optional(),
|
approved_at: z.string().nullable().optional(),
|
||||||
revoked_at: z.string().nullable().optional(),
|
revoked_at: z.string().nullable().optional(),
|
||||||
|
install_curl: z.string().nullable().optional(),
|
||||||
|
install_link_id: z.string().nullable().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const ipListSchema = z.object({
|
export const ipListSchema = z.object({
|
||||||
@@ -151,6 +158,7 @@ export const enrollBodySchema = z.object({
|
|||||||
platform: agentPlatformSchema.optional().default('linux'),
|
platform: agentPlatformSchema.optional().default('linux'),
|
||||||
token: z.string().min(16),
|
token: z.string().min(16),
|
||||||
client_version: z.string().optional(),
|
client_version: z.string().optional(),
|
||||||
|
install_link_id: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const applyReportBodySchema = z.object({
|
export const applyReportBodySchema = z.object({
|
||||||
@@ -192,6 +200,7 @@ export const installLinkSchema = z.object({
|
|||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
client_name: z.string(),
|
client_name: z.string(),
|
||||||
platform: agentPlatformSchema,
|
platform: agentPlatformSchema,
|
||||||
|
agent_id: z.string().nullable().optional(),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
revoked_at: z.string().nullable().optional(),
|
revoked_at: z.string().nullable().optional(),
|
||||||
last_used_at: z.string().nullable().optional(),
|
last_used_at: z.string().nullable().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user