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:
@@ -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'])
|
||||
|
||||
42
plugins/geenforce/migrations/versions/0002_manifest_blobs.py
Normal file
42
plugins/geenforce/migrations/versions/0002_manifest_blobs.py
Normal 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')
|
||||
@@ -10,6 +10,7 @@ from .manifest import (
|
||||
ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion,
|
||||
ManifestPayload,
|
||||
ManifestBlob,
|
||||
ManifestEnforcementReport,
|
||||
ManifestEnforcementResult,
|
||||
PcTypeAlias,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
91
tests/test_plugins/test_geenforce_httppayload.py
Normal file
91
tests/test_plugins/test_geenforce_httppayload.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""GE-Enforce HTTPS payload delivery: content-addressed blob store + the
|
||||
client-facing GET /api/geenforce/payload/<sha256> 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'
|
||||
Reference in New Issue
Block a user