Add open enrollment + update Windows installer

Open enrollment (OPEN_ENROLLMENT=true in .env):
- Agents can register with just --server <url>, no token needed
- Machines assigned to OPEN_ENROLLMENT_USER_EMAIL, first admin, or first user
- Falls back gracefully if env var not set
- agent.py register() now takes optional token; --server alone triggers registration

Agent CLI changes:
- --server without --enroll triggers open enrollment registration on first run
- --enroll still works for token-based or re-enrollment
- Error message updated to reflect new syntax

NSIS installer changes:
- Interactive mode: custom page prompts for server URL + optional token
- Silent mode: /SERVER= alone works with open enrollment, /ENROLL= still supported
- Cleans up config on uninstall

agent.spec: add pyperclip, base64, struct, uuid to hidden imports

docker-compose + .env: OPEN_ENROLLMENT and OPEN_ENROLLMENT_USER_EMAIL vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
monoadmin
2026-04-11 00:02:05 -07:00
parent 61edbf59bf
commit dcf88c7863
6 changed files with 168 additions and 95 deletions

View File

@@ -112,24 +112,26 @@ def save_config(data: dict):
# ── Registration ────────────────────────────────────────────────────────────── # ── Registration ──────────────────────────────────────────────────────────────
async def register(server_url: str, enrollment_token: str) -> dict: async def register(server_url: str, enrollment_token: Optional[str] = None) -> dict:
"""Self-register with the server using an enrollment token.""" """Self-register with the server. Token is optional when open enrollment is enabled."""
hostname = platform.node() hostname = platform.node()
os_name = platform.system() os_name = platform.system()
os_version = platform.version() os_version = platform.version()
payload: dict = {
"name": hostname,
"hostname": hostname,
"os": os_name,
"osVersion": os_version,
"agentVersion": AGENT_VERSION,
"macAddress": get_mac_address(),
}
if enrollment_token:
payload["enrollmentToken"] = enrollment_token
url = f"{server_url.rstrip('/')}/api/agent/register" url = f"{server_url.rstrip('/')}/api/agent/register"
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, json={ resp = await client.post(url, json=payload)
"enrollmentToken": enrollment_token,
"name": hostname,
"hostname": hostname,
"os": os_name,
"osVersion": os_version,
"agentVersion": AGENT_VERSION,
"ipAddress": None,
"macAddress": get_mac_address(),
})
if resp.status_code != 200: if resp.status_code != 200:
raise RuntimeError(f"Registration failed: {resp.status_code} {resp.text}") raise RuntimeError(f"Registration failed: {resp.status_code} {resp.text}")
data = resp.json() data = resp.json()
@@ -815,11 +817,25 @@ async def main():
config = load_config() config = load_config()
# ── First-time registration ────────────────────────────────────────────── # ── First-time registration ──────────────────────────────────────────────
if args.enroll: if args.server and not config:
# Register with enrollment token (if provided) or open enrollment
log.info(f"Registering with server {args.server}")
reg = await register(args.server, args.enroll or None)
relay_url = args.relay or _default_relay_url(args.server)
config = {
"server_url": args.server,
"relay_url": relay_url,
"machine_id": reg["machineId"],
"access_key": reg["accessKey"],
}
if not args.run_once:
save_config(config)
elif args.enroll:
# Re-enroll existing machine with a new token (replaces config)
if not args.server: if not args.server:
log.error("--server is required with --enroll") log.error("--server is required with --enroll")
sys.exit(1) sys.exit(1)
log.info(f"Enrolling with server {args.server}") log.info(f"Re-enrolling with server {args.server}")
reg = await register(args.server, args.enroll) reg = await register(args.server, args.enroll)
relay_url = args.relay or _default_relay_url(args.server) relay_url = args.relay or _default_relay_url(args.server)
config = { config = {
@@ -830,11 +846,10 @@ async def main():
} }
if not args.run_once: if not args.run_once:
save_config(config) save_config(config)
elif not config: elif not config:
log.error( log.error(
f"No config found at {CONFIG_FILE}.\n" f"No config found at {CONFIG_FILE}.\n"
"Run with --server <url> --enroll <token> to register this machine." "Run with --server <url> to register (or --server <url> --enroll <token> if enrollment tokens are required)."
) )
sys.exit(1) sys.exit(1)

View File

@@ -34,12 +34,16 @@ a = Analysis(
'websockets.legacy.client', 'websockets.legacy.client',
'httpx', 'httpx',
'httpcore', 'httpcore',
'pyperclip',
'asyncio', 'asyncio',
'json', 'json',
'logging', 'logging',
'platform', 'platform',
'subprocess', 'subprocess',
'signal', 'signal',
'base64',
'struct',
'uuid',
], ],
hookspath=[], hookspath=[],
runtime_hooks=[], runtime_hooks=[],

View File

@@ -1,21 +1,26 @@
; RemoteLink Agent NSIS Installer ; RemoteLink Agent NSIS Installer
; Requires: NSIS 3.x, the dist/ folder from PyInstaller build ; Requires: NSIS 3.x, the dist/ folder from PyInstaller build
; ;
; Build: makensis installer.nsi ; Build: makensis installer.nsi
; Silent install: RemoteLink-Setup.exe /S ;
; Silent install + enroll: RemoteLink-Setup.exe /S /SERVER=https://myserver.com /ENROLL=mytoken ; Silent install (open enrollment — no token):
; RemoteLink-Setup.exe /S /SERVER=https://myserver.com
;
; Silent install with enrollment token:
; RemoteLink-Setup.exe /S /SERVER=https://myserver.com /ENROLL=mytoken
;
; Interactive install:
; RemoteLink-Setup.exe
!define APP_NAME "RemoteLink Agent" !define APP_NAME "RemoteLink Agent"
!define APP_VERSION "1.0.0" !define APP_VERSION "1.0.0"
!define APP_PUBLISHER "RemoteLink" !define APP_PUBLISHER "RemoteLink"
!define APP_URL "https://remotelink.example.com"
!define INSTALL_DIR "$PROGRAMFILES64\RemoteLink" !define INSTALL_DIR "$PROGRAMFILES64\RemoteLink"
!define SERVICE_EXE "remotelink-agent-service.exe" !define SERVICE_EXE "remotelink-agent-service.exe"
!define AGENT_EXE "remotelink-agent.exe" !define AGENT_EXE "remotelink-agent.exe"
!define REG_KEY "Software\RemoteLink\Agent" !define REG_KEY "Software\RemoteLink\Agent"
!define UNINSTALL_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\RemoteLinkAgent" !define UNINSTALL_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\RemoteLinkAgent"
; Installer settings
Name "${APP_NAME} ${APP_VERSION}" Name "${APP_NAME} ${APP_VERSION}"
OutFile "RemoteLink-Setup.exe" OutFile "RemoteLink-Setup.exe"
InstallDir "${INSTALL_DIR}" InstallDir "${INSTALL_DIR}"
@@ -23,20 +28,22 @@ InstallDirRegKey HKLM "${REG_KEY}" "InstallDir"
RequestExecutionLevel admin RequestExecutionLevel admin
SetCompressor /SOLID lzma SetCompressor /SOLID lzma
; Command-line parameters
Var ServerURL Var ServerURL
Var EnrollToken Var EnrollToken
; Modern UI
!include "MUI2.nsh" !include "MUI2.nsh"
!include "LogicLib.nsh" !include "LogicLib.nsh"
!include "nsProcess.nsh"
!define MUI_ABORTWARNING !define MUI_ABORTWARNING
!define MUI_ICON "assets\icon.ico" !define MUI_ICON "assets\icon.ico"
!define MUI_UNICON "assets\icon.ico" !define MUI_UNICON "assets\icon.ico"
; Pages shown in interactive mode
!insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_WELCOME
; Custom server URL page (interactive only)
Page custom ServerURLPage ServerURLPageLeave
!insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH !insertmacro MUI_PAGE_FINISH
@@ -46,15 +53,48 @@ Var EnrollToken
!insertmacro MUI_LANGUAGE "English" !insertmacro MUI_LANGUAGE "English"
; ── Functions ───────────────────────────────────────────────────────────────── ; ── Custom server URL page ────────────────────────────────────────────────────
Var hServerEdit
Var hTokenEdit
Function ServerURLPage
IfSilent skip_page ; skip in silent/mass-deploy mode
nsDialogs::Create 1018
Pop $0
${NSD_CreateLabel} 0 0 100% 20u "Server URL (e.g. https://remotelink.example.com):"
${NSD_CreateText} 0 22u 100% 14u "$ServerURL"
Pop $hServerEdit
${NSD_CreateLabel} 0 44u 100% 20u "Enrollment Token (leave blank if open enrollment is enabled):"
${NSD_CreateText} 0 66u 100% 14u "$EnrollToken"
Pop $hTokenEdit
nsDialogs::Show
skip_page:
FunctionEnd
Function ServerURLPageLeave
IfSilent skip
${NSD_GetText} $hServerEdit $ServerURL
${NSD_GetText} $hTokenEdit $EnrollToken
${If} $ServerURL == ""
MessageBox MB_OK|MB_ICONEXCLAMATION "Server URL is required."
Abort
${EndIf}
skip:
FunctionEnd
; ── Init: parse command-line ──────────────────────────────────────────────────
Function .onInit Function .onInit
; Parse command-line switches /SERVER= and /ENROLL=
${GetParameters} $R0 ${GetParameters} $R0
ClearErrors
${GetOptions} $R0 "/SERVER=" $ServerURL ${GetOptions} $R0 "/SERVER=" $ServerURL
ClearErrors
${GetOptions} $R0 "/ENROLL=" $EnrollToken ${GetOptions} $R0 "/ENROLL=" $EnrollToken
; Silent mode: skip UI if /S passed
IfSilent 0 +2 IfSilent 0 +2
SetSilent silent SetSilent silent
FunctionEnd FunctionEnd
@@ -64,32 +104,39 @@ FunctionEnd
Section "Install" SEC_MAIN Section "Install" SEC_MAIN
SetOutPath "${INSTALL_DIR}" SetOutPath "${INSTALL_DIR}"
; Stop existing service if running ; Stop and remove existing service
nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" stop' nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" stop'
nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" remove' nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" remove'
Sleep 1000 Sleep 1000
; Copy files from PyInstaller dist/ ; Copy binaries
File "dist\remotelink-agent.exe" File "dist\remotelink-agent.exe"
File "dist\remotelink-agent-service.exe" File "dist\remotelink-agent-service.exe"
; Write registry ; Registry
WriteRegStr HKLM "${REG_KEY}" "InstallDir" "${INSTALL_DIR}" WriteRegStr HKLM "${REG_KEY}" "InstallDir" "${INSTALL_DIR}"
WriteRegStr HKLM "${REG_KEY}" "Version" "${APP_VERSION}" WriteRegStr HKLM "${REG_KEY}" "Version" "${APP_VERSION}"
; Run enrollment if tokens were provided (silent mass-deploy)
${If} $ServerURL != "" ${If} $ServerURL != ""
${AndIf} $EnrollToken != "" WriteRegStr HKLM "${REG_KEY}" "ServerURL" "$ServerURL"
DetailPrint "Enrolling with server $ServerURL…"
nsExec::ExecToLog '"${INSTALL_DIR}\${AGENT_EXE}" --server "$ServerURL" --enroll "$EnrollToken"'
${EndIf} ${EndIf}
; Install + start Windows service ; Enroll with server
${If} $ServerURL != ""
${If} $EnrollToken != ""
DetailPrint "Enrolling with token…"
nsExec::ExecToLog '"${INSTALL_DIR}\${AGENT_EXE}" --server "$ServerURL" --enroll "$EnrollToken"'
${Else}
DetailPrint "Registering (open enrollment)…"
nsExec::ExecToLog '"${INSTALL_DIR}\${AGENT_EXE}" --server "$ServerURL"'
${EndIf}
${EndIf}
; Install and start service
DetailPrint "Installing Windows service…" DetailPrint "Installing Windows service…"
nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" install' nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" install'
nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" start' nsExec::ExecToLog '"${INSTALL_DIR}\${SERVICE_EXE}" start'
; Create uninstaller ; Uninstaller
WriteUninstaller "${INSTALL_DIR}\Uninstall.exe" WriteUninstaller "${INSTALL_DIR}\Uninstall.exe"
WriteRegStr HKLM "${UNINSTALL_KEY}" "DisplayName" "${APP_NAME}" WriteRegStr HKLM "${UNINSTALL_KEY}" "DisplayName" "${APP_NAME}"
WriteRegStr HKLM "${UNINSTALL_KEY}" "UninstallString" "${INSTALL_DIR}\Uninstall.exe" WriteRegStr HKLM "${UNINSTALL_KEY}" "UninstallString" "${INSTALL_DIR}\Uninstall.exe"
@@ -108,9 +155,13 @@ Section "Uninstall"
Delete "${INSTALL_DIR}\remotelink-agent.exe" Delete "${INSTALL_DIR}\remotelink-agent.exe"
Delete "${INSTALL_DIR}\remotelink-agent-service.exe" Delete "${INSTALL_DIR}\remotelink-agent-service.exe"
Delete "${INSTALL_DIR}\agent.json"
Delete "${INSTALL_DIR}\Uninstall.exe" Delete "${INSTALL_DIR}\Uninstall.exe"
RMDir "${INSTALL_DIR}" RMDir "${INSTALL_DIR}"
; Delete config
RMDir /r "$APPDATA\RemoteLink"
DeleteRegKey HKLM "${REG_KEY}" DeleteRegKey HKLM "${REG_KEY}"
DeleteRegKey HKLM "${UNINSTALL_KEY}" DeleteRegKey HKLM "${UNINSTALL_KEY}"
SectionEnd SectionEnd

View File

@@ -1,21 +1,20 @@
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { machines, enrollmentTokens } from '@/lib/db/schema' import { machines, enrollmentTokens, users } from '@/lib/db/schema'
import { eq, and, isNull, or, gt } from 'drizzle-orm' import { eq, asc } from 'drizzle-orm'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
// POST /api/agent/register // POST /api/agent/register
// Two modes: // Three modes:
// 1. First-time: { enrollmentToken, name, hostname, os, osVersion, agentVersion, ipAddress } // 1. Re-register (existing agent): { accessKey, ... } → updates machine
// → creates machine, returns { machineId, accessKey } // 2. Enrollment token: { enrollmentToken, ... } → creates machine owned by token creator
// 2. Re-register: { accessKey, name, hostname, os, osVersion, agentVersion, ipAddress } // 3. Open enrollment (no token): { name, ... } → creates machine, OPEN_ENROLLMENT must be true
// → updates existing machine, returns { machineId }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { accessKey, enrollmentToken, name, hostname, os, osVersion, agentVersion, ipAddress } = body const { accessKey, enrollmentToken, name, hostname, os, osVersion, agentVersion, ipAddress, macAddress } = body
// ── Mode 2: existing agent re-registering ───────────────────────────────── // ── Mode 1: existing agent re-registering ─────────────────────────────────
if (accessKey) { if (accessKey) {
const result = await db const result = await db
.select() .select()
@@ -37,6 +36,7 @@ export async function POST(request: NextRequest) {
osVersion: osVersion || machine.osVersion, osVersion: osVersion || machine.osVersion,
agentVersion: agentVersion || machine.agentVersion, agentVersion: agentVersion || machine.agentVersion,
ipAddress: ipAddress || machine.ipAddress, ipAddress: ipAddress || machine.ipAddress,
macAddress: macAddress || machine.macAddress,
isOnline: true, isOnline: true,
lastSeen: new Date(), lastSeen: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -46,66 +46,67 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ machineId: machine.id }) return NextResponse.json({ machineId: machine.id })
} }
// ── Mode 1: first-time registration with enrollment token ───────────────── if (!name) {
if (!enrollmentToken) { return NextResponse.json({ error: 'name is required' }, { status: 400 })
}
const newAccessKey = randomBytes(32).toString('hex')
// ── Mode 2: enrollment token ───────────────────────────────────────────────
if (enrollmentToken) {
const tokenResult = await db
.select()
.from(enrollmentTokens)
.where(eq(enrollmentTokens.token, enrollmentToken))
.limit(1)
const token = tokenResult[0]
if (!token) return NextResponse.json({ error: 'Invalid enrollment token' }, { status: 401 })
if (token.revokedAt) return NextResponse.json({ error: 'Enrollment token revoked' }, { status: 401 })
if (token.expiresAt && token.expiresAt < new Date()) return NextResponse.json({ error: 'Enrollment token expired' }, { status: 401 })
if (token.maxUses !== null && token.usedCount >= token.maxUses) return NextResponse.json({ error: 'Enrollment token use limit reached' }, { status: 401 })
const newMachine = await db
.insert(machines)
.values({ userId: token.createdBy!, name, hostname, os, osVersion, agentVersion, ipAddress, macAddress, accessKey: newAccessKey, isOnline: true, lastSeen: new Date() })
.returning({ id: machines.id })
await db.update(enrollmentTokens).set({ usedCount: token.usedCount + 1 }).where(eq(enrollmentTokens.id, token.id))
return NextResponse.json({ machineId: newMachine[0].id, accessKey: newAccessKey })
}
// ── Mode 3: open enrollment (no token required) ────────────────────────────
const openEnrollment = process.env.OPEN_ENROLLMENT === 'true'
if (!openEnrollment) {
return NextResponse.json({ error: 'accessKey or enrollmentToken required' }, { status: 400 }) return NextResponse.json({ error: 'accessKey or enrollmentToken required' }, { status: 400 })
} }
const tokenResult = await db // Find the owner: OPEN_ENROLLMENT_USER_EMAIL env var, or first admin, or first user
.select() let ownerId: string | null = null
.from(enrollmentTokens) const ownerEmail = process.env.OPEN_ENROLLMENT_USER_EMAIL
.where(eq(enrollmentTokens.token, enrollmentToken)) if (ownerEmail) {
.limit(1) const ownerResult = await db.select({ id: users.id }).from(users).where(eq(users.email, ownerEmail)).limit(1)
ownerId = ownerResult[0]?.id ?? null
const token = tokenResult[0]
if (!token) {
return NextResponse.json({ error: 'Invalid enrollment token' }, { status: 401 })
} }
if (!ownerId) {
// Check revoked // Fall back to first admin, then first user
if (token.revokedAt) { const adminResult = await db.select({ id: users.id }).from(users).where(eq(users.role, 'admin')).orderBy(asc(users.createdAt)).limit(1)
return NextResponse.json({ error: 'Enrollment token has been revoked' }, { status: 401 }) ownerId = adminResult[0]?.id ?? null
} }
if (!ownerId) {
// Check expiry const anyUser = await db.select({ id: users.id }).from(users).orderBy(asc(users.createdAt)).limit(1)
if (token.expiresAt && token.expiresAt < new Date()) { ownerId = anyUser[0]?.id ?? null
return NextResponse.json({ error: 'Enrollment token has expired' }, { status: 401 })
} }
if (!ownerId) {
// Check max uses return NextResponse.json({ error: 'No users exist on the server yet. Create an account first.' }, { status: 503 })
if (token.maxUses !== null && token.usedCount >= token.maxUses) {
return NextResponse.json({ error: 'Enrollment token has reached its use limit' }, { status: 401 })
} }
if (!name) {
return NextResponse.json({ error: 'name is required for first-time registration' }, { status: 400 })
}
// Generate a secure access key for this machine
const newAccessKey = randomBytes(32).toString('hex')
const newMachine = await db const newMachine = await db
.insert(machines) .insert(machines)
.values({ .values({ userId: ownerId, name, hostname, os, osVersion, agentVersion, ipAddress, macAddress, accessKey: newAccessKey, isOnline: true, lastSeen: new Date() })
userId: token.createdBy!,
name,
hostname,
os,
osVersion,
agentVersion,
ipAddress,
accessKey: newAccessKey,
isOnline: true,
lastSeen: new Date(),
})
.returning({ id: machines.id }) .returning({ id: machines.id })
// Increment use count
await db
.update(enrollmentTokens)
.set({ usedCount: token.usedCount + 1 })
.where(eq(enrollmentTokens.id, token.id))
return NextResponse.json({ machineId: newMachine[0].id, accessKey: newAccessKey }) return NextResponse.json({ machineId: newMachine[0].id, accessKey: newAccessKey })
} catch (error) { } catch (error) {
console.error('[Agent Register] Error:', error) console.error('[Agent Register] Error:', error)

View File

@@ -37,6 +37,8 @@ services:
AUTH_SECRET: ${AUTH_SECRET} AUTH_SECRET: ${AUTH_SECRET}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
NEXT_PUBLIC_RELAY_URL: ${NEXT_PUBLIC_RELAY_URL:-localhost:8765} NEXT_PUBLIC_RELAY_URL: ${NEXT_PUBLIC_RELAY_URL:-localhost:8765}
OPEN_ENROLLMENT: ${OPEN_ENROLLMENT:-false}
OPEN_ENROLLMENT_USER_EMAIL: ${OPEN_ENROLLMENT_USER_EMAIL:-}
env_file: env_file:
- .env - .env
depends_on: depends_on:

File diff suppressed because one or more lines are too long