Compare commits
17 Commits
agent-v2.0
...
agent-v2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18b45e3f2 | ||
|
|
702de24e28 | ||
|
|
6b3e805ac2 | ||
|
|
7c84912ff5 | ||
|
|
355a53f6e3 | ||
|
|
589516a021 | ||
|
|
f60e6abd33 | ||
|
|
877fadcb6c | ||
|
|
e897a4802f | ||
|
|
c0b20f2f78 | ||
|
|
06e832fca1 | ||
|
|
009ceb86ad | ||
|
|
6f31c41dc3 | ||
|
|
99433a09d1 | ||
|
|
b442ef4102 | ||
|
|
856106174a | ||
|
|
463908b18e |
@@ -67,6 +67,43 @@ jobs:
|
|||||||
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
||||||
cat checksums.txt
|
cat checksums.txt
|
||||||
|
|
||||||
|
- name: Sign artifacts (minisign)
|
||||||
|
env:
|
||||||
|
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$MINISIGN_SECRET_KEY" ]; then
|
||||||
|
echo "::error::MINISIGN_SECRET_KEY secret is not set — refusing to publish unsigned agent artifacts."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# minisign isn't packaged for bullseye — fetch the official static binary.
|
||||||
|
curl -sSL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz -o /tmp/minisign.tgz
|
||||||
|
tar -xzf /tmp/minisign.tgz -C /tmp
|
||||||
|
MINISIGN="$(find /tmp -type f -name minisign -path '*linux*' | head -1)"
|
||||||
|
chmod +x "$MINISIGN"
|
||||||
|
"$MINISIGN" -v
|
||||||
|
# A minisign secret key file is TWO lines (comment + base64 blob). CI
|
||||||
|
# secret storage mangles embedded newlines, collapsing it to one line
|
||||||
|
# so minisign can't load it. Preferred form: store the secret
|
||||||
|
# base64-encoded (single line) — we decode it here. Auto-detect so a
|
||||||
|
# correctly-stored raw two-line key still works.
|
||||||
|
if printf '%s' "$MINISIGN_SECRET_KEY" | base64 -d 2>/dev/null | head -1 | grep -q "untrusted comment:"; then
|
||||||
|
printf '%s' "$MINISIGN_SECRET_KEY" | base64 -d > /tmp/sign.key
|
||||||
|
else
|
||||||
|
printf '%s\n' "$MINISIGN_SECRET_KEY" > /tmp/sign.key
|
||||||
|
fi
|
||||||
|
if ! head -1 /tmp/sign.key | grep -q "untrusted comment:"; then
|
||||||
|
echo "::error::MINISIGN_SECRET_KEY is neither base64 of a minisign key nor a raw two-line key file. Store it as: base64 < your-secret.key | tr -d '\n'"
|
||||||
|
rm -f /tmp/sign.key
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd corrosion-host-agent/bin
|
||||||
|
# Passwordless key (-W generated); feed empty stdin so it never blocks.
|
||||||
|
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
||||||
|
"$MINISIGN" -S -s /tmp/sign.key -m "$f" -x "$f.minisig" < /dev/null
|
||||||
|
done
|
||||||
|
rm -f /tmp/sign.key
|
||||||
|
echo "signed: $(ls *.minisig)"
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
env:
|
env:
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
@@ -82,7 +119,9 @@ jobs:
|
|||||||
"${API_URL}/repos/${REPO}/releases")
|
"${API_URL}/repos/${REPO}/releases")
|
||||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
|
||||||
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-linux-amd64.minisig \
|
||||||
|
corrosion-host-agent-windows-amd64.exe corrosion-host-agent-windows-amd64.exe.minisig \
|
||||||
|
checksums.txt checksums.txt.minisig; do
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
@@ -95,7 +134,9 @@ jobs:
|
|||||||
CDN_URL="https://cdn.corrosionmgmt.com"
|
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-linux-amd64.minisig \
|
||||||
|
corrosion-host-agent-windows-amd64.exe corrosion-host-agent-windows-amd64.exe.minisig \
|
||||||
|
checksums.txt checksums.txt.minisig; do
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@corrosion-host-agent/bin/$f" \
|
-F "file=@corrosion-host-agent/bin/$f" \
|
||||||
"${CDN_URL}/host-agent/alpha/$f"
|
"${CDN_URL}/host-agent/alpha/$f"
|
||||||
|
|||||||
@@ -449,3 +449,5 @@ Things I discovered about myself building a sister platform across multiple sess
|
|||||||
25. **Fixing a dead code path detonates the live code behind it — budget for the second bug.** The moment Lesson 24's fix made the NATS→WS bridge actually deliver events, the API crashed on the first forwarded heartbeat: `WebSocket.OPEN` was `undefined` at runtime because `esModuleInterop` is off, so `import WebSocket from 'ws'` compiled to `ws_1.default` (undefined). That crash had sat behind the dead bridge since the gateway was written — never hit because no event ever reached it. When you resurrect a path that was silently no-op, everything downstream of it is effectively *untested code running for the first time in production*. Verify the whole chain end-to-end (I watched the DB row appear, then flip offline), don't stop at "the subscription fires now." This is Lesson 10 with a fuse on it. Import-runtime gotcha worth remembering: when `esModuleInterop` is off, prefer instance constants (`client.OPEN`) over class statics (`WebSocket.OPEN`) for `ws`.
|
25. **Fixing a dead code path detonates the live code behind it — budget for the second bug.** The moment Lesson 24's fix made the NATS→WS bridge actually deliver events, the API crashed on the first forwarded heartbeat: `WebSocket.OPEN` was `undefined` at runtime because `esModuleInterop` is off, so `import WebSocket from 'ws'` compiled to `ws_1.default` (undefined). That crash had sat behind the dead bridge since the gateway was written — never hit because no event ever reached it. When you resurrect a path that was silently no-op, everything downstream of it is effectively *untested code running for the first time in production*. Verify the whole chain end-to-end (I watched the DB row appear, then flip offline), don't stop at "the subscription fires now." This is Lesson 10 with a fuse on it. Import-runtime gotcha worth remembering: when `esModuleInterop` is off, prefer instance constants (`client.OPEN`) over class statics (`WebSocket.OPEN`) for `ws`.
|
||||||
|
|
||||||
26. **A jail check at the entry point does not jail the recursive walk behind it — and my own "line-by-line" review missed it; the automated security review didn't.** The file manager's `jail()` correctly canonicalized and prefix-checked the top-level path, and I traced every escape vector through it and signed off. But `copy_recursive` then walked the directory tree with `fs::metadata` (which *follows* symlinks). A symlink planted inside the jail pointing at `/etc`, then a `copy` of its parent, would dereference it and pull external content *into* the jail to be read — a jail escape the entry check never sees, because the escape is reintroduced by a descendant during traversal. Fix: `symlink_metadata` (lstat) everywhere you recurse, and refuse/never-follow symlinks across the boundary. The transferable rule: **validate at the boundary AND at every step that re-derives a path** (recursion, `read_dir`, glob, archive extraction). And the humbling part — I was confident after reviewing the jail function; the security-review pass caught the HIGH I'd waved through. Trust adversarial verification over your own once-over on security-critical code, especially path/traversal logic.
|
26. **A jail check at the entry point does not jail the recursive walk behind it — and my own "line-by-line" review missed it; the automated security review didn't.** The file manager's `jail()` correctly canonicalized and prefix-checked the top-level path, and I traced every escape vector through it and signed off. But `copy_recursive` then walked the directory tree with `fs::metadata` (which *follows* symlinks). A symlink planted inside the jail pointing at `/etc`, then a `copy` of its parent, would dereference it and pull external content *into* the jail to be read — a jail escape the entry check never sees, because the escape is reintroduced by a descendant during traversal. Fix: `symlink_metadata` (lstat) everywhere you recurse, and refuse/never-follow symlinks across the boundary. The transferable rule: **validate at the boundary AND at every step that re-derives a path** (recursion, `read_dir`, glob, archive extraction). And the humbling part — I was confident after reviewing the jail function; the security-review pass caught the HIGH I'd waved through. Trust adversarial verification over your own once-over on security-critical code, especially path/traversal logic.
|
||||||
|
|
||||||
|
27. **Validate infra config BEFORE it reaches a deploy — and know that `docker compose up -d <service>` will recreate other services whose definitions changed.** During the NATS auth cutover I ran `docker compose up -d api` to pick up new env. Because the *nats* service definition had also changed (a new volume mount), compose recreated **corrosion-nats too** — and it failed to start on a config error (`no_auth_user` nested inside `authorization{}` instead of at top level), taking the broker down for ~3 minutes with the backend in offline mode. Two lessons: (a) a broker/proxy/DB config file is code — lint it before it can reach a restart (`nats-server -t -c cfg` to test-parse, `nginx -t`, etc.), don't let the first validation be the production container's startup; (b) `compose up -d <one-service>` is not surgical — it reconciles that service's **dependencies** too, so a stale edit to a depended-on service ships when you didn't mean it to. When touching shared-infra config, restart that service explicitly and watch it come up before moving on. Recovery also surfaced a third gotcha: recreating a client (api) while its server (nats) is down leaves the client stuck on a cached DNS failure (`EAI_AGAIN`) — restart the client once the server is healthy.
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
|||||||
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
||||||
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||||
import { FleetModule } from './modules/fleet/fleet.module';
|
import { FleetModule } from './modules/fleet/fleet.module';
|
||||||
|
import { InstancesModule } from './modules/instances/instances.module';
|
||||||
|
|
||||||
// Shared Services
|
// Shared Services
|
||||||
import { NatsService } from './services/nats.service';
|
import { NatsService } from './services/nats.service';
|
||||||
@@ -135,6 +136,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
RaidableBasesModule,
|
RaidableBasesModule,
|
||||||
EarlyAccessModule,
|
EarlyAccessModule,
|
||||||
FleetModule,
|
FleetModule,
|
||||||
|
InstancesModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// Global guards (order matters: auth first, then license, then permissions)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
nats: {
|
nats: {
|
||||||
url: process.env.NATS_URL || 'nats://localhost:4222',
|
url: process.env.NATS_URL || 'nats://localhost:4222',
|
||||||
|
// Public broker address shown to agents in setup instructions.
|
||||||
|
publicUrl: process.env.NATS_PUBLIC_URL || 'nats://nats.corrosionmgmt.com:4222',
|
||||||
// Privileged internal credentials for the backend's own NATS connection
|
// Privileged internal credentials for the backend's own NATS connection
|
||||||
// (full corrosion.> access). Empty = anonymous (transition period).
|
// (full corrosion.> access). Empty = anonymous (transition period).
|
||||||
internalUser: process.env.NATS_INTERNAL_USER || '',
|
internalUser: process.env.NATS_INTERNAL_USER || '',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, Delete, Param } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { FleetService } from './fleet.service';
|
import { FleetService } from './fleet.service';
|
||||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
@@ -16,4 +16,11 @@ export class FleetController {
|
|||||||
async getFleet(@CurrentTenant() licenseId: string) {
|
async getFleet(@CurrentTenant() licenseId: string) {
|
||||||
return this.fleetService.getFleet(licenseId);
|
return this.fleetService.getFleet(licenseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete('hosts/:id')
|
||||||
|
@RequirePermission('server.manage')
|
||||||
|
@ApiOperation({ summary: 'Remove a host and its instances (host must be offline)' })
|
||||||
|
async deleteHost(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.fleetService.deleteHost(licenseId, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { FleetController } from './fleet.controller';
|
|||||||
import { FleetService } from './fleet.service';
|
import { FleetService } from './fleet.service';
|
||||||
import { AgentHost } from '../../entities/agent-host.entity';
|
import { AgentHost } from '../../entities/agent-host.entity';
|
||||||
import { GameInstance } from '../../entities/game-instance.entity';
|
import { GameInstance } from '../../entities/game-instance.entity';
|
||||||
|
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([AgentHost, GameInstance])],
|
imports: [TypeOrmModule.forFeature([AgentHost, GameInstance, ServerConnection])],
|
||||||
controllers: [FleetController],
|
controllers: [FleetController],
|
||||||
providers: [FleetService],
|
providers: [FleetService],
|
||||||
exports: [FleetService],
|
exports: [FleetService],
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AgentHost } from '../../entities/agent-host.entity';
|
import { AgentHost } from '../../entities/agent-host.entity';
|
||||||
import { GameInstance } from '../../entities/game-instance.entity';
|
import { GameInstance } from '../../entities/game-instance.entity';
|
||||||
|
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||||
|
|
||||||
export interface FleetInstanceDto {
|
export interface FleetInstanceDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,8 +50,43 @@ export class FleetService {
|
|||||||
private readonly hostRepo: Repository<AgentHost>,
|
private readonly hostRepo: Repository<AgentHost>,
|
||||||
@InjectRepository(GameInstance)
|
@InjectRepository(GameInstance)
|
||||||
private readonly instanceRepo: Repository<GameInstance>,
|
private readonly instanceRepo: Repository<GameInstance>,
|
||||||
|
@InjectRepository(ServerConnection)
|
||||||
|
private readonly connectionRepo: Repository<ServerConnection>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a host and its game instances from the fleet.
|
||||||
|
*
|
||||||
|
* Refuses while the host is `connected` — a live agent re-registers on its
|
||||||
|
* next heartbeat, so the operator must stop the agent first. Deletes the
|
||||||
|
* host's instances explicitly (the FK is SET NULL, which would otherwise
|
||||||
|
* orphan them); instance_stats cascade. If this was the license's last host,
|
||||||
|
* the legacy single-server connection row is cleared too so the old
|
||||||
|
* Dashboard doesn't show a stale server.
|
||||||
|
*/
|
||||||
|
async deleteHost(
|
||||||
|
licenseId: string,
|
||||||
|
hostId: string,
|
||||||
|
): Promise<{ deleted: true; instances_removed: number }> {
|
||||||
|
const host = await this.hostRepo.findOne({ where: { id: hostId, license_id: licenseId } });
|
||||||
|
if (!host) throw new NotFoundException('Host not found');
|
||||||
|
if (host.status === 'connected') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Host is online — stop the agent first, or it will re-register on its next heartbeat',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const del = await this.instanceRepo.delete({ license_id: licenseId, host_id: hostId });
|
||||||
|
await this.hostRepo.delete({ id: hostId, license_id: licenseId });
|
||||||
|
|
||||||
|
const remaining = await this.hostRepo.count({ where: { license_id: licenseId } });
|
||||||
|
if (remaining === 0) {
|
||||||
|
await this.connectionRepo.delete({ license_id: licenseId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: true, instances_removed: del.affected ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
async getFleet(licenseId: string): Promise<FleetResponseDto> {
|
async getFleet(licenseId: string): Promise<FleetResponseDto> {
|
||||||
const [hosts, instances] = await Promise.all([
|
const [hosts, instances] = await Promise.all([
|
||||||
this.hostRepo.find({
|
this.hostRepo.find({
|
||||||
|
|||||||
133
backend-nest/src/modules/instances/instances.controller.ts
Normal file
133
backend-nest/src/modules/instances/instances.controller.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { Controller, Post, Get, Put, Body, Param, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { InstancesService, LifecycleFunc } from './instances.service';
|
||||||
|
|
||||||
|
@ApiTags('instances')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('instances')
|
||||||
|
export class InstancesController {
|
||||||
|
constructor(private readonly instances: InstancesService) {}
|
||||||
|
|
||||||
|
@Post(':id/lifecycle')
|
||||||
|
@RequirePermission('server.manage')
|
||||||
|
@ApiOperation({ summary: 'Send a lifecycle command to a game instance (start/stop/restart/status/steam_update)' })
|
||||||
|
async lifecycle(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { action: LifecycleFunc },
|
||||||
|
) {
|
||||||
|
return this.instances.lifecycle(licenseId, id, body.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/rcon')
|
||||||
|
@RequirePermission('server.console')
|
||||||
|
@ApiOperation({ summary: 'Send an RCON/console command to a game instance' })
|
||||||
|
async rcon(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { command: string },
|
||||||
|
) {
|
||||||
|
return this.instances.rcon(licenseId, id, body.command);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/files')
|
||||||
|
@RequirePermission('files.view')
|
||||||
|
@ApiOperation({ summary: 'List a directory in the instance (jailed to its root)' })
|
||||||
|
async listFiles(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('path') path?: string,
|
||||||
|
) {
|
||||||
|
return this.instances.listFiles(licenseId, id, path ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/file')
|
||||||
|
@RequirePermission('files.view')
|
||||||
|
@ApiOperation({ summary: 'Read a text file from the instance (jailed, 5 MiB cap)' })
|
||||||
|
async readFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('path') path: string,
|
||||||
|
) {
|
||||||
|
return this.instances.readFile(licenseId, id, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/file')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Write a text file in the instance (jailed)' })
|
||||||
|
async writeFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string; content: string },
|
||||||
|
) {
|
||||||
|
return this.instances.writeFile(licenseId, id, body.path, body.content ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/delete')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete a file or directory (jailed)' })
|
||||||
|
async deleteFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string },
|
||||||
|
) {
|
||||||
|
return this.instances.deleteFile(licenseId, id, body.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/rename')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Rename a file/directory within its parent (jailed)' })
|
||||||
|
async renameFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string; name: string },
|
||||||
|
) {
|
||||||
|
return this.instances.renameFile(licenseId, id, body.path, body.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/mkdir')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Create a directory (jailed)' })
|
||||||
|
async mkdir(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string },
|
||||||
|
) {
|
||||||
|
return this.instances.mkdir(licenseId, id, body.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/mkfile')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Create an empty file (jailed)' })
|
||||||
|
async mkfile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string },
|
||||||
|
) {
|
||||||
|
return this.instances.mkfile(licenseId, id, body.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/move')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Move a file/directory (jailed)' })
|
||||||
|
async moveFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string; dest: string },
|
||||||
|
) {
|
||||||
|
return this.instances.moveFile(licenseId, id, body.path, body.dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/copy')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Copy a file/directory (jailed)' })
|
||||||
|
async copyFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string; dest: string },
|
||||||
|
) {
|
||||||
|
return this.instances.copyFile(licenseId, id, body.path, body.dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend-nest/src/modules/instances/instances.module.ts
Normal file
13
backend-nest/src/modules/instances/instances.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { InstancesController } from './instances.controller';
|
||||||
|
import { InstancesService } from './instances.service';
|
||||||
|
import { GameInstance } from '../../entities/game-instance.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([GameInstance])],
|
||||||
|
controllers: [InstancesController],
|
||||||
|
providers: [InstancesService, NatsService],
|
||||||
|
})
|
||||||
|
export class InstancesModule {}
|
||||||
145
backend-nest/src/modules/instances/instances.service.ts
Normal file
145
backend-nest/src/modules/instances/instances.service.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
import { GameInstance } from '../../entities/game-instance.entity';
|
||||||
|
|
||||||
|
/** Lifecycle funcs the agent's {instance}.cmd handler accepts. */
|
||||||
|
const LIFECYCLE_FUNCS = ['start', 'stop', 'restart', 'status', 'steam_update'] as const;
|
||||||
|
export type LifecycleFunc = (typeof LIFECYCLE_FUNCS)[number];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InstancesService {
|
||||||
|
private readonly logger = new Logger(InstancesService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly nats: NatsService,
|
||||||
|
@InjectRepository(GameInstance)
|
||||||
|
private readonly instanceRepo: Repository<GameInstance>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Resolve an instance the caller's license actually owns (tenant guard). */
|
||||||
|
private async resolveInstance(licenseId: string, instanceId: string): Promise<GameInstance> {
|
||||||
|
const inst = await this.instanceRepo.findOne({
|
||||||
|
where: { id: instanceId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!inst) throw new NotFoundException('Instance not found');
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lifecycle(licenseId: string, instanceId: string, func: LifecycleFunc): Promise<unknown> {
|
||||||
|
if (!LIFECYCLE_FUNCS.includes(func)) {
|
||||||
|
throw new BadRequestException(`Unsupported action '${func}'`);
|
||||||
|
}
|
||||||
|
const inst = await this.resolveInstance(licenseId, instanceId);
|
||||||
|
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
|
||||||
|
this.logger.log(`instance ${inst.agent_instance_id}: ${func}`);
|
||||||
|
return this.nats.requestScoped(licenseId, subject, { func });
|
||||||
|
}
|
||||||
|
|
||||||
|
async rcon(licenseId: string, instanceId: string, command: string): Promise<unknown> {
|
||||||
|
if (!command || !command.trim()) {
|
||||||
|
throw new BadRequestException('command is required');
|
||||||
|
}
|
||||||
|
const inst = await this.resolveInstance(licenseId, instanceId);
|
||||||
|
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
|
||||||
|
// RCON can take longer than a lifecycle ack — give it more headroom.
|
||||||
|
return this.nats.requestScoped(licenseId, subject, { func: 'rcon', command }, 12_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// File access — jailed to the instance root by the agent's file manager.
|
||||||
|
// The agent protocol (corrosion-host-agent/src/filemanager.rs):
|
||||||
|
// { op: list|read|write|delete|rename|mkdir|mkfile|move|copy, path, ... }
|
||||||
|
// reply: { status: 'success'|'error', data?, message? }
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private filesSubject(inst: GameInstance, licenseId: string): string {
|
||||||
|
return `corrosion.${licenseId}.${inst.agent_instance_id}.files.cmd`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fileOp(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<{ status: string; data?: unknown; message?: string }> {
|
||||||
|
const inst = await this.resolveInstance(licenseId, instanceId);
|
||||||
|
const res = await this.nats.requestScoped<{ status: string; data?: unknown; message?: string }>(
|
||||||
|
licenseId,
|
||||||
|
this.filesSubject(inst, licenseId),
|
||||||
|
payload,
|
||||||
|
12_000,
|
||||||
|
);
|
||||||
|
if (res?.status === 'error') {
|
||||||
|
throw new BadRequestException(res.message ?? 'File operation failed');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFiles(licenseId: string, instanceId: string, path = ''): Promise<unknown> {
|
||||||
|
const res = await this.fileOp(licenseId, instanceId, { op: 'list', path });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
const res = await this.fileOp(licenseId, instanceId, { op: 'read', path });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
path: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
const res = await this.fileOp(licenseId, instanceId, { op: 'write', path, content });
|
||||||
|
return res.data ?? { status: 'success' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'delete', path })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameFile(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!path || !name) throw new BadRequestException('path and name are required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'rename', path, name })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(licenseId: string, instanceId: string, path: string): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'mkdir', path })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkfile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'mkfile', path })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveFile(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
path: string,
|
||||||
|
dest: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!path || !dest) throw new BadRequestException('path and dest are required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'move', path, dest })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyFile(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
path: string,
|
||||||
|
dest: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!path || !dest) throw new BadRequestException('path and dest are required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'copy', path, dest })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,13 @@ export class ServersController {
|
|||||||
return await this.serversService.getServer(licenseId);
|
return await this.serversService.getServer(licenseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('agent-credentials')
|
||||||
|
@RequirePermission('server.manage')
|
||||||
|
@ApiOperation({ summary: 'NATS credentials for this license\'s host agent' })
|
||||||
|
async getAgentCredentials(@CurrentTenant() licenseId: string) {
|
||||||
|
return await this.serversService.getAgentCredentials(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
@Put('config')
|
@Put('config')
|
||||||
@RequirePermission('server.manage')
|
@RequirePermission('server.manage')
|
||||||
@ApiOperation({ summary: 'Update server configuration' })
|
@ApiOperation({ summary: 'Update server configuration' })
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ export class ServersService {
|
|||||||
private readonly natsService: NatsService,
|
private readonly natsService: NatsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NATS credentials the customer puts in their host agent's config so it can
|
||||||
|
* authenticate to the per-license-scoped broker. Returns null if the broker
|
||||||
|
* isn't enforcing auth yet (NATS_TOKEN_SECRET unset).
|
||||||
|
*/
|
||||||
|
async getAgentCredentials(licenseId: string) {
|
||||||
|
return this.natsService.getAgentCredentials(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get server connection and config for a license.
|
* Get server connection and config for a license.
|
||||||
* Returns null fields if no server has been set up yet.
|
* Returns null fields if no server has been set up yet.
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
private async onHeartbeat(licenseId: string, payload: HeartbeatPayload): Promise<void> {
|
private async onHeartbeat(licenseId: string, payload: HeartbeatPayload): Promise<void> {
|
||||||
if (!(await this.isValidTenant(licenseId))) return;
|
if (!(await this.isValidTenant(licenseId))) return;
|
||||||
|
// A well-formed v2 heartbeat always carries a host block. Reject malformed
|
||||||
|
// payloads so a stray/empty publish can't create a phantom host row.
|
||||||
|
if (!payload || typeof payload.host !== 'object' || payload.host === null) {
|
||||||
|
this.logger.warn(`ignoring malformed heartbeat for license ${licenseId} (no host block)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await this.updateLegacyConnection(licenseId, now);
|
await this.updateLegacyConnection(licenseId, now);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { connect, NatsConnection, StringCodec, Subscription } from 'nats';
|
import { connect, NatsConnection, StringCodec, Subscription } from 'nats';
|
||||||
|
import { createHmac, randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export interface AgentCredentials {
|
||||||
|
license_id: string;
|
||||||
|
nats_user: string;
|
||||||
|
nats_password: string;
|
||||||
|
nats_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NatsService implements OnModuleInit, OnModuleDestroy {
|
export class NatsService implements OnModuleInit, OnModuleDestroy {
|
||||||
@@ -67,6 +75,64 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return sub;
|
return sub;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-reply to a host-agent subject with a LICENSE-SCOPED reply subject.
|
||||||
|
*
|
||||||
|
* Per-license agent users are confined to corrosion.{license}.> and have no
|
||||||
|
* _INBOX permission, so the agent cannot publish a reply to the default
|
||||||
|
* global inbox. The reply must live inside the license namespace
|
||||||
|
* (corrosion.{license}.reply.<id>); the privileged backend subscribes there.
|
||||||
|
* See corrosion-host-agent/PROTOCOL.md ("Reply-subject rule").
|
||||||
|
*/
|
||||||
|
async requestScoped<T = unknown>(
|
||||||
|
licenseId: string,
|
||||||
|
subject: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
timeoutMs = 8000,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!this.nc) {
|
||||||
|
throw new Error('NATS unavailable — agent is not reachable');
|
||||||
|
}
|
||||||
|
const replySubject = `corrosion.${licenseId}.reply.${randomUUID()}`;
|
||||||
|
const nc = this.nc;
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
nc.subscribe(replySubject, {
|
||||||
|
max: 1,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
callback: (err, msg) => {
|
||||||
|
if (err) {
|
||||||
|
reject(new Error(`agent did not respond within ${timeoutMs}ms`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(this.sc.decode(msg.data)) as T);
|
||||||
|
} catch {
|
||||||
|
resolve(this.sc.decode(msg.data) as unknown as T);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
nc.publish(subject, this.sc.encode(JSON.stringify(payload)), { reply: replySubject });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a license's agent NATS credentials. Password is
|
||||||
|
* HMAC-SHA256(license_id, NATS_TOKEN_SECRET) — must match the broker config
|
||||||
|
* generated by scripts/generate-nats-auth.mjs. Returns null if the secret
|
||||||
|
* isn't configured (broker not yet enforcing auth).
|
||||||
|
*/
|
||||||
|
getAgentCredentials(licenseId: string): AgentCredentials | null {
|
||||||
|
const secret = this.config.get<string>('nats.tokenSecret');
|
||||||
|
if (!secret) return null;
|
||||||
|
const password = createHmac('sha256', secret).update(licenseId).digest('hex');
|
||||||
|
return {
|
||||||
|
license_id: licenseId,
|
||||||
|
nats_user: licenseId,
|
||||||
|
nats_password: password,
|
||||||
|
nats_url: this.config.get<string>('nats.publicUrl') || 'nats://nats.corrosionmgmt.com:4222',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Publish a command to a specific license's server */
|
/** Publish a command to a specific license's server */
|
||||||
async sendServerCommand(licenseId: string, action: string, payload: Record<string, unknown> = {}): Promise<void> {
|
async sendServerCommand(licenseId: string, action: string, payload: Record<string, unknown> = {}): Promise<void> {
|
||||||
await this.publish(`corrosion.${licenseId}.cmd.server`, {
|
await this.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||||
|
|||||||
472
corrosion-host-agent/Cargo.lock
generated
472
corrosion-host-agent/Cargo.lock
generated
@@ -90,7 +90,7 @@ dependencies = [
|
|||||||
"nuid",
|
"nuid",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"regex",
|
"regex",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
@@ -100,7 +100,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_nanos",
|
"serde_nanos",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
@@ -110,6 +110,12 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -180,6 +186,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -264,7 +276,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "corrosion-host-agent"
|
name = "corrosion-host-agent"
|
||||||
version = "2.0.0-alpha.5"
|
version = "2.0.0-alpha.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
@@ -272,7 +284,9 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"futures",
|
"futures",
|
||||||
"libc",
|
"libc",
|
||||||
"rand",
|
"minisign-verify",
|
||||||
|
"rand 0.8.6",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
@@ -585,8 +599,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"r-efi 5.3.0",
|
||||||
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -597,7 +627,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
@@ -633,12 +663,94 @@ dependencies = [
|
|||||||
"itoa",
|
"itoa",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
"want",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"ipnet",
|
||||||
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.65"
|
version = "0.1.65"
|
||||||
@@ -784,6 +896,12 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnet"
|
||||||
|
version = "2.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -852,6 +970,12 @@ version = "0.4.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -867,6 +991,12 @@ version = "2.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -889,7 +1019,7 @@ dependencies = [
|
|||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"log",
|
"log",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"signatory",
|
"signatory",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -917,7 +1047,7 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
|
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1056,6 +1186,61 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -1065,6 +1250,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1078,8 +1269,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1089,7 +1290,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1101,6 +1312,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -1159,6 +1379,47 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -1173,6 +1434,12 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1237,6 +1504,7 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1268,6 +1536,12 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
@@ -1384,6 +1658,18 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -1438,7 +1724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
|
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -1450,7 +1736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1514,6 +1800,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -1558,7 +1853,16 @@ version = "1.0.69"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1572,6 +1876,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thread_local"
|
name = "thread_local"
|
||||||
version = "1.1.9"
|
version = "1.1.9"
|
||||||
@@ -1622,6 +1937,21 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec_macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.3"
|
version = "1.52.3"
|
||||||
@@ -1727,6 +2057,51 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-layer"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -1788,6 +2163,12 @@ dependencies = [
|
|||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "try-lock"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tryhard"
|
name = "tryhard"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -1810,9 +2191,9 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1882,6 +2263,15 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "want"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||||
|
dependencies = [
|
||||||
|
"try-lock",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -1919,6 +2309,16 @@ dependencies = [
|
|||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-futures"
|
||||||
|
version = "0.4.73"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.123"
|
version = "0.2.123"
|
||||||
@@ -1973,6 +2373,19 @@ dependencies = [
|
|||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-streams"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasmparser"
|
name = "wasmparser"
|
||||||
version = "0.244.0"
|
version = "0.244.0"
|
||||||
@@ -1985,6 +2398,35 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "corrosion-host-agent"
|
name = "corrosion-host-agent"
|
||||||
version = "2.0.0-alpha.5"
|
version = "2.0.0-alpha.8"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
||||||
license = "UNLICENSED"
|
license = "UNLICENSED"
|
||||||
@@ -26,6 +26,8 @@ anyhow = "1"
|
|||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
tokio-tungstenite = "0.24"
|
tokio-tungstenite = "0.24"
|
||||||
|
minisign-verify = "0.2.5"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ Request: `{ "func": "<name>" }`. Reply: `{ "status": "success" | "error", ... }`
|
|||||||
| `ping` | `version`, `commit`, `uptime_seconds` |
|
| `ping` | `version`, `commit`, `uptime_seconds` |
|
||||||
| `probe` | `report` — fresh ProbeReport (also cached for heartbeat) |
|
| `probe` | `report` — fresh ProbeReport (also cached for heartbeat) |
|
||||||
| `sysinfo` | `snapshot` — full heartbeat payload, collected on demand |
|
| `sysinfo` | `snapshot` — full heartbeat payload, collected on demand |
|
||||||
|
| `update` | `{ "func": "update", "url": "https://cdn.corrosionmgmt.com/host-agent/.../corrosion-host-agent-<plat>" }` → downloads the binary + `<url>.minisig`, verifies the minisign signature against the agent's EMBEDDED public key, atomically swaps (with `.old` rollback), replies `{ status: success, message: "...relaunching" }`, then relaunches the new binary. Rejects anything not signed by the release key and any URL that isn't `https://cdn.corrosionmgmt.com`. |
|
||||||
|
|
||||||
Unknown funcs return `status: "error"` with a message listing supported funcs.
|
Unknown funcs return `status: "error"` with a message listing supported funcs.
|
||||||
|
|
||||||
@@ -179,6 +180,23 @@ service that attempts connections to the customer's public IP/ports on
|
|||||||
request; that is specified as a Phase 1+ feature and will reuse this report
|
request; that is specified as a Phase 1+ feature and will reuse this report
|
||||||
format with `direction: "inbound"`.
|
format with `direction: "inbound"`.
|
||||||
|
|
||||||
|
## Authentication & tenant isolation
|
||||||
|
|
||||||
|
The broker enforces per-license auth: an agent connects with `user = license_id`,
|
||||||
|
`password = HMAC-SHA256(license_id, NATS_TOKEN_SECRET)` (shown on the panel
|
||||||
|
Server page), and is scoped to `corrosion.{license_id}.>` only. The backend uses
|
||||||
|
a privileged internal user. This makes cross-tenant access impossible at the
|
||||||
|
broker, not just by convention.
|
||||||
|
|
||||||
|
**Reply-subject rule:** per-license users have NO `_INBOX` permission (granting
|
||||||
|
it would let one license read another's request-reply traffic). Therefore any
|
||||||
|
backend→agent request-reply MUST use a reply subject inside the license
|
||||||
|
namespace — e.g. `corrosion.{license_id}.reply.<id>` — never the client's
|
||||||
|
default global `_INBOX`. The agent is unaffected: it responds to whatever
|
||||||
|
`msg.reply` it receives. The constraint is on the requester (the internal user
|
||||||
|
has full access). The contract/CI tests run against an unauthenticated broker
|
||||||
|
and use the default inbox; production request-reply must follow this rule.
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
- The agent embeds semver + git hash + build timestamp (`--version`,
|
- The agent embeds semver + git hash + build timestamp (`--version`,
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ instance on that host — Rust, Conan Exiles, Soulmask, Dune: Awakening.
|
|||||||
(integration-tested with real processes + live-NATS contract test)
|
(integration-tested with real processes + live-NATS contract test)
|
||||||
- [ ] Phase 1b: RCON trait (WebRCON rust / TCP conan+soulmask), SteamCMD, jailed file manager
|
- [ ] Phase 1b: RCON trait (WebRCON rust / TCP conan+soulmask), SteamCMD, jailed file manager
|
||||||
- [ ] Phase 2: Dune Docker adapter (compose lifecycle, RabbitMQ bus, Postgres admin)
|
- [ ] Phase 2: Dune Docker adapter (compose lifecycle, RabbitMQ bus, Postgres admin)
|
||||||
- [ ] Phase 3: signed self-update (enforced ed25519 — release gate), service install, supervisor split
|
- [x] Phase 3a: SIGNED self-update — minisign-verified download+swap+relaunch (NATS `update` func); embedded public key; CI signs releases
|
||||||
|
- [ ] Phase 3b: service install (systemd/SCM), PID adoption
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ use crate::agent::Agent;
|
|||||||
use crate::prober;
|
use crate::prober;
|
||||||
use crate::subjects;
|
use crate::subjects;
|
||||||
use crate::telemetry;
|
use crate::telemetry;
|
||||||
|
use crate::update;
|
||||||
use crate::version;
|
use crate::version;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct HostCommand {
|
struct HostCommand {
|
||||||
func: String,
|
func: String,
|
||||||
|
/// Signed-update artifact URL (for func = "update").
|
||||||
|
#[serde(default)]
|
||||||
|
url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(agent: Arc<Agent>) -> anyhow::Result<()> {
|
pub async fn run(agent: Arc<Agent>) -> anyhow::Result<()> {
|
||||||
@@ -55,20 +59,46 @@ async fn handle(agent: Arc<Agent>, msg: async_nats::Message) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = match serde_json::from_slice::<HostCommand>(&msg.payload) {
|
let cmd = match serde_json::from_slice::<HostCommand>(&msg.payload) {
|
||||||
Ok(cmd) => dispatch(&agent, &cmd.func).await,
|
Ok(cmd) => cmd,
|
||||||
Err(e) => json!({ "status": "error", "message": format!("invalid command payload: {e}") }),
|
|
||||||
};
|
|
||||||
|
|
||||||
let bytes = match serde_json::to_vec(&response) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("response serialize failed: {e}");
|
publish(&agent, &reply, json!({ "status": "error", "message": format!("invalid command payload: {e}") })).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
|
|
||||||
tracing::warn!("response publish failed: {e}");
|
// Self-update is special: it must reply BEFORE relaunching, because the
|
||||||
|
// relaunch replaces this process and nothing after it would run.
|
||||||
|
if cmd.func == "update" {
|
||||||
|
let Some(url) = cmd.url else {
|
||||||
|
publish(&agent, &reply, json!({ "status": "error", "message": "update requires a 'url'" })).await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match update::download_verify_swap(&url).await {
|
||||||
|
Ok(_) => {
|
||||||
|
publish(&agent, &reply, json!({ "status": "success", "func": "update", "message": "verified and swapped; relaunching" })).await;
|
||||||
|
let _ = agent.nats.flush().await;
|
||||||
|
update::relaunch_and_exit();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
publish(&agent, &reply, json!({ "status": "error", "func": "update", "message": format!("{e:#}") })).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = dispatch(&agent, &cmd.func).await;
|
||||||
|
publish(&agent, &reply, response).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn publish(agent: &Arc<Agent>, reply: &async_nats::Subject, value: serde_json::Value) {
|
||||||
|
match serde_json::to_vec(&value) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if let Err(e) = agent.nats.publish(reply.clone(), bytes.into()).await {
|
||||||
|
tracing::warn!("response publish failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("response serialize failed: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ pub mod rcon;
|
|||||||
pub mod steamcmd;
|
pub mod steamcmd;
|
||||||
pub mod subjects;
|
pub mod subjects;
|
||||||
pub mod telemetry;
|
pub mod telemetry;
|
||||||
|
pub mod update;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|||||||
154
corrosion-host-agent/src/update.rs
Normal file
154
corrosion-host-agent/src/update.rs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
//! Signed self-update.
|
||||||
|
//!
|
||||||
|
//! The agent only ever runs a binary whose minisign signature verifies against
|
||||||
|
//! the EMBEDDED public key below. Even if the CDN (which currently accepts
|
||||||
|
//! unauthenticated uploads) served a malicious binary, the agent refuses it
|
||||||
|
//! without a valid signature from the release private key (a CI secret).
|
||||||
|
//!
|
||||||
|
//! Flow: download binary + `.minisig` from the CDN → verify signature →
|
||||||
|
//! atomic swap (current → `.old`, new → current, rollback on failure) →
|
||||||
|
//! relaunch the new binary. Defence in depth mirrors the Vigilance updater:
|
||||||
|
//! a real URL parse rejecting credential-in-URL bypasses, an https + host
|
||||||
|
//! allowlist, and a size cap.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use minisign_verify::{PublicKey, Signature};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// minisign public key. The matching private key signs releases in CI
|
||||||
|
/// (Gitea Actions secret MINISIGN_SECRET_KEY). Rotating it means re-signing
|
||||||
|
/// every published artifact and shipping an agent build with the new key.
|
||||||
|
const PUBLIC_KEY: &str = "RWQKhJptuiwIkp31cZdz10z/R72UPZkl7/VtnZJ2Vfbe0dQfDlXHZYFC";
|
||||||
|
|
||||||
|
const ALLOWED_HOST: &str = "cdn.corrosionmgmt.com";
|
||||||
|
const MAX_BINARY_BYTES: usize = 100 * 1024 * 1024; // 100 MiB sanity cap
|
||||||
|
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
/// Verify a binary against the embedded public key + a minisign signature blob.
|
||||||
|
/// The security core of self-update — tampered or unsigned content is rejected.
|
||||||
|
pub fn verify_signature(binary: &[u8], signature_blob: &str) -> Result<()> {
|
||||||
|
let pk = PublicKey::from_base64(PUBLIC_KEY).context("embedded public key is invalid")?;
|
||||||
|
let sig = Signature::decode(signature_blob).context("malformed minisign signature")?;
|
||||||
|
pk.verify(binary, &sig, false)
|
||||||
|
.map_err(|e| anyhow::anyhow!("signature verification failed: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject anything but `https://cdn.corrosionmgmt.com/...` with no embedded
|
||||||
|
/// credentials (the userinfo-bypass class).
|
||||||
|
pub fn assert_url_allowed(url: &str) -> Result<()> {
|
||||||
|
let parsed = reqwest::Url::parse(url).context("invalid update URL")?;
|
||||||
|
if parsed.scheme() != "https" {
|
||||||
|
bail!("update URL must be https");
|
||||||
|
}
|
||||||
|
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||||
|
bail!("update URL must not contain credentials");
|
||||||
|
}
|
||||||
|
if parsed.host_str() != Some(ALLOWED_HOST) {
|
||||||
|
bail!("update URL host not allowed: {:?}", parsed.host_str());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download, verify, and atomically swap in a new agent binary. Does NOT
|
||||||
|
/// restart — the caller decides when to relaunch (after replying on NATS).
|
||||||
|
/// Returns the path of the now-current (new) binary.
|
||||||
|
pub async fn download_verify_swap(url: &str) -> Result<PathBuf> {
|
||||||
|
assert_url_allowed(url)?;
|
||||||
|
let sig_url = format!("{url}.minisig");
|
||||||
|
assert_url_allowed(&sig_url)?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(DOWNLOAD_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.context("building HTTP client")?;
|
||||||
|
|
||||||
|
let binary = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("downloading {url}"))?
|
||||||
|
.error_for_status()
|
||||||
|
.context("update binary download failed")?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.context("reading update binary")?;
|
||||||
|
|
||||||
|
if binary.len() > MAX_BINARY_BYTES {
|
||||||
|
bail!("update binary is {} bytes, exceeds the {MAX_BINARY_BYTES} cap", binary.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let signature = client
|
||||||
|
.get(&sig_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("downloading {sig_url}"))?
|
||||||
|
.error_for_status()
|
||||||
|
.context("signature download failed")?
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.context("reading signature")?;
|
||||||
|
|
||||||
|
verify_signature(&binary, &signature).context("refusing unsigned/tampered update")?;
|
||||||
|
tracing::info!("update signature verified ({} bytes)", binary.len());
|
||||||
|
|
||||||
|
let current = std::env::current_exe().context("resolving current executable")?;
|
||||||
|
swap_binary(¤t, &binary)?;
|
||||||
|
tracing::info!("update swapped in at {}", current.display());
|
||||||
|
Ok(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically replace `current` with `new_bytes`, keeping a `.old` backup and
|
||||||
|
/// rolling back if the rename fails.
|
||||||
|
pub fn swap_binary(current: &Path, new_bytes: &[u8]) -> Result<()> {
|
||||||
|
let dir = current.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
let stem = current.file_name().and_then(|s| s.to_str()).unwrap_or("corrosion-host-agent");
|
||||||
|
let new_path = dir.join(format!("{stem}.new"));
|
||||||
|
let backup = dir.join(format!("{stem}.old"));
|
||||||
|
|
||||||
|
std::fs::write(&new_path, new_bytes)
|
||||||
|
.with_context(|| format!("writing {}", new_path.display()))?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&new_path, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.context("chmod +x on new binary")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&backup);
|
||||||
|
std::fs::rename(current, &backup)
|
||||||
|
.with_context(|| format!("backing up current binary to {}", backup.display()))?;
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::rename(&new_path, current) {
|
||||||
|
// Roll back: restore the backup so the agent stays runnable.
|
||||||
|
let _ = std::fs::rename(&backup, current);
|
||||||
|
return Err(anyhow::anyhow!(e).context("installing new binary (rolled back)"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relaunch the (already-swapped) binary with the same args, then exit. No
|
||||||
|
/// service manager is required — the new process reconnects on its own. There
|
||||||
|
/// is a sub-second window with no agent; acceptable for an update.
|
||||||
|
pub fn relaunch_and_exit() -> ! {
|
||||||
|
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("corrosion-host-agent"));
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
tracing::info!("relaunching {} after update", exe.display());
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
// exec replaces this process image with the new binary — cleanest,
|
||||||
|
// no gap. Only returns on failure.
|
||||||
|
let err = std::process::Command::new(&exe).args(&args).exec();
|
||||||
|
tracing::error!("exec after update failed: {err}; exiting for service restart");
|
||||||
|
std::process::exit(70);
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = std::process::Command::new(&exe).args(&args).spawn();
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
corrosion-host-agent/tests/fixtures/sample.bin
vendored
Normal file
2
corrosion-host-agent/tests/fixtures/sample.bin
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
corrosion-host-agent signed-update test fixture
|
||||||
|
version 2.0.0-test
|
||||||
4
corrosion-host-agent/tests/fixtures/sample.bin.minisig
vendored
Normal file
4
corrosion-host-agent/tests/fixtures/sample.bin.minisig
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
untrusted comment: signature from minisign secret key
|
||||||
|
RUQKhJptuiwIkp378Z59BTwosDycAhmlhrdZZVwk1Vdb293OgcsXx0S3W0XezMtOXIXdgvQtW/DpDKlb1gdW4elQXLG5KFUgawI=
|
||||||
|
trusted comment: timestamp:1781222247 file:sample.bin hashed
|
||||||
|
QtUiOfJqRKYJZTL6QV93xeLVnODr8HXWvZIR3Q1AG0yqmqesZPyiKpVa9kD34Mwp1fQ76nx1Z7c6CB1v5KHQAw==
|
||||||
63
corrosion-host-agent/tests/update.rs
Normal file
63
corrosion-host-agent/tests/update.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//! Signed self-update tests — the security-critical part is signature
|
||||||
|
//! verification: a valid signature is accepted, anything tampered is rejected.
|
||||||
|
//! Fixtures (tests/fixtures/sample.bin + .minisig) were signed with the real
|
||||||
|
//! release private key, so these run with no key present (as in CI).
|
||||||
|
|
||||||
|
use corrosion_host_agent::update;
|
||||||
|
|
||||||
|
const SAMPLE: &[u8] = include_bytes!("fixtures/sample.bin");
|
||||||
|
const SAMPLE_SIG: &str = include_str!("fixtures/sample.bin.minisig");
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_a_validly_signed_binary() {
|
||||||
|
update::verify_signature(SAMPLE, SAMPLE_SIG).expect("valid signature must verify");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_tampered_binary() {
|
||||||
|
let mut tampered = SAMPLE.to_vec();
|
||||||
|
tampered[0] ^= 0xFF; // flip a byte
|
||||||
|
let err = update::verify_signature(&tampered, SAMPLE_SIG)
|
||||||
|
.expect_err("tampered binary must be rejected");
|
||||||
|
assert!(err.to_string().contains("verification failed"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_garbage_signature() {
|
||||||
|
assert!(update::verify_signature(SAMPLE, "not a real minisig blob").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_binary_against_real_sig() {
|
||||||
|
assert!(update::verify_signature(b"", SAMPLE_SIG).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn url_allowlist_enforced() {
|
||||||
|
// Allowed.
|
||||||
|
update::assert_url_allowed("https://cdn.corrosionmgmt.com/host-agent/alpha/corrosion-host-agent-linux-amd64")
|
||||||
|
.expect("the real CDN host must be allowed");
|
||||||
|
// http rejected.
|
||||||
|
assert!(update::assert_url_allowed("http://cdn.corrosionmgmt.com/x").is_err());
|
||||||
|
// wrong host rejected.
|
||||||
|
assert!(update::assert_url_allowed("https://evil.example.com/x").is_err());
|
||||||
|
// credential-in-URL (userinfo bypass) rejected.
|
||||||
|
assert!(update::assert_url_allowed("https://cdn.corrosionmgmt.com:[email protected]/x").is_err());
|
||||||
|
// host as userinfo trick rejected (real host is evil.com).
|
||||||
|
assert!(update::assert_url_allowed("https://[email protected]/x").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swap_binary_replaces_and_backs_up() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let current = dir.path().join("corrosion-host-agent");
|
||||||
|
std::fs::write(¤t, b"OLD BINARY").unwrap();
|
||||||
|
|
||||||
|
update::swap_binary(¤t, b"NEW BINARY").expect("swap should succeed");
|
||||||
|
|
||||||
|
assert_eq!(std::fs::read(¤t).unwrap(), b"NEW BINARY", "current is the new binary");
|
||||||
|
let backup = dir.path().join("corrosion-host-agent.old");
|
||||||
|
assert_eq!(std::fs::read(&backup).unwrap(), b"OLD BINARY", ".old holds the previous binary");
|
||||||
|
// the .new scratch file is consumed by the rename
|
||||||
|
assert!(!dir.path().join("corrosion-host-agent.new").exists());
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
# SAFE OPEN DEFAULT — anonymous full access, no secrets. Same behavior as the
|
# BOOTSTRAP DEFAULT — no secrets, safe to commit.
|
||||||
# pre-auth broker so fresh deploys and the repo stay valid.
|
|
||||||
#
|
#
|
||||||
# Regenerated on deploy by scripts/generate-nats-auth.mjs with the privileged
|
# Anonymous is mapped to a HARMLESS namespace (corrosion.unclaimed.>), never to
|
||||||
# internal user + per-license scoped users (those carry secrets and must NOT be
|
# real tenant subjects (corrosion.{uuid}.>) — so a fresh/stale deploy running
|
||||||
# committed — mark the host copy with `git update-index --assume-unchanged`).
|
# this default cannot read or forge any tenant's traffic. The REST API still
|
||||||
|
# works; agent telemetry just won't flow until the real config is generated.
|
||||||
|
#
|
||||||
|
# On every real deploy, scripts/generate-nats-auth.mjs OVERWRITES this file
|
||||||
|
# (on the host, not in git) with the privileged internal user + per-license
|
||||||
|
# scoped users. NATS_AUTH_STAGE defaults to "enforce" (anonymous rejected).
|
||||||
|
#
|
||||||
|
# NOTE: no_auth_user is a TOP-LEVEL field, NOT inside authorization { }.
|
||||||
authorization {
|
authorization {
|
||||||
users: [
|
users: [
|
||||||
{ user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } }
|
{ user: "anonymous", password: "", permissions: { publish: { allow: ["corrosion.unclaimed.>"] }, subscribe: { allow: ["corrosion.unclaimed.>"] } } }
|
||||||
]
|
]
|
||||||
no_auth_user: "anonymous"
|
|
||||||
}
|
}
|
||||||
|
no_auth_user: "anonymous"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ export interface GameProfile {
|
|||||||
terminology: GameTerminology
|
terminology: GameTerminology
|
||||||
/** Notable game-specific mechanics that affect server administration. */
|
/** Notable game-specific mechanics that affect server administration. */
|
||||||
special?: string[]
|
special?: string[]
|
||||||
|
/**
|
||||||
|
* Primary editable config file, relative to the instance root — prefilled in
|
||||||
|
* the Server-page config editor as a hint (operator can change it). null if
|
||||||
|
* the game has no single primary config file.
|
||||||
|
*/
|
||||||
|
primaryConfigFile?: string | null
|
||||||
/**
|
/**
|
||||||
* Stat field labels shown on server cards and the dashboard.
|
* Stat field labels shown on server cards and the dashboard.
|
||||||
* First entry is always Players; subsequent entries are game-specific.
|
* First entry is always Players; subsequent entries are game-specific.
|
||||||
@@ -185,6 +191,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
|
|||||||
group: 'Team',
|
group: 'Team',
|
||||||
},
|
},
|
||||||
statFields: ['Players', 'uMod', 'Wipe'],
|
statFields: ['Players', 'uMod', 'Wipe'],
|
||||||
|
primaryConfigFile: 'server/cfg/server.cfg',
|
||||||
nav: RUST_NAV,
|
nav: RUST_NAV,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -207,6 +214,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
|
|||||||
},
|
},
|
||||||
special: ['Clans', 'Thralls', 'Avatars', 'Purge', 'PvP windows'],
|
special: ['Clans', 'Thralls', 'Avatars', 'Purge', 'PvP windows'],
|
||||||
statFields: ['Players', 'Clans', 'Purge'],
|
statFields: ['Players', 'Clans', 'Purge'],
|
||||||
|
primaryConfigFile: 'ConanSandbox/Saved/Config/LinuxServer/ServerSettings.ini',
|
||||||
nav: [
|
nav: [
|
||||||
{ label: '', items: [NAV_DASHBOARD] },
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
{
|
{
|
||||||
@@ -252,6 +260,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
|
|||||||
},
|
},
|
||||||
special: ['Cluster', 'Tribes'],
|
special: ['Cluster', 'Tribes'],
|
||||||
statFields: ['Players', 'Tribe', 'Mask'],
|
statFields: ['Players', 'Tribe', 'Mask'],
|
||||||
|
primaryConfigFile: 'WS/Saved/GameplaySettings/GameXishu.json',
|
||||||
nav: [
|
nav: [
|
||||||
{ label: '', items: [NAV_DASHBOARD] },
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
{
|
{
|
||||||
@@ -294,6 +303,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
|
|||||||
},
|
},
|
||||||
special: ['Sietches', 'Deep Desert', 'Bases', 'Landsraad'],
|
special: ['Sietches', 'Deep Desert', 'Bases', 'Landsraad'],
|
||||||
statFields: ['Players', 'Sietches', 'Control'],
|
statFields: ['Players', 'Sietches', 'Control'],
|
||||||
|
primaryConfigFile: null,
|
||||||
nav: [
|
nav: [
|
||||||
{ label: '', items: [NAV_DASHBOARD] },
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
{
|
{
|
||||||
|
|||||||
136
frontend/src/stores/files.ts
Normal file
136
frontend/src/stores/files.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useInstancesStore } from '@/stores/instances'
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
is_dir: boolean
|
||||||
|
size: number
|
||||||
|
modified: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-instance file browser store.
|
||||||
|
* All operations target `/api/instances/{id}/...` — jailed to instance root.
|
||||||
|
* Guard: if no current instance, list() sets error and bails out early.
|
||||||
|
*/
|
||||||
|
export const useFilesStore = defineStore('files', () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const cwd = ref<string>('')
|
||||||
|
const entries = ref<FileEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
/** Join two relative path segments with a single forward slash. */
|
||||||
|
function joinPath(base: string, name: string): string {
|
||||||
|
if (!base) return name
|
||||||
|
return `${base}/${name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentId(): string | null {
|
||||||
|
// Retrieve fresh from the store each call — avoids stale closure.
|
||||||
|
return useInstancesStore().currentId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List a directory. Sets cwd + entries. Does NOT throw — sets error. */
|
||||||
|
async function list(path: string): Promise<void> {
|
||||||
|
const id = currentId()
|
||||||
|
if (!id) {
|
||||||
|
error.value = 'No instance — connect the host agent'
|
||||||
|
entries.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ entries: FileEntry[] }>(
|
||||||
|
`/instances/${id}/files?path=${encodeURIComponent(path)}`,
|
||||||
|
)
|
||||||
|
cwd.value = path
|
||||||
|
entries.value = data.entries ?? []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load directory'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a text file. Returns content string. Throws on error (binary/too big/not found). */
|
||||||
|
async function readFile(path: string): Promise<string> {
|
||||||
|
const id = currentId()
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
const data = await api.get<{ content: string }>(
|
||||||
|
`/instances/${id}/file?path=${encodeURIComponent(path)}`,
|
||||||
|
)
|
||||||
|
return data.content ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write / overwrite a text file. Throws on error. */
|
||||||
|
async function writeFile(path: string, content: string): Promise<void> {
|
||||||
|
const id = currentId()
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
await api.put(`/instances/${id}/file`, { path, content })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a file or directory (recursive). Throws on error. */
|
||||||
|
async function del(path: string): Promise<void> {
|
||||||
|
const id = currentId()
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
await api.post(`/instances/${id}/files/delete`, { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename within the same parent. `name` is the bare new filename. Throws on error. */
|
||||||
|
async function rename(path: string, name: string): Promise<void> {
|
||||||
|
const id = currentId()
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
await api.post(`/instances/${id}/files/rename`, { path, name })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a directory (and all missing ancestors). Throws on error. */
|
||||||
|
async function mkdir(path: string): Promise<void> {
|
||||||
|
const id = currentId()
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
await api.post(`/instances/${id}/files/mkdir`, { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an empty file. Throws on error. */
|
||||||
|
async function mkfile(path: string): Promise<void> {
|
||||||
|
const id = currentId()
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
await api.post(`/instances/${id}/files/mkfile`, { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move a file or directory. Both paths are relative to the instance root. Throws on error. */
|
||||||
|
async function move(path: string, dest: string): Promise<void> {
|
||||||
|
const id = currentId()
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
await api.post(`/instances/${id}/files/move`, { path, dest })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy a file or directory. Both paths are relative to the instance root. Throws on error. */
|
||||||
|
async function copy(path: string, dest: string): Promise<void> {
|
||||||
|
const id = currentId()
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
await api.post(`/instances/${id}/files/copy`, { path, dest })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
entries,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
joinPath,
|
||||||
|
list,
|
||||||
|
readFile,
|
||||||
|
writeFile,
|
||||||
|
del,
|
||||||
|
rename,
|
||||||
|
mkdir,
|
||||||
|
mkfile,
|
||||||
|
move,
|
||||||
|
copy,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -77,11 +77,22 @@ export const useFleetStore = defineStore('fleet', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a host and its instances. Throws on failure (e.g. 409 when the host
|
||||||
|
* is still online) so the caller can surface the message; refetches on
|
||||||
|
* success.
|
||||||
|
*/
|
||||||
|
async function removeHost(hostId: string): Promise<void> {
|
||||||
|
await api.del(`/fleet/hosts/${hostId}`)
|
||||||
|
await fetchFleet()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hosts,
|
hosts,
|
||||||
summary,
|
summary,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
fetchFleet,
|
fetchFleet,
|
||||||
|
removeHost,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
140
frontend/src/stores/instances.ts
Normal file
140
frontend/src/stores/instances.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import type { FleetData, FleetInstance } from '@/stores/fleet'
|
||||||
|
|
||||||
|
/** A game instance enriched with its host context, flattened from the fleet. */
|
||||||
|
export interface ManagedInstance extends FleetInstance {
|
||||||
|
host_id: string
|
||||||
|
host_hostname: string
|
||||||
|
host_status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LifecycleAction = 'start' | 'stop' | 'restart' | 'status' | 'steam_update'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance management — the Server page operates on a selected game instance
|
||||||
|
* (not the legacy single-server connection). Reads the fleet to enumerate
|
||||||
|
* instances and drives the per-instance command bridge
|
||||||
|
* (POST /api/instances/:id/lifecycle | /rcon).
|
||||||
|
*/
|
||||||
|
export const useInstancesStore = defineStore('instances', () => {
|
||||||
|
const instances = ref<ManagedInstance[]>([])
|
||||||
|
const currentId = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const acting = ref<LifecycleAction | null>(null)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const current = computed<ManagedInstance | null>(
|
||||||
|
() => instances.value.find((i) => i.id === currentId.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Fetch the fleet and flatten its instances. Optionally prefer a game. */
|
||||||
|
async function fetchInstances(preferGame?: string): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await api.get<FleetData>('/fleet')
|
||||||
|
const flat: ManagedInstance[] = []
|
||||||
|
for (const host of data.hosts) {
|
||||||
|
for (const inst of host.instances) {
|
||||||
|
flat.push({
|
||||||
|
...inst,
|
||||||
|
host_id: host.id,
|
||||||
|
host_hostname: host.hostname,
|
||||||
|
host_status: host.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instances.value = flat
|
||||||
|
// Keep the current selection if it still exists; else prefer the active
|
||||||
|
// game, else the first instance.
|
||||||
|
if (!flat.some((i) => i.id === currentId.value)) {
|
||||||
|
const preferred = preferGame ? flat.find((i) => i.game === preferGame) : undefined
|
||||||
|
currentId.value = (preferred ?? flat[0])?.id ?? null
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch instances:', e)
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load instances'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(id: string): void {
|
||||||
|
currentId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a lifecycle command to the current instance and apply the agent's
|
||||||
|
* reply state OPTIMISTICALLY. The reply is authoritative for the action just
|
||||||
|
* taken; the fleet DB only catches up on the next heartbeat (~10s), so an
|
||||||
|
* immediate refetch would read a stale state and clobber the result.
|
||||||
|
* Throws on failure so the view can toast.
|
||||||
|
*/
|
||||||
|
async function lifecycle(action: LifecycleAction): Promise<Record<string, unknown>> {
|
||||||
|
const id = currentId.value
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
acting.value = action
|
||||||
|
try {
|
||||||
|
const res = await api.post<Record<string, unknown>>(`/instances/${id}/lifecycle`, { action })
|
||||||
|
applyReplyState(id, res)
|
||||||
|
return res
|
||||||
|
} finally {
|
||||||
|
acting.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an instance's state/uptime from a lifecycle/status reply. */
|
||||||
|
function applyReplyState(id: string, res: Record<string, unknown>): void {
|
||||||
|
if ((res as { status?: string }).status !== 'success') return
|
||||||
|
const stateObj = (res as { state?: { state?: string } }).state
|
||||||
|
const newState = stateObj?.state
|
||||||
|
const inst = instances.value.find((i) => i.id === id)
|
||||||
|
if (inst && typeof newState === 'string') {
|
||||||
|
inst.state = newState
|
||||||
|
const up = (res as { uptime_seconds?: number }).uptime_seconds
|
||||||
|
inst.uptime_seconds = typeof up === 'number' ? up : newState === 'running' ? inst.uptime_seconds : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rcon(command: string): Promise<Record<string, unknown>> {
|
||||||
|
const id = currentId.value
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
return api.post<Record<string, unknown>>(`/instances/${id}/rcon`, { command })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a config/text file from the current instance (jailed to its root). */
|
||||||
|
async function readFile(path: string): Promise<string> {
|
||||||
|
const id = currentId.value
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
const res = await api.get<{ content?: string }>(
|
||||||
|
`/instances/${id}/file?path=${encodeURIComponent(path)}`,
|
||||||
|
)
|
||||||
|
return res?.content ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write a config/text file to the current instance. */
|
||||||
|
async function writeFile(path: string, content: string): Promise<void> {
|
||||||
|
const id = currentId.value
|
||||||
|
if (!id) throw new Error('No instance selected')
|
||||||
|
await api.put(`/instances/${id}/file`, { path, content })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
instances,
|
||||||
|
currentId,
|
||||||
|
current,
|
||||||
|
loading,
|
||||||
|
acting,
|
||||||
|
error,
|
||||||
|
fetchInstances,
|
||||||
|
select,
|
||||||
|
lifecycle,
|
||||||
|
rcon,
|
||||||
|
readFile,
|
||||||
|
writeFile,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,28 +1,176 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { VueFinder, RemoteDriver } from 'vuefinder'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useInstancesStore } from '@/stores/instances'
|
||||||
|
import { useFilesStore } from '@/stores/files'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { safeDate, safeFileSize } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
import Icon from '@/components/ds/core/Icon.vue'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const router = useRouter()
|
||||||
|
const instancesStore = useInstancesStore()
|
||||||
|
const files = useFilesStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
// Recreate the RemoteDriver reactively so the token stays current across
|
// ---- Editor state ----
|
||||||
// automatic refresh cycles (useApi composable silently rotates accessToken).
|
const editorPath = ref<string | null>(null)
|
||||||
const driver = computed(
|
const editorContent = ref('')
|
||||||
() =>
|
const editorLoading = ref(false)
|
||||||
new RemoteDriver({
|
const editorSaving = ref(false)
|
||||||
baseURL: '/api/files',
|
|
||||||
token: auth.accessToken ?? undefined,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Non-persistent config passed to VueFinder per session.
|
// ---- Inline confirm-delete ----
|
||||||
// maxFileSize in bytes — 10 MB limit matches the backend upload ceiling.
|
const pendingDelete = ref<string | null>(null)
|
||||||
const finderConfig = {
|
|
||||||
theme: 'midnight',
|
// ---- Sorted entries: dirs first, then alpha ----
|
||||||
maxFileSize: 10 * 1024 * 1024,
|
const sortedEntries = computed(() => {
|
||||||
showMenuBar: true,
|
return [...files.entries].sort((a, b) => {
|
||||||
showToolbar: true,
|
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Breadcrumbs from cwd ----
|
||||||
|
const breadcrumbs = computed<{ label: string; path: string }[]>(() => {
|
||||||
|
const crumbs: { label: string; path: string }[] = [{ label: 'Root', path: '' }]
|
||||||
|
if (!files.cwd) return crumbs
|
||||||
|
const parts = files.cwd.split('/').filter(Boolean)
|
||||||
|
let acc = ''
|
||||||
|
for (const p of parts) {
|
||||||
|
acc = acc ? `${acc}/${p}` : p
|
||||||
|
crumbs.push({ label: p, path: acc })
|
||||||
|
}
|
||||||
|
return crumbs
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Parent path for "Up" button ----
|
||||||
|
const parentPath = computed<string | null>(() => {
|
||||||
|
if (!files.cwd) return null
|
||||||
|
const idx = files.cwd.lastIndexOf('/')
|
||||||
|
return idx < 0 ? '' : files.cwd.slice(0, idx)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Lifecycle ----
|
||||||
|
onMounted(async () => {
|
||||||
|
await instancesStore.fetchInstances()
|
||||||
|
await files.list('')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Instance switch ----
|
||||||
|
async function onInstanceChange(e: Event) {
|
||||||
|
const id = (e.target as HTMLSelectElement).value
|
||||||
|
instancesStore.select(id)
|
||||||
|
editorPath.value = null
|
||||||
|
await files.list('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Navigation ----
|
||||||
|
async function navigate(path: string) {
|
||||||
|
editorPath.value = null
|
||||||
|
pendingDelete.value = null
|
||||||
|
await files.list(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Open a file in the editor ----
|
||||||
|
async function openFile(path: string) {
|
||||||
|
editorLoading.value = true
|
||||||
|
try {
|
||||||
|
const content = await files.readFile(path)
|
||||||
|
editorPath.value = path
|
||||||
|
editorContent.value = content
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Cannot open file (binary or too large)')
|
||||||
|
} finally {
|
||||||
|
editorLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
editorPath.value = null
|
||||||
|
editorContent.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFile() {
|
||||||
|
if (!editorPath.value) return
|
||||||
|
editorSaving.value = true
|
||||||
|
try {
|
||||||
|
await files.writeFile(editorPath.value, editorContent.value)
|
||||||
|
toast.success('File saved')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to save file')
|
||||||
|
} finally {
|
||||||
|
editorSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Toolbar: New folder ----
|
||||||
|
async function newFolder() {
|
||||||
|
const name = window.prompt('Folder name:')
|
||||||
|
if (!name || !name.trim()) return
|
||||||
|
if (name.includes('/') || name.includes('\\')) {
|
||||||
|
toast.error('Folder name cannot contain path separators')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await files.mkdir(files.joinPath(files.cwd, name.trim()))
|
||||||
|
toast.success('Folder created')
|
||||||
|
await files.list(files.cwd)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to create folder')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Toolbar: New file ----
|
||||||
|
async function newFile() {
|
||||||
|
const name = window.prompt('File name:')
|
||||||
|
if (!name || !name.trim()) return
|
||||||
|
if (name.includes('/') || name.includes('\\')) {
|
||||||
|
toast.error('File name cannot contain path separators')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await files.mkfile(files.joinPath(files.cwd, name.trim()))
|
||||||
|
toast.success('File created')
|
||||||
|
await files.list(files.cwd)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to create file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Row: Rename ----
|
||||||
|
async function renameEntry(path: string, isDir: boolean) {
|
||||||
|
const current = path.split('/').pop() ?? path
|
||||||
|
const name = window.prompt('New name:', current)
|
||||||
|
if (!name || !name.trim() || name.trim() === current) return
|
||||||
|
if (name.includes('/') || name.includes('\\')) {
|
||||||
|
toast.error('Name cannot contain path separators')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await files.rename(path, name.trim())
|
||||||
|
toast.success(`${isDir ? 'Folder' : 'File'} renamed`)
|
||||||
|
await files.list(files.cwd)
|
||||||
|
// If currently editing the renamed file, close editor
|
||||||
|
if (editorPath.value === path) closeEditor()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to rename')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Row: Delete ----
|
||||||
|
async function confirmDelete(path: string) {
|
||||||
|
try {
|
||||||
|
await files.del(path)
|
||||||
|
toast.success('Deleted')
|
||||||
|
pendingDelete.value = null
|
||||||
|
await files.list(files.cwd)
|
||||||
|
if (editorPath.value === path) closeEditor()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to delete')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,15 +189,232 @@ const finderConfig = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- VueFinder wrapper — only the outer chrome is re-skinned; internals untouched -->
|
<!-- No instances at all -->
|
||||||
<div class="fm__finder">
|
<Panel v-if="!instancesStore.loading && instancesStore.instances.length === 0">
|
||||||
<VueFinder
|
<EmptyState
|
||||||
id="corrosion-filemanager"
|
icon="server"
|
||||||
:driver="driver"
|
title="No host agent connected"
|
||||||
:config="finderConfig"
|
description="Install the host agent from the Server page to manage files on your game server."
|
||||||
locale="en"
|
>
|
||||||
/>
|
<template #action>
|
||||||
</div>
|
<Button variant="secondary" size="sm" icon="server" @click="router.push('/server')">
|
||||||
|
Go to Server page
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Instance selector -->
|
||||||
|
<div v-if="instancesStore.instances.length > 1" class="fm__instance-pick">
|
||||||
|
<span class="fm__field-label">Instance</span>
|
||||||
|
<select
|
||||||
|
class="fm__select"
|
||||||
|
:value="instancesStore.currentId ?? ''"
|
||||||
|
@change="onInstanceChange"
|
||||||
|
>
|
||||||
|
<option v-for="inst in instancesStore.instances" :key="inst.id" :value="inst.id">
|
||||||
|
{{ inst.label || inst.agent_instance_id }} ({{ inst.game }}) · {{ inst.host_hostname }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File browser panel -->
|
||||||
|
<Panel :flush-body="true">
|
||||||
|
<template #title>
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="fm__breadcrumb">
|
||||||
|
<button
|
||||||
|
v-for="(crumb, i) in breadcrumbs"
|
||||||
|
:key="crumb.path"
|
||||||
|
class="fm__crumb"
|
||||||
|
:class="{ 'fm__crumb--active': i === breadcrumbs.length - 1 }"
|
||||||
|
:disabled="i === breadcrumbs.length - 1"
|
||||||
|
@click="navigate(crumb.path)"
|
||||||
|
>{{ crumb.label }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<!-- Up button -->
|
||||||
|
<Button
|
||||||
|
v-if="parentPath !== null"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="chevron-left"
|
||||||
|
:disabled="files.loading"
|
||||||
|
@click="navigate(parentPath!)"
|
||||||
|
>
|
||||||
|
Up
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="folder-open"
|
||||||
|
:disabled="files.loading"
|
||||||
|
@click="newFolder"
|
||||||
|
>
|
||||||
|
New folder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="file-text"
|
||||||
|
:disabled="files.loading"
|
||||||
|
@click="newFile"
|
||||||
|
>
|
||||||
|
New file
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="refresh-cw"
|
||||||
|
:disabled="files.loading"
|
||||||
|
:loading="files.loading"
|
||||||
|
@click="files.list(files.cwd)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-if="files.error && !files.loading" class="fm__padded">
|
||||||
|
<Alert tone="danger" :title="files.error">
|
||||||
|
<template #actions>
|
||||||
|
<Button variant="danger-soft" size="sm" icon="refresh-cw" @click="files.list(files.cwd)">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<div v-else-if="files.loading" class="fm__padded fm__loading">
|
||||||
|
<Icon name="loader" :size="18" :stroke-width="2" class="fm__spinner" />
|
||||||
|
<span class="fm__loading-text">Loading…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty directory -->
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="sortedEntries.length === 0"
|
||||||
|
icon="folder-open"
|
||||||
|
title="Empty directory"
|
||||||
|
description="This directory contains no files or folders."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Entry table -->
|
||||||
|
<table v-else class="fm__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="fm__th fm__th--name">Name</th>
|
||||||
|
<th class="fm__th fm__th--size">Size</th>
|
||||||
|
<th class="fm__th fm__th--date">Modified</th>
|
||||||
|
<th class="fm__th fm__th--actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="entry in sortedEntries"
|
||||||
|
:key="entry.path"
|
||||||
|
class="fm__row"
|
||||||
|
:class="{ 'fm__row--active': editorPath === entry.path }"
|
||||||
|
>
|
||||||
|
<!-- Name -->
|
||||||
|
<td class="fm__td fm__td--name">
|
||||||
|
<button
|
||||||
|
class="fm__entry-btn"
|
||||||
|
@click="entry.is_dir ? navigate(entry.path) : openFile(entry.path)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="entry.is_dir ? 'folder-open' : 'file-text'"
|
||||||
|
:size="15"
|
||||||
|
:stroke-width="1.75"
|
||||||
|
class="fm__entry-icon"
|
||||||
|
:class="entry.is_dir ? 'fm__entry-icon--dir' : 'fm__entry-icon--file'"
|
||||||
|
/>
|
||||||
|
<span class="fm__entry-name">{{ entry.name }}</span>
|
||||||
|
<Icon v-if="entry.is_dir" name="chevron-right" :size="13" :stroke-width="2" class="fm__entry-chevron" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Size -->
|
||||||
|
<td class="fm__td fm__td--size">
|
||||||
|
{{ entry.is_dir ? '—' : safeFileSize(entry.size, '0 B') }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Modified -->
|
||||||
|
<td class="fm__td fm__td--date">{{ safeDate(entry.modified) }}</td>
|
||||||
|
|
||||||
|
<!-- Row actions -->
|
||||||
|
<td class="fm__td fm__td--actions">
|
||||||
|
<!-- Pending delete confirm -->
|
||||||
|
<template v-if="pendingDelete === entry.path">
|
||||||
|
<span class="fm__del-confirm-label">Delete?</span>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
@click="confirmDelete(entry.path)"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="pendingDelete = null"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="pencil"
|
||||||
|
:title="`Rename ${entry.name}`"
|
||||||
|
@click="renameEntry(entry.path, entry.is_dir)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="trash-2"
|
||||||
|
:title="`Delete ${entry.name}`"
|
||||||
|
@click="pendingDelete = entry.path"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- File editor panel -->
|
||||||
|
<Panel v-if="editorPath !== null" :title="editorPath">
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon="save"
|
||||||
|
:loading="editorSaving"
|
||||||
|
@click="saveFile"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" icon="x" @click="closeEditor">Close</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="editorLoading" class="fm__padded fm__loading">
|
||||||
|
<Icon name="loader" :size="18" :stroke-width="2" class="fm__spinner" />
|
||||||
|
<span class="fm__loading-text">Loading file…</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-else
|
||||||
|
v-model="editorContent"
|
||||||
|
class="fm__editor"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -76,12 +441,113 @@ const finderConfig = {
|
|||||||
color: var(--text-primary); margin-top: 3px;
|
color: var(--text-primary); margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Finder container — surface panel chrome, VueFinder renders inside */
|
/* Instance selector */
|
||||||
.fm__finder {
|
.fm__instance-pick { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.fm__field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-tertiary); }
|
||||||
|
.fm__select {
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
border-radius: var(--radius-lg);
|
color: var(--text-primary);
|
||||||
box-shadow: var(--ring-default);
|
border: none;
|
||||||
overflow: hidden;
|
box-shadow: var(--ring-subtle);
|
||||||
min-height: 640px;
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
min-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Breadcrumb */
|
||||||
|
.fm__breadcrumb { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.fm__crumb {
|
||||||
|
background: none; border: none; cursor: pointer; padding: 0 4px;
|
||||||
|
font-size: var(--text-sm); font-weight: 500; color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-sm); transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.fm__crumb:hover:not(:disabled) { color: var(--text-primary); background: var(--surface-hover); }
|
||||||
|
.fm__crumb:disabled { cursor: default; }
|
||||||
|
.fm__crumb--active { color: var(--text-primary); font-weight: 600; }
|
||||||
|
.fm__crumb:not(:last-child)::after { content: '/'; margin-left: 4px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.fm__padded { padding: 24px 16px; }
|
||||||
|
.fm__loading { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.fm__spinner { animation: fm-spin 0.75s linear infinite; color: var(--text-tertiary); }
|
||||||
|
@keyframes fm-spin { to { transform: rotate(360deg); } }
|
||||||
|
.fm__loading-text { font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.fm__table {
|
||||||
|
width: 100%; border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm); table-layout: fixed;
|
||||||
|
}
|
||||||
|
.fm__th {
|
||||||
|
padding: 9px 14px; font-size: var(--text-xs); font-weight: 600;
|
||||||
|
color: var(--text-tertiary); text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
}
|
||||||
|
.fm__th--name { width: auto; }
|
||||||
|
.fm__th--size { width: 90px; }
|
||||||
|
.fm__th--date { width: 180px; }
|
||||||
|
.fm__th--actions { width: 130px; }
|
||||||
|
|
||||||
|
.fm__row { transition: background var(--dur-fast) var(--ease-standard); }
|
||||||
|
.fm__row:hover { background: var(--surface-hover); }
|
||||||
|
.fm__row--active { background: var(--accent-soft); }
|
||||||
|
.fm__row + .fm__row { border-top: 1px solid var(--border-subtle); }
|
||||||
|
|
||||||
|
.fm__td {
|
||||||
|
padding: 8px 14px; color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.fm__td--name { width: auto; }
|
||||||
|
.fm__td--size { text-align: right; font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||||
|
.fm__td--date { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); }
|
||||||
|
.fm__td--actions {
|
||||||
|
text-align: right;
|
||||||
|
display: flex; align-items: center; justify-content: flex-end; gap: 2px;
|
||||||
|
padding-top: 6px; padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entry button */
|
||||||
|
.fm__entry-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
background: none; border: none; cursor: pointer; padding: 0;
|
||||||
|
color: var(--text-primary); font-size: var(--text-sm); font-weight: 500;
|
||||||
|
max-width: 100%; text-align: left;
|
||||||
|
}
|
||||||
|
.fm__entry-btn:hover .fm__entry-name { text-decoration: underline; text-decoration-color: var(--border-subtle); }
|
||||||
|
.fm__entry-icon--dir { color: var(--accent); }
|
||||||
|
.fm__entry-icon--file { color: var(--text-tertiary); }
|
||||||
|
.fm__entry-name {
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.fm__entry-chevron { color: var(--text-muted); flex: none; }
|
||||||
|
|
||||||
|
/* Inline delete confirm */
|
||||||
|
.fm__del-confirm-label {
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--danger);
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File editor textarea */
|
||||||
|
.fm__editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
box-shadow: var(--ring-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.fm__editor:focus { box-shadow: var(--focus-ring); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
*
|
*
|
||||||
* No fabricated data. All nulls render as '—' via safeFixed/safeDate.
|
* No fabricated data. All nulls render as '—' via safeFixed/safeDate.
|
||||||
*/
|
*/
|
||||||
import { onMounted, computed } from 'vue'
|
import { onMounted, computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useFleetStore } from '@/stores/fleet'
|
import { useFleetStore } from '@/stores/fleet'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { FleetHost } from '@/stores/fleet'
|
import type { FleetHost } from '@/stores/fleet'
|
||||||
import { safeFixed, safeDate } from '@/utils/formatters'
|
import { safeFixed, safeDate } from '@/utils/formatters'
|
||||||
import Panel from '@/components/ds/data/Panel.vue'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
@@ -30,6 +31,7 @@ import Icon from '@/components/ds/core/Icon.vue'
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const fleet = useFleetStore()
|
const fleet = useFleetStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fleet.fetchFleet()
|
fleet.fetchFleet()
|
||||||
@@ -40,6 +42,25 @@ onMounted(() => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const hasHosts = computed(() => fleet.hosts.length > 0)
|
const hasHosts = computed(() => fleet.hosts.length > 0)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Remove host (offline only — a live agent re-registers)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const confirmHostId = ref<string | null>(null)
|
||||||
|
const removingHostId = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function removeHost(host: FleetHost) {
|
||||||
|
removingHostId.value = host.id
|
||||||
|
try {
|
||||||
|
await fleet.removeHost(host.id)
|
||||||
|
toast.success(`Removed ${host.hostname}`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to remove host')
|
||||||
|
} finally {
|
||||||
|
removingHostId.value = null
|
||||||
|
confirmHostId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Map host status → Badge tone */
|
/** Map host status → Badge tone */
|
||||||
function hostTone(status: string): 'online' | 'offline' | 'warn' {
|
function hostTone(status: string): 'online' | 'offline' | 'warn' {
|
||||||
if (status === 'connected') return 'online'
|
if (status === 'connected') return 'online'
|
||||||
@@ -184,6 +205,24 @@ function relativeHeartbeat(iso: string | null): string {
|
|||||||
<span class="fleet-host__meta-item" v-if="host.os || host.arch">
|
<span class="fleet-host__meta-item" v-if="host.os || host.arch">
|
||||||
<Icon name="cpu" :size="12" />{{ [host.os, host.arch].filter(Boolean).join(' / ') }}
|
<Icon name="cpu" :size="12" />{{ [host.os, host.arch].filter(Boolean).join(' / ') }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Remove host — offline only; a live agent re-registers -->
|
||||||
|
<template v-if="confirmHostId === host.id">
|
||||||
|
<span class="fleet-host__confirm">Remove host & its instances?</span>
|
||||||
|
<Button
|
||||||
|
variant="danger-soft"
|
||||||
|
size="sm"
|
||||||
|
:loading="removingHostId === host.id"
|
||||||
|
@click="removeHost(host)"
|
||||||
|
>Remove</Button>
|
||||||
|
<Button variant="ghost" size="sm" :disabled="removingHostId === host.id" @click="confirmHostId = null">Cancel</Button>
|
||||||
|
</template>
|
||||||
|
<Button
|
||||||
|
v-else-if="host.status !== 'connected'"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="trash-2"
|
||||||
|
@click="confirmHostId = host.id"
|
||||||
|
>Remove</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useInstancesStore } from '@/stores/instances'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useThemeGame } from '@/composables/useThemeGame'
|
import { useThemeGame } from '@/composables/useThemeGame'
|
||||||
import { useGameProfile } from '@/config/gameProfiles'
|
import { useGameProfile } from '@/config/gameProfiles'
|
||||||
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
import Panel from '@/components/ds/data/Panel.vue'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
import Button from '@/components/ds/core/Button.vue'
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
import Badge from '@/components/ds/core/Badge.vue'
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
@@ -19,10 +20,38 @@ import Switch from '@/components/ds/forms/Switch.vue'
|
|||||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
const auth = useAuthStore()
|
const instancesStore = useInstancesStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const { activeGame } = useThemeGame()
|
const { activeGame } = useThemeGame()
|
||||||
|
|
||||||
|
// ---- Current game instance (the thing this page actually manages) ----
|
||||||
|
const currentInstance = computed(() => instancesStore.current)
|
||||||
|
const instanceState = computed(() => currentInstance.value?.state ?? null)
|
||||||
|
const instanceRunning = computed(() => instanceState.value === 'running')
|
||||||
|
const instanceManaged = computed(() =>
|
||||||
|
!!instanceState.value && !['unmanaged', 'configured', 'missing_root'].includes(instanceState.value),
|
||||||
|
)
|
||||||
|
const instanceStateTone = computed<'online' | 'offline' | 'warn'>(() => {
|
||||||
|
const s = instanceState.value
|
||||||
|
if (s === 'running') return 'online'
|
||||||
|
if (s === 'crashed') return 'warn'
|
||||||
|
return 'offline'
|
||||||
|
})
|
||||||
|
const instanceStateLabel = computed(() => {
|
||||||
|
const s = instanceState.value
|
||||||
|
if (!s) return 'No instance'
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ')
|
||||||
|
})
|
||||||
|
function fmtUptime(secs: number | undefined): string {
|
||||||
|
if (!secs || secs <= 0) return '—'
|
||||||
|
const d = Math.floor(secs / 86400)
|
||||||
|
const h = Math.floor((secs % 86400) / 3600)
|
||||||
|
const m = Math.floor((secs % 3600) / 60)
|
||||||
|
if (d > 0) return `${d}d ${h}h`
|
||||||
|
if (h > 0) return `${h}h ${m}m`
|
||||||
|
return `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
// Profile follows the GameSwitcher. 'all' defaults to rust (neutral house skin).
|
// Profile follows the GameSwitcher. 'all' defaults to rust (neutral house skin).
|
||||||
const profile = computed(() => {
|
const profile = computed(() => {
|
||||||
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
|
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||||
@@ -66,6 +95,18 @@ const deployLoading = ref(false)
|
|||||||
const oxideStatus = ref<{ stage: string; progress: number; message: string; error?: string } | null>(null)
|
const oxideStatus = ref<{ stage: string; progress: number; message: string; error?: string } | null>(null)
|
||||||
const isInstallingOxide = ref(false)
|
const isInstallingOxide = ref(false)
|
||||||
|
|
||||||
|
// Agent credentials (fetched from /api/servers/agent-credentials on mount)
|
||||||
|
interface AgentCreds {
|
||||||
|
license_id: string
|
||||||
|
nats_user: string
|
||||||
|
nats_password: string
|
||||||
|
nats_url: string
|
||||||
|
}
|
||||||
|
const agentCreds = ref<AgentCreds | null>(null)
|
||||||
|
const showCreds = ref(false)
|
||||||
|
// Ref for the TOML block copy button
|
||||||
|
const tomlCopied = ref(false)
|
||||||
|
|
||||||
const deployForm = ref<DeploymentConfig>({
|
const deployForm = ref<DeploymentConfig>({
|
||||||
server_name: 'My Rust Server',
|
server_name: 'My Rust Server',
|
||||||
max_players: 100,
|
max_players: 100,
|
||||||
@@ -97,25 +138,62 @@ const agentLastSeenLabel = computed(() => {
|
|||||||
return d.toLocaleDateString()
|
return d.toLocaleDateString()
|
||||||
})
|
})
|
||||||
|
|
||||||
const licenseKey = computed(() => auth.license?.license_key || 'YOUR-LICENSE-KEY')
|
|
||||||
|
|
||||||
const linuxCommands = computed(() => `# Download the agent
|
const linuxCommands = computed(() => `# Download the agent
|
||||||
curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64
|
curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64
|
||||||
chmod +x corrosion-host-agent-linux-amd64
|
chmod +x corrosion-host-agent-linux-amd64
|
||||||
|
|
||||||
# Start with your license key
|
# Write /etc/corrosion/agent.toml (see config block below), then run:
|
||||||
export LICENSE_ID="${licenseKey.value}"
|
sudo mkdir -p /etc/corrosion
|
||||||
export NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
sudo ./corrosion-host-agent-linux-amd64 --config /etc/corrosion/agent.toml`)
|
||||||
./corrosion-host-agent-linux-amd64`)
|
|
||||||
|
|
||||||
const windowsCommands = computed(() => `# Requires PowerShell (not Command Prompt)
|
const windowsCommands = computed(() => `# Requires PowerShell (not Command Prompt)
|
||||||
# Download the agent
|
# Download the agent
|
||||||
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe" -OutFile "corrosion-host-agent-windows-amd64.exe"
|
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe" -OutFile "corrosion-host-agent-windows-amd64.exe"
|
||||||
|
|
||||||
# Start with your license key
|
# Write C:\\ProgramData\\Corrosion\\agent.toml (see config block below), then run:
|
||||||
$env:LICENSE_ID="${licenseKey.value}"
|
New-Item -ItemType Directory -Force -Path "C:\\ProgramData\\Corrosion"
|
||||||
$env:NATS_URL="nats://nats.corrosionmgmt.com:4222"
|
.\\corrosion-host-agent-windows-amd64.exe --config "C:\\ProgramData\\Corrosion\\agent.toml"`)
|
||||||
.\\corrosion-host-agent-windows-amd64.exe`)
|
|
||||||
|
const agentTomlConfig = computed(() => {
|
||||||
|
const c = agentCreds.value
|
||||||
|
const licenseId = c?.license_id ?? 'YOUR-LICENSE-ID'
|
||||||
|
const natsUrl = c?.nats_url ?? 'nats://nats.corrosionmgmt.com:4222'
|
||||||
|
const natsUser = c?.nats_user ?? 'YOUR-LICENSE-ID'
|
||||||
|
const natsPassword = c ? (showCreds.value ? c.nats_password : '••••••••') : 'YOUR-AGENT-TOKEN'
|
||||||
|
return `[agent]
|
||||||
|
license_id = "${licenseId}"
|
||||||
|
nats_url = "${natsUrl}"
|
||||||
|
nats_user = "${natsUser}"
|
||||||
|
nats_password = "${natsPassword}"
|
||||||
|
heartbeat_seconds = 60
|
||||||
|
|
||||||
|
[[instance]]
|
||||||
|
id = "rust-main"
|
||||||
|
game = "rust"
|
||||||
|
root = "/opt/rustserver"
|
||||||
|
label = "My Server"`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Returns the raw (unmasked) TOML for clipboard — always use actual password if available
|
||||||
|
const agentTomlConfigRaw = computed(() => {
|
||||||
|
const c = agentCreds.value
|
||||||
|
const licenseId = c?.license_id ?? 'YOUR-LICENSE-ID'
|
||||||
|
const natsUrl = c?.nats_url ?? 'nats://nats.corrosionmgmt.com:4222'
|
||||||
|
const natsUser = c?.nats_user ?? 'YOUR-LICENSE-ID'
|
||||||
|
const natsPassword = c?.nats_password ?? 'YOUR-AGENT-TOKEN'
|
||||||
|
return `[agent]
|
||||||
|
license_id = "${licenseId}"
|
||||||
|
nats_url = "${natsUrl}"
|
||||||
|
nats_user = "${natsUser}"
|
||||||
|
nats_password = "${natsPassword}"
|
||||||
|
heartbeat_seconds = 60
|
||||||
|
|
||||||
|
[[instance]]
|
||||||
|
id = "rust-main"
|
||||||
|
game = "rust"
|
||||||
|
root = "/opt/rustserver"
|
||||||
|
label = "My Server"`
|
||||||
|
})
|
||||||
|
|
||||||
async function copySetupCommands() {
|
async function copySetupCommands() {
|
||||||
try {
|
try {
|
||||||
@@ -133,6 +211,16 @@ async function copySetupCommands() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyTomlConfig() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(agentTomlConfigRaw.value)
|
||||||
|
tomlCopied.value = true
|
||||||
|
setTimeout(() => { tomlCopied.value = false }, 2000)
|
||||||
|
} catch {
|
||||||
|
// Clipboard API unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function startDeploy() {
|
async function startDeploy() {
|
||||||
if (!deployForm.value.rcon_password || deployForm.value.rcon_password.length < 6) return
|
if (!deployForm.value.rcon_password || deployForm.value.rcon_password.length < 6) return
|
||||||
deployLoading.value = true
|
deployLoading.value = true
|
||||||
@@ -238,20 +326,82 @@ async function saveConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function serverAction(action: 'start' | 'stop' | 'restart') {
|
async function serverAction(action: 'start' | 'stop' | 'restart') {
|
||||||
|
if (!currentInstance.value) {
|
||||||
|
toast.error('No game instance to control — connect the host agent first')
|
||||||
|
return
|
||||||
|
}
|
||||||
actionLoading.value = action
|
actionLoading.value = action
|
||||||
try {
|
try {
|
||||||
if (action === 'start') await server.startServer()
|
const res = await instancesStore.lifecycle(action)
|
||||||
else if (action === 'stop') await server.stopServer()
|
if ((res as { status?: string }).status === 'error') {
|
||||||
else await server.restartServer()
|
toast.error(String((res as { message?: string }).message ?? `Failed to ${action}`))
|
||||||
await server.fetchServer()
|
} else {
|
||||||
toast.success(`Server ${action} command sent`)
|
toast.success(`${currentInstance.value?.agent_instance_id ?? 'Instance'}: ${action} ok`)
|
||||||
} catch {
|
}
|
||||||
toast.error(`Failed to ${action} server`)
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : `Failed to ${action} server`)
|
||||||
} finally {
|
} finally {
|
||||||
actionLoading.value = null
|
actionLoading.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshInstanceStatus() {
|
||||||
|
if (!currentInstance.value) return
|
||||||
|
actionLoading.value = 'status'
|
||||||
|
try {
|
||||||
|
await instancesStore.lifecycle('status')
|
||||||
|
} catch {
|
||||||
|
/* status best-effort */
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Config file editor (reads/writes via the jailed agent file manager) ----
|
||||||
|
const cfgPath = ref('')
|
||||||
|
const cfgContent = ref('')
|
||||||
|
const cfgLoaded = ref(false)
|
||||||
|
const cfgLoading = ref(false)
|
||||||
|
const cfgSaving = ref(false)
|
||||||
|
const cfgError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// A reasonable default config-file hint per game (operator can change it).
|
||||||
|
const cfgHint = computed(() => profile.value.primaryConfigFile ?? '')
|
||||||
|
|
||||||
|
async function loadConfigFile() {
|
||||||
|
const path = (cfgPath.value || cfgHint.value).trim()
|
||||||
|
if (!path || !currentInstance.value) return
|
||||||
|
cfgPath.value = path
|
||||||
|
cfgLoading.value = true
|
||||||
|
cfgError.value = null
|
||||||
|
try {
|
||||||
|
cfgContent.value = await instancesStore.readFile(path)
|
||||||
|
cfgLoaded.value = true
|
||||||
|
} catch (e) {
|
||||||
|
// Not-found is fine — present an empty editor to create it.
|
||||||
|
cfgContent.value = ''
|
||||||
|
cfgLoaded.value = true
|
||||||
|
cfgError.value = e instanceof Error ? e.message : 'File not found — saving will create it'
|
||||||
|
} finally {
|
||||||
|
cfgLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfigFile() {
|
||||||
|
const path = cfgPath.value.trim()
|
||||||
|
if (!path || !currentInstance.value) return
|
||||||
|
cfgSaving.value = true
|
||||||
|
try {
|
||||||
|
await instancesStore.writeFile(path, cfgContent.value)
|
||||||
|
cfgError.value = null
|
||||||
|
toast.success(`Saved ${path}`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to save file')
|
||||||
|
} finally {
|
||||||
|
cfgSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleAutomation(field: 'crash_recovery_enabled' | 'auto_update_on_force_wipe' | 'force_wipe_eligible') {
|
async function toggleAutomation(field: 'crash_recovery_enabled' | 'auto_update_on_force_wipe' | 'force_wipe_eligible') {
|
||||||
if (!server.config) return
|
if (!server.config) return
|
||||||
const newValue = !server.config[field]
|
const newValue = !server.config[field]
|
||||||
@@ -296,6 +446,17 @@ const connStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await server.fetchServer()
|
await server.fetchServer()
|
||||||
loadFormFromConfig()
|
loadFormFromConfig()
|
||||||
|
// Load the fleet's instances; prefer one matching the active game.
|
||||||
|
const game = activeGame.value === 'all' ? undefined : activeGame.value
|
||||||
|
await instancesStore.fetchInstances(game)
|
||||||
|
|
||||||
|
// Fetch agent credentials for the TOML config block (leave null on error — honest fallback)
|
||||||
|
try {
|
||||||
|
const creds = await useApi().get<AgentCreds | null>('/servers/agent-credentials')
|
||||||
|
agentCreds.value = creds
|
||||||
|
} catch {
|
||||||
|
agentCreds.value = null
|
||||||
|
}
|
||||||
|
|
||||||
const ws = useWebSocket()
|
const ws = useWebSocket()
|
||||||
ws.subscribe((msg) => {
|
ws.subscribe((msg) => {
|
||||||
@@ -360,31 +521,93 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Game instance — real per-instance state + lifecycle -->
|
||||||
<Panel title="Controls">
|
<Panel title="Game instance">
|
||||||
<div class="sv__controls">
|
<template #actions>
|
||||||
<Button
|
<Badge :tone="instanceStateTone" :dot="true" :pulse="instanceRunning">{{ instanceStateLabel }}</Badge>
|
||||||
variant="outline"
|
</template>
|
||||||
icon="play"
|
|
||||||
:loading="actionLoading === 'start'"
|
<!-- No instance yet -->
|
||||||
:disabled="server.connection?.connection_status === 'connected' || actionLoading !== null"
|
<EmptyState
|
||||||
@click="serverAction('start')"
|
v-if="!currentInstance"
|
||||||
>Start server</Button>
|
icon="server"
|
||||||
<Button
|
title="No game instance connected"
|
||||||
variant="danger-soft"
|
:description="'Install the host agent and add a ' + profile.label + ' instance to its config to manage it here.'"
|
||||||
icon="power"
|
/>
|
||||||
:loading="actionLoading === 'stop'"
|
|
||||||
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null"
|
<template v-else>
|
||||||
@click="serverAction('stop')"
|
<!-- Instance selector when more than one -->
|
||||||
>Stop server</Button>
|
<div v-if="instancesStore.instances.length > 1" class="sv__instance-pick sv__mb">
|
||||||
<Button
|
<span class="sv__field-label">Instance</span>
|
||||||
variant="secondary"
|
<select
|
||||||
icon="refresh-cw"
|
class="sv__select"
|
||||||
:loading="actionLoading === 'restart'"
|
:value="instancesStore.currentId ?? ''"
|
||||||
:disabled="server.connection?.connection_status !== 'connected' || actionLoading !== null"
|
@change="instancesStore.select(($event.target as HTMLSelectElement).value)"
|
||||||
@click="serverAction('restart')"
|
>
|
||||||
>Restart server</Button>
|
<option v-for="i in instancesStore.instances" :key="i.id" :value="i.id">
|
||||||
</div>
|
{{ i.label || i.agent_instance_id }} ({{ i.game }}) · {{ i.host_hostname }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instance facts -->
|
||||||
|
<div class="sv__grid4 sv__mb">
|
||||||
|
<div class="sv__field">
|
||||||
|
<div class="sv__field-label">Instance</div>
|
||||||
|
<div class="sv__field-val sv__field-val--mono">{{ currentInstance.agent_instance_id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sv__field">
|
||||||
|
<div class="sv__field-label">State</div>
|
||||||
|
<div class="sv__field-val sv__field-val--inline">
|
||||||
|
<StatusDot :tone="instanceStateTone" :pulse="instanceRunning" />
|
||||||
|
<span>{{ instanceStateLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sv__field">
|
||||||
|
<div class="sv__field-label">Uptime</div>
|
||||||
|
<div class="sv__field-val sv__field-val--mono">{{ fmtUptime(currentInstance.uptime_seconds) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sv__field">
|
||||||
|
<div class="sv__field-label">Host</div>
|
||||||
|
<div class="sv__field-val sv__field-val--mono">{{ currentInstance.host_hostname }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lifecycle controls — gated on real instance state -->
|
||||||
|
<div class="sv__controls">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
icon="play"
|
||||||
|
:loading="actionLoading === 'start'"
|
||||||
|
:disabled="instanceRunning || !instanceManaged || actionLoading !== null"
|
||||||
|
@click="serverAction('start')"
|
||||||
|
>Start</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger-soft"
|
||||||
|
icon="power"
|
||||||
|
:loading="actionLoading === 'stop'"
|
||||||
|
:disabled="!instanceRunning || actionLoading !== null"
|
||||||
|
@click="serverAction('stop')"
|
||||||
|
>Stop</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon="refresh-cw"
|
||||||
|
:loading="actionLoading === 'restart'"
|
||||||
|
:disabled="!instanceManaged || actionLoading !== null"
|
||||||
|
@click="serverAction('restart')"
|
||||||
|
>Restart</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="refresh-cw"
|
||||||
|
:loading="actionLoading === 'status'"
|
||||||
|
:disabled="actionLoading !== null"
|
||||||
|
@click="refreshInstanceStatus"
|
||||||
|
>Refresh</Button>
|
||||||
|
</div>
|
||||||
|
<Alert v-if="!instanceManaged" tone="info" class="sv__mt-sm">
|
||||||
|
This instance is telemetry-only — add an <code>executable</code> to its agent config to enable start/stop.
|
||||||
|
</Alert>
|
||||||
|
</template>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Host agent -->
|
<!-- Host agent -->
|
||||||
@@ -463,10 +686,9 @@ onMounted(async () => {
|
|||||||
<p class="sv__cmt"># Download the agent</p>
|
<p class="sv__cmt"># Download the agent</p>
|
||||||
<p>curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
<p>curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
||||||
<p>chmod +x corrosion-host-agent-linux-amd64</p>
|
<p>chmod +x corrosion-host-agent-linux-amd64</p>
|
||||||
<p class="sv__cmt sv__mt"># Start with your license key</p>
|
<p class="sv__cmt sv__mt"># Write /etc/corrosion/agent.toml (see config block below), then run:</p>
|
||||||
<p>export LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
|
<p>sudo mkdir -p /etc/corrosion</p>
|
||||||
<p>export NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
<p>sudo ./corrosion-host-agent-linux-amd64 --config <span class="sv__accent">/etc/corrosion/agent.toml</span></p>
|
||||||
<p>./corrosion-host-agent-linux-amd64</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Windows commands -->
|
<!-- Windows commands -->
|
||||||
@@ -474,11 +696,38 @@ onMounted(async () => {
|
|||||||
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
|
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
|
||||||
<p class="sv__cmt"># Download the agent</p>
|
<p class="sv__cmt"># Download the agent</p>
|
||||||
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-host-agent-windows-amd64.exe"</span></p>
|
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-host-agent-windows-amd64.exe"</span></p>
|
||||||
<p class="sv__cmt sv__mt"># Start with your license key</p>
|
<p class="sv__cmt sv__mt"># Write C:\ProgramData\Corrosion\agent.toml (see config block below), then run:</p>
|
||||||
<p>$env:LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
|
<p>New-Item -ItemType Directory -Force -Path <span class="sv__accent">"C:\ProgramData\Corrosion"</span></p>
|
||||||
<p>$env:NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
|
<p>.\corrosion-host-agent-windows-amd64.exe --config <span class="sv__accent">"C:\ProgramData\Corrosion\agent.toml"</span></p>
|
||||||
<p>.\corrosion-host-agent-windows-amd64.exe</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent configuration (agent.toml) -->
|
||||||
|
<div class="sv__section-head sv__mt">
|
||||||
|
<Icon name="file-text" :size="14" />
|
||||||
|
<span>Agent configuration (agent.toml)</span>
|
||||||
|
</div>
|
||||||
|
<div class="sv__setup-head">
|
||||||
|
<div class="sv__toml-reveal">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:icon="showCreds ? 'eye-off' : 'eye'"
|
||||||
|
@click="showCreds = !showCreds"
|
||||||
|
>{{ showCreds ? 'Hide credentials' : 'Reveal credentials' }}</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
:icon="tomlCopied ? 'check' : 'copy'"
|
||||||
|
@click="copyTomlConfig"
|
||||||
|
>{{ tomlCopied ? 'Copied' : 'Copy' }}</Button>
|
||||||
|
</div>
|
||||||
|
<div class="sv__codeblock">
|
||||||
|
<pre class="sv__pre">{{ agentTomlConfig }}</pre>
|
||||||
|
</div>
|
||||||
|
<Alert v-if="!agentCreds" tone="warn" class="sv__mt">
|
||||||
|
Could not load credentials from server. Copy this config and replace the placeholders with values from your Corrosion dashboard settings.
|
||||||
|
</Alert>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Deploy Server — Rust only (SteamCMD path). Other games use docker-compose or external tooling. -->
|
<!-- Deploy Server — Rust only (SteamCMD path). Other games use docker-compose or external tooling. -->
|
||||||
@@ -778,15 +1027,21 @@ onMounted(async () => {
|
|||||||
<div class="sv__field-label">Max players</div>
|
<div class="sv__field-label">Max players</div>
|
||||||
<div class="sv__field-val sv__field-val--mono">{{ server.config?.max_players ?? '—' }}</div>
|
<div class="sv__field-val sv__field-val--mono">{{ server.config?.max_players ?? '—' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sv__field">
|
<!-- Rust-only: world size and seed are Facepunch/procgen concepts -->
|
||||||
|
<div v-if="isRust" class="sv__field">
|
||||||
<div class="sv__field-label">World size</div>
|
<div class="sv__field-label">World size</div>
|
||||||
<div class="sv__field-val sv__field-val--mono">{{ server.config?.world_size ?? '—' }}</div>
|
<div class="sv__field-val sv__field-val--mono">{{ server.config?.world_size ?? '—' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sv__field">
|
<div v-if="isRust" class="sv__field">
|
||||||
<div class="sv__field-label">Current seed</div>
|
<div class="sv__field-label">Current seed</div>
|
||||||
<div class="sv__field-val sv__field-val--mono">{{ server.config?.current_seed ?? '—' }}</div>
|
<div class="sv__field-val sv__field-val--mono">{{ server.config?.current_seed ?? '—' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Non-Rust: game-specific settings live in config files on the host -->
|
||||||
|
<Alert v-if="!editMode && !isRust" tone="neutral" class="sv__mt">
|
||||||
|
Game-specific settings for {{ profile.label }} live in config files on the host — manage them in the
|
||||||
|
<Button variant="ghost" size="sm" icon="folder" @click="$router.push('/files')">File Manager</Button>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<!-- Edit mode -->
|
<!-- Edit mode -->
|
||||||
<form v-else @submit.prevent="saveConfig" class="sv__form">
|
<form v-else @submit.prevent="saveConfig" class="sv__form">
|
||||||
@@ -803,7 +1058,9 @@ onMounted(async () => {
|
|||||||
type="number"
|
type="number"
|
||||||
:mono="true"
|
:mono="true"
|
||||||
/>
|
/>
|
||||||
|
<!-- Rust-only: world size and seed are Facepunch/procgen concepts -->
|
||||||
<Input
|
<Input
|
||||||
|
v-if="isRust"
|
||||||
:model-value="String(form.world_size)"
|
:model-value="String(form.world_size)"
|
||||||
@update:model-value="v => { form.world_size = Number(v) }"
|
@update:model-value="v => { form.world_size = Number(v) }"
|
||||||
label="World size"
|
label="World size"
|
||||||
@@ -811,6 +1068,7 @@ onMounted(async () => {
|
|||||||
:mono="true"
|
:mono="true"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
v-if="isRust"
|
||||||
:model-value="String(form.current_seed)"
|
:model-value="String(form.current_seed)"
|
||||||
@update:model-value="v => { form.current_seed = Number(v) }"
|
@update:model-value="v => { form.current_seed = Number(v) }"
|
||||||
label="Current seed"
|
label="Current seed"
|
||||||
@@ -818,10 +1076,53 @@ onMounted(async () => {
|
|||||||
:mono="true"
|
:mono="true"
|
||||||
class="sv__col-span2"
|
class="sv__col-span2"
|
||||||
/>
|
/>
|
||||||
|
<!-- Non-Rust: redirect to file manager for game-specific config -->
|
||||||
|
<Alert v-if="!isRust" tone="neutral" class="sv__col-span2">
|
||||||
|
Game-specific settings for {{ profile.label }} live in config files on the host — manage them in the
|
||||||
|
<Button variant="ghost" size="sm" icon="folder" @click="$router.push('/files')">File Manager</Button>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Config file editor — reads/writes via the jailed agent file manager -->
|
||||||
|
<Panel v-if="currentInstance" title="Configuration file" subtitle="Edit a config file directly on the host (jailed to the instance)">
|
||||||
|
<div class="sv__cfg-row sv__mb-sm">
|
||||||
|
<Input
|
||||||
|
v-model="cfgPath"
|
||||||
|
:placeholder="cfgHint || 'path/relative/to/instance/root'"
|
||||||
|
class="sv__cfg-path"
|
||||||
|
:mono="true"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon="folder-open"
|
||||||
|
:loading="cfgLoading"
|
||||||
|
:disabled="(!cfgPath && !cfgHint) || cfgLoading"
|
||||||
|
@click="loadConfigFile"
|
||||||
|
>Load</Button>
|
||||||
|
<Button
|
||||||
|
v-if="cfgLoaded"
|
||||||
|
icon="check"
|
||||||
|
:loading="cfgSaving"
|
||||||
|
:disabled="!cfgPath || cfgSaving"
|
||||||
|
@click="saveConfigFile"
|
||||||
|
>Save</Button>
|
||||||
|
</div>
|
||||||
|
<Alert v-if="cfgError" tone="info" class="sv__mb-sm">{{ cfgError }}</Alert>
|
||||||
|
<textarea
|
||||||
|
v-if="cfgLoaded"
|
||||||
|
v-model="cfgContent"
|
||||||
|
class="sv__cfg-editor"
|
||||||
|
spellcheck="false"
|
||||||
|
rows="16"
|
||||||
|
></textarea>
|
||||||
|
<p v-else class="sv__cfg-hint">
|
||||||
|
Load <code>{{ cfgHint || 'a config file' }}</code> to view and edit it. Changes are written
|
||||||
|
straight to the host through the agent — jailed to this instance's directory.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Automation -->
|
<!-- Automation -->
|
||||||
<Panel title="Automation">
|
<Panel title="Automation">
|
||||||
<div class="sv__toggles">
|
<div class="sv__toggles">
|
||||||
@@ -907,6 +1208,36 @@ onMounted(async () => {
|
|||||||
|
|
||||||
/* Controls */
|
/* Controls */
|
||||||
.sv__controls { display: flex; flex-wrap: wrap; gap: 10px; }
|
.sv__controls { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.sv__mt-sm { margin-top: 12px; }
|
||||||
|
.sv__instance-pick { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.sv__cfg-row { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.sv__cfg-path { flex: 1; }
|
||||||
|
.sv__cfg-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 320px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
box-shadow: var(--ring-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.sv__cfg-hint { margin: 0; font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.55; }
|
||||||
|
.sv__select {
|
||||||
|
background: var(--surface-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
box-shadow: var(--ring-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Section head (label inside panel body) */
|
/* Section head (label inside panel body) */
|
||||||
.sv__section-head {
|
.sv__section-head {
|
||||||
@@ -931,6 +1262,12 @@ onMounted(async () => {
|
|||||||
/* Setup head */
|
/* Setup head */
|
||||||
.sv__setup-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
.sv__setup-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
/* TOML reveal row */
|
||||||
|
.sv__toml-reveal { display: flex; align-items: center; }
|
||||||
|
|
||||||
|
/* Pre inside codeblock — preserve whitespace, no extra margin */
|
||||||
|
.sv__pre { margin: 0; white-space: pre; }
|
||||||
|
|
||||||
/* Code block */
|
/* Code block */
|
||||||
.sv__codeblock {
|
.sv__codeblock {
|
||||||
background: var(--surface-inset); border-radius: var(--radius-md);
|
background: var(--surface-inset); border-radius: var(--radius-md);
|
||||||
|
|||||||
@@ -6,11 +6,20 @@
|
|||||||
// whose publish/subscribe is restricted to corrosion.{license_id}.> (+ _INBOX
|
// whose publish/subscribe is restricted to corrosion.{license_id}.> (+ _INBOX
|
||||||
// for request-reply). The backend uses a privileged internal user.
|
// for request-reply). The backend uses a privileged internal user.
|
||||||
//
|
//
|
||||||
// STAGING (NATS_AUTH_STAGE env):
|
// STAGING (NATS_AUTH_STAGE env) — defaults to "enforce" (secure by default):
|
||||||
// "open" (default) — defines a full-access `anonymous` user and sets
|
// "enforce" (default) — no anonymous; unauthenticated connections rejected.
|
||||||
// no_auth_user, so unauthenticated clients still work.
|
// "open" — EXPLICIT opt-in for a brief migration window. Maps
|
||||||
// Non-breaking; lets you verify real creds first.
|
// anonymous to a HARMLESS namespace (corrosion.unclaimed.>),
|
||||||
// "enforce" — omits no_auth_user; anonymous connections are rejected.
|
// NEVER full access, so a stale "open" deploy cannot
|
||||||
|
// read or forge real tenant (corrosion.{uuid}.>) traffic.
|
||||||
|
//
|
||||||
|
// REPLY SUBJECTS: per-license users are scoped to corrosion.{license}.> ONLY —
|
||||||
|
// no _INBOX grant (that would let one license read another's request-reply
|
||||||
|
// responses). Backend→agent request-reply MUST therefore use a reply subject
|
||||||
|
// inside the license namespace, e.g. corrosion.{license}.reply.<id>, not the
|
||||||
|
// default global _INBOX. The agent simply responds to msg.reply, so no agent
|
||||||
|
// change is needed — the constraint is on the requester (the internal user has
|
||||||
|
// full > and is unaffected).
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
// DATABASE_URL=... NATS_INTERNAL_USER=... NATS_INTERNAL_PASSWORD=... \
|
// DATABASE_URL=... NATS_INTERNAL_USER=... NATS_INTERNAL_PASSWORD=... \
|
||||||
@@ -30,7 +39,7 @@ const {
|
|||||||
NATS_INTERNAL_USER,
|
NATS_INTERNAL_USER,
|
||||||
NATS_INTERNAL_PASSWORD,
|
NATS_INTERNAL_PASSWORD,
|
||||||
NATS_TOKEN_SECRET,
|
NATS_TOKEN_SECRET,
|
||||||
NATS_AUTH_STAGE = 'open',
|
NATS_AUTH_STAGE = 'enforce',
|
||||||
} = process.env;
|
} = process.env;
|
||||||
|
|
||||||
for (const [k, v] of Object.entries({ DATABASE_URL, NATS_INTERNAL_USER, NATS_INTERNAL_PASSWORD, NATS_TOKEN_SECRET })) {
|
for (const [k, v] of Object.entries({ DATABASE_URL, NATS_INTERNAL_USER, NATS_INTERNAL_PASSWORD, NATS_TOKEN_SECRET })) {
|
||||||
@@ -58,27 +67,31 @@ const main = async () => {
|
|||||||
// Privileged internal user — the backend (full corrosion.> + _INBOX + _SYS).
|
// Privileged internal user — the backend (full corrosion.> + _INBOX + _SYS).
|
||||||
lines.push(` { user: "${esc(NATS_INTERNAL_USER)}", password: "${esc(NATS_INTERNAL_PASSWORD)}", permissions: { publish: ">", subscribe: ">" } }`);
|
lines.push(` { user: "${esc(NATS_INTERNAL_USER)}", password: "${esc(NATS_INTERNAL_PASSWORD)}", permissions: { publish: ">", subscribe: ">" } }`);
|
||||||
|
|
||||||
// Per-license scoped users.
|
// Per-license scoped users — corrosion.{id}.> ONLY. No _INBOX grant:
|
||||||
|
// replies ride the license namespace (see header). This is the whole
|
||||||
|
// point — one license can never touch another's subjects.
|
||||||
for (const { id } of rows) {
|
for (const { id } of rows) {
|
||||||
const pw = licensePassword(id, NATS_TOKEN_SECRET);
|
const pw = licensePassword(id, NATS_TOKEN_SECRET);
|
||||||
const scope = `corrosion.${id}.>`;
|
const scope = `corrosion.${id}.>`;
|
||||||
lines.push(
|
lines.push(
|
||||||
` { user: "${esc(id)}", password: "${esc(pw)}", permissions: { ` +
|
` { user: "${esc(id)}", password: "${esc(pw)}", permissions: { ` +
|
||||||
`publish: { allow: ["${scope}", "_INBOX.>"] }, ` +
|
`publish: { allow: ["${scope}"] }, ` +
|
||||||
`subscribe: { allow: ["${scope}", "_INBOX.>"] } } }`,
|
`subscribe: { allow: ["${scope}"] } } }`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (NATS_AUTH_STAGE === 'open') {
|
if (NATS_AUTH_STAGE === 'open') {
|
||||||
// Transition: unauthenticated clients map to a full-access user so nothing
|
// EXPLICIT migration opt-in only. Anonymous gets a HARMLESS namespace —
|
||||||
// breaks while real credentials roll out. Remove for enforcement.
|
// never real tenant subjects — so a stale "open" deploy leaks nothing.
|
||||||
lines.push(' { user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } }');
|
lines.push(' { user: "anonymous", password: "", permissions: { publish: { allow: ["corrosion.unclaimed.>"] }, subscribe: { allow: ["corrosion.unclaimed.>"] } } }');
|
||||||
}
|
}
|
||||||
lines.push(' ]');
|
lines.push(' ]');
|
||||||
if (NATS_AUTH_STAGE === 'open') {
|
|
||||||
lines.push(' no_auth_user: "anonymous"');
|
|
||||||
}
|
|
||||||
lines.push('}');
|
lines.push('}');
|
||||||
|
// no_auth_user is a TOP-LEVEL field, NOT inside authorization { } — nesting
|
||||||
|
// it makes nats-server reject the whole config ("unknown field no_auth_user").
|
||||||
|
if (NATS_AUTH_STAGE === 'open') {
|
||||||
|
lines.push('no_auth_user: "anonymous"');
|
||||||
|
}
|
||||||
process.stdout.write(lines.join('\n') + '\n');
|
process.stdout.write(lines.join('\n') + '\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user