Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/docs/docs/development/dev-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ The first run creates `.dev/dev.env` and `apps/web/.env.local` with local-only
secrets and URLs. The root `.env` used by the release/test-server path is not
modified.

For local Docker agent containers, the generated config sets
`AGENT_DOWNLOAD_BASE_URL=http://dev-proxy`,
`CT_OPS_AGENT_CONTAINER_INGEST_ADDRESS=ingest-dev:9443`, and an empty
`CT_OPS_ENROLMENT_TOKEN=` placeholder. Paste a valid token into that placeholder
before running `deploy/scripts/create-agent-dev-container.sh`. The helper joins
the dev stack Docker network so these service names resolve inside the test
container without exposing the dev stack on your LAN.

The `web-migrate` and `web-dev` containers run `pnpm install --frozen-lockfile`
inside Docker using named volumes for `node_modules`, so Linux container
dependencies do not overwrite host dependencies. The `ingest-dev` container uses
Expand Down Expand Up @@ -84,6 +92,15 @@ For browser-based agent install bundles, the app will use
`AGENT_DOWNLOAD_BASE_URL=http://100.x.y.z:3000`, so the remote host must also be
able to reach port `3000`.

For same-machine Docker agent containers, leave the stack in local mode and run:

```bash
deploy/scripts/create-agent-dev-container.sh
```

The helper reads `.dev/dev.env`, creates one long-lived systemd Ubuntu
container, and runs the normal CT-Ops agent install script inside it.

Switch back to loopback-only mode with:

```bash
Expand Down
21 changes: 11 additions & 10 deletions apps/web/app/(dashboard)/settings/agents/agents-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { formatDistanceToNow, format } from 'date-fns'

Check warning on line 8 in apps/web/app/(dashboard)/settings/agents/agents-client.tsx

View workflow job for this annotation

GitHub Actions / Web (lint / type-check / build)

'formatDistanceToNow' is defined but never used
import { Plus, Trash2, Copy, Check, Key, RefreshCw, Eye, Package, Download } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
Expand Down Expand Up @@ -40,8 +40,8 @@
createEnrolmentToken,
revokeEnrolmentToken,
} from '@/lib/actions/agents'
import type { AgentEnrolmentToken } from '@/lib/db/schema'

Check warning on line 43 in apps/web/app/(dashboard)/settings/agents/agents-client.tsx

View workflow job for this annotation

GitHub Actions / Web (lint / type-check / build)

'AgentEnrolmentToken' is defined but never used
import type { EnrolmentTokenSafe } from '@/lib/actions/agents-core'
import type { EnrolmentTokenForAdmin } from '@/lib/actions/agents-core'
import { TagEditor, type EditorTag } from '@/components/shared/tag-editor'
import {
DEFAULT_ENROLMENT_TOKEN_EXPIRY_DAYS,
Expand All @@ -60,7 +60,7 @@
type CreateTokenForm = z.infer<typeof createTokenSchema>

interface AgentsSettingsClientProps {
initialTokens: EnrolmentTokenSafe[]
initialTokens: EnrolmentTokenForAdmin[]
appUrl: string
}

Expand All @@ -84,7 +84,7 @@
)
}

function tokenStatus(token: EnrolmentTokenSafe): { label: string; className: string } {
function tokenStatus(token: EnrolmentTokenForAdmin): { label: string; className: string } {
if (token.deletedAt) {
return { label: 'Revoked', className: 'bg-red-100 text-red-800 border-red-200' }
}
Expand All @@ -105,7 +105,7 @@
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [newTokenValue, setNewTokenValue] = useState<string | null>(null)
const [newInstallCommand, setNewInstallCommand] = useState<string | null>(null)
const [viewToken, setViewToken] = useState<EnrolmentTokenSafe | null>(null)
const [viewToken, setViewToken] = useState<EnrolmentTokenForAdmin | null>(null)
const [showBundleDialog, setShowBundleDialog] = useState(false)
const [tokenTags, setTokenTags] = useState<EditorTag[]>([])
const [browserOrigin, setBrowserOrigin] = useState('')
Expand Down Expand Up @@ -170,7 +170,7 @@
const onSubmit = handleSubmit((data) => createMutation.mutate(data))
const installCommandBaseUrl = appUrl || browserOrigin
const viewInstallCommand = viewToken && installCommandBaseUrl
? buildAgentInstallCommand(installCommandBaseUrl, viewToken.skipVerify)
? buildAgentInstallCommand(installCommandBaseUrl, viewToken.skipVerify, viewToken.token)
: null

useEffect(() => {
Expand Down Expand Up @@ -310,8 +310,8 @@
{viewToken && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
The full token was shown only once when it was created. To use this token,
generate a new install bundle from the Download Agent Bundle dialog.
Use this command on each server you want to enrol. The token is passed through
the environment instead of being placed in the installer URL.
</p>
{viewInstallCommand && (
<div className="space-y-1.5">
Expand All @@ -325,9 +325,10 @@
</div>
)}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Token hint</p>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Raw token</p>
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
<code className="text-xs font-mono flex-1">········…{viewToken.tokenHint}</code>
<code className="text-xs font-mono flex-1 break-all">{viewToken.token}</code>
<CopyButton text={viewToken.token} />
</div>
</div>
<DialogFooter>
Expand Down Expand Up @@ -527,7 +528,7 @@
interface BundleDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
activeTokens: EnrolmentTokenSafe[]
activeTokens: EnrolmentTokenForAdmin[]
}

function BundleDialog({ open, onOpenChange, activeTokens }: BundleDialogProps) {
Expand Down
13 changes: 13 additions & 0 deletions apps/web/lib/actions/agents-authz.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,16 @@ test('privileged agent and host mutations require tooling access', () => {
)
}
})

test('listing enrolment tokens requires privileged access because plaintext tokens are returned', () => {
const start = agentsSource.lastIndexOf('export async function listEnrolmentTokens')
assert.notEqual(start, -1, 'expected listEnrolmentTokens to exist in agents-core.ts')
const next = agentsSource.indexOf('\nexport async function ', start + 1)
const segment = agentsSource.slice(start, next === -1 ? undefined : next)

assert.match(
segment,
/requireInstance(?:Admin|Tooling)Access\(instanceId\)/,
'listEnrolmentTokens must require privileged access before returning enrolment token secrets',
)
})
10 changes: 5 additions & 5 deletions apps/web/lib/actions/agents-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,21 +645,21 @@ export async function createEnrolmentToken(
}
}

export type EnrolmentTokenSafe = Omit<AgentEnrolmentToken, 'token' | 'tokenHash'> & {
export type EnrolmentTokenForAdmin = Omit<AgentEnrolmentToken, 'tokenHash'> & {
tokenHint: string
}

export async function listEnrolmentTokens(instanceId: string): Promise<EnrolmentTokenSafe[]> {
await requireInstanceAccess(instanceId)
export async function listEnrolmentTokens(instanceId: string): Promise<EnrolmentTokenForAdmin[]> {
await requireInstanceAdminAccess(instanceId)
const rows = await db.query.agentEnrolmentTokens.findMany({
where: and(
eq(agentEnrolmentTokens.instanceId, instanceId),
isNull(agentEnrolmentTokens.deletedAt),
),
})
return rows.map(({ token, tokenHash: _hash, ...rest }) => ({
return rows.map(({ tokenHash: _hash, ...rest }) => ({
...rest,
tokenHint: token.slice(-4),
tokenHint: rest.token.slice(-4),
}))
}

Expand Down
4 changes: 2 additions & 2 deletions apps/web/lib/actions/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
listEnrolmentTokens as listEnrolmentTokensCore,
revokeEnrolmentToken as revokeEnrolmentTokenCore,
uninstallAndDeleteHost as uninstallAndDeleteHostCore,
type EnrolmentTokenSafe,
type EnrolmentTokenForAdmin,
type HeartbeatPoint,
type HostListParams,
type HostListResult,
Expand Down Expand Up @@ -114,7 +114,7 @@ export async function createEnrolmentToken(
return createEnrolmentTokenCore(resolveCurrentActionScope(session), input)
}

export async function listEnrolmentTokens(): Promise<EnrolmentTokenSafe[]> {
export async function listEnrolmentTokens(): Promise<EnrolmentTokenForAdmin[]> {
const session = await getRequiredSession()
const currentScope = resolveOptionalActionScope(session)
if (!currentScope) return []
Expand Down
2 changes: 2 additions & 0 deletions apps/web/tests/e2e/settings/agent-enrolment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ test('admin can view the install URL for an existing enrolment token', async ({
const dialog = page.getByRole('dialog')
await expect(dialog).toContainText('View Existing Token')
await expect(page.getByTestId('agent-enrolment-view-install-command')).toContainText('/api/agent/install')
await expect(page.getByTestId('agent-enrolment-view-install-command')).toContainText('CT_OPS_ENROLMENT_TOKEN=')
await expect(page.getByTestId('agent-enrolment-view-install-command')).toContainText('view-existing-token-value')
await expect(page.getByTestId('agent-enrolment-view-install-command')).not.toContainText('token=')
})

Expand Down
17 changes: 17 additions & 0 deletions deploy/docker/agent-dev-container/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM ubuntu:24.04

ENV container=docker

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
sudo \
systemd \
systemd-sysv \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

STOPSIGNAL SIGRTMIN+3

CMD ["/sbin/init"]
Loading
Loading