Browse Source

add DepotFile + improve CDNClient

* DepotFile for Manifest mappings
* CDNClient now handles content servers selection better
* CDNClient can auto get and cache cdn auth tokens and depot keys
* added get_chunk() to CDNClient, only ZIP compression, TODO LZMA
pull/191/head
Rossen Georgiev 6 years ago
parent
commit
eb57e82b5b
  1. 14
      steam/client/builtins/apps.py
  2. 95
      steam/client/cdn.py
  3. 147
      steam/core/manifest.py
  4. 13
      steam/enums/common.py

14
steam/client/builtins/apps.py

@ -142,20 +142,20 @@ class Apps(object):
timeout=10 timeout=10
) )
def get_depot_key(self, depot_id, app_id=0): def get_depot_key(self, app_id, depot_id):
"""Get depot decryption key """Get depot decryption key
:param depot_id: depot id
:type depot_id: :class:`int`
:param app_id: app id :param app_id: app id
:type app_id: :class:`int` :type app_id: :class:`int`
:param depot_id: depot id
:type depot_id: :class:`int`
:return: `CMsgClientGetDepotDecryptionKeyResponse <https://github.com/ValvePython/steam/blob/39627fe883feeed2206016bacd92cf0e4580ead6/protobufs/steammessages_clientserver_2.proto#L533-L537>`_ :return: `CMsgClientGetDepotDecryptionKeyResponse <https://github.com/ValvePython/steam/blob/39627fe883feeed2206016bacd92cf0e4580ead6/protobufs/steammessages_clientserver_2.proto#L533-L537>`_
:rtype: proto message :rtype: proto message
""" """
return self.send_job_and_wait(MsgProto(EMsg.ClientGetDepotDecryptionKey), return self.send_job_and_wait(MsgProto(EMsg.ClientGetDepotDecryptionKey),
{ {
'depot_id': depot_id,
'app_id': app_id, 'app_id': app_id,
'depot_id': depot_id,
}, },
timeout=10 timeout=10
) )
@ -182,9 +182,9 @@ class Apps(object):
"""Get access tokens """Get access tokens
:param app_ids: list of app ids :param app_ids: list of app ids
:type app_ids: :class:`list` :type app_ids: :class:`list`
:param package_ids: list of package ids :param package_ids: list of package ids
:type package_ids: :class:`list` :type package_ids: :class:`list`
:return: dict with ``apps`` and ``packages`` containing their access tokens, see example below :return: dict with ``apps`` and ``packages`` containing their access tokens, see example below
:rtype: :class:`dict`, :class:`None` :rtype: :class:`dict`, :class:`None`

95
steam/client/cdn.py

@ -2,9 +2,11 @@
from collections import OrderedDict, deque from collections import OrderedDict, deque
from six import itervalues from six import itervalues
import vdf import vdf
from steam import webapi from steam import webapi
from steam.enums import EServerType from steam.enums import EResult, EServerType
from steam.util.web import make_requests_session from steam.util.web import make_requests_session
from stema.core.crypto symmetric_decrypt
from steam.core.manifest import DepotManifest from steam.core.manifest import DepotManifest
@ -67,12 +69,14 @@ class CDNClient(object):
self.app_id = app_id self.app_id = app_id
self.web = make_requests_session() self.web = make_requests_session()
self.servers = deque() self.servers = deque()
self.cdn_auth_tokens = {}
self.depot_keys = {}
@property @property
def cell_id(self): def cell_id(self):
return self.steam.cell_id return self.steam.cell_id
def init_servers(self, num_servers=10): def init_servers(self, num_servers=20):
self.servers.clear() self.servers.clear()
for ip, port in self.steam.servers[EServerType.CS]: for ip, port in self.steam.servers[EServerType.CS]:
@ -85,32 +89,85 @@ class CDNClient(object):
if not self.servers: if not self.servers:
raise RuntimeError("No content servers on SteamClient instance. Is it logged in?") raise RuntimeError("No content servers on SteamClient instance. Is it logged in?")
def get_content_server(self): def get_content_server(self, rotate=True):
server = self.servers[0] if rotate:
self.servers.rotate(-1) self.servers.rotate(-1)
return server return self.servers[0]
def get_cdn_auth_token(self, depot_id):
if depot_id not in self.cdn_auth_tokens:
msg = self.steam.get_cdn_auth_token(depot_id, 'steampipe.steamcontent.com')
if msg.eresult == EResult.OK:
self.cdn_auth_tokens[depot_id] = msg.token
elif msg is None:
raise Exception("Failed getting depot key: %s" % repr(EResult.Timeout))
else:
raise Exception("Failed getting depot key: %s" % repr(EResult(msg.eresult)))
return self.cdn_auth_tokens[depot_id]
def get_depot_key(self, depot_id):
if depot_id not in self.depot_keys:
msg = self.steam.get_depot_key(self.app_id, depot_id)
if msg.eresult == EResult.OK:
self.depot_keys[depot_id] = msg.depot_encryption_key
elif msg is None:
raise Exception("Failed getting depot key: %s" % repr(EResult.Timeout))
else:
raise Exception("Failed getting depot key: %s" % repr(EResult(msg.eresult)))
return self.depot_keys[depot_id]
def get(self, command, args, auth_token=''): def get(self, command, args, auth_token=''):
server = self.get_content_server() server = self.get_content_server()
url = "%s://%s:%s/%s/%s%s" % ( while True:
'https' if server.https else 'http', url = "%s://%s:%s/%s/%s%s" % (
server.host, 'https' if server.https else 'http',
server.port, server.host,
command, server.port,
args, command,
auth_token, args,
) auth_token,
)
resp = self.web.get(url)
return self.web.get(url) if resp.ok:
return resp
elif resp.status_code in (401, 403, 404):
resp.raise_for_status()
def get_manifest(self, depot_id, manifest_id, auth_token): server = self.get_content_server(rotate=True)
resp = self.get('depot', '%s/manifest/%s/5' % (depot_id, manifest_id), auth_token)
resp.raise_for_status() def get_manifest(self, depot_id, manifest_id, cdn_auth_token=None, decrypt=True):
if cdn_auth_token is None:
cdn_auth_token = self.get_cdn_auth_token(depot_id)
resp = self.get('depot', '%s/manifest/%s/5' % (depot_id, manifest_id), cdn_auth_token)
if resp.ok: if resp.ok:
return DepotManifest(resp.content) manifest = DepotManifest(resp.content)
if decrypt:
manifest.decrypt_filenames(self.get_depot_key(depot_id))
return manifest
def get_chunk(self, depot_id, chunk_id, cdn_auth_token=None):
if cdn_auth_token is None:
cdn_auth_token = self.get_cdn_auth_token(depot_id)
resp = self.get('depot', '%s/chunk/%s' % (depot_id, chunk_id), cdn_auth_token)
if resp.ok:
data = symmetric_decrypt(resp.content, self.get_depot_key(depot_id))
if data[:2] == b'VZ':
raise Exception("Implement LZMA lol")
else:
with ZipFile(BytesIO(data)) as zf:
data = zf.read(zf.filelist[0])
return data
class ContentServer(object): class ContentServer(object):

147
steam/core/manifest.py

@ -1,10 +1,12 @@
from base64 import b64decode from base64 import b64decode
from io import BytesIO from io import BytesIO
from zipfile import ZipFile, ZIP_DEFLATED from zipfile import ZipFile, ZIP_DEFLATED, BadZipFile
from struct import pack from struct import pack
from datetime import datetime from datetime import datetime
from fnmatch import fnmatch
from steam.enums import EDepotFileFlag
from steam.core.crypto import symmetric_decrypt from steam.core.crypto import symmetric_decrypt
from steam.util.binary import StructReader from steam.util.binary import StructReader
from steam.protobufs.content_manifest_pb2 import (ContentManifestMetadata, from steam.protobufs.content_manifest_pb2 import (ContentManifestMetadata,
@ -18,7 +20,12 @@ class DepotManifest(object):
PROTOBUF_SIGNATURE_MAGIC = 0x1B81B817 PROTOBUF_SIGNATURE_MAGIC = 0x1B81B817
PROTOBUF_ENDOFMANIFEST_MAGIC = 0x32C415AB PROTOBUF_ENDOFMANIFEST_MAGIC = 0x32C415AB
def __init__(self, data): def __init__(self, data=None):
"""Manage depot manifest
:param data: manifest data
:type data: bytes
"""
self.metadata = ContentManifestMetadata() self.metadata = ContentManifestMetadata()
self.payload = ContentManifestPayload() self.payload = ContentManifestPayload()
self.signature = ContentManifestSignature() self.signature = ContentManifestSignature()
@ -28,8 +35,8 @@ class DepotManifest(object):
def __repr__(self): def __repr__(self):
params = ', '.join([ params = ', '.join([
str(self.metadata.depot_id), str(self.depot_id),
str(self.metadata.gid_manifest), str(self.gid),
repr(datetime.utcfromtimestamp(self.metadata.creation_time).isoformat().replace('T', ' ')), repr(datetime.utcfromtimestamp(self.metadata.creation_time).isoformat().replace('T', ' ')),
]) ])
@ -41,9 +48,35 @@ class DepotManifest(object):
params, params,
) )
@property
def depot_id(self):
return self.metadata.depot_id
@property
def gid(self):
return self.metadata.gid_manifest
@property
def creation_time(self):
return self.metadata.creation_time
@property
def size_original(self):
return self.metadata.cb_disk_original
@property
def size_compressed(self):
return self.metadata.cb_disk_compressed
def decrypt_filenames(self, depot_key): def decrypt_filenames(self, depot_key):
"""Decrypt all filenames in the manifest
:param depot_key: depot key
:type depot_key: bytes
:raises: :class:`RuntimeError`
"""
if not self.metadata.filenames_encrypted: if not self.metadata.filenames_encrypted:
return True return
for mapping in self.payload.mappings: for mapping in self.payload.mappings:
filename = b64decode(mapping.filename) filename = b64decode(mapping.filename)
@ -51,17 +84,25 @@ class DepotManifest(object):
try: try:
filename = symmetric_decrypt(filename, depot_key) filename = symmetric_decrypt(filename, depot_key)
except Exception: except Exception:
print("Unable to decrypt filename for depot manifest") RuntimeError("Unable to decrypt filename for depot manifest")
return False
mapping.filename = filename mapping.filename = filename
self.metadata.filenames_encrypted = False self.metadata.filenames_encrypted = False
return True
def deserialize(self, data): def deserialize(self, data):
with ZipFile(BytesIO(data)) as zf: """Deserialize a manifest (compressed or uncompressed)
data = StructReader(zf.read(zf.filelist[0]))
:param data: manifest data
:type data: bytes
"""
try:
with ZipFile(BytesIO(data)) as zf:
data = zf.read(zf.filelist[0])
except BadZipFile:
pass
data = StructReader(data)
magic, length = data.unpack('<II') magic, length = data.unpack('<II')
@ -92,7 +133,12 @@ class DepotManifest(object):
if magic != DepotManifest.PROTOBUF_ENDOFMANIFEST_MAGIC: if magic != DepotManifest.PROTOBUF_ENDOFMANIFEST_MAGIC:
raise Exception("Expecting end of manifest") raise Exception("Expecting end of manifest")
def serialize(self): def serialize(self, compress=True):
"""Serialize manifest
:param compress: wether the output should be Zip compressed
:type compress: bytes
"""
data = BytesIO() data = BytesIO()
part = self.payload.SerializeToString() part = self.payload.SerializeToString()
@ -109,8 +155,79 @@ class DepotManifest(object):
data.write(pack('<I', DepotManifest.PROTOBUF_ENDOFMANIFEST_MAGIC)) data.write(pack('<I', DepotManifest.PROTOBUF_ENDOFMANIFEST_MAGIC))
zbuff = BytesIO() if compress:
with ZipFile(zbuff, 'w', ZIP_DEFLATED) as zf: zbuff = BytesIO()
zf.writestr('z', data.getvalue()) with ZipFile(zbuff, 'w', ZIP_DEFLATED) as zf:
zf.writestr('z', data.getvalue())
return zbuff.getvalue()
else:
return data.getvalue()
def __iter__(self):
for mapping in self.payload.mappings:
yield DepotFile(self, mapping)
def iter_files(self, pattern=None):
"""
:param pattern: unix shell wildcard pattern, see :module:`.fnmatch`
:type pattern: str
"""
for mapping in self.payload.mappings:
if (pattern is not None
and not fnmatch(mapping.filename.rstrip('\x00 \n\t'), pattern)):
continue
yield DepotFile(self, mapping)
def __len__(self):
return len(self.payload.mappings)
class DepotFile(object):
def __init__(self, manifest, file_mapping):
"""Depot file
:param manifest: depot manifest
:type manifest: :class:`.DepotManifest`
:param file_mapping: depot file mapping instance
:type file_mapping: ContentManifestPayload.FileMapping
"""
if not isinstance(manifest, DepotManifest):
raise ValueError("Expected 'manifest' to be of type DepotManifest")
if not isinstance(file_mapping, ContentManifestPayload.FileMapping):
raise ValueError("Expected 'file_mapping' to be of type ContentManifestPayload.FileMapping")
self.manifest = manifest
self.file_mapping = file_mapping
def __repr__(self):
return "<%s(%s, %s, %s, %s)>" % (
self.__class__.__name__,
self.manifest.depot_id,
self.manifest.gid,
repr(self.filename),
'is_directory=True' if self.is_directory else self.size,
)
@property
def filename(self):
return self.file_mapping.filename.rstrip('\x00 \n\t')
@property
def size(self):
return self.file_mapping.size
@property
def chunks(self):
return self.file_mapping.chunks
@property
def flags(self):
return self.file_mapping.flags
@property
def is_directory(self):
return self.flags & EDepotFileFlag.Directory > 0
return zbuff.getvalue() @property
def is_file(self):
return not self.is_directory

13
steam/enums/common.py

@ -539,6 +539,19 @@ class ECurrencyCode(SteamIntEnum):
Max = 42 Max = 42
class EDepotFileFlag(SteamIntEnum):
UserConfig = 1
VersionedUserConfig = 2
Encrypted = 4
ReadOnly = 8
Hidden = 16
Executable = 32
Directory = 64
CustomExecutable = 128
InstallScript = 256
Symlink = 512
# Do not remove # Do not remove
from enum import EnumMeta from enum import EnumMeta

Loading…
Cancel
Save