python-plugin 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.
- pyplugin/__init__.py +54 -0
- pyplugin/_generated/__init__.py +0 -0
- pyplugin/_generated/grpc_broker_grpc.py +40 -0
- pyplugin/_generated/grpc_broker_pb2.py +41 -0
- pyplugin/_generated/grpc_controller_grpc.py +40 -0
- pyplugin/_generated/grpc_controller_pb2.py +39 -0
- pyplugin/_generated/grpc_stdio_grpc.py +41 -0
- pyplugin/_generated/grpc_stdio_pb2.py +42 -0
- pyplugin/broker.py +225 -0
- pyplugin/client.py +399 -0
- pyplugin/controller.py +22 -0
- pyplugin/cookie.py +43 -0
- pyplugin/errors.py +39 -0
- pyplugin/handshake.py +121 -0
- pyplugin/health.py +38 -0
- pyplugin/logging_bridge.py +70 -0
- pyplugin/mtls.py +169 -0
- pyplugin/plugin.py +38 -0
- pyplugin/process.py +66 -0
- pyplugin/proto/grpc_broker.proto +21 -0
- pyplugin/proto/grpc_controller.proto +12 -0
- pyplugin/proto/grpc_stdio.proto +22 -0
- pyplugin/reattach.py +27 -0
- pyplugin/server.py +204 -0
- pyplugin/stdio.py +36 -0
- pyplugin/transport.py +103 -0
- python_plugin-0.1.0.dist-info/METADATA +254 -0
- python_plugin-0.1.0.dist-info/RECORD +30 -0
- python_plugin-0.1.0.dist-info/WHEEL +4 -0
- python_plugin-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Parse hclog log lines into structured ``logging.LogRecord`` calls.
|
|
2
|
+
|
|
3
|
+
go-plugin pipes plugin stderr through go-hclog. hclog has two output modes:
|
|
4
|
+
|
|
5
|
+
* JSON: ``{"@level":"info","@message":"...","@module":"foo","@timestamp":"...",...}``
|
|
6
|
+
* Pretty: ``2025-01-01T12:00:00.000Z [INFO] module: msg: key=value key2="quoted"``
|
|
7
|
+
|
|
8
|
+
We try JSON first (cheap discriminator: starts with ``{``) and fall back to
|
|
9
|
+
the pretty regex. Anything we can't parse is logged at INFO with the raw
|
|
10
|
+
text. We always emit on the caller's logger so they can hook formatters.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_LEVELS = {
|
|
20
|
+
"trace": logging.DEBUG, # logging has no TRACE; map to DEBUG
|
|
21
|
+
"debug": logging.DEBUG,
|
|
22
|
+
"info": logging.INFO,
|
|
23
|
+
"warn": logging.WARNING,
|
|
24
|
+
"warning": logging.WARNING,
|
|
25
|
+
"error": logging.ERROR,
|
|
26
|
+
"fatal": logging.CRITICAL,
|
|
27
|
+
"critical": logging.CRITICAL,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Pretty format: optional timestamp, then [LEVEL] module: message ...
|
|
31
|
+
_PRETTY = re.compile(
|
|
32
|
+
r"^(?P<ts>\S+)?\s*\[(?P<level>\w+)\]\s*(?P<module>[^:]+):\s*(?P<rest>.*)$"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def emit(logger: logging.Logger, raw: str) -> None:
|
|
37
|
+
"""Parse ``raw`` and emit on ``logger`` at the matching level."""
|
|
38
|
+
raw = raw.rstrip("\r\n")
|
|
39
|
+
if not raw:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
if raw.lstrip().startswith("{"):
|
|
43
|
+
rec = _parse_json(raw)
|
|
44
|
+
if rec is not None:
|
|
45
|
+
level, msg, module, extra = rec
|
|
46
|
+
logger.log(level, msg, extra={"plugin": module, "fields": extra})
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
m = _PRETTY.match(raw)
|
|
50
|
+
if m is not None:
|
|
51
|
+
level = _LEVELS.get(m["level"].lower(), logging.INFO)
|
|
52
|
+
module = m["module"].strip()
|
|
53
|
+
logger.log(level, m["rest"].strip(), extra={"plugin": module})
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
logger.info(raw)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_json(raw: str) -> tuple[int, str, str, dict[str, Any]] | None:
|
|
60
|
+
try:
|
|
61
|
+
obj = json.loads(raw)
|
|
62
|
+
except (json.JSONDecodeError, ValueError):
|
|
63
|
+
return None
|
|
64
|
+
if not isinstance(obj, dict):
|
|
65
|
+
return None
|
|
66
|
+
level = _LEVELS.get(str(obj.pop("@level", "info")).lower(), logging.INFO)
|
|
67
|
+
msg = str(obj.pop("@message", ""))
|
|
68
|
+
module = str(obj.pop("@module", ""))
|
|
69
|
+
obj.pop("@timestamp", None)
|
|
70
|
+
return level, msg, module, obj
|
pyplugin/mtls.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""AutoMTLS — ephemeral certificate generation matching go-plugin/mtls.go EXACTLY.
|
|
2
|
+
|
|
3
|
+
go-plugin uses ECDSA P-521 + SHA-512. Python's ``ssl`` module (OpenSSL,
|
|
4
|
+
which grpclib runs on) supports this natively, so we can match
|
|
5
|
+
go-plugin's cert template byte-for-byte:
|
|
6
|
+
|
|
7
|
+
* Curve: ECDSA P-521 (SECP521R1)
|
|
8
|
+
* Signature: ECDSA-SHA-512
|
|
9
|
+
* CN/SAN: ``localhost``
|
|
10
|
+
* O: ``HashiCorp``
|
|
11
|
+
* IsCA: true, BasicConstraints critical
|
|
12
|
+
* KeyUsage: digital signature | key encipherment | key agreement | cert sign
|
|
13
|
+
* ExtKeyUsage: clientAuth + serverAuth
|
|
14
|
+
* Validity: NotBefore -30s, NotAfter +262980h (~30 years)
|
|
15
|
+
|
|
16
|
+
The host's leaf cert is sent to the plugin via ``PLUGIN_CLIENT_CERT`` (PEM);
|
|
17
|
+
the plugin's leaf is returned as base64.RawStdEncoding(DER) in handshake
|
|
18
|
+
field 6.
|
|
19
|
+
|
|
20
|
+
Both sides pin the peer's leaf cert as the trust root and use
|
|
21
|
+
``RequireAndVerifyClientCert`` semantics.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
import datetime
|
|
27
|
+
import secrets
|
|
28
|
+
import ssl
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
from cryptography import x509
|
|
32
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
33
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
34
|
+
from cryptography.x509.oid import NameOID
|
|
35
|
+
|
|
36
|
+
_NOT_BEFORE_SKEW = datetime.timedelta(seconds=30)
|
|
37
|
+
_VALIDITY = datetime.timedelta(hours=262980)
|
|
38
|
+
_HOST = "localhost"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Cert:
|
|
43
|
+
"""A self-signed leaf usable as both the cert and its own CA (matches go-plugin)."""
|
|
44
|
+
cert_pem: bytes
|
|
45
|
+
key_pem: bytes
|
|
46
|
+
cert_der: bytes
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def generate() -> Cert:
|
|
50
|
+
"""Generate one ephemeral cert+key pair, byte-compatible with go-plugin's ``generateCert``."""
|
|
51
|
+
key = ec.generate_private_key(ec.SECP521R1())
|
|
52
|
+
|
|
53
|
+
# Match Go's serial: random in [0, 2^128).
|
|
54
|
+
serial = int.from_bytes(secrets.token_bytes(16), "big")
|
|
55
|
+
|
|
56
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
57
|
+
subject = issuer = x509.Name([
|
|
58
|
+
x509.NameAttribute(NameOID.COMMON_NAME, _HOST),
|
|
59
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "HashiCorp"),
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
builder = (
|
|
63
|
+
x509.CertificateBuilder()
|
|
64
|
+
.subject_name(subject)
|
|
65
|
+
.issuer_name(issuer)
|
|
66
|
+
.public_key(key.public_key())
|
|
67
|
+
.serial_number(serial)
|
|
68
|
+
.not_valid_before(now - _NOT_BEFORE_SKEW)
|
|
69
|
+
.not_valid_after(now + _VALIDITY)
|
|
70
|
+
.add_extension(x509.SubjectAlternativeName([x509.DNSName(_HOST)]), critical=False)
|
|
71
|
+
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
72
|
+
.add_extension(
|
|
73
|
+
x509.KeyUsage(
|
|
74
|
+
digital_signature=True,
|
|
75
|
+
content_commitment=False,
|
|
76
|
+
key_encipherment=True,
|
|
77
|
+
data_encipherment=False,
|
|
78
|
+
key_agreement=True,
|
|
79
|
+
key_cert_sign=True,
|
|
80
|
+
crl_sign=False,
|
|
81
|
+
encipher_only=False,
|
|
82
|
+
decipher_only=False,
|
|
83
|
+
),
|
|
84
|
+
critical=False,
|
|
85
|
+
)
|
|
86
|
+
.add_extension(
|
|
87
|
+
x509.ExtendedKeyUsage([
|
|
88
|
+
x509.ExtendedKeyUsageOID.CLIENT_AUTH,
|
|
89
|
+
x509.ExtendedKeyUsageOID.SERVER_AUTH,
|
|
90
|
+
]),
|
|
91
|
+
critical=False,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
cert = builder.sign(private_key=key, algorithm=hashes.SHA512())
|
|
96
|
+
cert_der = cert.public_bytes(serialization.Encoding.DER)
|
|
97
|
+
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
|
98
|
+
key_pem = key.private_bytes(
|
|
99
|
+
encoding=serialization.Encoding.PEM,
|
|
100
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL, # "EC PRIVATE KEY"
|
|
101
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
102
|
+
)
|
|
103
|
+
return Cert(cert_pem=cert_pem, key_pem=key_pem, cert_der=cert_der)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def encode_handshake_cert(cert_der: bytes) -> str:
|
|
107
|
+
"""Encode a leaf cert DER for handshake field 6: base64.RawStdEncoding (no padding)."""
|
|
108
|
+
return base64.b64encode(cert_der).rstrip(b"=").decode("ascii")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def decode_handshake_cert(b64: str) -> bytes:
|
|
112
|
+
"""Inverse of :func:`encode_handshake_cert` — returns raw DER bytes."""
|
|
113
|
+
pad = "=" * (-len(b64) % 4)
|
|
114
|
+
return base64.b64decode(b64 + pad)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def der_to_pem(cert_der: bytes) -> bytes:
|
|
118
|
+
"""Wrap a raw DER cert in a single PEM CERTIFICATE block."""
|
|
119
|
+
cert = x509.load_der_x509_certificate(cert_der)
|
|
120
|
+
return cert.public_bytes(serialization.Encoding.PEM)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def server_ssl_context(*, cert_pem: bytes, key_pem: bytes, peer_cert_pem: bytes) -> ssl.SSLContext:
|
|
124
|
+
"""Build a server-side ``SSLContext`` mirroring go-plugin's tls.Config:
|
|
125
|
+
|
|
126
|
+
``Certificates``, ``RequireAndVerifyClientCert``, ``ClientCAs=peer``,
|
|
127
|
+
``RootCAs=peer``, ``MinVersion=TLS1.2``, ``ServerName=localhost``.
|
|
128
|
+
Uses ALPN ``h2`` for HTTP/2.
|
|
129
|
+
"""
|
|
130
|
+
import tempfile
|
|
131
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
132
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
133
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
134
|
+
ctx.set_alpn_protocols(["h2"])
|
|
135
|
+
# SSLContext.load_* take file paths only — write to temp files.
|
|
136
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cf, \
|
|
137
|
+
tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as kf, \
|
|
138
|
+
tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as rf:
|
|
139
|
+
cf.write(cert_pem); cf.flush()
|
|
140
|
+
kf.write(key_pem); kf.flush()
|
|
141
|
+
rf.write(peer_cert_pem); rf.flush()
|
|
142
|
+
ctx.load_cert_chain(certfile=cf.name, keyfile=kf.name)
|
|
143
|
+
ctx.load_verify_locations(cafile=rf.name)
|
|
144
|
+
return ctx
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def client_ssl_context(*, cert_pem: bytes, key_pem: bytes, peer_cert_pem: bytes) -> ssl.SSLContext:
|
|
148
|
+
"""Build a client-side ``SSLContext`` for AutoMTLS.
|
|
149
|
+
|
|
150
|
+
Trust root is the peer's cert; we present our own cert+key as client cert.
|
|
151
|
+
Hostname verification is **disabled** because (a) we're connecting over
|
|
152
|
+
unix sockets where there's no real hostname, and (b) we've already pinned
|
|
153
|
+
the exact peer cert, so hostname check would be redundant anyway.
|
|
154
|
+
"""
|
|
155
|
+
import tempfile
|
|
156
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
157
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
158
|
+
ctx.check_hostname = False
|
|
159
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
160
|
+
ctx.set_alpn_protocols(["h2"])
|
|
161
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cf, \
|
|
162
|
+
tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as kf, \
|
|
163
|
+
tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as rf:
|
|
164
|
+
cf.write(cert_pem); cf.flush()
|
|
165
|
+
kf.write(key_pem); kf.flush()
|
|
166
|
+
rf.write(peer_cert_pem); rf.flush()
|
|
167
|
+
ctx.load_cert_chain(certfile=cf.name, keyfile=kf.name)
|
|
168
|
+
ctx.load_verify_locations(cafile=rf.name)
|
|
169
|
+
return ctx
|
pyplugin/plugin.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Plugin ABC and type aliases (grpclib version).
|
|
2
|
+
|
|
3
|
+
A pyplugin author implements one ``Plugin`` subclass per service exposed.
|
|
4
|
+
``servicers`` returns the grpclib ``Base`` instances to register on the
|
|
5
|
+
plugin-side server; ``stub`` builds a typed client stub on the host side
|
|
6
|
+
from an open ``grpclib.client.Channel``.
|
|
7
|
+
|
|
8
|
+
Mirrors go-plugin's ``GRPCPlugin`` interface — both methods receive the
|
|
9
|
+
``GRPCBroker`` so plugins can request callbacks back into the host.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import abc
|
|
14
|
+
from typing import Any, Mapping, TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from grpclib.client import Channel
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .broker import GRPCBroker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Plugin(abc.ABC):
|
|
23
|
+
"""Abstract base for a plugin type."""
|
|
24
|
+
|
|
25
|
+
@abc.abstractmethod
|
|
26
|
+
def servicers(self, broker: "GRPCBroker") -> list:
|
|
27
|
+
"""Return grpclib servicer instances to register on the plugin server.
|
|
28
|
+
|
|
29
|
+
Called once on the plugin side during ``serve()`` setup.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
@abc.abstractmethod
|
|
33
|
+
def stub(self, broker: "GRPCBroker", channel: Channel) -> Any:
|
|
34
|
+
"""Build a typed grpclib client stub from an open channel (host side)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
PluginSet = Mapping[str, Plugin]
|
|
38
|
+
VersionedPlugins = Mapping[int, PluginSet]
|
pyplugin/process.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Subprocess + cross-platform termination helpers.
|
|
2
|
+
|
|
3
|
+
go-plugin escalates Kill() through: client.Close() (calls
|
|
4
|
+
``GRPCController.Shutdown``) → wait 2s for graceful exit → SIGKILL via the
|
|
5
|
+
runner. We use the same shape, but split into discrete steps the client
|
|
6
|
+
can sequence.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_windows() -> bool:
|
|
18
|
+
return sys.platform.startswith("win")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def spawn(
|
|
22
|
+
cmd: Sequence[str],
|
|
23
|
+
*,
|
|
24
|
+
env: Mapping[str, str],
|
|
25
|
+
cwd: str | None = None,
|
|
26
|
+
) -> subprocess.Popen[bytes]:
|
|
27
|
+
"""Spawn a plugin subprocess. stdin → DEVNULL, stdout/stderr → pipes."""
|
|
28
|
+
creationflags = 0
|
|
29
|
+
start_new_session = False
|
|
30
|
+
if is_windows():
|
|
31
|
+
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
32
|
+
else:
|
|
33
|
+
start_new_session = True # so SIGKILL won't take down the host
|
|
34
|
+
|
|
35
|
+
return subprocess.Popen(
|
|
36
|
+
list(cmd),
|
|
37
|
+
env=dict(env),
|
|
38
|
+
cwd=cwd,
|
|
39
|
+
stdin=subprocess.DEVNULL,
|
|
40
|
+
stdout=subprocess.PIPE,
|
|
41
|
+
stderr=subprocess.PIPE,
|
|
42
|
+
bufsize=0,
|
|
43
|
+
close_fds=True,
|
|
44
|
+
creationflags=creationflags,
|
|
45
|
+
start_new_session=start_new_session,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def terminate(p: subprocess.Popen) -> None:
|
|
50
|
+
"""SIGTERM (or CTRL_BREAK on Windows). Safe to call after process exit."""
|
|
51
|
+
if p.poll() is not None:
|
|
52
|
+
return
|
|
53
|
+
if is_windows():
|
|
54
|
+
try:
|
|
55
|
+
p.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
|
|
56
|
+
except (ValueError, OSError):
|
|
57
|
+
p.terminate()
|
|
58
|
+
else:
|
|
59
|
+
p.terminate()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def kill(p: subprocess.Popen) -> None:
|
|
63
|
+
"""SIGKILL / TerminateProcess. Last resort."""
|
|
64
|
+
if p.poll() is not None:
|
|
65
|
+
return
|
|
66
|
+
p.kill()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Vendored from hashicorp/go-plugin internal/plugin/grpc_broker.proto
|
|
2
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
syntax = "proto3";
|
|
4
|
+
package plugin;
|
|
5
|
+
option go_package = "github.com/hashicorp/go-plugin/internal/plugin";
|
|
6
|
+
|
|
7
|
+
message ConnInfo {
|
|
8
|
+
uint32 service_id = 1;
|
|
9
|
+
string network = 2;
|
|
10
|
+
string address = 3;
|
|
11
|
+
message Knock {
|
|
12
|
+
bool knock = 1;
|
|
13
|
+
bool ack = 2;
|
|
14
|
+
string error = 3;
|
|
15
|
+
}
|
|
16
|
+
Knock knock = 4;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
service GRPCBroker {
|
|
20
|
+
rpc StartStream(stream ConnInfo) returns (stream ConnInfo);
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Vendored from hashicorp/go-plugin internal/plugin/grpc_controller.proto
|
|
2
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
syntax = "proto3";
|
|
4
|
+
package plugin;
|
|
5
|
+
option go_package = "github.com/hashicorp/go-plugin/internal/plugin";
|
|
6
|
+
|
|
7
|
+
message Empty {
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
service GRPCController {
|
|
11
|
+
rpc Shutdown(Empty) returns (Empty);
|
|
12
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Vendored from hashicorp/go-plugin internal/plugin/grpc_stdio.proto
|
|
2
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
syntax = "proto3";
|
|
4
|
+
package plugin;
|
|
5
|
+
option go_package = "github.com/hashicorp/go-plugin/internal/plugin";
|
|
6
|
+
|
|
7
|
+
import "google/protobuf/empty.proto";
|
|
8
|
+
|
|
9
|
+
service GRPCStdio {
|
|
10
|
+
rpc StreamStdio(google.protobuf.Empty) returns (stream StdioData);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
message StdioData {
|
|
14
|
+
enum Channel {
|
|
15
|
+
INVALID = 0;
|
|
16
|
+
STDOUT = 1;
|
|
17
|
+
STDERR = 2;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Channel channel = 1;
|
|
21
|
+
bytes data = 2;
|
|
22
|
+
}
|
pyplugin/reattach.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""ReattachConfig — host re-connects to a pre-existing plugin process."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .handshake import NETWORK_UNIX, PROTOCOL_GRPC
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ReattachConfig:
|
|
11
|
+
"""Information needed to re-attach to a running plugin without spawning.
|
|
12
|
+
|
|
13
|
+
Mirrors go-plugin's ``ReattachConfig``. ``test=True`` (set by serving with
|
|
14
|
+
``ServeConfig.test_mode``) tells the host's ``kill()`` to NOT terminate
|
|
15
|
+
the process — the test harness is responsible for shutdown.
|
|
16
|
+
"""
|
|
17
|
+
pid: int
|
|
18
|
+
addr: str # unix path or "host:port"
|
|
19
|
+
network: str = NETWORK_UNIX
|
|
20
|
+
protocol: str = PROTOCOL_GRPC
|
|
21
|
+
protocol_version: int = 1
|
|
22
|
+
server_cert_pem: bytes | None = None # for AutoMTLS reattach
|
|
23
|
+
# If reattaching with AutoMTLS, the host must supply the *original* host
|
|
24
|
+
# client cert+key it generated for the plugin so the TLS handshake works.
|
|
25
|
+
client_cert_pem: bytes | None = None
|
|
26
|
+
client_key_pem: bytes | None = None
|
|
27
|
+
test: bool = False
|
pyplugin/server.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Plugin-side ``serve()`` entry point (grpclib async).
|
|
2
|
+
|
|
3
|
+
Mirrors go-plugin's ``Serve(opts *ServeConfig)`` lifecycle:
|
|
4
|
+
|
|
5
|
+
1. Validate magic cookie (or skip in test mode).
|
|
6
|
+
2. Negotiate protocol version against ``PLUGIN_PROTOCOL_VERSIONS``.
|
|
7
|
+
3. Open a listener (unix on POSIX, TCP loopback on Windows or when forced).
|
|
8
|
+
4. Build SSL context if ``PLUGIN_CLIENT_CERT`` is set (AutoMTLS).
|
|
9
|
+
5. Collect servicers: GRPCBroker + GRPCController + GRPCStdio + Health
|
|
10
|
+
(service name = "plugin") + reflection + each user plugin.
|
|
11
|
+
6. Build ``grpclib.server.Server`` and start it on the listener.
|
|
12
|
+
7. Emit the handshake line to stdout, flush.
|
|
13
|
+
8. Block until ``GRPCController.Shutdown`` fires or SIGTERM is received.
|
|
14
|
+
|
|
15
|
+
``serve()`` is sync from the caller's perspective; it spins up its own
|
|
16
|
+
asyncio event loop. Plugin authors implement async servicers but call
|
|
17
|
+
``serve(config)`` from a normal ``main()``.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import signal
|
|
25
|
+
import ssl
|
|
26
|
+
import sys
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Optional, Union
|
|
29
|
+
|
|
30
|
+
from grpclib.reflection.service import ServerReflection
|
|
31
|
+
from grpclib.server import Server
|
|
32
|
+
|
|
33
|
+
from . import mtls, transport
|
|
34
|
+
from .broker import TLSMaterial, make_server_side_broker
|
|
35
|
+
from .controller import GRPCControllerServicer
|
|
36
|
+
from .cookie import validate_or_exit
|
|
37
|
+
from .health import StaticHealth
|
|
38
|
+
from .handshake import (
|
|
39
|
+
HandshakeConfig,
|
|
40
|
+
PROTOCOL_GRPC,
|
|
41
|
+
format_line,
|
|
42
|
+
)
|
|
43
|
+
from .plugin import Plugin, PluginSet, VersionedPlugins
|
|
44
|
+
from .stdio import GRPCStdioServicer
|
|
45
|
+
|
|
46
|
+
GRPC_HEALTH_SERVICE_NAME = "plugin" # what the go-plugin host pings
|
|
47
|
+
ENV_CLIENT_CERT = "PLUGIN_CLIENT_CERT"
|
|
48
|
+
ENV_PROTOCOL_VERSIONS = "PLUGIN_PROTOCOL_VERSIONS"
|
|
49
|
+
ENV_MULTIPLEX_GRPC = "PLUGIN_MULTIPLEX_GRPC"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ServeConfig:
|
|
54
|
+
"""Configuration handed to :func:`serve`."""
|
|
55
|
+
handshake_config: HandshakeConfig
|
|
56
|
+
plugins: Union[PluginSet, VersionedPlugins]
|
|
57
|
+
logger: Optional[logging.Logger] = None
|
|
58
|
+
test_mode: bool = False
|
|
59
|
+
force_tcp: bool = False
|
|
60
|
+
grpc_options: list = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_versioned(p: Union[PluginSet, VersionedPlugins]) -> bool:
|
|
64
|
+
if not p:
|
|
65
|
+
return False
|
|
66
|
+
return all(isinstance(k, int) for k in p.keys())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _negotiate_version(cfg: ServeConfig) -> tuple[int, PluginSet]:
|
|
70
|
+
"""Pick (version, PluginSet) — mirrors go-plugin's protocolVersion()."""
|
|
71
|
+
versioned: dict[int, PluginSet] = {}
|
|
72
|
+
if _is_versioned(cfg.plugins):
|
|
73
|
+
versioned.update(cfg.plugins) # type: ignore[arg-type]
|
|
74
|
+
else:
|
|
75
|
+
versioned[cfg.handshake_config.protocol_version] = cfg.plugins # type: ignore[assignment]
|
|
76
|
+
|
|
77
|
+
client_versions: list[int] = []
|
|
78
|
+
raw = os.environ.get(ENV_PROTOCOL_VERSIONS, "")
|
|
79
|
+
for s in (p for p in raw.split(",") if p):
|
|
80
|
+
try:
|
|
81
|
+
client_versions.append(int(s))
|
|
82
|
+
except ValueError:
|
|
83
|
+
sys.stderr.write(f"server sent invalid plugin version {s!r}\n")
|
|
84
|
+
|
|
85
|
+
server_versions = sorted(versioned.keys(), reverse=True)
|
|
86
|
+
for v in server_versions:
|
|
87
|
+
if v in client_versions:
|
|
88
|
+
return v, versioned[v]
|
|
89
|
+
|
|
90
|
+
fallback = sorted(versioned.keys())[0]
|
|
91
|
+
return fallback, versioned[fallback]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _serve_async(config: ServeConfig) -> None:
|
|
95
|
+
logger = config.logger or logging.getLogger("pyplugin.server")
|
|
96
|
+
|
|
97
|
+
proto_version, plugin_set = _negotiate_version(config)
|
|
98
|
+
listener = transport.open_listener(force_tcp=config.force_tcp)
|
|
99
|
+
|
|
100
|
+
# AutoMTLS — match server.go: read PLUGIN_CLIENT_CERT, generate our cert,
|
|
101
|
+
# trust the host's cert as both client CA and root.
|
|
102
|
+
server_cert_b64 = ""
|
|
103
|
+
server_ssl: Optional[ssl.SSLContext] = None
|
|
104
|
+
server_cert: Optional[mtls.Cert] = None
|
|
105
|
+
client_cert_pem = os.environ.get(ENV_CLIENT_CERT)
|
|
106
|
+
if client_cert_pem:
|
|
107
|
+
server_cert = mtls.generate()
|
|
108
|
+
server_cert_b64 = mtls.encode_handshake_cert(server_cert.cert_der)
|
|
109
|
+
server_ssl = mtls.server_ssl_context(
|
|
110
|
+
cert_pem=server_cert.cert_pem,
|
|
111
|
+
key_pem=server_cert.key_pem,
|
|
112
|
+
peer_cert_pem=client_cert_pem.encode(),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Build the broker's servicer + facade; the demux task we'll start under
|
|
116
|
+
# this same event loop.
|
|
117
|
+
broker_tls: Optional[TLSMaterial] = None
|
|
118
|
+
if server_cert is not None and client_cert_pem:
|
|
119
|
+
broker_tls = TLSMaterial(
|
|
120
|
+
cert_pem=server_cert.cert_pem,
|
|
121
|
+
key_pem=server_cert.key_pem,
|
|
122
|
+
peer_cert_pem=client_cert_pem.encode(),
|
|
123
|
+
)
|
|
124
|
+
broker_servicer, broker, demux_task = make_server_side_broker(broker_tls)
|
|
125
|
+
|
|
126
|
+
controller = GRPCControllerServicer()
|
|
127
|
+
stdio_servicer = GRPCStdioServicer()
|
|
128
|
+
health = StaticHealth()
|
|
129
|
+
|
|
130
|
+
user_servicers: list = []
|
|
131
|
+
for name, p in plugin_set.items():
|
|
132
|
+
if not isinstance(p, Plugin):
|
|
133
|
+
raise TypeError(f"plugin {name!r} must be a pyplugin.Plugin instance")
|
|
134
|
+
user_servicers.extend(p.servicers(broker))
|
|
135
|
+
|
|
136
|
+
base_servicers: list = [
|
|
137
|
+
broker_servicer,
|
|
138
|
+
controller,
|
|
139
|
+
stdio_servicer,
|
|
140
|
+
health,
|
|
141
|
+
] + user_servicers
|
|
142
|
+
|
|
143
|
+
# Reflection extends the servicer list with its own service.
|
|
144
|
+
all_servicers = ServerReflection.extend(base_servicers)
|
|
145
|
+
|
|
146
|
+
server = Server(all_servicers)
|
|
147
|
+
if listener.network == "unix":
|
|
148
|
+
await server.start(path=listener.address, ssl=server_ssl)
|
|
149
|
+
else:
|
|
150
|
+
host, port = listener.address.split(":")
|
|
151
|
+
await server.start(host=host, port=int(port), ssl=server_ssl)
|
|
152
|
+
|
|
153
|
+
# Emit handshake. go-plugin always emits 6 segments; if PLUGIN_MULTIPLEX_GRPC
|
|
154
|
+
# is set, the 7th tells the host whether we *support* mux. We don't, so we
|
|
155
|
+
# advertise false (opt-out) — the host will then fail loudly if it required it.
|
|
156
|
+
multiplex: Optional[bool] = None
|
|
157
|
+
if os.environ.get(ENV_MULTIPLEX_GRPC):
|
|
158
|
+
multiplex = False
|
|
159
|
+
line = format_line(
|
|
160
|
+
app_protocol_version=proto_version,
|
|
161
|
+
network=listener.network,
|
|
162
|
+
address=listener.address,
|
|
163
|
+
protocol=PROTOCOL_GRPC,
|
|
164
|
+
server_cert=server_cert_b64,
|
|
165
|
+
multiplex_supported=multiplex,
|
|
166
|
+
)
|
|
167
|
+
if not config.test_mode:
|
|
168
|
+
sys.stdout.write(line + "\n")
|
|
169
|
+
sys.stdout.flush()
|
|
170
|
+
|
|
171
|
+
# Suppress SIGINT — go-plugin "eats" interrupts so the host owns shutdown.
|
|
172
|
+
# SIGTERM still triggers shutdown via the signal handler below.
|
|
173
|
+
loop = asyncio.get_running_loop()
|
|
174
|
+
if not config.test_mode:
|
|
175
|
+
try:
|
|
176
|
+
loop.add_signal_handler(signal.SIGINT, lambda: None)
|
|
177
|
+
loop.add_signal_handler(signal.SIGTERM, controller.shutdown_event.set)
|
|
178
|
+
except (NotImplementedError, RuntimeError):
|
|
179
|
+
pass # Windows / non-main thread — best effort
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
await controller.shutdown_event.wait()
|
|
183
|
+
finally:
|
|
184
|
+
# GracefulStop the gRPC server, then unlink the unix socket.
|
|
185
|
+
server.close()
|
|
186
|
+
try:
|
|
187
|
+
await asyncio.wait_for(server.wait_closed(), timeout=2.0)
|
|
188
|
+
except asyncio.TimeoutError:
|
|
189
|
+
pass
|
|
190
|
+
await broker.close()
|
|
191
|
+
stdio_servicer.close()
|
|
192
|
+
demux_task.cancel()
|
|
193
|
+
if listener.cleanup_path and os.path.exists(listener.cleanup_path):
|
|
194
|
+
try:
|
|
195
|
+
os.remove(listener.cleanup_path)
|
|
196
|
+
except OSError:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def serve(config: ServeConfig) -> None:
|
|
201
|
+
"""Run the plugin server (sync entry point). Plugin's ``main()`` calls this."""
|
|
202
|
+
if not config.test_mode:
|
|
203
|
+
validate_or_exit(config.handshake_config)
|
|
204
|
+
asyncio.run(_serve_async(config))
|
pyplugin/stdio.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""GRPCStdio servicer — streams plugin stdout/stderr writes to the host (grpclib async)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
from ._generated import grpc_stdio_grpc, grpc_stdio_pb2
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GRPCStdioServicer(grpc_stdio_grpc.GRPCStdioBase):
|
|
10
|
+
"""Streams stdio chunks. ``StreamStdio`` is called once by the host."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._q: asyncio.Queue[grpc_stdio_pb2.StdioData | None] = asyncio.Queue()
|
|
14
|
+
self._consumed = asyncio.Event()
|
|
15
|
+
|
|
16
|
+
def write_stdout(self, data: bytes) -> None:
|
|
17
|
+
if data:
|
|
18
|
+
self._q.put_nowait(grpc_stdio_pb2.StdioData(
|
|
19
|
+
channel=grpc_stdio_pb2.StdioData.STDOUT, data=data))
|
|
20
|
+
|
|
21
|
+
def write_stderr(self, data: bytes) -> None:
|
|
22
|
+
if data:
|
|
23
|
+
self._q.put_nowait(grpc_stdio_pb2.StdioData(
|
|
24
|
+
channel=grpc_stdio_pb2.StdioData.STDERR, data=data))
|
|
25
|
+
|
|
26
|
+
def close(self) -> None:
|
|
27
|
+
self._q.put_nowait(None)
|
|
28
|
+
|
|
29
|
+
async def StreamStdio(self, stream) -> None: # noqa: N802
|
|
30
|
+
await stream.recv_message() # request is google.protobuf.Empty
|
|
31
|
+
self._consumed.set()
|
|
32
|
+
while True:
|
|
33
|
+
chunk = await self._q.get()
|
|
34
|
+
if chunk is None:
|
|
35
|
+
return
|
|
36
|
+
await stream.send_message(chunk)
|