Make the one-line install survive Defender, and approval leave a PC that works
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

All three found testing the bootstrap installer on Windows.

- The published command used the irm-pipe-iex form, which Microsoft Defender
  detects as Trojan:Win32/Commando.A!ml and kills before it runs, every time,
  on a default install. The command now downloads the script to a file, runs
  it, and removes it in a finally block, which Defender does not object to.
  The UI also offers the script as a plain download for a tech who would
  rather save it.
- Approving a request only linked the asset, so a PC whose pending reason was
  "unknown hostname" - which is every PC installed this way - kept getting 403
  on every call afterwards, because its computer record had no hostname to
  match the credential against. Approval now stamps the hostname when the
  record has none, refuses with a 409 naming both when the record has a
  different one, and overwrites only when the admin confirms. The Credentials
  tab offers that confirmation.
- A hostname that resolves to no record now says so instead of reading as an
  impostor, while still refusing the request.
- While waiting for approval the agent retried on the 15-minute telemetry
  cadence, so a PC approved immediately enrolled a quarter of an hour later
  and the installer always ended on "still pending". It now retries about
  every minute for the first ten minutes, then every five to thirty minutes,
  then every fifteen, with jitter. A rate limit or server error still uses the
  slower backoff.
This commit is contained in:
cproudlock
2026-09-15 20:46:04 -04:00
parent ca57b7f728
commit 2557d1967d
12 changed files with 580 additions and 65 deletions

View File

@@ -65,6 +65,19 @@ REASON_NONCE_CHANGED = 'nonce changed'
REASON_REJECTED = 'rejected'
REASON_REENROLL_BLOCKED = 'reenroll blocked'
# What an approval did to the computer's hostname. Reported back so the UI can
# say so, and so an admin can tell a rename from a no-op.
HOSTNAME_UNCHANGED = 'unchanged'
HOSTNAME_SET = 'set'
HOSTNAME_OVERWRITTEN = 'overwritten'
HOSTNAME_MESSAGES = {
HOSTNAME_UNCHANGED: 'Enroll request approved',
HOSTNAME_SET: 'Enroll request approved; the hostname is now recorded on '
'that computer',
HOSTNAME_OVERWRITTEN: 'Enroll request approved; that computer was renamed '
'to the hostname the PC reports',
}
MIGRATION_STATES = ('enrolled', 'pending', 'upgrading', 'ready', 'blocked',
'notreporting')
ENROLLMENT_STATES = ('enrolled', 'notenrolled', 'pending', 'revoked')
@@ -557,7 +570,21 @@ def get_enrollment():
methods=['POST'])
@require_role('admin')
def approve_enroll_request(requestid):
"""Bind a pending request to the asset the admin picked."""
"""Bind a pending request to the asset the admin picked.
The computer record also has to CARRY the request's hostname, or the PC
403s on every call after it collects its credential: bind_hostname resolves
the hostname the agent sends to an asset and compares it with the
credential's asset, and a computer with no hostname resolves to nothing.
The pending reason for a one-off PC is by definition `unknown hostname`, so
this is the normal case, not the exception.
Body: {"assetid": 123, "sethostname": false}. A computer with no hostname
is stamped with the request's. A computer carrying a DIFFERENT hostname is
refused with 409 naming both, until the caller repeats the approval with
"sethostname": true. Either change is audited, and the response says which
of unchanged / set / overwritten happened.
"""
row = db.session.get(EndpointEnrollRequest, requestid)
if row is None:
return error_response(ErrorCodes.NOT_FOUND,
@@ -572,8 +599,13 @@ def approve_enroll_request(requestid):
if isinstance(assetid, bool) or not isinstance(assetid, int):
return error_response(ErrorCodes.VALIDATION_ERROR,
'assetid must be an integer')
sethostname = payload.get('sethostname', False)
if not isinstance(sethostname, bool):
return error_response(ErrorCodes.VALIDATION_ERROR,
'sethostname must be true or false')
Computer = _computer_model()
if Computer.query.filter_by(assetid=assetid).first() is None:
computer = Computer.query.filter_by(assetid=assetid).first()
if computer is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Asset {assetid} is not a computer')
if agentauth.active_credential_for(assetid) is not None:
@@ -585,6 +617,35 @@ def approve_enroll_request(requestid):
'That computer is blocked from enrolling again; '
'allow re-enrollment first', http_code=409)
requesthostname = (row.hostname or '').strip()
currenthostname = (computer.hostname or '').strip()
hostnameresult = HOSTNAME_UNCHANGED
if requesthostname and not currenthostname:
hostnameresult = HOSTNAME_SET
elif requesthostname and currenthostname.lower() != requesthostname.lower():
if not sethostname:
return error_response(
ErrorCodes.CONFLICT,
'That computer is recorded as {0}, but the request comes from '
'{1}. Approving as it stands leaves the PC unable to '
'authenticate. Approve again with "sethostname": true to '
'rename the computer to {1}.'.format(currenthostname,
requesthostname),
details={'hostnameconflict': True,
'computerhostname': currenthostname,
'requesthostname': requesthostname},
http_code=409)
hostnameresult = HOSTNAME_OVERWRITTEN
if hostnameresult != HOSTNAME_UNCHANGED:
computer.hostname = requesthostname
AuditLog.log('updated', 'Computer', entityid=computer.computerid,
entityname=requesthostname,
changes={'hostname': {'old': currenthostname or None,
'new': requesthostname}},
details='hostname {0} from enroll request {1}'.format(
hostnameresult, row.requestid))
row.status = EndpointEnrollRequest.STATUS_APPROVED
row.matchedassetid = assetid
row.reason = None
@@ -592,9 +653,12 @@ def approve_enroll_request(requestid):
row.decidedby = _username()
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
entityname=row.hostname,
details='enroll request approved for asset {}'.format(assetid))
details='enroll request approved for asset {0}, hostname {1}'
.format(assetid, hostnameresult))
db.session.commit()
return success_response(row.to_dict(), message='Enroll request approved')
approved = row.to_dict()
approved['hostnameresult'] = hostnameresult
return success_response(approved, message=HOSTNAME_MESSAGES[hostnameresult])
@endpoints_bp.route('/enrollment/requests/<int:requestid>/reject',