wildburrow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. wildburrow/__init__.py +0 -0
  2. wildburrow/__main__.py +6 -0
  3. wildburrow/auth/__init__.py +0 -0
  4. wildburrow/auth/base.py +24 -0
  5. wildburrow/auth/keys.py +60 -0
  6. wildburrow/auth/password.py +66 -0
  7. wildburrow/auth/sshsig.py +68 -0
  8. wildburrow/auth/token.py +46 -0
  9. wildburrow/certs/__init__.py +0 -0
  10. wildburrow/certs/acme.py +118 -0
  11. wildburrow/certs/manager.py +119 -0
  12. wildburrow/certs/store.py +56 -0
  13. wildburrow/cli/__init__.py +54 -0
  14. wildburrow/cli/ask.py +59 -0
  15. wildburrow/cli/client.py +181 -0
  16. wildburrow/cli/errors.py +12 -0
  17. wildburrow/cli/server.py +182 -0
  18. wildburrow/cli/service.py +310 -0
  19. wildburrow/client/__init__.py +0 -0
  20. wildburrow/client/config.py +60 -0
  21. wildburrow/client/retry.py +19 -0
  22. wildburrow/client/ssh.py +75 -0
  23. wildburrow/client/websocket.py +173 -0
  24. wildburrow/config.py +100 -0
  25. wildburrow/core/__init__.py +0 -0
  26. wildburrow/core/context.py +50 -0
  27. wildburrow/core/errors.py +6 -0
  28. wildburrow/core/names.py +55 -0
  29. wildburrow/core/registry.py +32 -0
  30. wildburrow/core/types.py +68 -0
  31. wildburrow/dns/__init__.py +0 -0
  32. wildburrow/dns/base.py +12 -0
  33. wildburrow/dns/cloudflare.py +72 -0
  34. wildburrow/edge/__init__.py +0 -0
  35. wildburrow/edge/http.py +179 -0
  36. wildburrow/edge/proxy.py +88 -0
  37. wildburrow/envfile.py +63 -0
  38. wildburrow/plugins.py +32 -0
  39. wildburrow/protocol.py +74 -0
  40. wildburrow/server.py +126 -0
  41. wildburrow/service/__init__.py +140 -0
  42. wildburrow/service/launchd.py +81 -0
  43. wildburrow/service/systemd.py +80 -0
  44. wildburrow/streams.py +28 -0
  45. wildburrow/transports/__init__.py +0 -0
  46. wildburrow/transports/ssh.py +197 -0
  47. wildburrow/transports/websocket.py +226 -0
  48. wildburrow-0.1.0.dist-info/METADATA +231 -0
  49. wildburrow-0.1.0.dist-info/RECORD +52 -0
  50. wildburrow-0.1.0.dist-info/WHEEL +4 -0
  51. wildburrow-0.1.0.dist-info/entry_points.txt +14 -0
  52. wildburrow-0.1.0.dist-info/licenses/LICENSE +21 -0
wildburrow/__init__.py ADDED
File without changes
wildburrow/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from wildburrow.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
File without changes
@@ -0,0 +1,24 @@
1
+ from collections.abc import Sequence
2
+ from typing import ClassVar, Protocol
3
+
4
+ from wildburrow.core.types import Credential, Identity, Peer
5
+
6
+
7
+ class AuthProvider(Protocol):
8
+ handles: ClassVar[tuple[type, ...]]
9
+
10
+ async def authenticate(self, credential: Credential, peer: Peer) -> Identity | None: ...
11
+
12
+
13
+ class Authenticator:
14
+ def __init__(self, providers: Sequence[AuthProvider]) -> None:
15
+ self._providers = tuple(providers)
16
+
17
+ def accepts(self, kind: type) -> bool:
18
+ return any(kind in provider.handles for provider in self._providers)
19
+
20
+ async def authenticate(self, credential: Credential, peer: Peer) -> Identity | None:
21
+ for provider in self._providers:
22
+ if type(credential) in provider.handles and (identity := await provider.authenticate(credential, peer)):
23
+ return identity
24
+ return None
@@ -0,0 +1,60 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+ from typing import ClassVar
4
+
5
+ import asyncssh
6
+
7
+ from wildburrow import protocol
8
+ from wildburrow.auth import sshsig
9
+ from wildburrow.config import SHOWN, ConfigError
10
+ from wildburrow.core.types import Identity, Peer, PublicKeyCredential, SignedChallengeCredential
11
+
12
+ # The keys that can already log in to the account the server runs as.
13
+ DEFAULT_AUTHORIZED_KEYS = Path("~/.ssh/authorized_keys")
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class KeySettings:
18
+ authorized_keys: str = field(default="", metadata={SHOWN: str(DEFAULT_AUTHORIZED_KEYS)})
19
+
20
+
21
+ def _default_keys() -> str:
22
+ try:
23
+ return DEFAULT_AUTHORIZED_KEYS.expanduser().read_text()
24
+ except (OSError, RuntimeError) as exc:
25
+ raise ConfigError(f"authorized_keys: not set, and {DEFAULT_AUTHORIZED_KEYS} is unreadable: {exc}") from None
26
+
27
+
28
+ class KeyAuth:
29
+ Settings = KeySettings
30
+ handles: ClassVar[tuple[type, ...]] = (PublicKeyCredential, SignedChallengeCredential)
31
+
32
+ def __init__(self, settings: KeySettings) -> None:
33
+ self._keys: dict[bytes, tuple[str, str]] = {}
34
+ for line in (settings.authorized_keys or _default_keys()).splitlines():
35
+ if not (line := line.strip()) or line.startswith("#"):
36
+ continue
37
+ try:
38
+ key = asyncssh.import_public_key(line)
39
+ except (asyncssh.KeyImportError, ValueError) as exc:
40
+ raise ConfigError(f"authorized_keys: cannot parse {line[:40]!r}: {exc}") from None
41
+ fingerprint = key.get_fingerprint()
42
+ self._keys[key.public_data] = (key.get_comment() or fingerprint, fingerprint)
43
+ if not self._keys:
44
+ raise ConfigError("authorized_keys: no keys found")
45
+
46
+ async def authenticate(
47
+ self, credential: PublicKeyCredential | SignedChallengeCredential, peer: Peer
48
+ ) -> Identity | None:
49
+ known = self._keys.get(credential.public_key)
50
+ if known is None:
51
+ return None
52
+ if isinstance(credential, SignedChallengeCredential):
53
+ try:
54
+ sshsig.verify(
55
+ credential.signature, credential.public_key, protocol.SIGNATURE_NAMESPACE, credential.message
56
+ )
57
+ except sshsig.InvalidSignature:
58
+ return None
59
+ name, fingerprint = known
60
+ return Identity(name, "key", fingerprint)
@@ -0,0 +1,66 @@
1
+ import asyncio
2
+ import time
3
+ from collections import deque
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import ClassVar
7
+
8
+ from argon2 import PasswordHasher, extract_parameters
9
+ from argon2.exceptions import InvalidHashError, VerificationError
10
+
11
+ from wildburrow.config import ConfigError
12
+ from wildburrow.core.types import Identity, PasswordCredential, Peer
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class PasswordSettings:
17
+ hash: str
18
+ max_failures: int = 5
19
+ block_seconds: int = 900
20
+
21
+
22
+ class PasswordAuth:
23
+ Settings = PasswordSettings
24
+ handles: ClassVar[tuple[type, ...]] = (PasswordCredential,)
25
+
26
+ def __init__(self, settings: PasswordSettings, clock: Callable[[], float] = time.monotonic) -> None:
27
+ try:
28
+ extract_parameters(settings.hash)
29
+ except InvalidHashError:
30
+ raise ConfigError("hash: not an argon2 hash; create one with `wildburrow hash-password`") from None
31
+ if settings.max_failures < 1 or settings.block_seconds < 1:
32
+ raise ConfigError("max_failures and block_seconds must be at least 1")
33
+ self._settings = settings
34
+ self._clock = clock
35
+ self._hasher = PasswordHasher()
36
+ self._failures: dict[str, deque[float]] = {}
37
+
38
+ def blocked(self, host: str) -> bool:
39
+ self._expire()
40
+ return len(self._failures.get(host, ())) >= self._settings.max_failures
41
+
42
+ async def authenticate(self, credential: PasswordCredential, peer: Peer) -> Identity | None:
43
+ if self.blocked(peer.host):
44
+ return None
45
+ attempt = self._clock()
46
+ failures = self._failures.setdefault(peer.host, deque())
47
+ failures.append(attempt)
48
+ if await asyncio.to_thread(self._verify, credential.password):
49
+ failures.remove(attempt)
50
+ return Identity(credential.username, "password", credential.username)
51
+ return None
52
+
53
+ def _verify(self, password: str) -> bool:
54
+ try:
55
+ return self._hasher.verify(self._settings.hash, password)
56
+ except VerificationError:
57
+ return False
58
+
59
+ def _expire(self) -> None:
60
+ cutoff = self._clock() - self._settings.block_seconds
61
+ for host in list(self._failures):
62
+ failures = self._failures[host]
63
+ while failures and failures[0] <= cutoff:
64
+ failures.popleft()
65
+ if not failures:
66
+ del self._failures[host]
@@ -0,0 +1,68 @@
1
+ import base64
2
+ import hashlib
3
+
4
+ from asyncssh.public_key import decode_ssh_public_key
5
+
6
+ MAGIC = b"SSHSIG"
7
+ VERSION = 1
8
+ ARMOR_BEGIN = "-----BEGIN SSH SIGNATURE-----"
9
+ ARMOR_END = "-----END SSH SIGNATURE-----"
10
+ HASHES = {"sha256": hashlib.sha256, "sha512": hashlib.sha512}
11
+
12
+
13
+ class InvalidSignature(Exception):
14
+ pass
15
+
16
+
17
+ def _string(value: bytes) -> bytes:
18
+ return len(value).to_bytes(4) + value
19
+
20
+
21
+ class _Reader:
22
+ def __init__(self, data: bytes) -> None:
23
+ self._data = data
24
+ self._offset = 0
25
+
26
+ def take(self, size: int) -> bytes:
27
+ if self._offset + size > len(self._data):
28
+ raise InvalidSignature("truncated signature")
29
+ chunk = self._data[self._offset : self._offset + size]
30
+ self._offset += size
31
+ return chunk
32
+
33
+ def uint32(self) -> int:
34
+ return int.from_bytes(self.take(4))
35
+
36
+ def string(self) -> bytes:
37
+ return self.take(self.uint32())
38
+
39
+ def done(self) -> bool:
40
+ return self._offset == len(self._data)
41
+
42
+
43
+ def verify(armored: str, public_key: bytes, namespace: str, message: bytes) -> None:
44
+ body = armored.strip()
45
+ if not body.startswith(ARMOR_BEGIN) or not body.endswith(ARMOR_END):
46
+ raise InvalidSignature("not an armored SSH signature")
47
+ try:
48
+ blob = base64.b64decode("".join(body[len(ARMOR_BEGIN) : -len(ARMOR_END)].split()), validate=True)
49
+ except ValueError:
50
+ raise InvalidSignature("signature is not valid base64") from None
51
+ reader = _Reader(blob)
52
+ if reader.take(len(MAGIC)) != MAGIC or reader.uint32() != VERSION:
53
+ raise InvalidSignature("unsupported signature format")
54
+ signer, signed_namespace, reserved = reader.string(), reader.string(), reader.string()
55
+ hash_name, signature = reader.string().decode(errors="replace"), reader.string()
56
+ if not reader.done():
57
+ raise InvalidSignature("trailing data after the signature")
58
+ if signer != public_key:
59
+ raise InvalidSignature("signed with a different key")
60
+ if signed_namespace != namespace.encode():
61
+ raise InvalidSignature("signed for a different namespace")
62
+ if hash_name not in HASHES:
63
+ raise InvalidSignature(f"unsupported hash {hash_name!r}")
64
+ signed = MAGIC + b"".join(
65
+ _string(part) for part in (signed_namespace, reserved, hash_name.encode(), HASHES[hash_name](message).digest())
66
+ )
67
+ if not decode_ssh_public_key(public_key).verify(signed, signature):
68
+ raise InvalidSignature("signature does not match")
@@ -0,0 +1,46 @@
1
+ import hashlib
2
+ import secrets
3
+ from dataclasses import dataclass
4
+ from typing import ClassVar
5
+
6
+ from wildburrow.config import ConfigError
7
+ from wildburrow.core.types import Identity, Peer, TokenCredential
8
+
9
+ PREFIX = "wb_"
10
+ TOKEN_BYTES = 32
11
+
12
+
13
+ def new_token() -> str:
14
+ # The only mutant left here, token_urlsafe(None), asks for the default of 32 bytes.
15
+ return PREFIX + secrets.token_urlsafe(TOKEN_BYTES) # pragma: no mutate
16
+
17
+
18
+ def digest(token: str) -> str:
19
+ return hashlib.sha256(token.encode()).hexdigest()
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class TokenSettings:
24
+ hashes: str
25
+
26
+
27
+ class TokenAuth:
28
+ Settings = TokenSettings
29
+ handles: ClassVar[tuple[type, ...]] = (TokenCredential,)
30
+
31
+ def __init__(self, settings: TokenSettings) -> None:
32
+ self._names: dict[str, str] = {}
33
+ for line in settings.hashes.splitlines():
34
+ if not (line := line.strip()) or line.startswith("#"):
35
+ continue
36
+ name, sep, hexdigest = line.rpartition(":")
37
+ if not sep or not name or len(hexdigest) != 64 or not all(c in "0123456789abcdef" for c in hexdigest):
38
+ raise ConfigError(f"hashes: expected 'name:sha256hex', got {line[:40]!r}")
39
+ self._names[hexdigest] = name
40
+ if not self._names:
41
+ raise ConfigError("hashes: no tokens found")
42
+
43
+ async def authenticate(self, credential: TokenCredential, peer: Peer) -> Identity | None:
44
+ hexdigest = digest(credential.token)
45
+ name = self._names.get(hexdigest)
46
+ return Identity(name, "token", hexdigest) if name is not None else None
File without changes
@@ -0,0 +1,118 @@
1
+ import asyncio
2
+ import hashlib
3
+ import logging
4
+ import os
5
+ from collections.abc import Sequence
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Protocol
9
+
10
+ import josepy
11
+ from acme import challenges, client, messages
12
+ from cryptography import x509
13
+ from cryptography.hazmat.primitives import hashes, serialization
14
+ from cryptography.hazmat.primitives.asymmetric import ec
15
+
16
+ from wildburrow.dns.base import DNSError, DNSProvider
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ USER_AGENT = "wildburrow"
21
+
22
+
23
+ class IssueError(Exception):
24
+ pass
25
+
26
+
27
+ class Issuer(Protocol):
28
+ async def issue(self, domains: Sequence[str]) -> tuple[bytes, bytes]: ...
29
+
30
+
31
+ def _private_key_pem(key: ec.EllipticCurvePrivateKey) -> bytes:
32
+ encoding, private_format = serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8
33
+ return key.private_bytes(encoding, private_format, serialization.NoEncryption())
34
+
35
+
36
+ def make_csr(domains: Sequence[str]) -> tuple[bytes, bytes]:
37
+ key = ec.generate_private_key(ec.SECP256R1())
38
+ san = x509.SubjectAlternativeName([x509.DNSName(domain) for domain in domains])
39
+ builder = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([]))
40
+ # ACME servers take only the names from a CSR and set the certificate's extensions themselves.
41
+ builder = builder.add_extension(san, critical=False) # pragma: no mutate
42
+ csr = builder.sign(key, hashes.SHA256())
43
+ return _private_key_pem(key), csr.public_bytes(serialization.Encoding.PEM)
44
+
45
+
46
+ class ACMEIssuer:
47
+ def __init__(
48
+ self, directory: str, email: str, state_dir: Path, dns: DNSProvider, propagation: float, timeout: float
49
+ ) -> None:
50
+ self._directory = directory
51
+ self._email = email
52
+ self._state_dir = state_dir / hashlib.sha256(directory.encode()).hexdigest()[:16]
53
+ self._dns = dns
54
+ self._propagation = propagation
55
+ self._timeout = timeout
56
+ self._lock = asyncio.Lock()
57
+ self._client: client.ClientV2 | None = None
58
+ self._jwk: josepy.JWKEC | None = None
59
+
60
+ def _account_key(self) -> josepy.JWKEC:
61
+ path = self._state_dir / "account.key"
62
+ if path.exists():
63
+ key = serialization.load_pem_private_key(path.read_bytes(), password=None)
64
+ else:
65
+ key = ec.generate_private_key(ec.SECP256R1())
66
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
67
+ with os.fdopen(fd, "wb") as file:
68
+ file.write(_private_key_pem(key))
69
+ return josepy.JWKEC(key=key)
70
+
71
+ def _connect(self) -> tuple[client.ClientV2, josepy.JWKEC]:
72
+ self._state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
73
+ jwk = self._account_key()
74
+ net = client.ClientNetwork(jwk, alg=josepy.ES256, user_agent=USER_AGENT)
75
+ acme = client.ClientV2(client.ClientV2.get_directory(self._directory, net), net)
76
+ account = self._state_dir / "account.json"
77
+ if account.exists():
78
+ net.account = messages.RegistrationResource.json_loads(account.read_text())
79
+ else:
80
+ registration = messages.NewRegistration.from_data(email=self._email or None, terms_of_service_agreed=True)
81
+ account.write_text(acme.new_account(registration).json_dumps())
82
+ return acme, jwk
83
+
84
+ async def issue(self, domains: Sequence[str]) -> tuple[bytes, bytes]:
85
+ async with self._lock:
86
+ if self._client is None:
87
+ self._client, self._jwk = await asyncio.to_thread(self._connect)
88
+ return await self._issue(self._client, self._jwk, domains) # type: ignore[arg-type]
89
+
90
+ async def _issue(self, acme: client.ClientV2, jwk: josepy.JWKEC, domains: Sequence[str]) -> tuple[bytes, bytes]:
91
+ key_pem, csr_pem = make_csr(domains)
92
+ order = await asyncio.to_thread(acme.new_order, csr_pem)
93
+ records: list[object] = []
94
+ try:
95
+ pending = []
96
+ for authorization in order.authorizations:
97
+ if authorization.body.status == messages.STATUS_VALID:
98
+ continue
99
+ challenge = next(
100
+ (c for c in authorization.body.challenges if isinstance(c.chall, challenges.DNS01)), None
101
+ )
102
+ if challenge is None:
103
+ raise IssueError(f"no dns-01 challenge offered for {authorization.body.identifier.value}")
104
+ name = challenge.chall.validation_domain_name(authorization.body.identifier.value)
105
+ records.append(await self._dns.create_txt(name, challenge.chall.validation(jwk)))
106
+ pending.append(challenge)
107
+ await asyncio.sleep(self._propagation)
108
+ for challenge in pending:
109
+ await asyncio.to_thread(acme.answer_challenge, challenge, challenge.chall.response(jwk))
110
+ deadline = datetime.now() + timedelta(seconds=self._timeout)
111
+ order = await asyncio.to_thread(acme.poll_and_finalize, order, deadline)
112
+ finally:
113
+ for record in records:
114
+ try:
115
+ await self._dns.delete_txt(record)
116
+ except DNSError as exc:
117
+ log.warning("could not remove a challenge record: %s", exc)
118
+ return key_pem, order.fullchain_pem.encode()
@@ -0,0 +1,119 @@
1
+ import asyncio
2
+ import logging
3
+ import ssl
4
+ from collections.abc import Callable, Iterable
5
+ from datetime import UTC, datetime, timedelta
6
+
7
+ from wildburrow.certs.acme import Issuer
8
+ from wildburrow.certs.store import CertStore
9
+ from wildburrow.core.names import Name, host_labels
10
+ from wildburrow.dns.base import DNSProvider
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ ALPN = ["http/1.1"]
15
+
16
+
17
+ def server_context() -> ssl.SSLContext:
18
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
19
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
20
+ context.set_alpn_protocols(ALPN)
21
+ return context
22
+
23
+
24
+ class CertManager:
25
+ def __init__(
26
+ self,
27
+ domain: str,
28
+ store: CertStore,
29
+ issuer: Issuer,
30
+ dns: DNSProvider,
31
+ manage_records: bool,
32
+ renew_before: timedelta,
33
+ clock: Callable[[], datetime] = lambda: datetime.now(UTC),
34
+ ) -> None:
35
+ self.domain = domain
36
+ self._store = store
37
+ self._issuer = issuer
38
+ self._dns = dns
39
+ self._manage_records = manage_records
40
+ self._renew_before = renew_before
41
+ self._clock = clock
42
+ self._contexts: dict[str, ssl.SSLContext] = {}
43
+ self._inflight: dict[str, asyncio.Task[None]] = {}
44
+
45
+ def parent(self, name: Name) -> str:
46
+ labels = name.labels if name.wildcard else name.labels[1:]
47
+ return ".".join((*labels, self.domain))
48
+
49
+ def ready(self, parent: str) -> bool:
50
+ stored = self._store.load(parent)
51
+ return stored is not None and stored.expires > self._clock()
52
+
53
+ async def ensure(self, parent: str) -> None:
54
+ if not self.ready(parent):
55
+ await self._issue(parent)
56
+
57
+ async def _issue(self, parent: str) -> None:
58
+ task = self._inflight.get(parent)
59
+ if task is None:
60
+ task = asyncio.create_task(self._obtain(parent))
61
+ self._inflight[parent] = task
62
+ task.add_done_callback(lambda _: self._inflight.pop(parent))
63
+ await asyncio.shield(task)
64
+
65
+ async def _obtain(self, parent: str) -> None:
66
+ if self._manage_records and parent != self.domain:
67
+ await self._dns.ensure_cname(f"*.{parent}", self.domain)
68
+ domains = [f"*.{parent}", parent] if parent == self.domain else [f"*.{parent}"]
69
+ log.info("requesting a certificate for %s", ", ".join(domains))
70
+ key_pem, chain_pem = await self._issuer.issue(domains)
71
+ self._store.save(parent, key_pem, chain_pem)
72
+ self._contexts.pop(parent, None)
73
+
74
+ def context_for(self, server_name: str | None) -> ssl.SSLContext | None:
75
+ if server_name is None:
76
+ return None
77
+ server_name = server_name.lower()
78
+ if server_name == self.domain:
79
+ parent = self.domain
80
+ elif (labels := host_labels(server_name, self.domain)) is not None:
81
+ parent = ".".join((*labels[1:], self.domain))
82
+ else:
83
+ return None
84
+ if parent not in self._contexts:
85
+ stored = self._store.load(parent)
86
+ if stored is None:
87
+ return None
88
+ context = server_context()
89
+ context.load_cert_chain(stored.chain, stored.key)
90
+ self._contexts[parent] = context
91
+ return self._contexts[parent]
92
+
93
+ def sni_callback(self, sslobj: ssl.SSLObject, server_name: str | None, _: ssl.SSLContext) -> int | None:
94
+ context = self.context_for(server_name)
95
+ if context is None:
96
+ return ssl.ALERT_DESCRIPTION_UNRECOGNIZED_NAME
97
+ sslobj.context = context
98
+ return None
99
+
100
+ async def renew_due(self, in_use: Iterable[str]) -> None:
101
+ wanted = {self.domain, *in_use}
102
+ for parent in self._store.parents():
103
+ stored = self._store.load(parent)
104
+ if parent in wanted and stored is not None and stored.expires - self._clock() < self._renew_before:
105
+ try:
106
+ await self._issue(parent)
107
+ except Exception:
108
+ log.exception("renewing *.%s failed; will retry", parent)
109
+
110
+ async def maintain(self, in_use: Callable[[], Iterable[str]], interval: float, retry: float) -> None:
111
+ while True:
112
+ try:
113
+ await self.ensure(self.domain)
114
+ except Exception:
115
+ log.exception("getting the *.%s certificate failed; retrying in %ss", self.domain, retry)
116
+ await asyncio.sleep(retry)
117
+ continue
118
+ await self.renew_due(in_use())
119
+ await asyncio.sleep(interval)
@@ -0,0 +1,56 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from cryptography import x509
7
+
8
+ CHAIN = "chain.pem"
9
+ KEY = "key.pem"
10
+ PARTIAL = ".partial"
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class StoredCert:
15
+ chain: Path
16
+ key: Path
17
+ expires: datetime
18
+
19
+
20
+ def _write(path: Path, data: bytes, mode: int) -> None:
21
+ partial = path.with_suffix(PARTIAL)
22
+ fd = os.open(partial, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
23
+ with os.fdopen(fd, "wb") as file:
24
+ file.write(data)
25
+ partial.replace(path)
26
+
27
+
28
+ class CertStore:
29
+ """Certificates keyed by the parent domain their wildcard covers: `foo.example.com` holds `*.foo.example.com`."""
30
+
31
+ def __init__(self, root: Path) -> None:
32
+ self._root = root
33
+
34
+ def save(self, parent: str, key_pem: bytes, chain_pem: bytes) -> StoredCert:
35
+ directory = self._root / parent
36
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
37
+ _write(directory / KEY, key_pem, 0o600)
38
+ _write(directory / CHAIN, chain_pem, 0o644)
39
+ return self._read(directory)
40
+
41
+ def load(self, parent: str) -> StoredCert | None:
42
+ directory = self._root / parent
43
+ if not (directory / CHAIN).exists() or not (directory / KEY).exists():
44
+ return None
45
+ return self._read(directory)
46
+
47
+ def parents(self) -> list[str]:
48
+ if not self._root.exists():
49
+ return []
50
+ return sorted(path.name for path in self._root.iterdir() if path.is_dir())
51
+
52
+ @staticmethod
53
+ def _read(directory: Path) -> StoredCert:
54
+ chain = directory / CHAIN
55
+ leaf = x509.load_pem_x509_certificates(chain.read_bytes())[0]
56
+ return StoredCert(chain, directory / KEY, leaf.not_valid_after_utc)
@@ -0,0 +1,54 @@
1
+ import sys
2
+ from collections.abc import Sequence
3
+ from importlib.metadata import version
4
+ from typing import Any
5
+
6
+ import click
7
+
8
+ from wildburrow.cli import client, server, service
9
+ from wildburrow.service import ServiceError
10
+
11
+ PROG = "wildburrow"
12
+ CONTEXT = {"help_option_names": ["-h", "--help"], "max_content_width": 100}
13
+
14
+
15
+ class _Tunnels(click.Group):
16
+ """`wildburrow 3000 myapp` is `wildburrow connect 3000 myapp`."""
17
+
18
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
19
+ if args and args[0] not in self.commands and not args[0].startswith("-"):
20
+ args = ["connect", *args]
21
+ return super().parse_args(ctx, args)
22
+
23
+ def list_commands(self, ctx: click.Context) -> list[str]:
24
+ return list(self.commands)
25
+
26
+
27
+ @click.group(cls=_Tunnels, context_settings=CONTEXT)
28
+ @click.version_option(version(PROG), "--version", prog_name=PROG, message="%(version)s")
29
+ def cli() -> None:
30
+ """Expose local ports on your own domain."""
31
+
32
+
33
+ for command in (client.connect, client.login, client.logout, server.serve, service.service, server.hash_password,
34
+ server.token): # fmt: skip
35
+ cli.add_command(command)
36
+
37
+
38
+ def main(argv: Sequence[str] | None = None) -> int:
39
+ # Driven by hand rather than through cli.main, so a command's return value is the exit code.
40
+ try:
41
+ with cli.make_context(PROG, list(sys.argv[1:] if argv is None else argv)) as ctx:
42
+ result: Any = cli.invoke(ctx)
43
+ except click.exceptions.Exit as exc:
44
+ return exc.exit_code
45
+ except click.ClickException as exc:
46
+ exc.show()
47
+ return exc.exit_code
48
+ except ServiceError as exc:
49
+ click.echo(str(exc), err=True)
50
+ return 1
51
+ except click.Abort, KeyboardInterrupt, EOFError:
52
+ click.echo("aborted", err=True)
53
+ return 1
54
+ return result or 0