Skip to content

Commit f771d21

Browse files
committed
Feature: VmClient
1 parent 1d3d5e5 commit f771d21

File tree

8 files changed

+181
-15
lines changed

8 files changed

+181
-15
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dependencies = [
2828
"coincurve>=19.0.0; python_version>=\"3.11\"",
2929
"eth_abi>=4.0.0; python_version>=\"3.11\"",
3030
"eth_account>=0.4.0,<0.11.0",
31+
"jwcrypto==1.5.6",
3132
"python-magic",
3233
"typer",
3334
"typing_extensions",

src/aleph/sdk/chains/common.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,3 @@ def get_fallback_private_key(path: Optional[Path] = None) -> bytes:
170170
if not default_key_path.exists():
171171
default_key_path.symlink_to(path)
172172
return private_key
173-
174-
175-
def bytes_from_hex(hex_string: str) -> bytes:
176-
if hex_string.startswith("0x"):
177-
hex_string = hex_string[2:]
178-
hex_string = bytes.fromhex(hex_string)
179-
return hex_string

src/aleph/sdk/chains/ethereum.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,8 @@
77
from eth_keys.exceptions import BadSignature as EthBadSignatureError
88

99
from ..exceptions import BadSignatureError
10-
from .common import (
11-
BaseAccount,
12-
bytes_from_hex,
13-
get_fallback_private_key,
14-
get_public_key,
15-
)
10+
from ..utils import bytes_from_hex
11+
from .common import BaseAccount, get_fallback_private_key, get_public_key
1612

1713

1814
class ETHAccount(BaseAccount):

src/aleph/sdk/chains/substrate.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99

1010
from ..conf import settings
1111
from ..exceptions import BadSignatureError
12-
from .common import BaseAccount, bytes_from_hex, get_verification_buffer
12+
from ..utils import bytes_from_hex
13+
from .common import BaseAccount, get_verification_buffer
1314

1415
logger = logging.getLogger(__name__)
1516

src/aleph/sdk/client/vmclient.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import datetime
2+
import json
3+
import logging
4+
from typing import Any, Dict, Tuple
5+
6+
import aiohttp
7+
from eth_account.messages import encode_defunct
8+
from jwcrypto import jwk
9+
from jwcrypto.jwa import JWA
10+
11+
from aleph.sdk.types import Account
12+
from aleph.sdk.utils import to_0x_hex
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class VmClient:
18+
def __init__(self, account: Account, domain: str = ""):
19+
self.account: Account = account
20+
self.ephemeral_key: jwk.JWK = jwk.JWK.generate(kty="EC", crv="P-256")
21+
self.domain: str = domain
22+
self.pubkey_payload = self._generate_pubkey_payload()
23+
self.pubkey_signature_header: str = ""
24+
self.session = aiohttp.ClientSession()
25+
26+
def _generate_pubkey_payload(self) -> Dict[str, Any]:
27+
return {
28+
"pubkey": json.loads(self.ephemeral_key.export_public()),
29+
"alg": "ECDSA",
30+
"domain": self.domain,
31+
"address": self.account.get_address(),
32+
"expires": (
33+
datetime.datetime.utcnow() + datetime.timedelta(days=1)
34+
).isoformat()
35+
+ "Z",
36+
}
37+
38+
async def _generate_pubkey_signature_header(self) -> str:
39+
pubkey_payload = json.dumps(self.pubkey_payload).encode("utf-8").hex()
40+
signable_message = encode_defunct(hexstr=pubkey_payload)
41+
buffer_to_sign = signable_message.body
42+
43+
signed_message = await self.account.sign_raw(buffer_to_sign)
44+
pubkey_signature = to_0x_hex(signed_message)
45+
46+
return json.dumps(
47+
{
48+
"sender": self.account.get_address(),
49+
"payload": pubkey_payload,
50+
"signature": pubkey_signature,
51+
"content": {"domain": self.domain},
52+
}
53+
)
54+
55+
async def _generate_header(
56+
self, vm_id: str, operation: str
57+
) -> Tuple[str, Dict[str, str]]:
58+
base_url = f"http://{self.domain}"
59+
path = (
60+
f"/logs/{vm_id}"
61+
if operation == "logs"
62+
else f"/control/machine/{vm_id}/{operation}"
63+
)
64+
65+
payload = {
66+
"time": datetime.datetime.utcnow().isoformat() + "Z",
67+
"method": "POST",
68+
"path": path,
69+
}
70+
payload_as_bytes = json.dumps(payload).encode("utf-8")
71+
headers = {"X-SignedPubKey": self.pubkey_signature_header}
72+
payload_signature = JWA.signing_alg("ES256").sign(
73+
self.ephemeral_key, payload_as_bytes
74+
)
75+
headers["X-SignedOperation"] = json.dumps(
76+
{
77+
"payload": payload_as_bytes.hex(),
78+
"signature": payload_signature.hex(),
79+
}
80+
)
81+
82+
return f"{base_url}{path}", headers
83+
84+
async def perform_operation(self, vm_id, operation):
85+
if not self.pubkey_signature_header:
86+
self.pubkey_signature_header = (
87+
await self._generate_pubkey_signature_header()
88+
)
89+
90+
url, header = await self._generate_header(vm_id=vm_id, operation=operation)
91+
92+
try:
93+
async with self.session.post(url, headers=header) as response:
94+
response_text = await response.text()
95+
return response.status, response_text
96+
except aiohttp.ClientError as e:
97+
logger.error(f"HTTP error during operation {operation}: {str(e)}")
98+
return None, str(e)
99+
100+
async def get_logs(self, vm_id):
101+
if not self.pubkey_signature_header:
102+
self.pubkey_signature_header = (
103+
await self._generate_pubkey_signature_header()
104+
)
105+
106+
ws_url, header = await self._generate_header(vm_id=vm_id, operation="logs")
107+
108+
async with aiohttp.ClientSession() as session:
109+
async with session.ws_connect(ws_url) as ws:
110+
auth_message = {
111+
"auth": {
112+
"X-SignedPubKey": header["X-SignedPubKey"],
113+
"X-SignedOperation": header["X-SignedOperation"],
114+
}
115+
}
116+
await ws.send_json(auth_message)
117+
async for msg in ws: # msg is of type aiohttp.WSMessage
118+
if msg.type == aiohttp.WSMsgType.TEXT:
119+
yield msg.data
120+
elif msg.type == aiohttp.WSMsgType.ERROR:
121+
break
122+
123+
async def start_instance(self, vm_id):
124+
return await self.notify_allocation(vm_id)
125+
126+
async def stop_instance(self, vm_id):
127+
return await self.perform_operation(vm_id, "stop")
128+
129+
async def reboot_instance(self, vm_id):
130+
131+
return await self.perform_operation(vm_id, "reboot")
132+
133+
async def erase_instance(self, vm_id):
134+
return await self.perform_operation(vm_id, "erase")
135+
136+
async def expire_instance(self, vm_id):
137+
return await self.perform_operation(vm_id, "expire")
138+
139+
async def notify_allocation(self, vm_id) -> Tuple[Any, str]:
140+
json_data = {"instance": vm_id}
141+
async with self.session.post(
142+
f"https://{self.domain}/control/allocation/notify", json=json_data
143+
) as s:
144+
form_response_text = await s.text()
145+
return s.status, form_response_text
146+
147+
async def manage_instance(self, vm_id, operations):
148+
for operation in operations:
149+
status, response = await self.perform_operation(vm_id, operation)
150+
if status != 200:
151+
return status, response
152+
return
153+
154+
async def close(self):
155+
await self.session.close()
156+
157+
async def __aenter__(self):
158+
return self
159+
160+
async def __aexit__(self, exc_type, exc_value, traceback):
161+
await self.close()

src/aleph/sdk/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class Account(Protocol):
2020
@abstractmethod
2121
async def sign_message(self, message: Dict) -> Dict: ...
2222

23+
@abstractmethod
24+
async def sign_raw(self, buffer: bytes) -> bytes: ...
2325
@abstractmethod
2426
def get_address(self) -> str: ...
2527

src/aleph/sdk/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,14 @@ def parse_volume(volume_dict: Union[Mapping, MachineVolume]) -> MachineVolume:
184184
def compute_sha256(s: str) -> str:
185185
"""Compute the SHA256 hash of a string."""
186186
return hashlib.sha256(s.encode()).hexdigest()
187+
188+
189+
def to_0x_hex(b: bytes) -> str:
190+
return "0x" + bytes.hex(b)
191+
192+
193+
def bytes_from_hex(hex_string: str) -> bytes:
194+
if hex_string.startswith("0x"):
195+
hex_string = hex_string[2:]
196+
hex_string = bytes.fromhex(hex_string)
197+
return hex_string

src/aleph/sdk/wallets/ledger/ethereum.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from ledgereth.messages import sign_message
1010
from ledgereth.objects import LedgerAccount, SignedMessage
1111

12-
from ...chains.common import BaseAccount, bytes_from_hex, get_verification_buffer
12+
from ...chains.common import BaseAccount, get_verification_buffer
13+
from ...utils import bytes_from_hex
1314

1415

1516
class LedgerETHAccount(BaseAccount):

0 commit comments

Comments
 (0)