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.
- wildburrow/__init__.py +0 -0
- wildburrow/__main__.py +6 -0
- wildburrow/auth/__init__.py +0 -0
- wildburrow/auth/base.py +24 -0
- wildburrow/auth/keys.py +60 -0
- wildburrow/auth/password.py +66 -0
- wildburrow/auth/sshsig.py +68 -0
- wildburrow/auth/token.py +46 -0
- wildburrow/certs/__init__.py +0 -0
- wildburrow/certs/acme.py +118 -0
- wildburrow/certs/manager.py +119 -0
- wildburrow/certs/store.py +56 -0
- wildburrow/cli/__init__.py +54 -0
- wildburrow/cli/ask.py +59 -0
- wildburrow/cli/client.py +181 -0
- wildburrow/cli/errors.py +12 -0
- wildburrow/cli/server.py +182 -0
- wildburrow/cli/service.py +310 -0
- wildburrow/client/__init__.py +0 -0
- wildburrow/client/config.py +60 -0
- wildburrow/client/retry.py +19 -0
- wildburrow/client/ssh.py +75 -0
- wildburrow/client/websocket.py +173 -0
- wildburrow/config.py +100 -0
- wildburrow/core/__init__.py +0 -0
- wildburrow/core/context.py +50 -0
- wildburrow/core/errors.py +6 -0
- wildburrow/core/names.py +55 -0
- wildburrow/core/registry.py +32 -0
- wildburrow/core/types.py +68 -0
- wildburrow/dns/__init__.py +0 -0
- wildburrow/dns/base.py +12 -0
- wildburrow/dns/cloudflare.py +72 -0
- wildburrow/edge/__init__.py +0 -0
- wildburrow/edge/http.py +179 -0
- wildburrow/edge/proxy.py +88 -0
- wildburrow/envfile.py +63 -0
- wildburrow/plugins.py +32 -0
- wildburrow/protocol.py +74 -0
- wildburrow/server.py +126 -0
- wildburrow/service/__init__.py +140 -0
- wildburrow/service/launchd.py +81 -0
- wildburrow/service/systemd.py +80 -0
- wildburrow/streams.py +28 -0
- wildburrow/transports/__init__.py +0 -0
- wildburrow/transports/ssh.py +197 -0
- wildburrow/transports/websocket.py +226 -0
- wildburrow-0.1.0.dist-info/METADATA +231 -0
- wildburrow-0.1.0.dist-info/RECORD +52 -0
- wildburrow-0.1.0.dist-info/WHEEL +4 -0
- wildburrow-0.1.0.dist-info/entry_points.txt +14 -0
- wildburrow-0.1.0.dist-info/licenses/LICENSE +21 -0
wildburrow/__init__.py
ADDED
|
File without changes
|
wildburrow/__main__.py
ADDED
|
File without changes
|
wildburrow/auth/base.py
ADDED
|
@@ -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
|
wildburrow/auth/keys.py
ADDED
|
@@ -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")
|
wildburrow/auth/token.py
ADDED
|
@@ -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
|
wildburrow/certs/acme.py
ADDED
|
@@ -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
|