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).
|
collector's managed-token pattern (X-API-Key or Bearer PAT).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from functools import wraps
|
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 flask_jwt_extended import jwt_required
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ SHAREROOT_SETTING = 'geenforce_share_root'
|
|||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
||||||
ManifestEnforcementReport, ManifestPayload, ENTRY_TYPES, PHASES,
|
ManifestEnforcementReport, ManifestPayload, ManifestBlob, ENTRY_TYPES, PHASES,
|
||||||
)
|
)
|
||||||
from ..serializer import scope_to_manifest, entry_to_dict
|
from ..serializer import scope_to_manifest, entry_to_dict
|
||||||
from ..importer import build_entry, populate_entry
|
from ..importer import build_entry, populate_entry
|
||||||
@@ -98,6 +99,48 @@ def get_manifest():
|
|||||||
'X-Manifest-Version': str(published.versionnumber)})
|
'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) ----------------------------------------
|
# -- client reporting (observed state) ----------------------------------------
|
||||||
|
|
||||||
@geenforce_bp.route('/report', methods=['POST'])
|
@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,
|
ManifestInUseCheckProcess,
|
||||||
ManifestPublishedVersion,
|
ManifestPublishedVersion,
|
||||||
ManifestPayload,
|
ManifestPayload,
|
||||||
|
ManifestBlob,
|
||||||
ManifestEnforcementReport,
|
ManifestEnforcementReport,
|
||||||
ManifestEnforcementResult,
|
ManifestEnforcementResult,
|
||||||
PcTypeAlias,
|
PcTypeAlias,
|
||||||
|
|||||||
@@ -351,3 +351,21 @@ class PcTypeAlias(db.Model):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
db.UniqueConstraint('aliasgroup', 'aliasname', name='uq_alias_group_name'),
|
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 (
|
from .models import (
|
||||||
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
|
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
|
||||||
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
|
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||||
ManifestPublishedVersion, ManifestPayload, ManifestEnforcementReport,
|
ManifestPublishedVersion, ManifestPayload, ManifestBlob,
|
||||||
ManifestEnforcementResult, PcTypeAlias,
|
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
|
||||||
)
|
)
|
||||||
from .filters import ALIAS_GROUPS
|
from .filters import ALIAS_GROUPS
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ class GeEnforcePlugin(BasePlugin):
|
|||||||
ManifestScope, ManifestEntry, ManifestEntryPcType,
|
ManifestScope, ManifestEntry, ManifestEntryPcType,
|
||||||
ManifestEntryHostname, ManifestEntryMachineNumber,
|
ManifestEntryHostname, ManifestEntryMachineNumber,
|
||||||
ManifestInUseCheck, ManifestInUseCheckProcess,
|
ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||||
ManifestPublishedVersion, ManifestPayload,
|
ManifestPublishedVersion, ManifestPayload, ManifestBlob,
|
||||||
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
|
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -191,4 +191,24 @@ class GeEnforcePlugin(BasePlugin):
|
|||||||
path = export_scope_to_share(scopename, phase, shareroot)
|
path = export_scope_to_share(scopename, phase, shareroot)
|
||||||
click.echo(f"Exported to {path}.")
|
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]
|
return [geenforce_cli]
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ def entry_to_dict(entry):
|
|||||||
for attr, key in _BOOL_FLAGS:
|
for attr, key in _BOOL_FLAGS:
|
||||||
if getattr(entry, attr):
|
if getattr(entry, attr):
|
||||||
result[key] = True
|
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[]).
|
# InUseCheck (nested object + Processes[]).
|
||||||
if entry.inusecheck:
|
if entry.inusecheck:
|
||||||
procs = []
|
procs = []
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
from shopdb.api import db, Application
|
from shopdb.api import db, Application
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
||||||
ManifestEnforcementResult, ManifestPayload,
|
ManifestEnforcementResult, ManifestPayload, ManifestBlob,
|
||||||
)
|
)
|
||||||
from .importer import build_entry
|
from .importer import build_entry
|
||||||
from .serializer import scope_to_json
|
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):
|
def store_inline_payload(entry, filename, contenttype, rawbytes):
|
||||||
"""Replace the entry's inline payload with these bytes (uncommitted).
|
"""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