Install an agent on a one-off PC from a single command
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

An admin generates a short-lived token in Settings > Endpoints > Endpoints and
copies the command it produces. A tech runs that one line in an elevated
PowerShell on a PC with no agent:

    powershell -ExecutionPolicy Bypass -c "irm https://<server>/shopdb/api/
    endpoints/siteagent/bootstrap.ps1?t=<token> | iex"

The script refuses to run unelevated, refuses a PC that already has the agent
(a managed bay gets it from GE-Enforce), downloads the package the existing
builder produces, checks both binaries against the hashes the server baked in,
installs with NO shared key, and reports what happened. The PC then asks to
enroll and waits for an admin to approve it, which the script watches for
locally for two minutes before exiting.

- New endpointbootstraptokens table and migration
  endpoints0024bootstraptokens: hash only, expiry, use count, revoke.
- Admin routes to generate, list and revoke tokens; the secret is shown once.
- Two token-only routes: the script, and the package (which spends a use).
  Anything wrong with the token answers 404 rather than confirming it exists,
  both are rate limited per address and both are audited with the caller.
- The package builder is now shared rather than duplicated, and a bootstrap
  package can never carry a collector key.
This commit is contained in:
cproudlock
2026-09-15 19:33:00 -04:00
parent 408cf08628
commit ca57b7f728
14 changed files with 1718 additions and 31 deletions

View File

@@ -0,0 +1,65 @@
"""One-line bootstrap install tokens.
Revision ID: endpoints0024bootstraptokens
Revises: endpoints0023credentials
Create Date: 2026-09-15
Creates endpointbootstraptokens: the short-lived, admin-minted authorizations
that let a tech fetch the bootstrap script and the keyless agent package with a
single command. Only the sha256 of the secret is stored.
Guarded so the revision is safe on a database built by create_all and on a
re-run. MySQL 5.6 safe: no JSON columns, and the only index is the unique key
on a fixed-length CHAR(64), well under the 767-byte limit on utf8mb4.
Downgrade drops the table.
"""
from alembic import op
import sqlalchemy as sa
revision = 'endpoints0024bootstraptokens'
down_revision = 'endpoints0023credentials'
branch_labels = None
depends_on = None
plugin_name = 'endpoints'
TOKENS = 'endpointbootstraptokens'
def _inspector():
from sqlalchemy import inspect
return inspect(op.get_bind())
def _has_table(name):
return _inspector().has_table(name)
def upgrade():
if _has_table(TOKENS):
return
op.create_table(
TOKENS,
sa.Column('tokenid', sa.Integer(), nullable=False),
sa.Column('tokenhash', sa.CHAR(length=64), nullable=False),
sa.Column('tokenprefix', sa.String(length=12), nullable=False),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.Column('createdby', sa.String(length=64), nullable=True),
sa.Column('expiresat', sa.DateTime(), nullable=False),
sa.Column('maxuses', sa.Integer(), nullable=False),
sa.Column('usecount', sa.Integer(), nullable=False,
server_default='0'),
sa.Column('revokedat', sa.DateTime(), nullable=True),
sa.Column('lastusedat', sa.DateTime(), nullable=True),
sa.Column('lastusedip', sa.String(length=45), nullable=True),
sa.PrimaryKeyConstraint('tokenid'),
sa.UniqueConstraint('tokenhash'),
)
def downgrade():
if _has_table(TOKENS):
op.drop_table(TOKENS)