diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index c93d34e..e2a773f 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -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/', 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']) diff --git a/plugins/geenforce/migrations/versions/0002_manifest_blobs.py b/plugins/geenforce/migrations/versions/0002_manifest_blobs.py new file mode 100644 index 0000000..d196a2c --- /dev/null +++ b/plugins/geenforce/migrations/versions/0002_manifest_blobs.py @@ -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 /geenforce/payloads/; +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') diff --git a/plugins/geenforce/models/__init__.py b/plugins/geenforce/models/__init__.py index bf3e31a..1c1f79f 100644 --- a/plugins/geenforce/models/__init__.py +++ b/plugins/geenforce/models/__init__.py @@ -10,6 +10,7 @@ from .manifest import ( ManifestInUseCheckProcess, ManifestPublishedVersion, ManifestPayload, + ManifestBlob, ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias, diff --git a/plugins/geenforce/models/manifest.py b/plugins/geenforce/models/manifest.py index 8e40c90..1d2865f 100644 --- a/plugins/geenforce/models/manifest.py +++ b/plugins/geenforce/models/manifest.py @@ -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 /geenforce/payloads/ (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/ 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) diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py index 228deb2..eca23c9 100644 --- a/plugins/geenforce/plugin.py +++ b/plugins/geenforce/plugin.py @@ -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/.""" + 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] diff --git a/plugins/geenforce/serializer.py b/plugins/geenforce/serializer.py index 7fe4073..4bdfb37 100644 --- a/plugins/geenforce/serializer.py +++ b/plugins/geenforce/serializer.py @@ -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/ 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 = [] diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py index f004bd2..8476760 100644 --- a/plugins/geenforce/service.py +++ b/plugins/geenforce/service.py @@ -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/. + """ + 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). diff --git a/tests/test_plugins/test_geenforce_httppayload.py b/tests/test_plugins/test_geenforce_httppayload.py new file mode 100644 index 0000000..5240b4f --- /dev/null +++ b/tests/test_plugins/test_geenforce_httppayload.py @@ -0,0 +1,91 @@ +"""GE-Enforce HTTPS payload delivery: content-addressed blob store + the +client-facing GET /api/geenforce/payload/ endpoint. + +Lets share-less (Intune/local-account) PCs pull installers the manifest +references over HTTPS instead of SMB. Auth = a geenforce.fetch service token +(same as /manifest). The sha256 IS the integrity guarantee (client re-verifies). +""" + +import hashlib + +from plugins.geenforce import service +from plugins.geenforce.models import ManifestBlob + + +def _fetch_key(client, auth_headers): + resp = client.post('/api/apitokens', + json={'name': 'svc', 'scopes': ['geenforce.fetch']}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return {'X-API-Key': resp.get_json()['data']['secret']} + + +def _store(app, raw, filename='setup.exe'): + with app.app_context(): + sha = service.store_blob(raw, filename, 'application/octet-stream') + service.db.session.commit() + return sha + + +def test_store_blob_dedups_and_registers(app, db): + raw = b'MZ fake installer bytes' + sha1 = _store(app, raw) + sha2 = _store(app, raw) # same content + assert sha1 == sha2 == hashlib.sha256(raw).hexdigest() + with app.app_context(): + assert ManifestBlob.query.count() == 1 + blob = service.db.session.get(ManifestBlob, sha1) + assert blob.sizebytes == len(raw) + + +def test_download_returns_exact_bytes(client, app, auth_headers): + raw = b'\x00\x01 big binary installer \xff\xfe' + sha = _store(app, raw, 'kiosk.exe') + headers = _fetch_key(client, auth_headers) + + resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers) + assert resp.status_code == 200 + assert resp.data == raw + assert resp.headers['ETag'] == f'"{sha}"' + assert 'kiosk.exe' in resp.headers.get('Content-Disposition', '') + + +def test_download_requires_fetch_token(client, app, auth_headers): + sha = _store(app, b'data') + # no key + assert client.get(f'/api/geenforce/payload/{sha}').status_code == 401 + + +def test_download_unknown_hash_404(client, auth_headers): + headers = _fetch_key(client, auth_headers) + sha = 'a' * 64 + assert client.get(f'/api/geenforce/payload/{sha}', headers=headers).status_code == 404 + + +def test_download_bad_hash_400(client, auth_headers): + headers = _fetch_key(client, auth_headers) + assert client.get('/api/geenforce/payload/not-a-hash', headers=headers).status_code == 400 + + +def test_etag_304(client, app, auth_headers): + sha = _store(app, b'cacheable') + headers = _fetch_key(client, auth_headers) + resp = client.get(f'/api/geenforce/payload/{sha}', + headers={**headers, 'If-None-Match': f'"{sha}"'}) + assert resp.status_code == 304 + + +def test_serializer_emits_payload_transport_for_http_only(app): + """http/inline entries emit PayloadSource + PayloadSha256; smb stays bare.""" + from plugins.geenforce.serializer import entry_to_dict + from plugins.geenforce.models import ManifestEntry + + smb = ManifestEntry(name='a', entrytype='EXE', payloadsource='smb') + assert 'PayloadSource' not in entry_to_dict(smb) + + http = ManifestEntry(name='b', entrytype='EXE', payloadsource='http', + payloadsha256='f' * 64, payloadref='kiosk.exe') + out = entry_to_dict(http) + assert out['PayloadSource'] == 'http' + assert out['PayloadSha256'] == 'f' * 64 + assert out['PayloadRef'] == 'kiosk.exe'