Log Collector: a new collect_log action reads a plaintext log file from a known path and returns its tail for viewing and download. Rides the existing endpoints.updateapp grant rather than inventing one, since collecting a log is the same trust level as pushing an application. Collected logs land in their own table with per-asset retention of 20 instead of overloading the command audit row, and the agent refuses UNC, traversal, wildcards, directories and non-text content independently of the server check. collect_log is POLICY_EXEMPT. Every existing Configuration Policy predates the action and so cannot name it, and the intersection in ALLOWED_ACTIONS_FOR drops anything unnamed - so every policied endpoint refused log collection outright, including for admins, who saw a permission-shaped error for a grant they held. Exemption from policy is not exemption from permission: the elevated grant still applies, and a regression test asserts both halves so a future edit cannot quietly loosen the grant while "fixing" the policy gate. Script output capture: successful runs can return output, gated per script by captureoutput. Failures always returned it; success never did, which left a green badge as the only evidence a run produced. Timestamps: Activity and Operator Reports rendered date-only because the shared formatter's 'date' branch drops the clock time. Run Scripts additionally labels its timestamp, since a bare time meant "completed" on a terminal row but "queued" on an in-flight one - indistinguishable, and the wrong reading. Agent 1.16.0. Minor: collect_log is a new action name, so an older agent asked for it fails closed on the unknown-action default.
93 lines
4.5 KiB
Python
93 lines
4.5 KiB
Python
"""Add endpointlogcollections
|
|
|
|
Backs the manual Log Collector tab: one row per plaintext log file an operator
|
|
collected from an endpoint by path.
|
|
|
|
Collected content gets its own table rather than living in
|
|
endpointcommands.resultjson for three reasons. It is bulky, and resultjson is
|
|
read by every command-history list that only wants a status. It needs its own
|
|
retention rule, which a command row cannot carry without deleting the audit
|
|
record of the command itself. And the download route has to address a single
|
|
collection by id and serve it as a file, which is awkward against a JSON column.
|
|
|
|
Retention is PER ASSET (20, enforced on insert in the result route), not global.
|
|
The history renders on a single machine's detail page, so a global cap would let
|
|
one busy endpoint quietly evict the history of another - the panel would be
|
|
unreliable exactly where it is read.
|
|
|
|
endpointcommandid is SET NULL rather than CASCADE: commands are reaped and
|
|
expired over time, and a collected log an operator is still reading must not
|
|
vanish because its originating command row was cleaned up. assetid stays CASCADE
|
|
because a log has no meaning once the machine is gone.
|
|
|
|
filesizebytes is BIGINT while contentbytes is INTEGER on purpose - the file on
|
|
disk may be gigabytes, while what we store is a bounded tail.
|
|
|
|
Revision ID: 7d51_endpoint_logcollections
|
|
Revises: 7d50_script_captureoutput
|
|
Create Date: 2026-09-09
|
|
|
|
"""
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
|
|
revision = '7d51_endpoint_logcollections'
|
|
down_revision = '7d50_script_captureoutput'
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade():
|
|
op.create_table(
|
|
'endpointlogcollections',
|
|
sa.Column('endpointlogcollectionid', sa.Integer(), nullable=False),
|
|
sa.Column('assetid', sa.Integer(), nullable=False),
|
|
sa.Column('endpointcommandid', sa.Integer(), nullable=True),
|
|
sa.Column('sourcepath', sa.String(length=512), nullable=False,
|
|
comment='Path as the AGENT resolved it, which may differ '
|
|
'from what the operator typed once environment '
|
|
'variables are expanded endpoint-side.'),
|
|
sa.Column('content', sa.Text(length=16777215), nullable=True),
|
|
sa.Column('filesizebytes', sa.BigInteger(), nullable=True,
|
|
comment='Size of the file on disk. Differs from contentbytes '
|
|
'when the stored copy is a truncated tail.'),
|
|
sa.Column('contentbytes', sa.Integer(), nullable=True),
|
|
sa.Column('linecount', sa.Integer(), nullable=True),
|
|
sa.Column('istruncated', sa.Boolean(), nullable=False,
|
|
server_default=sa.false(),
|
|
comment='True when content is only the tail of a larger file.'),
|
|
sa.Column('filemodifiedat', sa.DateTime(), nullable=True),
|
|
sa.Column('collectedbyuserid', sa.Integer(), nullable=True),
|
|
sa.Column('collectedbyusername', sa.String(length=100), nullable=True,
|
|
comment='Username snapshot; the id alone becomes unreadable '
|
|
'if the user is later removed.'),
|
|
sa.Column('collectedat', sa.DateTime(), nullable=False),
|
|
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
|
ondelete='CASCADE'),
|
|
sa.ForeignKeyConstraint(['endpointcommandid'],
|
|
['endpointcommands.endpointcommandid'],
|
|
ondelete='SET NULL'),
|
|
sa.ForeignKeyConstraint(['collectedbyuserid'], ['users.userid'],
|
|
ondelete='SET NULL'),
|
|
sa.PrimaryKeyConstraint('endpointlogcollectionid'),
|
|
)
|
|
op.create_index('ix_endpointlogcollections_assetid',
|
|
'endpointlogcollections', ['assetid'])
|
|
op.create_index('ix_endpointlogcollections_collectedat',
|
|
'endpointlogcollections', ['collectedat'])
|
|
# Serves both hot paths: listing an asset's history newest-first, and
|
|
# finding that asset's oldest rows to prune on insert.
|
|
op.create_index('idx_endpointlogcollections_assetcollected',
|
|
'endpointlogcollections', ['assetid', 'collectedat'])
|
|
|
|
|
|
def downgrade():
|
|
op.drop_index('idx_endpointlogcollections_assetcollected',
|
|
table_name='endpointlogcollections')
|
|
op.drop_index('ix_endpointlogcollections_collectedat',
|
|
table_name='endpointlogcollections')
|
|
op.drop_index('ix_endpointlogcollections_assetid',
|
|
table_name='endpointlogcollections')
|
|
op.drop_table('endpointlogcollections')
|