geenforce: HTTPS payload delivery (content-addressed blob store + endpoint)

Lets share-less (Intune/local-account) PCs pull installers the manifest
references over HTTPS instead of SMB - the general capability the whole fleet
migrates toward. New ManifestBlob registry (migration 0002) with bytes on disk
at instance/geenforce/payloads/<sha256> (deduped by content); service.store_blob
+ blob_path; client-facing GET /api/geenforce/payload/<sha256> (geenforce.fetch
token, ETag=hash, serves the blob store or an inline DB payload by hash). The
serializer now emits PayloadSource/PayloadSha256/PayloadRef for http/inline
entries only (smb entries round-trip unchanged - parity green). CLI
'flask geenforce add-payload <file>' registers a blob and prints its sha256.
This is the shopdb half (B1); the PS client/engine fetch is B2.
This commit is contained in:
cproudlock
2026-07-21 10:10:59 -04:00
parent 60e2947fc7
commit b00ef72581
8 changed files with 268 additions and 6 deletions

View File

@@ -8,9 +8,10 @@ Two audiences:
collector's managed-token pattern (X-API-Key or Bearer PAT).
"""
import os
from functools import wraps
from flask import Blueprint, request, Response
from flask import Blueprint, request, Response, send_file
from flask_jwt_extended import jwt_required
from sqlalchemy.exc import IntegrityError
@@ -23,7 +24,7 @@ SHAREROOT_SETTING = 'geenforce_share_root'
from ..models import (
ManifestScope, ManifestEntry, ManifestPublishedVersion,
ManifestEnforcementReport, ManifestPayload, ENTRY_TYPES, PHASES,
ManifestEnforcementReport, ManifestPayload, ManifestBlob, ENTRY_TYPES, PHASES,
)
from ..serializer import scope_to_manifest, entry_to_dict
from ..importer import build_entry, populate_entry
@@ -98,6 +99,48 @@ def get_manifest():
'X-Manifest-Version': str(published.versionnumber)})
# -- client payload download (share-less installer delivery) ------------------
@geenforce_bp.route('/payload/<sha256>', methods=['GET'])
@require_fetch_token
def get_payload(sha256):
"""Serve a payload blob by content hash over HTTPS.
Lets share-less (Intune/local-account) PCs pull installers the manifest
references without SMB. Sources: the content-addressed blob store (large
http payloads) first, then an inline DB payload with this hash. The client
re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the
hash (content is immutable).
"""
sha = (sha256 or '').strip().lower()
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
http_code=400)
etag = f'"{sha}"'
if request.headers.get('If-None-Match') == etag:
return Response(status=304, headers={'ETag': etag})
blob = db.session.get(ManifestBlob, sha)
if blob and os.path.isfile(service.blob_path(sha)):
response = send_file(
service.blob_path(sha),
mimetype=blob.contenttype or 'application/octet-stream',
as_attachment=True, download_name=blob.filename)
response.headers['ETag'] = etag
return response
inline = ManifestPayload.query.filter_by(payloadsha256=sha).first()
if inline:
return Response(
inline.payloadbytes,
mimetype=inline.contenttype or 'application/octet-stream',
headers={'ETag': etag,
'Content-Disposition': f'attachment; filename="{inline.filename}"'})
return error_response(ErrorCodes.NOT_FOUND, 'No payload for that hash',
http_code=404)
# -- client reporting (observed state) ----------------------------------------
@geenforce_bp.route('/report', methods=['POST'])

View File

@@ -0,0 +1,42 @@
"""geenforce: manifestblobs (content-addressed http payload store)
Big installers reach share-less (Intune/local-account) PCs by HTTPS download
instead of SMB. The bytes live on disk at <instance>/geenforce/payloads/<sha>;
this table is the registry (dedup by content hash). Idempotent guard.
Revision ID: geenforce0002blobs
Revises: geenforce0001baseline
Create Date: 2026-07-21
"""
from alembic import op
import sqlalchemy as sa
revision = 'geenforce0002blobs'
down_revision = 'geenforce0001baseline'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'manifestblobs' in insp.get_table_names():
return
op.create_table(
'manifestblobs',
sa.Column('sha256', sa.String(length=64), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('contenttype', sa.String(length=128), nullable=True),
sa.Column('sizebytes', sa.BigInteger(), nullable=False),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('sha256'),
)
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'manifestblobs' in insp.get_table_names():
op.drop_table('manifestblobs')

View File

@@ -10,6 +10,7 @@ from .manifest import (
ManifestInUseCheckProcess,
ManifestPublishedVersion,
ManifestPayload,
ManifestBlob,
ManifestEnforcementReport,
ManifestEnforcementResult,
PcTypeAlias,

View File

@@ -351,3 +351,21 @@ class PcTypeAlias(db.Model):
__table_args__ = (
db.UniqueConstraint('aliasgroup', 'aliasname', name='uq_alias_group_name'),
)
class ManifestBlob(db.Model):
"""Content-addressed payload blob for http-delivered installers.
Bytes live on disk at <instance>/geenforce/payloads/<sha256> (deduped by
content, so a payload shared by many entries is stored once). Manifest
entries reference a blob by payloadsha256; the client fetches it from
GET /api/geenforce/payload/<sha256> over HTTPS and verifies the hash. This
is how big installers reach share-less (Intune/local-account) PCs.
"""
__tablename__ = 'manifestblobs'
sha256 = db.Column(db.String(64), primary_key=True)
filename = db.Column(db.String(255), nullable=False)
contenttype = db.Column(db.String(128), nullable=True)
sizebytes = db.Column(db.BigInteger, nullable=False)
createdat = db.Column(db.DateTime, nullable=False)

View File

@@ -21,8 +21,8 @@ from .api import geenforce_bp
from .models import (
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
ManifestPublishedVersion, ManifestPayload, ManifestEnforcementReport,
ManifestEnforcementResult, PcTypeAlias,
ManifestPublishedVersion, ManifestPayload, ManifestBlob,
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
)
from .filters import ALIAS_GROUPS
@@ -62,7 +62,7 @@ class GeEnforcePlugin(BasePlugin):
ManifestScope, ManifestEntry, ManifestEntryPcType,
ManifestEntryHostname, ManifestEntryMachineNumber,
ManifestInUseCheck, ManifestInUseCheckProcess,
ManifestPublishedVersion, ManifestPayload,
ManifestPublishedVersion, ManifestPayload, ManifestBlob,
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
]
@@ -191,4 +191,24 @@ class GeEnforcePlugin(BasePlugin):
path = export_scope_to_share(scopename, phase, shareroot)
click.echo(f"Exported to {path}.")
@geenforce_cli.command('add-payload')
@click.argument('filepath')
@click.option('--contenttype', default=None)
def add_payload_cmd(filepath, contenttype):
"""Store a file in the content-addressed payload store for HTTPS
delivery, and print its sha256. Set an entry's PayloadSource=http +
PayloadSha256 to serve it from GET /api/geenforce/payload/<sha>."""
import os as _os
from flask import current_app
from .service import store_blob
with current_app.app_context():
with open(filepath, 'rb') as handle:
raw = handle.read()
sha = store_blob(raw, _os.path.basename(filepath), contenttype)
db.session.commit()
click.echo(f"stored {len(raw)} bytes")
click.echo(f"PayloadSha256: {sha}")
click.echo(f"URL: /api/geenforce/payload/{sha}")
return [geenforce_cli]

View File

@@ -73,6 +73,15 @@ def entry_to_dict(entry):
for attr, key in _BOOL_FLAGS:
if getattr(entry, attr):
result[key] = True
# Payload transport for share-less (http/inline) delivery. Omitted for the
# default 'smb' source so existing share manifests round-trip unchanged; the
# client fetches GET /api/geenforce/payload/<PayloadSha256> for http/inline.
if entry.payloadsource and entry.payloadsource != 'smb':
result['PayloadSource'] = entry.payloadsource
if entry.payloadsha256:
result['PayloadSha256'] = entry.payloadsha256
if entry.payloadref:
result['PayloadRef'] = entry.payloadref
# InUseCheck (nested object + Processes[]).
if entry.inusecheck:
procs = []

View File

@@ -12,13 +12,14 @@ import os
import tempfile
from datetime import datetime, timezone
from flask import current_app
from sqlalchemy import func
from shopdb.api import db, Application
from .models import (
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
ManifestEnforcementResult, ManifestPayload,
ManifestEnforcementResult, ManifestPayload, ManifestBlob,
)
from .importer import build_entry
from .serializer import scope_to_json
@@ -276,6 +277,43 @@ def compliance_for_scope(scope):
}
def _payload_dir():
d = os.path.join(current_app.instance_path, 'geenforce', 'payloads')
os.makedirs(d, exist_ok=True)
return d
def blob_path(sha256):
"""Filesystem path where a blob's bytes live (may or may not exist)."""
return os.path.join(_payload_dir(), sha256)
def store_blob(rawbytes, filename, contenttype=None):
"""Store bytes in the content-addressed payload store; return the sha256.
Deduped by content hash - an already-present blob is not rewritten. Upserts
a ManifestBlob registry row (uncommitted). Entries reference it by
payloadsha256; the client fetches GET /api/geenforce/payload/<sha256>.
"""
sha = hashlib.sha256(rawbytes).hexdigest()
path = blob_path(sha)
if not os.path.exists(path):
fd, tmp = tempfile.mkstemp(dir=_payload_dir(), suffix='.tmp')
try:
with os.fdopen(fd, 'wb') as handle:
handle.write(rawbytes)
os.replace(tmp, path)
except Exception:
if os.path.exists(tmp):
os.remove(tmp)
raise
if not db.session.get(ManifestBlob, sha):
db.session.add(ManifestBlob(
sha256=sha, filename=filename, contenttype=contenttype,
sizebytes=len(rawbytes), createdat=_utcnow()))
return sha
def store_inline_payload(entry, filename, contenttype, rawbytes):
"""Replace the entry's inline payload with these bytes (uncommitted).