]> go.fuhry.dev Git - runtime.git/commitdiff
Initial commit
authorDan Fuhry <dan@fuhry.com>
Wed, 13 Dec 2023 03:48:13 +0000 (22:48 -0500)
committerDan Fuhry <dan@fuhry.com>
Wed, 13 Dec 2023 13:14:43 +0000 (08:14 -0500)
141 files changed:
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
attestation/Makefile [new file with mode: 0644]
attestation/c/config.m4 [new file with mode: 0644]
attestation/c/main.c [new file with mode: 0644]
attestation/cgo/extension.h [new file with mode: 0644]
attestation/cgo/extension_api.go [new file with mode: 0644]
attestation/client/Makefile [new file with mode: 0644]
attestation/client/attest.log [new file with mode: 0644]
attestation/client/main.go [new file with mode: 0644]
attestation/internal/attestation/api.go [new file with mode: 0644]
attestation/internal/attestation/client.go [new file with mode: 0644]
attestation/internal/attestation/rpc_server.go [new file with mode: 0644]
attestation/internal/attestation/server.go [new file with mode: 0644]
attestation/php.ini.in [new file with mode: 0644]
attestation/php/composer.json [new file with mode: 0644]
attestation/php/composer.lock [new file with mode: 0644]
attestation/php/phpunit.xml [new file with mode: 0644]
attestation/php/src/ActivationChallenge.php [new file with mode: 0644]
attestation/php/src/AttestationParameters.php [new file with mode: 0644]
attestation/php/src/PlatformParameters.php [new file with mode: 0644]
attestation/php/tests/ActivationChallengeTest.php [new file with mode: 0644]
attestation/php/tests/AttestPlatformTest.php [new file with mode: 0644]
attestation/rpc_client/Makefile [new file with mode: 0644]
attestation/rpc_client/main.go [new file with mode: 0644]
attestation/rpc_server/Makefile [new file with mode: 0644]
attestation/rpc_server/attest_acl.yaml [new file with mode: 0644]
attestation/rpc_server/main.go [new file with mode: 0644]
constants/constants.go [new file with mode: 0644]
echo/client/Makefile [new file with mode: 0644]
echo/client/main.go [new file with mode: 0644]
echo/server.go [new file with mode: 0644]
echo/server/Makefile [new file with mode: 0644]
echo/server/echo_acl.yaml [new file with mode: 0644]
echo/server/main.go [new file with mode: 0644]
go.mod [new file with mode: 0644]
go.sum [new file with mode: 0644]
grpc/acl/acl_yaml.go [new file with mode: 0644]
grpc/client.go [new file with mode: 0644]
grpc/conn_base.go [new file with mode: 0644]
grpc/conn_quic.go [new file with mode: 0644]
grpc/conn_tcp.go [new file with mode: 0644]
grpc/context.go [new file with mode: 0644]
grpc/server.go [new file with mode: 0644]
ldap/health_exporter/Makefile [new file with mode: 0644]
ldap/health_exporter/main.go [new file with mode: 0644]
ldap/health_exporter/systemd/ldap-health-exporter@.service [new file with mode: 0644]
ldap/scraper.go [new file with mode: 0644]
ldap/server.go [new file with mode: 0644]
machines/client.go [new file with mode: 0644]
machines/event_monitor/Makefile [new file with mode: 0644]
machines/event_monitor/main.go [new file with mode: 0644]
machines/event_watcher.go [new file with mode: 0644]
machines/oauth2.go [new file with mode: 0644]
machines/types.go [new file with mode: 0644]
metrics/apcups_exporter/Makefile [new file with mode: 0644]
metrics/apcups_exporter/main.go [new file with mode: 0644]
metrics/metricbus/PROTOCOL.md [new file with mode: 0644]
metrics/metricbus/constants.go [new file with mode: 0644]
metrics/metricbus/dbus-1/dev.fuhry.runtime.metrics.conf [new file with mode: 0644]
metrics/metricbus/internal/dbus_interface.go [new file with mode: 0644]
metrics/metricbus/internal/server.go [new file with mode: 0644]
metrics/metricbus/mbclient/client.go [new file with mode: 0644]
metrics/metricbus/mbclient/conn.go [new file with mode: 0644]
metrics/metricbus/mbclient/example/Makefile [new file with mode: 0644]
metrics/metricbus/mbclient/example/main.go [new file with mode: 0644]
metrics/metricbus/mbclient/intf.go [new file with mode: 0644]
metrics/metricbus/mbclient/metrics.go [new file with mode: 0644]
metrics/metricbus/mbserver/Makefile [new file with mode: 0644]
metrics/metricbus/mbserver/main.go [new file with mode: 0644]
metrics/metricbus/systemd/system/metric-collector.service [new file with mode: 0644]
mtls/certutil/certutil.go [new file with mode: 0644]
mtls/fsnotify/fsnotify.go [new file with mode: 0644]
mtls/identity.go [new file with mode: 0644]
mtls/pkcs11.go [new file with mode: 0644]
mtls/provider_file.go [new file with mode: 0644]
mtls/provider_interface.go [new file with mode: 0644]
mtls/provider_keychain_macos.go [new file with mode: 0644]
mtls/provider_shared.go [new file with mode: 0644]
mtls/provider_tpm2_pkcs11.go [new file with mode: 0644]
mtls/verify_names.go [new file with mode: 0644]
mtls/verify_roots.go [new file with mode: 0644]
mtls/verify_tool/Makefile [new file with mode: 0644]
mtls/verify_tool/main.go [new file with mode: 0644]
net/dns/dns_cache.go [new file with mode: 0644]
proto/service/attest/Makefile [new file with mode: 0644]
proto/service/attest/attest_server.pb.go [new file with mode: 0644]
proto/service/attest/attest_server.proto [new file with mode: 0644]
proto/service/attest/attest_server_grpc.pb.go [new file with mode: 0644]
proto/service/attest/convert.go [new file with mode: 0644]
proto/service/echo/Makefile [new file with mode: 0644]
proto/service/echo/echo_server.pb.go [new file with mode: 0644]
proto/service/echo/echo_server.proto [new file with mode: 0644]
proto/service/echo/echo_server_grpc.pb.go [new file with mode: 0644]
rand/range.go [new file with mode: 0644]
sase/acl.go [new file with mode: 0644]
sase/acl_test.go [new file with mode: 0644]
sase/client.go [new file with mode: 0644]
sase/happy_eyeballs.go [new file with mode: 0644]
sase/machines_networks.go [new file with mode: 0644]
sase/read_writer.go [new file with mode: 0644]
sase/ws_proxy.go [new file with mode: 0644]
sase/ws_proxy_client/Makefile [new file with mode: 0644]
sase/ws_proxy_client/main.go [new file with mode: 0644]
sase/ws_tcp_proxy/Makefile [new file with mode: 0644]
sase/ws_tcp_proxy/main.go [new file with mode: 0644]
sd/etcd_factory.go [new file with mode: 0644]
sd/health_exporter/Makefile [new file with mode: 0644]
sd/health_exporter/main.go [new file with mode: 0644]
sd/healthcheck.go [new file with mode: 0644]
sd/healthcheck_http.go [new file with mode: 0644]
sd/healthcheck_ldap.go [new file with mode: 0644]
sd/monitor.go [new file with mode: 0644]
sd/publish.go [new file with mode: 0644]
sd/sd_publish/Makefile [new file with mode: 0644]
sd/sd_publish/main.go [new file with mode: 0644]
sd/sd_register/Makefile [new file with mode: 0644]
sd/sd_register/main.go [new file with mode: 0644]
sd/sd_watcher/Makefile [new file with mode: 0644]
sd/sd_watcher/main.go [new file with mode: 0644]
sd/systemd/sd-health-exporter.service [new file with mode: 0644]
sd/systemd/sd-register.service [new file with mode: 0644]
sd/systemd/sd-register@.service [new file with mode: 0644]
sd/watcher.go [new file with mode: 0644]
thirdparty/registry/Makefile [new file with mode: 0644]
thirdparty/registry/config.yml [new file with mode: 0644]
thirdparty/registry/main.go [new file with mode: 0644]
thirdparty/registry/systemd/docker-registry.service [new file with mode: 0644]
utils/ansi/color.go [new file with mode: 0644]
utils/debounce/debounce.go [new file with mode: 0644]
utils/debounce/debounce_test.go [new file with mode: 0644]
utils/generics/math.go [new file with mode: 0644]
utils/hashset/hashset.go [new file with mode: 0644]
utils/hashset/hashset_test.go [new file with mode: 0644]
utils/hostname/hostname.go [new file with mode: 0644]
utils/hostname/hostname_macos.go [new file with mode: 0644]
utils/log/level.go [new file with mode: 0644]
utils/log/log.go [new file with mode: 0644]
utils/log/util.go [new file with mode: 0644]
utils/reverse.go [new file with mode: 0644]
utils/stringmatch/stringmatch.go [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..d81dcea
--- /dev/null
@@ -0,0 +1,49 @@
+/attestation/c/.libs
+/attestation/c/autom4te.cache
+/attestation/c/build
+/attestation/c/include
+/attestation/c/modules
+/attestation/c/config.h
+/attestation/c/config.h.in
+/attestation/c/config.log
+/attestation/c/config.nice
+/attestation/c/config.status
+/attestation/c/configure
+/attestation/c/configure.ac
+/attestation/c/libtool
+/attestation/c/main.dep
+/attestation/c/main.lo
+/attestation/c/Makefile
+/attestation/c/Makefile.fragments
+/attestation/c/Makefile.objects
+/attestation/c/tpm_attestation.la
+/attestation/c/run-tests.php
+/attestation/cgo/libtpmattestation.h
+/attestation/cgo/libtpmattestation.so
+/attestation/php/vendor/
+/attestation/php/.phpunit.result.cache
+/attestation/php.ini
+
+attestation/client/client
+machines/event_monitor/event_monitor
+sase/ws_proxy_client/ws_proxy_client
+sase/ws_tcp_proxy/ws_tcp_proxy
+sd/health_exporter/health_exporter
+sd/sd_register/sd_register
+sd/sd_publish/sd_publish
+sd/sd_watcher/sd_watcher
+echo/server/server
+echo/client/client
+attestation/rpc_server/rpc_server
+attestation/rpc_client/rpc_client
+thirdparty/registry/registry
+metrics/metricbus/mbclient/example/example
+metrics/metricbus/mbserver/mbserver
+mtls/verify_tool/main
+metrics/apcups_exporter/apcups_exporter
+ansible/client/client
+ansible/admin_tool/admin_tool
+ansible/server/server
+mtls/verify_tool/verify_tool
+ldap/health_exporter/health_exporter
+envoy/xds/envoy_xds/envoy_xds
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..1978c74
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,50 @@
+GOMAINSRCS = $(shell find . -type f -name main.go | cut -c 3- | paste -s -d ' ' -)
+GOMAINDIRS = $(GOMAINSRCS:/main.go=)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+GOMAINS =
+
+ROOT_DOMAIN := fuhry.dev
+DEFAULT_REGION := hq
+DEFAULT_HOST_DOMAIN := $(DEFAULT_REGION).$(ROOT_DOMAIN)
+SD_DOMAIN := v.$(ROOT_DOMAIN)
+WEB_SERVICES_DOMAIN := $(ROOT_DOMAIN)
+MACHINES_HOST := machines.$(WEB_SERVICES_DOMAIN)
+MACHINES_MQTT_TOPIC := machines/events
+ROOT_CA_NAME := FooCorp Root
+INT_CA_NAME := FooCorp Intermediate mTLS
+DEVICE_TRUST_TOKEN_NAME := FooCorp Device Trust
+
+LDFLAGS :=
+
+LDFLAGS += -X=go.fuhry.dev/runtime/constants.RootDomain=$(ROOT_DOMAIN)
+LDFLAGS += -X=go.fuhry.dev/runtime/constants.DefaultRegion=$(DEFAULT_REGION)
+LDFLAGS += -X=go.fuhry.dev/runtime/constants.DefaultHostDomain=$(DEFAULT_HOST_DOMAIN)
+LDFLAGS += -X=go.fuhry.dev/runtime/constants.SDDomain=$(SD_DOMAIN)
+LDFLAGS += -X=go.fuhry.dev/runtime/constants.WebServicesDomain=$(WEB_SERVICES_DOMAIN)
+LDFLAGS += -X=go.fuhry.dev/runtime/constants.MachinesHost=$(MACHINES_HOST)
+LDFLAGS += -X=go.fuhry.dev/runtime/constants.MachinesMqttTopic=$(MACHINES_MQTT_TOPIC)
+#LDFLAGS += -X="go.fuhry.dev/runtime/constants.RootCAName=$(ROOT_CA_NAME)"
+#LDFLAGS += -X="go.fuhry.dev/runtime/constants.IntCAName=$(INT_CA_NAME)"
+#LDFLAGS += -X="go.fuhry.dev/runtime/constants.DeviceTrustTokenName=$(DEVICE_TRUST_TOKEN_NAME)"
+
+define GOPROG_template =
+GOMAINS += $(1)/$(2)
+all: $(1)/$(2)
+$(1)/$(2): $(1)/main.go
+       go build -ldflags '$$(LDFLAGS)' $$(GOBUILDFLAGS) -o $$@ $$<
+
+endef
+
+$(foreach maindir,$(GOMAINDIRS),$(eval $(call GOPROG_template,$(maindir),$(shell basename $(maindir)))))
+
+.PHONY: $(GOMAINDIRS) clean all
+
+clean:
+       if test -z "$(GOMAINS)"; then \
+               echo "ERROR: Failed to discover main.go sources in tree." >&2; \
+               echo "Debug info:" >&2; \
+               echo "  GOMAINSRCS = $(GOMAINSRCS)" >&2; \
+               echo "  GOMAINDIRS = $(GOMAINDIRS)" >&2; \
+               exit 1; \
+       fi
+       rm -fv $(GOMAINS)
diff --git a/attestation/Makefile b/attestation/Makefile
new file mode 100644 (file)
index 0000000..bdb19ef
--- /dev/null
@@ -0,0 +1,58 @@
+CONFIGURE_ARGS :=
+
+all: cgo c
+
+.PHONY: clean
+clean:
+       /bin/rm -rfv \
+               c/.libs \
+               c/autom4te.cache \
+               c/build \
+               c/include \
+               c/modules
+       
+       /bin/rm -fv \
+               c/config.h \
+               c/config.h.in \
+               c/config.log \
+               c/config.nice \
+               c/config.status \
+               c/configure \
+               c/configure.ac \
+               c/libtool \
+               c/main.dep \
+               c/main.lo \
+               c/Makefile \
+               c/Makefile.fragments \
+               c/Makefile.objects \
+               c/tpm_attestation.la \
+               c/run-tests.php \
+               cgo/libtpmattestation.h \
+               cgo/libtpmattestation.so \
+
+cgo/libtpmattestation.so:
+       cd ./cgo && go build -o libtpmattestation.so -buildmode=c-shared ./extension_api.go
+
+.PHONY: cgo
+cgo: cgo/libtpmattestation.so
+
+c/configure:
+       cd ./c && phpize
+
+c/Makefile: c/configure
+       cd ./c && ./configure $(CONFIGURE_ARGS)
+
+.PHONY: c
+c: c/configure c/Makefile cgo
+       cd ./c && EXTRA_CFLAGS="-I`pwd`/../cgo -L`pwd`/../cgo -ltpmattestation" make
+
+install: cgo c
+       cd ./c && make install
+
+php.ini: php.ini.in
+       sed -re 's;@PWD@;$(PWD);g' $< > $@
+
+.PHONY: test
+test: php.ini c
+       cd $(PWD)/php && \
+               env LD_LIBRARY_PATH=$(PWD)/cgo php -c $(PWD)/php.ini ./vendor/bin/phpunit
diff --git a/attestation/c/config.m4 b/attestation/c/config.m4
new file mode 100644 (file)
index 0000000..7d02d27
--- /dev/null
@@ -0,0 +1,5 @@
+PHP_ARG_ENABLE(tpm_attestation, Whether to enable the TPM Attestation extension, [ --enable-tpm-attestation Enable TPM Attestation module])
+
+if test "$TPM_ATTESTATION" != "no"; then
+    PHP_NEW_EXTENSION(tpm_attestation, main.c, $ext_shared)
+fi
diff --git a/attestation/c/main.c b/attestation/c/main.c
new file mode 100644 (file)
index 0000000..1e996ea
--- /dev/null
@@ -0,0 +1,754 @@
+// include the PHP API itself
+#include <php.h>
+#include <zend_exceptions.h>
+
+#include "libtpmattestation.h"
+
+#define PHP_TPM_ATTESTATION_EXTNAME "tpm-attestation"
+#define PHP_TPM_ATTESTATION_VERSION "0.0.1"
+
+static zend_class_entry *attestation_parameters_class_entry = NULL;
+static zend_class_entry *activation_challenge_class_entry = NULL;
+static zend_class_entry *platform_parameters_class_entry = NULL;
+static zend_class_entry *quote_class_entry = NULL;
+static zend_class_entry *pcr_class_entry = NULL;
+
+#define COND_FREE_BYTES(x)   { if (x.Data != NULL) free(x.Data); }
+#define ZVAL_STRING_DECLARE(var, value) \
+    zval var; \
+    ZVAL_STRING(&var, value);
+
+///////////////////////////////////////////////////////////////////////////////
+// C STRUCT INITIALIZATION/FREEING
+///////////////////////////////////////////////////////////////////////////////
+
+PlatformParameters* xa_platform_parameters_new(uint32_t quotes_count, uint32_t pcrs_count) {
+    PlatformParameters* pp = (PlatformParameters *)malloc(sizeof(PlatformParameters));
+    if (pp == NULL) {
+        return NULL;
+    }
+    memset(pp, 0, sizeof(PlatformParameters));
+
+    pp->Quotes = malloc(sizeof(Quote*) * (quotes_count + 1));
+    if (pp->Quotes == NULL) {
+        goto err0;
+    }
+    pp->PCRs = malloc(sizeof(PCR*) * (pcrs_count + 1));
+    if (pp->PCRs == NULL) {
+        goto err1;
+    }
+
+    for (int i = 0; i < quotes_count; i++) {
+        pp->Quotes[i] = malloc(sizeof(Quote));
+        if (pp->Quotes[i] == NULL) {
+            goto err2;
+        }
+    }
+    pp->Quotes[quotes_count] = NULL;
+
+    for (int i = 0; i < pcrs_count; i++) {
+        pp->PCRs[i] = malloc(sizeof(PCR));
+        if (pp->PCRs[i] == NULL) {
+            goto err3;
+        }
+    }
+    pp->PCRs[pcrs_count] = NULL;
+
+    return pp;
+
+err3:
+    for (int i = 0; pp->PCRs[i] != NULL; i++) {
+        free(pp->PCRs[i]);
+    }
+err2:
+    for (int i = 0; pp->Quotes[i] != NULL; i++) {
+        free(pp->Quotes[i]);
+    }
+    free(pp->PCRs);
+err1:
+    free(pp->Quotes);
+err0:
+    free(pp);
+    return NULL;
+}
+
+void xa_platform_parameters_free(PlatformParameters* pp) {
+    for (int i = 0; pp->Quotes[i] != NULL; i++) {
+        php_printf("free pp->Quotes[%d]\n", i);
+        free(pp->Quotes[i]);
+    }
+    php_printf("free pp->Quotes\n");
+    free(pp->Quotes);
+
+    for (int i = 0; pp->PCRs[i] != NULL; i++) {
+        php_printf("free pp->PCRs[%d]\n", i);
+        free(pp->PCRs[i]);
+    }
+    php_printf("free pp->PCRs\n");
+    free(pp->PCRs);
+}
+
+void xa_attestation_parameters_free(AttestationParameters* ap) {
+    if (ap == NULL) return;
+    
+    free(ap);
+}
+
+void xa_activation_challenge_response_free(ActivationChallengeResponse* r) {
+    if (r == NULL) return;
+
+    COND_FREE_BYTES(r->Error);
+
+    if (r->Response != NULL) {
+        COND_FREE_BYTES(r->Response->Secret);
+        COND_FREE_BYTES(r->Response->EncryptedSecret);
+        COND_FREE_BYTES(r->Response->Credential);
+        
+        free(r->Response);
+    }
+
+    free(r);
+}
+
+void xa_attest_platform_response_free(AttestPlatformResponse* r) {
+    if (r == NULL) return;
+
+    COND_FREE_BYTES(r->Error);
+    COND_FREE_BYTES(r->Response);
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// C STRUCT CONVERSION TO PHP
+///////////////////////////////////////////////////////////////////////////////
+
+void cActivationChallengeToPhp(ActivationChallenge* c, zval* php) {
+    zend_class_entry *ce = activation_challenge_class_entry;
+    object_init_ex(php, activation_challenge_class_entry);
+
+    zval zSecret, zEncryptedSecret, zCredential;
+    ZVAL_STRINGL(&zSecret, c->Secret.Data, c->Secret.Length);
+    ZVAL_STRINGL(&zEncryptedSecret, c->EncryptedSecret.Data, c->EncryptedSecret.Length);
+    ZVAL_STRINGL(&zCredential, c->Credential.Data, c->Credential.Length);
+
+    zend_update_property(activation_challenge_class_entry, Z_OBJ_P(php), "Secret", sizeof("Secret")-1, &zSecret);
+    zend_update_property(activation_challenge_class_entry, Z_OBJ_P(php), "EncryptedSecret", sizeof("EncryptedSecret")-1, &zEncryptedSecret);
+    zend_update_property(activation_challenge_class_entry, Z_OBJ_P(php), "Credential", sizeof("Credential")-1, &zCredential);
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// PHP OBJECT CONVERSION TO C STRUCTS
+///////////////////////////////////////////////////////////////////////////////
+
+AttestationParameters* phpAttestationParametersToC(zend_object* php) {
+    zval temp_;
+    AttestationParameters* c;
+
+    c = (AttestationParameters*)malloc(sizeof(AttestationParameters));
+    if (c == NULL) {
+        return c;
+    }
+    memset(c, 0, sizeof(AttestationParameters));
+
+    zval *zPublic = zend_read_property(attestation_parameters_class_entry, php, "Public", sizeof("Public")-1, 1, &temp_);
+    c->Public.Data = Z_STRVAL_P(zPublic);
+    c->Public.Length = Z_STRLEN_P(zPublic);
+
+    zval *zUseTCSDActivationFormat = zend_read_property(attestation_parameters_class_entry, php, "UseTCSDActivationFormat", sizeof("UseTCSDActivationFormat")-1, 1, &temp_);
+    c->UseTCSDActivationFormat = Z_LVAL_P(zUseTCSDActivationFormat);
+
+    zval *zCreateData = zend_read_property(attestation_parameters_class_entry, php, "CreateData", sizeof("CreateData")-1, 1, &temp_);
+    c->CreateData.Data = Z_STRVAL(*zCreateData);
+    c->CreateData.Length = Z_STRLEN(*zCreateData);
+
+    zval *zCreateAttestation = zend_read_property(attestation_parameters_class_entry, php, "CreateAttestation", sizeof("CreateAttestation")-1, 1, &temp_);
+    c->CreateAttestation.Data = Z_STRVAL(*zCreateAttestation);
+    c->CreateAttestation.Length = Z_STRLEN(*zCreateAttestation);
+
+    zval *zCreateSignature = zend_read_property(attestation_parameters_class_entry, php, "CreateSignature", sizeof("CreateSignature")-1, 1, &temp_);
+    c->CreateSignature.Data = Z_STRVAL(*zCreateSignature);
+    c->CreateSignature.Length = Z_STRLEN(*zCreateSignature);
+
+    return c;
+}
+
+void phpQuoteToC(zend_object* php, Quote* c) {
+    zval temp_;
+
+    zval *zVersion = zend_read_property(quote_class_entry, php, "Version", sizeof("Version") - 1, 1, &temp_);
+    c->Version = Z_LVAL_P(zVersion);
+
+    zval *zQuote = zend_read_property(quote_class_entry, php, "Quote", sizeof("Quote") - 1, 1, &temp_);
+    c->Quote.Data = Z_STRVAL(*zQuote);
+    c->Quote.Length = Z_STRLEN(*zQuote);
+
+    zval *zSignature = zend_read_property(quote_class_entry, php, "Signature", sizeof("Signature") - 1, 1, &temp_);
+    c->Signature.Data = Z_STRVAL(*zSignature);
+    c->Signature.Length = Z_STRLEN(*zSignature);
+}
+
+void phpPcrToC(zend_object* php, PCR* c) {
+    zval temp_;
+
+    zval *zIndex = zend_read_property(pcr_class_entry, php, "Index", sizeof("Index") - 1, 1, &temp_);
+    c->Index = Z_LVAL_P(zIndex);
+
+    zval *zDigestAlg = zend_read_property(pcr_class_entry, php, "DigestAlg", sizeof("DigestAlg") - 1, 1, &temp_);
+    c->DigestAlg = Z_LVAL_P(zDigestAlg);
+
+    zval *zDigest = zend_read_property(pcr_class_entry, php, "Digest", sizeof("Digest") - 1, 1, &temp_);
+    c->Digest.Data = Z_STRVAL(*zDigest);
+    c->Digest.Length = Z_STRLEN(*zDigest);
+}
+
+PlatformParameters* phpPlatformParametersToC(zend_object* php) {
+    zval temp_;
+    PlatformParameters* c;
+
+    zval *zQuotes = zend_read_property(platform_parameters_class_entry, php, "Quotes", sizeof("Quotes") - 1, 0, &temp_);
+    zval *zPCRs = zend_read_property(platform_parameters_class_entry, php, "PCRs", sizeof("PCRs") - 1, 0, &temp_);
+
+    if (UNEXPECTED(Z_TYPE_P(zQuotes) != IS_ARRAY)) {
+        zend_throw_exception(NULL, "PlatformParameters: \"Quotes\" property is not an array", 0);
+        return NULL;
+    }
+    if (UNEXPECTED(Z_TYPE_P(zPCRs) != IS_ARRAY)) {
+        zend_throw_exception(NULL, "PlatformParameters: \"PCRs\" property is not an array", 0);
+        return NULL;
+    }
+    c = xa_platform_parameters_new(zend_array_count(Z_ARR_P(zQuotes)), zend_array_count(Z_ARR_P(zPCRs)));
+    if (UNEXPECTED(c == NULL)) {
+        zend_throw_exception(NULL, "PlatformParameters: internal error: memory allocation failed for C struct", 0);
+        return NULL;
+    }
+
+    zval *zTPMVersion = zend_read_property(platform_parameters_class_entry, php, "TPMVersion", sizeof("TPMVersion") - 1, 0, &temp_);
+    c->TPMVersion = Z_LVAL_P(zTPMVersion);
+
+    zval *zPublic = zend_read_property(platform_parameters_class_entry, php, "Public", sizeof("Public") - 1, 0, &temp_);
+    c->Public.Data = Z_STRVAL(*zPublic);
+    c->Public.Length = Z_STRLEN(*zPublic);
+
+    zval *zEventLog = zend_read_property(platform_parameters_class_entry, php, "EventLog", sizeof("EventLog") - 1, 0, &temp_);
+    c->EventLog.Data = Z_STRVAL(*zEventLog);
+    c->EventLog.Length = Z_STRLEN(*zEventLog);
+
+    zend_ulong i;
+    void *_key;
+    zval *val;
+
+    ZEND_HASH_FOREACH_KEY_VAL(Z_ARR_P(zQuotes), i, _key, val) {
+        phpQuoteToC(Z_OBJ_P(val), c->Quotes[i]);
+    } ZEND_HASH_FOREACH_END();
+
+    ZEND_HASH_FOREACH_KEY_VAL(Z_ARR_P(zPCRs), i, _key, val) {
+        phpPcrToC(Z_OBJ_P(val), c->PCRs[i]);
+    } ZEND_HASH_FOREACH_END();
+
+    return c;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// AttestationParameters class
+///////////////////////////////////////////////////////////////////////////////
+
+// AttestationParameters constructor
+PHP_METHOD(AttestationParameters, __construct)
+{
+    zend_string* Public;
+    zend_bool UseTCSDActivationFormat;
+    zend_string* CreateData;
+    zend_string* CreateAttestation;
+    zend_string* CreateSignature;
+
+    ZEND_PARSE_PARAMETERS_START(5, 5)
+        Z_PARAM_STR(Public)
+        Z_PARAM_BOOL(UseTCSDActivationFormat)
+        Z_PARAM_STR(CreateData)
+        Z_PARAM_STR(CreateAttestation)
+        Z_PARAM_STR(CreateSignature)
+    ZEND_PARSE_PARAMETERS_END();
+
+    zend_update_property_str(attestation_parameters_class_entry, Z_OBJ_P(ZEND_THIS), "Public", sizeof("Public")-1, Public);
+    zend_update_property_bool(attestation_parameters_class_entry, Z_OBJ_P(ZEND_THIS), "UseTCSDActivationFormat", sizeof("UseTCSDActivationFormat")-1, UseTCSDActivationFormat);
+    zend_update_property_str(attestation_parameters_class_entry, Z_OBJ_P(ZEND_THIS), "CreateData", sizeof("CreateData")-1, CreateData);
+    zend_update_property_str(attestation_parameters_class_entry, Z_OBJ_P(ZEND_THIS), "CreateAttestation", sizeof("CreateAttestation")-1, CreateAttestation);
+    zend_update_property_str(attestation_parameters_class_entry, Z_OBJ_P(ZEND_THIS), "CreateSignature", sizeof("CreateSignature")-1, CreateSignature);
+}
+ZEND_BEGIN_ARG_INFO(arginfo_attestation_parameters_construct, 4)
+    ZEND_ARG_INFO(0, Public)
+    ZEND_ARG_INFO(0, UseTCSDActivationFormat)
+    ZEND_ARG_INFO(0, CreateData)
+    ZEND_ARG_INFO(0, CreateAttestation)
+    ZEND_ARG_INFO(0, CreateSignature)
+ZEND_END_ARG_INFO()
+
+// AttestationParameters fromJson alternate constructor
+PHP_METHOD(AttestationParameters, fromJson)
+{
+    zval* inp = NULL;
+
+    ZEND_PARSE_PARAMETERS_START(1, 1)
+        Z_PARAM_ARRAY(inp)
+    ZEND_PARSE_PARAMETERS_END();
+
+    zval *zPublic, *zUseTCSDActivationFormat, *zCreateData, *zCreateSignature, *zCreateAttestation;
+    ZVAL_STRING_DECLARE(iPublic, "Public");
+    ZVAL_STRING_DECLARE(iUseTCSDActivationFormat, "UseTCSDActivationFormat");
+    ZVAL_STRING_DECLARE(iCreateData, "CreateData");
+    ZVAL_STRING_DECLARE(iCreateAttestation, "CreateAttestation");
+    ZVAL_STRING_DECLARE(iCreateSignature, "CreateSignature");
+
+    zPublic = zend_hash_find(Z_ARR_P(inp), Z_STR(iPublic));
+    zUseTCSDActivationFormat = zend_hash_find(Z_ARR_P(inp), Z_STR(iUseTCSDActivationFormat));
+    zCreateData = zend_hash_find(Z_ARR_P(inp), Z_STR(iCreateData));
+    zCreateAttestation = zend_hash_find(Z_ARR_P(inp), Z_STR(iCreateAttestation));
+    zCreateSignature = zend_hash_find(Z_ARR_P(inp), Z_STR(iCreateSignature));
+
+    if (zPublic == NULL || Z_TYPE_P(zPublic) != IS_STRING) {
+        zend_throw_exception(NULL, "Key \"Public\" must be present and must be a string.", 0);
+        return;
+    }
+    if (zUseTCSDActivationFormat == NULL || !(Z_TYPE_P(zUseTCSDActivationFormat) == IS_TRUE || Z_TYPE_P(zUseTCSDActivationFormat) == IS_FALSE)) {
+        zend_throw_exception(NULL, "Key \"UseTCSDActivationFormat\" must be present and must be a boolean.", 0);
+        return;
+    }
+    if (zCreateData == NULL || Z_TYPE_P(zCreateData) != IS_STRING) {
+        zend_throw_exception(NULL, "Key \"CreateData\" must be present and must be a string.", 0);
+        return;
+    }
+    if (zCreateAttestation == NULL || Z_TYPE_P(zCreateAttestation) != IS_STRING) {
+        zend_throw_exception(NULL, "Key \"CreateAttestation\" must be present and must be a string.", 0);
+        return;
+    }
+    if (zCreateSignature == NULL || Z_TYPE_P(zCreateSignature) != IS_STRING) {
+        zend_throw_exception(NULL, "Key \"CreateSignature\" must be present and must be a string.", 0);
+        return;
+    }
+
+    object_init_ex(return_value, attestation_parameters_class_entry);
+
+    zend_update_property(attestation_parameters_class_entry, Z_OBJ_P(return_value), "Public", sizeof("Public")-1, zPublic);
+    zend_update_property(attestation_parameters_class_entry, Z_OBJ_P(return_value), "UseTCSDActivationFormat", sizeof("UseTCSDActivationFormat")-1, zUseTCSDActivationFormat);
+    zend_update_property(attestation_parameters_class_entry, Z_OBJ_P(return_value), "CreateData", sizeof("CreateData")-1, zCreateData);
+    zend_update_property(attestation_parameters_class_entry, Z_OBJ_P(return_value), "CreateAttestation", sizeof("CreateAttestation")-1, zCreateAttestation);
+    zend_update_property(attestation_parameters_class_entry, Z_OBJ_P(return_value), "CreateSignature", sizeof("CreateSignature")-1, zCreateSignature);
+}
+ZEND_BEGIN_ARG_INFO(arginfo_attestation_parameters_fromjson, 0)
+    ZEND_ARG_INFO(0, json)
+ZEND_END_ARG_INFO()
+
+// GetActivationChallenge method
+PHP_METHOD(AttestationParameters, GetActivationChallenge)
+{
+    zend_long tpm_version;
+    zend_string* endorsement_key = NULL;
+
+    ZEND_PARSE_PARAMETERS_START(2, 2)
+        Z_PARAM_LONG(tpm_version)
+        Z_PARAM_STR(endorsement_key)
+    ZEND_PARSE_PARAMETERS_END();
+
+    GoUint8 gTPMVersion = tpm_version;
+    Bytes gEndorsementKey;
+    gEndorsementKey.Data = ZSTR_VAL(endorsement_key);
+    gEndorsementKey.Length = ZSTR_LEN(endorsement_key);
+
+    AttestationParameters* ap = phpAttestationParametersToC(Z_OBJ_P(ZEND_THIS));
+
+    ActivationChallengeResponse* response = GetActivationChallenge(
+        gTPMVersion,
+        ap,
+        gEndorsementKey);
+
+    xa_attestation_parameters_free(ap);
+
+    if (response == NULL) {
+        zend_throw_exception(NULL, "Internal error: GetActivationChallenge() returned NULL", 0);
+        return;
+    }
+    if (response->Error.Data != NULL) {
+        xa_activation_challenge_response_free(response);
+        zend_throw_exception(NULL, response->Error.Data, 0);
+        return;
+    }
+
+    cActivationChallengeToPhp(response->Response, return_value);
+    xa_activation_challenge_response_free(response);
+}
+ZEND_BEGIN_ARG_INFO(arginfo_attestation_parameters_getactivationchallenge, 1)
+    ZEND_ARG_INFO(0, TPMVersion)
+    ZEND_ARG_INFO(0, EndorsementKey)
+ZEND_END_ARG_INFO()
+
+///////////////////////////////////////////////////////////////////////////////
+// ActivationChallenge class
+///////////////////////////////////////////////////////////////////////////////
+
+// getSecret returns the Secret. This should not be shared with the client.
+// It should be cached on the server and associated with the identity of the
+// client.
+PHP_METHOD(ActivationChallenge, getSecret)
+{
+    zval temp_;
+
+    zval *secret = zend_read_property(activation_challenge_class_entry, Z_OBJ_P(ZEND_THIS), "Secret", sizeof("Secret")-1, 1, &temp_);
+
+    if (secret == NULL) {
+        zend_throw_exception(NULL, "Internal error: Failed to read Secret property", 0);
+        return;
+    }
+
+    ZVAL_STR_COPY(return_value, Z_STR_P(secret));
+}
+ZEND_BEGIN_ARG_INFO(arginfo_activation_challenge_get_secret, 1)
+ZEND_END_ARG_INFO()
+
+// toJson returns an array containing the ActivationChallenge properties that
+// should be sent back to the client (EncryptedSecret and Credential).
+PHP_METHOD(ActivationChallenge, toJson)
+{
+    zval temp_;
+
+    zval *encrypted_secret = zend_read_property(activation_challenge_class_entry, Z_OBJ_P(ZEND_THIS), "EncryptedSecret", sizeof("EncryptedSecret")-1, 1, &temp_);
+    if (encrypted_secret == NULL) {
+        zend_throw_exception(NULL, "Internal error: Failed to read EncryptedSecret property", 0);
+        return;
+    }
+
+    zval *credential = zend_read_property(activation_challenge_class_entry, Z_OBJ_P(ZEND_THIS), "Credential", sizeof("Credential")-1, 1, &temp_);
+    if (credential == NULL) {
+        zend_throw_exception(NULL, "Internal error: Failed to read Credential property", 0);
+        return;
+    }
+
+    array_init_size(return_value, 2);
+    add_assoc_zval(return_value, "EncryptedSecret", encrypted_secret);
+    add_assoc_zval(return_value, "Credential", credential);
+
+}
+ZEND_BEGIN_ARG_INFO(arginfo_activation_challenge_to_json, 1)
+ZEND_END_ARG_INFO()
+
+///////////////////////////////////////////////////////////////////////////////
+// PlatformParameters class
+///////////////////////////////////////////////////////////////////////////////
+
+// Alternate fromJson constructor
+PHP_METHOD(PlatformParameters, fromJson)
+{
+    zval* inp = NULL;
+
+    ZEND_PARSE_PARAMETERS_START(1, 1)
+        Z_PARAM_ARRAY(inp)
+    ZEND_PARSE_PARAMETERS_END();
+
+    zval *zTPMVersion,
+        *zPublic,
+        *zQuotesJ,
+        *zPCRsJ,
+        *zEventLog;
+    ZVAL_STRING_DECLARE(iTPMVersion, "TPMVersion");
+    ZVAL_STRING_DECLARE(iPublic, "Public");
+    ZVAL_STRING_DECLARE(iQuotes, "Quotes");
+    ZVAL_STRING_DECLARE(iPCRs, "PCRs");
+    ZVAL_STRING_DECLARE(iEventLog, "EventLog");
+
+    ZVAL_STRING_DECLARE(iVersion, "Version");
+    ZVAL_STRING_DECLARE(iQuote, "Quote");
+    ZVAL_STRING_DECLARE(iSignature, "Signature");
+
+    ZVAL_STRING_DECLARE(iIndex, "Index");
+    ZVAL_STRING_DECLARE(iDigest, "Digest");
+    ZVAL_STRING_DECLARE(iDigestAlg, "DigestAlg");
+
+    zTPMVersion = zend_hash_find(Z_ARR_P(inp), Z_STR(iTPMVersion));
+    zPublic = zend_hash_find(Z_ARR_P(inp), Z_STR(iPublic));
+    zQuotesJ = zend_hash_find(Z_ARR_P(inp), Z_STR(iQuotes));
+    zPCRsJ = zend_hash_find(Z_ARR_P(inp), Z_STR(iPCRs));
+    zEventLog = zend_hash_find(Z_ARR_P(inp), Z_STR(iEventLog));
+    
+    if (zTPMVersion == NULL || Z_TYPE_P(zTPMVersion) != IS_LONG) {
+        zend_throw_exception(NULL, "Key \"TPMVersion\" must be present and must be an integer.", 0);
+        return;
+    }
+    if (zPublic == NULL || Z_TYPE_P(zPublic) != IS_STRING) {
+        zend_throw_exception(NULL, "Key \"Public\" must be present and must be a string.", 0);
+        return;
+    }
+    if (zQuotesJ == NULL || Z_TYPE_P(zQuotesJ) != IS_ARRAY) {
+        zend_throw_exception(NULL, "Key \"Quotes\" must be present and must be an array.", 0);
+        return;
+    }
+    if (zPCRsJ == NULL || Z_TYPE_P(zPCRsJ) != IS_ARRAY) {
+        zend_throw_exception(NULL, "Key \"PCRs\" must be present and must be an array.", 0);
+        return;
+    }
+    if (zEventLog == NULL || Z_TYPE_P(zEventLog) != IS_STRING) {
+        zend_throw_exception(NULL, "Key \"EventLog\" must be present and must be a string.", 0);
+        return;
+    }
+
+    zval zQuotes, zPCRs;
+    
+    array_init_size(&zQuotes, zend_array_count(Z_ARR_P(zQuotesJ)));
+    array_init_size(&zPCRs, zend_array_count(Z_ARR_P(zPCRsJ)));
+
+    {
+        zend_ulong i_;
+        void *k_;
+        zval *val;
+        ZEND_HASH_FOREACH_KEY_VAL(Z_ARR_P(zQuotesJ), i_, k_, val) {
+            if (Z_TYPE_P(val) != IS_ARRAY) {
+                zend_throw_exception(NULL, "All entries in the \"Quotes\" property must be arrays.", 0);
+                return;
+            }
+            zval *zVersion = zend_hash_find(Z_ARR_P(val), Z_STR(iVersion));
+            zval *zQuote = zend_hash_find(Z_ARR_P(val), Z_STR(iQuote));
+            zval *zSignature = zend_hash_find(Z_ARR_P(val), Z_STR(iSignature));
+
+            if (zVersion == NULL || Z_TYPE_P(zVersion) != IS_LONG) {
+                zend_throw_exception(NULL, "While parsing Quotes: Key \"Version\" must be present and must be an integer.", 0);
+                return;
+            }
+            if (zQuote == NULL || Z_TYPE_P(zQuote) != IS_STRING) {
+                zend_throw_exception(NULL, "While parsing Quotes: Key \"Quote\" must be present and must be a string.", 0);
+                return;
+            }
+            if (zSignature == NULL || Z_TYPE_P(zSignature) != IS_STRING) {
+                zend_throw_exception(NULL, "While parsing Quotes: Key \"Signature\" must be present and must be a string.", 0);
+                return;
+            }
+
+            zval zVersionC, zQuoteC, zSignatureC;
+            ZVAL_LONG(&zVersionC, Z_LVAL_P(zVersion));
+            ZVAL_STR(&zQuoteC, Z_STR_P(zQuote));
+            ZVAL_STR(&zSignatureC, Z_STR_P(zSignature));
+
+            zval zQuoteObj;
+            object_init_ex(&zQuoteObj, quote_class_entry);
+            zend_update_property(quote_class_entry, Z_OBJ(zQuoteObj), "Version", sizeof("Version")-1, &zVersionC);
+            zend_update_property(quote_class_entry, Z_OBJ(zQuoteObj), "Quote", sizeof("Quote")-1, &zQuoteC);
+            zend_update_property(quote_class_entry, Z_OBJ(zQuoteObj), "Signature", sizeof("Signature")-1, &zSignatureC);
+
+
+            add_next_index_zval(&zQuotes, &zQuoteObj);
+
+        } ZEND_HASH_FOREACH_END();
+    }
+
+    {
+        zend_ulong i_;
+        void *k_;
+        zval *val;
+        ZEND_HASH_FOREACH_KEY_VAL(Z_ARR_P(zPCRsJ), i_, k_, val) {
+            if (Z_TYPE_P(val) != IS_ARRAY) {
+                zend_throw_exception(NULL, "All entries in the \"PCRs\" property must be arrays.", 0);
+                return;
+            }
+            zval *zIndex = zend_hash_find(Z_ARR_P(val), Z_STR(iIndex));
+            zval *zDigest = zend_hash_find(Z_ARR_P(val), Z_STR(iDigest));
+            zval *zDigestAlg = zend_hash_find(Z_ARR_P(val), Z_STR(iDigestAlg));
+
+            if (zIndex == NULL || Z_TYPE_P(zIndex) != IS_LONG) {
+                zend_throw_exception(NULL, "While parsing PCRs: Key \"Index\" must be present and must be an integer.", 0);
+                return;
+            }
+            if (zDigest == NULL || Z_TYPE_P(zDigest) != IS_STRING) {
+                zend_throw_exception(NULL, "While parsing PCRs: Key \"Digest\" must be present and must be a string.", 0);
+                return;
+            }
+            if (zDigestAlg == NULL || Z_TYPE_P(zDigestAlg) != IS_LONG) {
+                zend_throw_exception(NULL, "While parsing PCRs: Key \"DigestAlg\" must be present and must be an integer.", 0);
+                return;
+            }
+
+            zval zIndexC, zDigestC, zDigestAlgC;
+            ZVAL_LONG(&zIndexC, Z_LVAL_P(zIndex));
+            ZVAL_STR(&zDigestC, Z_STR_P(zDigest));
+            ZVAL_LONG(&zDigestAlgC, Z_LVAL_P(zDigestAlg));
+
+            zval zPCRObj;
+            object_init_ex(&zPCRObj, pcr_class_entry);
+            zend_update_property(quote_class_entry, Z_OBJ(zPCRObj), "Index", sizeof("Index")-1, &zIndexC);
+            zend_update_property(quote_class_entry, Z_OBJ(zPCRObj), "Digest", sizeof("Digest")-1, &zDigestC);
+            zend_update_property(quote_class_entry, Z_OBJ(zPCRObj), "DigestAlg", sizeof("DigestAlg")-1, &zDigestAlgC);
+
+            add_next_index_zval(&zPCRs, &zPCRObj);
+
+        } ZEND_HASH_FOREACH_END();
+    }
+
+    object_init_ex(return_value, platform_parameters_class_entry);
+
+    zend_update_property(platform_parameters_class_entry, Z_OBJ_P(return_value), "TPMVersion", sizeof("TPMVersion")-1, zTPMVersion);
+    zend_update_property(platform_parameters_class_entry, Z_OBJ_P(return_value), "Public", sizeof("Public")-1, zPublic);
+    zend_update_property(platform_parameters_class_entry, Z_OBJ_P(return_value), "Quotes", sizeof("Quotes")-1, &zQuotes);
+    zend_update_property(platform_parameters_class_entry, Z_OBJ_P(return_value), "PCRs", sizeof("PCRs")-1, &zPCRs);
+    zend_update_property(platform_parameters_class_entry, Z_OBJ_P(return_value), "EventLog", sizeof("EventLog")-1, zEventLog);
+}
+ZEND_BEGIN_ARG_INFO(arginfo_platform_parameters_fromjson, 0)
+    ZEND_ARG_INFO(0, json)
+ZEND_END_ARG_INFO()
+
+// AttestPlatform 
+PHP_METHOD(PlatformParameters, AttestPlatform)
+{
+    zend_string* nonce = NULL;
+
+    ZEND_PARSE_PARAMETERS_START(1, 1)
+        Z_PARAM_STR(nonce)
+    ZEND_PARSE_PARAMETERS_END();
+
+    if (nonce == NULL) {
+        zend_throw_exception(NULL, "The argument \"nonce\" to AttestPlatform must be a base64-encoded or binary string.", 0);
+        return;
+    }
+
+    Bytes gNonce;
+    gNonce.Data = ZSTR_VAL(nonce);
+    gNonce.Length = ZSTR_LEN(nonce);
+
+    PlatformParameters* pp = phpPlatformParametersToC(Z_OBJ_P(ZEND_THIS));
+
+    AttestPlatformResponse* response = AttestPlatform(gNonce, pp);
+
+    // xa_platform_parameters_free(pp);
+
+    if (response == NULL) {
+        zend_throw_exception(NULL, "Internal error: AttestPlatform() returned NULL", 0);
+        return;
+    }
+    if (response->Error.Data != NULL) {
+        zend_throw_exception(NULL, response->Error.Data, 0);
+        xa_attest_platform_response_free(response);
+        return;
+    }
+
+    ZVAL_STRINGL(return_value, response->Response.Data, response->Response.Length);
+    xa_attest_platform_response_free(response);
+}
+ZEND_BEGIN_ARG_INFO(arginfo_platform_parameters_attest_platform, 0)
+    ZEND_ARG_INFO(0, nonce)
+ZEND_END_ARG_INFO()
+
+///////////////////////////////////////////////////////////////////////////////
+// FUNCTION TABLES
+///////////////////////////////////////////////////////////////////////////////
+
+const zend_function_entry attestation_parameters_functions[] = {
+    //  public methods
+    PHP_ME(AttestationParameters, __construct, arginfo_attestation_parameters_construct, ZEND_ACC_PUBLIC)
+    PHP_ME(AttestationParameters, fromJson, arginfo_attestation_parameters_fromjson, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC)
+    PHP_ME(AttestationParameters, GetActivationChallenge, arginfo_attestation_parameters_getactivationchallenge, ZEND_ACC_PUBLIC)
+    PHP_FE_END
+};
+
+const zend_function_entry activation_challenge_functions[] = {
+    //  public methods
+    // PHP_ME(ActivationChallenge, __construct, arginfo_activation_challenge_construct, ZEND_ACC_PUBLIC)
+    PHP_ME(ActivationChallenge, getSecret, arginfo_activation_challenge_get_secret, ZEND_ACC_PUBLIC)
+    PHP_ME(ActivationChallenge, toJson, arginfo_activation_challenge_to_json, ZEND_ACC_PUBLIC)
+    PHP_FE_END
+};
+
+const zend_function_entry platform_parameters_functions[] = {
+    //  public methods
+    // PHP_ME(PlatformParameters, __construct, arginfo_platform_parameters_construct, ZEND_ACC_PUBLIC)
+    PHP_ME(PlatformParameters, fromJson, arginfo_platform_parameters_fromjson, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC)
+    PHP_ME(PlatformParameters, AttestPlatform, arginfo_platform_parameters_attest_platform, ZEND_ACC_PUBLIC)
+    PHP_FE_END
+};
+
+const zend_function_entry quote_functions[] = {
+    //  public methods
+    // PHP_ME(PlatformParameters, __construct, arginfo_platform_parameters_construct, ZEND_ACC_PUBLIC)
+    PHP_FE_END
+};
+
+const zend_function_entry pcr_functions[] = {
+    //  public methods
+    // PHP_ME(PlatformParameters, __construct, arginfo_platform_parameters_construct, ZEND_ACC_PUBLIC)
+    PHP_FE_END
+};
+
+///////////////////////////////////////////////////////////////////////////////
+// MODULE INITIALIZATION
+///////////////////////////////////////////////////////////////////////////////
+
+PHP_MINIT_FUNCTION(TPMAttestation)
+{
+    /* AttestationParameters */
+    zend_class_entry attestationParametersEntry;
+    INIT_NS_CLASS_ENTRY(attestationParametersEntry, "TPMAttestation", "AttestationParameters", attestation_parameters_functions);
+
+    attestation_parameters_class_entry = zend_register_internal_class(&attestationParametersEntry);
+    zend_declare_property_string(attestation_parameters_class_entry, "Public", strlen("Public"), "", ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_bool(attestation_parameters_class_entry, "UseTCSDActivationFormat", strlen("UseTCSDActivationFormat"), 0, ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_string(attestation_parameters_class_entry, "CreateData", strlen("CreateData"), "", ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_string(attestation_parameters_class_entry, "CreateAttestation", strlen("CreateAttestation"), "", ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_string(attestation_parameters_class_entry, "CreateSignature", strlen("CreateSignature"), "", ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+
+    /* ActivationChallenge */
+    zend_class_entry activationChallengeEntry;
+    INIT_NS_CLASS_ENTRY(activationChallengeEntry, "TPMAttestation", "ActivationChallenge", activation_challenge_functions);
+
+    activation_challenge_class_entry = zend_register_internal_class(&activationChallengeEntry);
+    zend_declare_property_string(activation_challenge_class_entry, "Secret", strlen("Secret"), "", ZEND_ACC_PUBLIC);
+    zend_declare_property_string(activation_challenge_class_entry, "EncryptedSecret", strlen("EncryptedSecret"), "", ZEND_ACC_PUBLIC);
+    zend_declare_property_string(activation_challenge_class_entry, "Credential", strlen("Credential"), "", ZEND_ACC_PUBLIC);
+
+    /* PlatformParameters */
+    zend_class_entry platformParametersEntry;
+    INIT_NS_CLASS_ENTRY(platformParametersEntry, "TPMAttestation", "PlatformParameters", platform_parameters_functions);
+
+    platform_parameters_class_entry = zend_register_internal_class(&platformParametersEntry);
+    zend_declare_property_long(platform_parameters_class_entry, "TPMVersion", strlen("TPMVersion"), 0, ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_string(platform_parameters_class_entry, "Public", strlen("Public"), "", ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_string(platform_parameters_class_entry, "EventLog", strlen("EventLog"), "", ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_null(platform_parameters_class_entry, "Quotes", strlen("Quotes"), ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_null(platform_parameters_class_entry, "PCRs", strlen("PCRs"), ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+
+    /* Quote */
+    zend_class_entry quoteEntry;
+    INIT_NS_CLASS_ENTRY(quoteEntry, "TPMAttestation", "Quote", quote_functions);
+
+    quote_class_entry = zend_register_internal_class(&quoteEntry);
+    zend_declare_property_long(quote_class_entry, "Version", strlen("Version"), 0, ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_string(quote_class_entry, "Quote", strlen("Quote"), "", ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_string(quote_class_entry, "Signature", strlen("Signature"), "", ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+
+    /* PCR */
+    zend_class_entry pcrEntry;
+    INIT_NS_CLASS_ENTRY(pcrEntry, "TPMAttestation", "PCR", pcr_functions);
+
+    pcr_class_entry = zend_register_internal_class(&pcrEntry);
+    zend_declare_property_long(pcr_class_entry, "Index", strlen("Index"), 0, ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_long(pcr_class_entry, "DigestAlg", strlen("DigestAlg"), 0, ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+    zend_declare_property_string(pcr_class_entry, "Digest", strlen("Digest"), "", ZEND_ACC_PUBLIC | ZEND_ACC_READONLY);
+
+    return SUCCESS;
+}
+
+// register our function to the PHP API
+// so that PHP knows, which functions are in this module
+zend_function_entry tpm_attestation_functions[] = {
+    // ZEND_NS_FE("TPMAttestation", receive_scalar, arginfo_receive_scalar)
+    PHP_FE_END // null termination
+};
+
+// some pieces of information about our module
+zend_module_entry tpm_attestation_module_entry = {
+    STANDARD_MODULE_HEADER,
+    PHP_TPM_ATTESTATION_EXTNAME,
+    tpm_attestation_functions,
+    PHP_MINIT(TPMAttestation),
+    NULL,
+    NULL,
+    NULL,
+    NULL,
+    PHP_TPM_ATTESTATION_VERSION,
+    STANDARD_MODULE_PROPERTIES
+};
+
+// argument must be prefix of *_module_entry
+ZEND_GET_MODULE(tpm_attestation);
diff --git a/attestation/cgo/extension.h b/attestation/cgo/extension.h
new file mode 100644 (file)
index 0000000..63ed2f2
--- /dev/null
@@ -0,0 +1,56 @@
+#ifndef TPM_ATTESTATION_EXTENSION_H
+#define TPM_ATTESTATION_EXTENSION_H
+
+#include <stdlib.h>
+#include <stdint.h>
+
+typedef struct Bytes {
+    size_t Length;
+    char* Data;
+} Bytes;
+
+typedef struct AttestationParameters {
+    Bytes Public;
+    int UseTCSDActivationFormat;
+    Bytes CreateData;
+    Bytes CreateAttestation;
+    Bytes CreateSignature;
+} AttestationParameters;
+
+typedef struct ActivationChallenge {
+    Bytes Secret;
+    Bytes EncryptedSecret;
+    Bytes Credential;
+} ActivationChallenge;
+
+typedef struct ActivationChallengeResponse {
+    ActivationChallenge* Response;
+    Bytes Error;
+} ActivationChallengeResponse;
+
+typedef struct Quote {
+    uint8_t Version;
+    Bytes Quote;
+    Bytes Signature;
+} Quote;
+
+typedef struct PCR {
+    int Index;
+    Bytes Digest;
+    uint32_t DigestAlg;
+} PCR;
+
+typedef struct PlatformParameters {
+    uint8_t TPMVersion;
+    Bytes Public;
+    Quote** Quotes;
+    PCR** PCRs;
+    Bytes EventLog;
+} PlatformParameters;
+
+typedef struct AttestPlatformResponse {
+    Bytes Response;
+    Bytes Error;
+} AttestPlatformResponse;
+
+#endif /* TPM_ATTESTATION_EXTENSION_H */
diff --git a/attestation/cgo/extension_api.go b/attestation/cgo/extension_api.go
new file mode 100644 (file)
index 0000000..9c7cd77
--- /dev/null
@@ -0,0 +1,168 @@
+package main
+
+// #include <stdlib.h>
+// #include <stdint.h>
+// #include <extension.h>
+import "C"
+
+import (
+       "crypto"
+       "encoding/base64"
+       "regexp"
+       "unsafe"
+
+       "github.com/google/go-attestation/attest"
+       "go.fuhry.dev/runtime/attestation/internal/attestation"
+)
+
+const (
+       RE_LOOKS_LIKE_BASE64 = "^[A-Za-z0-9/+]+={0,3}"
+)
+
+var regexpLooksLikeBase64 *regexp.Regexp
+
+//export GetActivationChallenge
+func GetActivationChallenge(tpmVersion uint8, attestationParameters *C.AttestationParameters, endorsementKey C.Bytes) *C.ActivationChallengeResponse {
+       req := &attestation.GetActivationChallenge_Request{
+               TPMVersion:            tpmVersion,
+               EndorsementKey:        C.GoStringN(endorsementKey.Data, C.int(endorsementKey.Length)),
+               AttestationParameters: cAttestationParamsToGo(attestationParameters),
+       }
+
+       cresp := newActivationChallengeResponse()
+       resp, err := attestation.GetActivationChallenge(req)
+       cresp.Response = nil
+       cresp.Error.Length = 0
+       cresp.Error.Data = nil
+
+       if err != nil {
+               cStrCpy(&cresp.Error, err.Error())
+               return cresp
+       }
+
+       cresp.Response = goActivationResponseToC(resp)
+       return cresp
+}
+
+//export AttestPlatform
+func AttestPlatform(decryptedSecret C.Bytes, platformParameters *C.PlatformParameters) *C.AttestPlatformResponse {
+       req := &attestation.AttestPlatform_Request{
+               DecryptedSecret:    cBytesToGoBytes(decryptedSecret),
+               PlatformParameters: cPlatformParamsToGo(platformParameters),
+       }
+
+       cresp := newAttestPlatformResponse()
+       resp, err := attestation.AttestPlatform(req)
+
+       if err != nil {
+               cStrCpy(&cresp.Error, err.Error())
+               return cresp
+       }
+
+       cBytesCopy(&cresp.Response, resp.Nonce)
+       return cresp
+}
+
+func newActivationChallengeResponse() *C.ActivationChallengeResponse {
+       return (*C.ActivationChallengeResponse)(C.malloc(C.size_t(unsafe.Sizeof(C.ActivationChallengeResponse{}))))
+}
+
+func goActivationResponseToC(resp *attestation.GetActivationChallenge_Response) *C.ActivationChallenge {
+       cs := (*C.ActivationChallenge)(C.malloc(C.size_t(unsafe.Sizeof(C.ActivationChallenge{}))))
+
+       cBytesCopy(&cs.Secret, resp.Secret)
+       cBytesCopy(&cs.EncryptedSecret, resp.EncryptedSecret)
+       cBytesCopy(&cs.Credential, resp.Credential)
+
+       return cs
+}
+
+func newAttestPlatformResponse() *C.AttestPlatformResponse {
+       r := (*C.AttestPlatformResponse)(C.malloc(C.size_t(unsafe.Sizeof(C.AttestPlatformResponse{}))))
+
+       r.Response.Data = nil
+       r.Response.Length = 0
+
+       r.Error.Data = nil
+       r.Error.Length = 0
+
+       return r
+}
+
+func cAttestationParamsToGo(cAttParms *C.AttestationParameters) attest.AttestationParameters {
+       return attest.AttestationParameters{
+               Public:                  cBytesToGoBytes(cAttParms.Public),
+               UseTCSDActivationFormat: bool(cAttParms.UseTCSDActivationFormat != 0),
+               CreateData:              cBytesToGoBytes(cAttParms.CreateData),
+               CreateAttestation:       cBytesToGoBytes(cAttParms.CreateAttestation),
+               CreateSignature:         cBytesToGoBytes(cAttParms.CreateSignature),
+       }
+}
+
+func cPlatformParamsToGo(cPlatParms *C.PlatformParameters) attest.PlatformParameters {
+       quotes := make([]attest.Quote, 0)
+       pcrs := make([]attest.PCR, 0)
+
+       cQuoteRefs := (*[1 << 32]*C.Quote)(unsafe.Pointer(cPlatParms.Quotes))
+       for i := 0; cQuoteRefs[i] != nil; i++ {
+               cQuote := cQuoteRefs[i]
+               quote := attest.Quote{
+                       Version:   attest.TPMVersion(cQuote.Version),
+                       Quote:     cBytesToGoBytes(cQuote.Quote),
+                       Signature: cBytesToGoBytes(cQuote.Signature),
+               }
+               quotes = append(quotes, quote)
+       }
+
+       cPcrRefs := (*[1 << 32]*C.PCR)(unsafe.Pointer(cPlatParms.PCRs))
+       for i := 0; cPcrRefs[i] != nil; i++ {
+               cPCR := cPcrRefs[i]
+               pcr := attest.PCR{
+                       Index:     int(cPCR.Index),
+                       DigestAlg: crypto.Hash(cPCR.DigestAlg),
+                       Digest:    cBytesToGoBytes(cPCR.Digest),
+               }
+               pcrs = append(pcrs, pcr)
+       }
+
+       return attest.PlatformParameters{
+               TPMVersion: attest.TPMVersion(cPlatParms.TPMVersion),
+               Public:     cBytesToGoBytes(cPlatParms.Public),
+               Quotes:     quotes,
+               PCRs:       pcrs,
+               EventLog:   cBytesToGoBytes(cPlatParms.EventLog),
+       }
+}
+
+func cStrCpy(cBytes *C.Bytes, src string) {
+       cBytes.Length = C.size_t(len(src))
+       cBytes.Data = C.CString(src)
+}
+
+func cBytesCopy(cBytes *C.Bytes, src []byte) {
+       srcB64 := base64.StdEncoding.EncodeToString(src)
+       cBytes.Data = C.CString(srcB64)
+       cBytes.Length = C.size_t(len(srcB64))
+}
+
+func cBytesToGoBytes(cb C.Bytes) []byte {
+       s := C.GoStringN(cb.Data, C.int(cb.Length))
+
+       if regexpLooksLikeBase64.MatchString(s) {
+               b, err := base64.StdEncoding.DecodeString(s)
+               if err != nil {
+                       return []byte{}
+               }
+               return b
+       }
+
+       return []byte(s)
+}
+
+func init() {
+       regexpLooksLikeBase64 = regexp.MustCompile(RE_LOOKS_LIKE_BASE64)
+}
+
+func main() {
+       panic("Don't call this directly")
+}
diff --git a/attestation/client/Makefile b/attestation/client/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/attestation/client/attest.log b/attestation/client/attest.log
new file mode 100644 (file)
index 0000000..9c445b3
--- /dev/null
@@ -0,0 +1,2 @@
+getting a new access token, because: access token has expired
+fqWDVE7jfW3f7IqUsx9TgUC3lsAMFngn0JM1Em2SsSQ=
diff --git a/attestation/client/main.go b/attestation/client/main.go
new file mode 100644 (file)
index 0000000..18306b1
--- /dev/null
@@ -0,0 +1,19 @@
+package main
+
+import (
+       "flag"
+       "fmt"
+
+       "go.fuhry.dev/runtime/attestation/internal/attestation"
+)
+
+func main() {
+       flag.Parse()
+
+       nonce, err := attestation.AttestHost()
+       if err != nil {
+               panic(err)
+       }
+
+       fmt.Println(nonce)
+}
diff --git a/attestation/internal/attestation/api.go b/attestation/internal/attestation/api.go
new file mode 100644 (file)
index 0000000..6b4d0de
--- /dev/null
@@ -0,0 +1,33 @@
+package attestation
+
+import (
+       "github.com/google/go-attestation/attest"
+)
+
+type GetActivationChallenge_Request struct {
+       TPMVersion            uint8                        `json:"tpm_version"`
+       AttestationParameters attest.AttestationParameters `json:"attestation_parameters"`
+       EndorsementKey        string                       `json:"endorsement_key"`
+}
+
+type GetActivationChallenge_Response struct {
+       Secret          []byte `json:"-"`
+       EncryptedSecret []byte `json:"EncryptedSecret"`
+       Credential      []byte `json:"Credential"`
+}
+
+type AttestPlatform_Request struct {
+       DecryptedSecret    []byte                    `json:"nonce"`
+       PlatformParameters attest.PlatformParameters `json:"platform_parameters"`
+}
+
+type AttestPlatform_Response struct {
+       Nonce []byte `json:"Nonce"`
+}
+
+func (r *GetActivationChallenge_Response) EC() attest.EncryptedCredential {
+       return attest.EncryptedCredential{
+               Credential: r.Credential,
+               Secret:     r.EncryptedSecret,
+       }
+}
diff --git a/attestation/internal/attestation/client.go b/attestation/internal/attestation/client.go
new file mode 100644 (file)
index 0000000..7196d61
--- /dev/null
@@ -0,0 +1,195 @@
+package attestation
+
+import (
+       "context"
+       "crypto/ecdsa"
+       "crypto/rsa"
+       "crypto/x509"
+       "encoding/base64"
+       "encoding/pem"
+       "fmt"
+
+       "github.com/google/go-attestation/attest"
+       "go.fuhry.dev/runtime/machines"
+       attest_pb "go.fuhry.dev/runtime/proto/service/attest"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+func AttestHost() (string, error) {
+       machines, err := machines.NewDefaultMachinesClient("host.attestation.client")
+       if err != nil {
+               return "", err
+       }
+
+       // Open the TPM
+       tpm, err := attest.OpenTPM(nil)
+       if err != nil {
+               return "", err
+       }
+       defer tpm.Close()
+
+       // Create EK
+       eks, err := tpm.EKs()
+       if err != nil {
+               return "", err
+       }
+       ek := eks[0]
+
+       // Create AK
+       ak, err := tpm.NewAK(nil)
+       if err != nil {
+               return "", err
+       }
+
+       defer ak.Close(tpm)
+
+       // Marshal EK to bytes, assemble JSON request
+       ekBytes, err := marshalEK(ek)
+       if err != nil {
+               return "", err
+       }
+
+       activationParams := GetActivationChallenge_Request{
+               TPMVersion:            uint8(tpm.Version()),
+               AttestationParameters: ak.AttestationParameters(),
+               EndorsementKey:        string(ekBytes),
+       }
+
+       // Get activation parameters
+       activationChallenge := &GetActivationChallenge_Response{}
+       err = machines.APICall("host/attest/get_activation_challenge", activationParams, activationChallenge)
+       // activationChallenge, err := GetActivationChallenge(&activationParams)
+       if err != nil {
+               return "", fmt.Errorf("error calling rpc get_activation_challenge: %v", err)
+       }
+
+       decryptedSecret, err := ak.ActivateCredential(tpm, activationChallenge.EC())
+       if err != nil {
+               return "", fmt.Errorf("error decrypting the secret: %v", err)
+       }
+
+       attestation, err := tpm.AttestPlatform(ak, decryptedSecret, nil)
+       if err != nil {
+               return "", err
+       }
+
+       attestationParams := &AttestPlatform_Request{
+               DecryptedSecret:    decryptedSecret,
+               PlatformParameters: *attestation,
+       }
+       attestationResponse := &AttestPlatform_Response{}
+       err = machines.APICall("host/attest/submit_quote", attestationParams, attestationResponse)
+       // attestationResponse, err := AttestPlatform(attestationParams)
+       if err != nil {
+               return "", err
+       }
+
+       return base64.StdEncoding.EncodeToString(attestationResponse.Nonce), nil
+}
+
+func AttestHostGrpc(ctx context.Context, attestCl attest_pb.AttestClient) (string, error) {
+       logger := log.WithPrefix("AttestHostGrpc")
+       logger.V(2).Infof("Opening TPM")
+       // Open the TPM
+       tpm, err := attest.OpenTPM(nil)
+       if err != nil {
+               return "", err
+       }
+       defer tpm.Close()
+
+       logger.V(2).Infof("Collecting endorsement key")
+       // Create EK
+       eks, err := tpm.EKs()
+       if err != nil {
+               return "", err
+       }
+       ek := eks[0]
+
+       logger.V(2).Infof("Creating attestation key")
+       // Create AK
+       ak, err := tpm.NewAK(nil)
+       if err != nil {
+               return "", err
+       }
+
+       defer ak.Close(tpm)
+
+       // Marshal EK to bytes, assemble JSON request
+       ekBytes, err := marshalEK(ek)
+       if err != nil {
+               return "", err
+       }
+
+       logger.V(2).Infof("Marshaled EK: %s", base64.StdEncoding.EncodeToString(ekBytes))
+       activationParams := &attest_pb.GetActivationChallengeRequest{
+               TpmVersion:            uint32(tpm.Version()),
+               AttestationParameters: attest_pb.AttestationParametersToProto(ak.AttestationParameters()),
+               EndorsementKey:        ekBytes,
+       }
+
+       // Get activation parameters
+       activationChallenge, err := attestCl.GetActivationChallenge(ctx, activationParams)
+       if err != nil {
+               return "", fmt.Errorf("error calling rpc get_activation_challenge: %v", err)
+       }
+       logger.V(2).Infof("Received encrypted activation challenge from server: %s", base64.StdEncoding.EncodeToString(activationChallenge.EncryptedSecret))
+
+       decryptedSecret, err := ak.ActivateCredential(tpm, activationChallenge.EC())
+       if err != nil {
+               return "", fmt.Errorf("error decrypting the secret: %v", err)
+       }
+       logger.V(2).Infof("Decrypted activation challenge to: %s", base64.StdEncoding.EncodeToString(decryptedSecret))
+
+       attestation, err := tpm.AttestPlatform(ak, decryptedSecret, nil)
+       if err != nil {
+               return "", err
+       }
+
+       logger.V(2).Infof("Quote: %+v", attestation.Quotes)
+       logger.V(2).Infof("PCRs: %+v", attestation.PCRs)
+       pp, err := attest_pb.PlatformParametersToProto(attestation)
+       if err != nil {
+               return "", err
+       }
+
+       attestationParams := &attest_pb.AttestPlatformRequest{
+               Nonce:              decryptedSecret,
+               PlatformParameters: pp,
+       }
+       attestationResponse, err := attestCl.AttestPlatform(ctx, attestationParams)
+       if err != nil {
+               return "", err
+       }
+       logger.V(2).Noticef("Attestation successful! Nonce: %s", base64.RawStdEncoding.EncodeToString(attestationResponse.Nonce))
+
+       return base64.StdEncoding.EncodeToString(attestationResponse.Nonce), nil
+}
+
+func marshalEK(ek attest.EK) ([]byte, error) {
+       if ek.Certificate != nil {
+               return pem.EncodeToMemory(&pem.Block{
+                       Type:  "CERTIFICATE",
+                       Bytes: ek.Certificate.Raw,
+               }), nil
+       }
+
+       switch pub := ek.Public.(type) {
+       case *ecdsa.PublicKey:
+               data, err := x509.MarshalPKIXPublicKey(pub)
+               if err != nil {
+                       return nil, fmt.Errorf("marshaling ec public key: %v", err)
+               }
+               return pem.EncodeToMemory(&pem.Block{
+                       Type:  "EC PUBLIC KEY",
+                       Bytes: data,
+               }), nil
+
+       case *rsa.PublicKey:
+               return pem.EncodeToMemory(&pem.Block{
+                       Type:  "RSA PUBLIC KEY",
+                       Bytes: x509.MarshalPKCS1PublicKey(pub),
+               }), nil
+       default:
+               return nil, fmt.Errorf("unsupported public key type %T", pub)
+       }
+}
diff --git a/attestation/internal/attestation/rpc_server.go b/attestation/internal/attestation/rpc_server.go
new file mode 100644 (file)
index 0000000..62e6344
--- /dev/null
@@ -0,0 +1,206 @@
+package attestation
+
+import (
+       "context"
+       "crypto/subtle"
+       "encoding/json"
+       "fmt"
+       "net/url"
+       "time"
+
+       "google.golang.org/grpc/peer"
+
+       "go.fuhry.dev/runtime/grpc"
+       attest_pb "go.fuhry.dev/runtime/proto/service/attest"
+       "google.golang.org/protobuf/encoding/protojson"
+       "google.golang.org/protobuf/reflect/protoreflect"
+)
+
+type sessionKey int
+
+const (
+       kExpectedSecret sessionKey = iota
+       kAttestTimestamp
+       kEndorsementKey
+       kAttestationKey
+       kHostGUID
+)
+
+type AttestServer struct {
+       attest_pb.UnimplementedAttestServer
+}
+
+type rpcClientInfo struct {
+       SPIFFEID *url.URL
+}
+
+type rpcStoreQuoteParams struct {
+       EK string         `json:"endorsement_key"`
+       PP map[string]any `json:"platform_parameters"`
+}
+
+func NewAttestationServer() attest_pb.AttestServer {
+       return &AttestServer{}
+}
+
+func (s *AttestServer) GetActivationChallenge(ctx context.Context, req *attest_pb.GetActivationChallengeRequest) (*attest_pb.GetActivationChallengeResponse, error) {
+       peer, ok := peer.FromContext(ctx)
+       if !ok {
+               return nil, fmt.Errorf("provided context did not contain peer anything")
+       }
+
+       spiffe, err := grpc.PeerIdentity(peer)
+       if err != nil {
+               return nil, err
+       }
+
+       rci := &rpcClientInfo{
+               SPIFFEID: spiffe,
+       }
+       ek, err := unmarshalEK(string(req.EndorsementKey))
+       if err != nil {
+               return nil, err
+       }
+       hostGUID, err := verifyEK(ek, rci)
+       if err != nil {
+               return nil, fmt.Errorf("could not verify endorsement key with Machines API: %v", err)
+       }
+
+       reqNative := &GetActivationChallenge_Request{
+               TPMVersion:            uint8(req.TpmVersion),
+               AttestationParameters: attest_pb.AttestationParametersFromProto(req.AttestationParameters),
+               EndorsementKey:        string(req.EndorsementKey),
+       }
+
+       respNative, err := GetActivationChallenge(reqNative)
+       if err != nil {
+               return nil, err
+       }
+
+       session := grpc.SessionFromContext(ctx)
+       if session == nil {
+               return nil, fmt.Errorf("could not init session from connection context")
+       }
+       session.Set(kExpectedSecret, respNative.Secret)
+       session.Set(kAttestTimestamp, time.Now())
+       session.Set(kEndorsementKey, string(req.GetEndorsementKey()))
+       session.Set(kAttestationKey, req.AttestationParameters.Public)
+       session.Set(kHostGUID, hostGUID)
+
+       reply := &attest_pb.GetActivationChallengeResponse{
+               EncryptedSecret: respNative.EncryptedSecret,
+               Credential:      respNative.Credential,
+       }
+
+       return reply, nil
+}
+
+func (s *AttestServer) AttestPlatform(ctx context.Context, req *attest_pb.AttestPlatformRequest) (*attest_pb.AttestPlatformResponse, error) {
+       peer, ok := peer.FromContext(ctx)
+       if !ok {
+               return nil, fmt.Errorf("provided context did not contain peer anything")
+       }
+
+       spiffe, err := grpc.PeerIdentity(peer)
+       if err != nil {
+               return nil, err
+       }
+
+       rci := &rpcClientInfo{
+               SPIFFEID: spiffe,
+       }
+
+       session := grpc.SessionFromContext(ctx)
+       if session == nil {
+               return nil, fmt.Errorf("could not init session from connection context")
+       }
+
+       secret, ok := session.Get(kExpectedSecret)
+       if !ok {
+               return nil, fmt.Errorf("activation challenge was not yet requested during this session")
+       }
+       if string(secret.([]uint8)) != string(req.Nonce) {
+               return nil, fmt.Errorf("activation challenge response does not match the expected value")
+       }
+       ak, ok := session.Get(kAttestationKey)
+       if !ok {
+               return nil, fmt.Errorf("could not get attestation key from session")
+       }
+       if subtle.ConstantTimeCompare(ak.([]byte), req.PlatformParameters.Public) == 0 {
+               return nil, fmt.Errorf("attestation key that signed quote is different from the attestation key on file for the session")
+       }
+       timestamp, ok := session.Get(kAttestTimestamp)
+       if !ok {
+               return nil, fmt.Errorf("could not get timestamp of activation challenge")
+       }
+       if timestamp.(time.Time).Add(5 * time.Second).Before(time.Now()) {
+               return nil, fmt.Errorf("client took too long to attest")
+       }
+       hostGUID, ok := session.Get(kHostGUID)
+       if !ok {
+               return nil, fmt.Errorf("could not get host guid")
+       }
+
+       pp, err := attest_pb.PlatformParametersFromProto(req.PlatformParameters)
+       if err != nil {
+               return nil, err
+       }
+
+       ek, ok := session.Get(kEndorsementKey)
+       if !ok {
+               return nil, fmt.Errorf("endorsement key not in session")
+       }
+
+       storeParams := &rpcStoreQuoteParams{
+               EK: ek.(string),
+               PP: mustProtoMarshal(req.PlatformParameters),
+       }
+
+       err = storeQuote(rci, hostGUID.(string), storeParams)
+       if err != nil {
+               return nil, err
+       }
+
+       reqNative := &AttestPlatform_Request{
+               DecryptedSecret:    req.Nonce,
+               PlatformParameters: *pp,
+       }
+
+       respNative, err := AttestPlatform(reqNative)
+       if err != nil {
+               return nil, err
+       }
+
+       reply := &attest_pb.AttestPlatformResponse{
+               Nonce: respNative.Nonce,
+       }
+
+       return reply, nil
+}
+
+func mustProtoMarshalSlice[T protoreflect.ProtoMessage](messages []T) []map[string]any {
+       results := make([]map[string]any, 0)
+       for _, msg := range messages {
+               results = append(results, mustProtoMarshal(msg))
+       }
+
+       return results
+}
+
+func mustProtoMarshal(msg protoreflect.ProtoMessage) map[string]any {
+       marshaler := &protojson.MarshalOptions{
+               EmitUnpopulated: true,
+               UseEnumNumbers:  true,
+       }
+
+       marshaled, err := marshaler.Marshal(msg)
+       if err != nil {
+               panic(err)
+       }
+       unmarshaled := make(map[string]any, 0)
+       err = json.Unmarshal(marshaled, &unmarshaled)
+       if err != nil {
+               panic(err)
+       }
+       return unmarshaled
+}
diff --git a/attestation/internal/attestation/server.go b/attestation/internal/attestation/server.go
new file mode 100644 (file)
index 0000000..2fcd2f5
--- /dev/null
@@ -0,0 +1,223 @@
+package attestation
+
+import (
+       "crypto/dsa"
+       "crypto/ecdsa"
+       "crypto/ed25519"
+       "crypto/rand"
+       "crypto/rsa"
+       "crypto/sha256"
+       "crypto/subtle"
+       "encoding/hex"
+       "encoding/pem"
+       "errors"
+       "fmt"
+       "strings"
+
+       "github.com/google/certificate-transparency-go/x509"
+       "github.com/google/go-attestation/attest"
+       "go.fuhry.dev/runtime/machines"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+var logger *log.Logger
+
+func init() {
+       logger = log.WithPrefix("AttestationServer")
+}
+
+func GetActivationChallenge(req *GetActivationChallenge_Request) (*GetActivationChallenge_Response, error) {
+       ek, err := unmarshalEK(req.EndorsementKey)
+       if err != nil {
+               return nil, fmt.Errorf("failed to unmarshal endorsement key: %v", err)
+       }
+
+       ap := attest.ActivationParameters{
+               TPMVersion: attest.TPMVersion(req.TPMVersion),
+               EK:         ek.Public,
+               AK:         req.AttestationParameters,
+       }
+
+       secret, encryptedChallenge, err := ap.Generate()
+       if err != nil {
+               return nil, fmt.Errorf("failed to generate activation challenge: %v", err)
+       }
+
+       return &GetActivationChallenge_Response{
+               Secret:          secret,
+               EncryptedSecret: encryptedChallenge.Secret,
+               Credential:      encryptedChallenge.Credential,
+       }, nil
+}
+
+func AttestPlatform(req *AttestPlatform_Request) (*AttestPlatform_Response, error) {
+       errs := make([]error, 0)
+       errStr := ""
+
+       pub, err := attest.ParseAKPublic(req.PlatformParameters.TPMVersion, req.PlatformParameters.Public)
+       if err != nil {
+               return nil, err
+       }
+
+       for i, quote := range req.PlatformParameters.Quotes {
+               if err = pub.Verify(quote, req.PlatformParameters.PCRs, req.DecryptedSecret); err != nil {
+                       errStr += fmt.Sprintf("\n  %v", err)
+                       errs = append(errs, fmt.Errorf("failed to verify quote %d: %v", i, err))
+               }
+       }
+       if len(errs) > 0 {
+               return nil, fmt.Errorf("failed to verify signature on %d of %d quotes: %s",
+                       len(errs),
+                       len(req.PlatformParameters.Quotes),
+                       errStr)
+       }
+
+       nonce := make([]byte, 32)
+       _, err = rand.Reader.Read(nonce)
+       if err != nil {
+               return nil, err
+       }
+       return &AttestPlatform_Response{
+               Nonce: nonce,
+       }, nil
+}
+
+func unmarshalEK(ekStr string) (*attest.EK, error) {
+       pemBlock, _ := pem.Decode([]byte(ekStr))
+
+       if pemBlock == nil {
+               return nil, errors.New("failed to decode PEM block containing endorsement key")
+       }
+
+       switch pemBlock.Type {
+       case "CERTIFICATE":
+               cert, err := x509.ParseCertificate(pemBlock.Bytes)
+               if err != nil {
+                       return nil, err
+               }
+               return &attest.EK{
+                       Public:         cert.PublicKey,
+                       Certificate:    cert,
+                       CertificateURL: "",
+               }, nil
+       case "RSA PUBLIC KEY":
+               publicKey, err := x509.ParsePKCS1PublicKey(pemBlock.Bytes)
+               if err != nil {
+                       return nil, err
+               }
+
+               return &attest.EK{
+                       Public:         publicKey,
+                       Certificate:    nil,
+                       CertificateURL: "",
+               }, nil
+       case "EC PUBLIC KEY", "PUBLIC KEY":
+               publicKey, err := x509.ParsePKIXPublicKey(pemBlock.Bytes)
+               if err != nil {
+                       return nil, err
+               }
+
+               return &attest.EK{
+                       Public:         publicKey,
+                       Certificate:    nil,
+                       CertificateURL: "",
+               }, nil
+       }
+
+       return nil, fmt.Errorf("fnable to unmarshal EK with PEM block type: %s", pemBlock.Type)
+}
+
+func verifyEK(ek *attest.EK, clientInfo *rpcClientInfo) (string, error) {
+       client, err := machines.NewDefaultMachinesClient("read", "host.read.notowned", "host.attestation.server")
+       if err != nil {
+               logger.Error("failed to get machines client:", err)
+               return "", fmt.Errorf("failed to get machines client: %v", err)
+       }
+       logger.V(1).Infof("looking up host %q in Machines API", clientInfo.SPIFFEID.Hostname())
+       hostname := strings.Split(clientInfo.SPIFFEID.Hostname(), ".")
+       if len(hostname) < 2 {
+               return "", fmt.Errorf("failed to split hostname in url")
+       }
+       udn := hostname[0]
+       machinesHost := &machines.Host{}
+
+       err = client.APICall("/host/"+udn, nil, machinesHost)
+       if err != nil {
+               logger.Errorf("failed to lookup host %q with machines API: %+v", udn, err)
+               return "", err
+       }
+       if machinesHost.ID == "" {
+               logger.Errorf("failed to lookup host %q with machines API: API call returned OK, but host ID is empty", udn, err)
+               return "", fmt.Errorf("cannot get host UUID from Machines API")
+       }
+       logger.V(1).Infof("host %q has machines UUID %s", udn, machinesHost.ID)
+
+       machinesEK := &machines.EndorsementKey{}
+       err = client.APICall("/host/"+machinesHost.ID+"/endorsement_key", nil, machinesEK)
+       if err != nil {
+               logger.Errorf("failed to retrieve endorsement key for host %q from machines API: %+v", udn, err)
+               return "", err
+       }
+
+       if !machinesEK.Found {
+               // logger.Errorf("host %q does not have an endorsement key on file with Machines", udn)
+               // return "", fmt.Errorf("host %q does not have an endorsement key on file with Machines", udn)
+               // if the host doesn't have an endorsement key on file, we TOFU.
+               return machinesHost.ID, nil
+       }
+
+       expectFingerprint := machinesEK.EndorsementKey.Fingerprint.SHA256.AsBytes()
+       hash := sha256.New()
+       if ek.Certificate != nil {
+               logger.V(1).Infof("expected fingerprint for host %q EK certificate: %s", udn, machinesEK.EndorsementKey.Fingerprint.SHA256)
+               hash.Write(ek.Certificate.Raw)
+       } else {
+               switch pubKey := ek.Public.(type) {
+               case *rsa.PublicKey:
+                       logger.V(1).Infof("expected fingerprint for host %q EK RSA public key: %s", udn, machinesEK.EndorsementKey.Fingerprint.SHA256)
+                       pubKeyDER := x509.MarshalPKCS1PublicKey(pubKey)
+                       hash.Write(pubKeyDER)
+               case *dsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey:
+                       logger.V(1).Infof("expected fingerprint for host %q EK PKIX (%T) public key: %s", udn, pubKey, machinesEK.EndorsementKey.Fingerprint.SHA256)
+                       pubKeyDER, err := x509.MarshalPKIXPublicKey(pubKey)
+                       if err != nil {
+                               return "", err
+                       }
+                       hash.Write(pubKeyDER)
+               default:
+                       return "", fmt.Errorf("unsupported type for public key: %T", pubKey)
+               }
+       }
+
+       actualFingerprint := hash.Sum(nil)
+       logger.V(1).Infof("    actual fingerprint: %s", hex.EncodeToString(actualFingerprint))
+       result := subtle.ConstantTimeCompare(actualFingerprint, expectFingerprint) == 1
+
+       if !result {
+               logger.Noticef("host %q: fingerprint mismatch, rejecting EK", udn)
+               return "", fmt.Errorf("endorsement key or certificate doesn't match the expected fingerprint")
+       }
+
+       logger.Noticef("host %q: fingerprint matched, proceeding with attestation", udn)
+       return machinesHost.ID, nil
+}
+
+func storeQuote(rci *rpcClientInfo, hostGUID string, quote *rpcStoreQuoteParams) error {
+       client, err := machines.NewDefaultMachinesClient("read", "host.read.notowned", "host.attestation.server")
+       if err != nil {
+               logger.Error("failed to get machines client:", err)
+               return fmt.Errorf("failed to get machines client: %v", err)
+       }
+
+       logger.V(1).Infof("posting attestation results to Machines API")
+       route := fmt.Sprintf("/host/%s/tpm_quote", hostGUID)
+       resp := make(map[string]any, 0)
+       err = client.APICall(route, quote, &resp)
+       if err != nil {
+               logger.Error("failed to POST %s: %v:", route, err)
+               return fmt.Errorf("failed to POST %s: %v", route, err)
+       }
+       logger.V(1).Debugf("response: %+v", resp)
+
+       return nil
+}
diff --git a/attestation/php.ini.in b/attestation/php.ini.in
new file mode 100644 (file)
index 0000000..3494c76
--- /dev/null
@@ -0,0 +1 @@
+extension=@PWD@/c/modules/tpm_attestation.so
diff --git a/attestation/php/composer.json b/attestation/php/composer.json
new file mode 100644 (file)
index 0000000..d7a46de
--- /dev/null
@@ -0,0 +1,20 @@
+{
+    "name": "fuhry/attestation-tests",
+    "description": "Unit tests for tpm-attestation PHP extension",
+    "type": "project",
+    "require": {
+        "phpunit/phpunit": "^9.5"
+    },
+    "license": "MIT",
+    "autoload": {
+        "psr-4": {
+            "TPMAttestation\\": "src/"
+        }
+    },
+    "authors": [
+        {
+            "name": "Dan Fuhry",
+            "email": "dan@fuhry.com"
+        }
+    ]
+}
diff --git a/attestation/php/composer.lock b/attestation/php/composer.lock
new file mode 100644 (file)
index 0000000..537380f
--- /dev/null
@@ -0,0 +1,1749 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "b2246d07a6a2beb6fe595cc1a1a498b1",
+    "packages": [
+        {
+            "name": "doctrine/instantiator",
+            "version": "1.4.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/instantiator.git",
+                "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/10dcfce151b967d20fde1b34ae6640712c3891bc",
+                "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^9",
+                "ext-pdo": "*",
+                "ext-phar": "*",
+                "phpbench/phpbench": "^0.16 || ^1",
+                "phpstan/phpstan": "^1.4",
+                "phpstan/phpstan-phpunit": "^1",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
+                "vimeo/psalm": "^4.22"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Marco Pivetta",
+                    "email": "ocramius@gmail.com",
+                    "homepage": "https://ocramius.github.io/"
+                }
+            ],
+            "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+            "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+            "keywords": [
+                "constructor",
+                "instantiate"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/instantiator/issues",
+                "source": "https://github.com/doctrine/instantiator/tree/1.4.1"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-03-03T08:28:38+00:00"
+        },
+        {
+            "name": "myclabs/deep-copy",
+            "version": "1.11.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/myclabs/DeepCopy.git",
+                "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/14daed4296fae74d9e3201d2c4925d1acb7aa614",
+                "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "conflict": {
+                "doctrine/collections": "<1.6.8",
+                "doctrine/common": "<2.13.3 || >=3,<3.2.2"
+            },
+            "require-dev": {
+                "doctrine/collections": "^1.6.8",
+                "doctrine/common": "^2.13.3 || ^3.2.2",
+                "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/DeepCopy/deep_copy.php"
+                ],
+                "psr-4": {
+                    "DeepCopy\\": "src/DeepCopy/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Create deep copies (clones) of your objects",
+            "keywords": [
+                "clone",
+                "copy",
+                "duplicate",
+                "object",
+                "object graph"
+            ],
+            "support": {
+                "issues": "https://github.com/myclabs/DeepCopy/issues",
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.11.0"
+            },
+            "funding": [
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-03-03T13:19:32+00:00"
+        },
+        {
+            "name": "nikic/php-parser",
+            "version": "v4.15.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nikic/PHP-Parser.git",
+                "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/0ef6c55a3f47f89d7a374e6f835197a0b5fcf900",
+                "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900",
+                "shasum": ""
+            },
+            "require": {
+                "ext-tokenizer": "*",
+                "php": ">=7.0"
+            },
+            "require-dev": {
+                "ircmaxell/php-yacc": "^0.0.7",
+                "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0"
+            },
+            "bin": [
+                "bin/php-parse"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.9-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpParser\\": "lib/PhpParser"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Nikita Popov"
+                }
+            ],
+            "description": "A PHP parser written in PHP",
+            "keywords": [
+                "parser",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/nikic/PHP-Parser/issues",
+                "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.1"
+            },
+            "time": "2022-09-04T07:30:47+00:00"
+        },
+        {
+            "name": "phar-io/manifest",
+            "version": "2.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/manifest.git",
+                "reference": "97803eca37d319dfa7826cc2437fc020857acb53"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53",
+                "reference": "97803eca37d319dfa7826cc2437fc020857acb53",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-phar": "*",
+                "ext-xmlwriter": "*",
+                "phar-io/version": "^3.0.1",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+            "support": {
+                "issues": "https://github.com/phar-io/manifest/issues",
+                "source": "https://github.com/phar-io/manifest/tree/2.0.3"
+            },
+            "time": "2021-07-20T11:28:43+00:00"
+        },
+        {
+            "name": "phar-io/version",
+            "version": "3.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/version.git",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Library for handling version information and constraints",
+            "support": {
+                "issues": "https://github.com/phar-io/version/issues",
+                "source": "https://github.com/phar-io/version/tree/3.2.1"
+            },
+            "time": "2022-02-21T01:04:05+00:00"
+        },
+        {
+            "name": "phpunit/php-code-coverage",
+            "version": "9.2.17",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+                "reference": "aa94dc41e8661fe90c7316849907cba3007b10d8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/aa94dc41e8661fe90c7316849907cba3007b10d8",
+                "reference": "aa94dc41e8661fe90c7316849907cba3007b10d8",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-xmlwriter": "*",
+                "nikic/php-parser": "^4.14",
+                "php": ">=7.3",
+                "phpunit/php-file-iterator": "^3.0.3",
+                "phpunit/php-text-template": "^2.0.2",
+                "sebastian/code-unit-reverse-lookup": "^2.0.2",
+                "sebastian/complexity": "^2.0",
+                "sebastian/environment": "^5.1.2",
+                "sebastian/lines-of-code": "^1.0.3",
+                "sebastian/version": "^3.0.1",
+                "theseer/tokenizer": "^1.2.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-pcov": "*",
+                "ext-xdebug": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "9.2-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+            "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+            "keywords": [
+                "coverage",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.17"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-08-30T12:24:04+00:00"
+        },
+        {
+            "name": "phpunit/php-file-iterator",
+            "version": "3.0.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+                "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+                "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+            "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+            "keywords": [
+                "filesystem",
+                "iterator"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-12-02T12:48:52+00:00"
+        },
+        {
+            "name": "phpunit/php-invoker",
+            "version": "3.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-invoker.git",
+                "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+                "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "ext-pcntl": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-pcntl": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Invoke callables with a timeout",
+            "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+            "keywords": [
+                "process"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+                "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:58:55+00:00"
+        },
+        {
+            "name": "phpunit/php-text-template",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-text-template.git",
+                "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+                "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Simple template engine.",
+            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+            "keywords": [
+                "template"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+                "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T05:33:50+00:00"
+        },
+        {
+            "name": "phpunit/php-timer",
+            "version": "5.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-timer.git",
+                "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+                "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Utility class for timing",
+            "homepage": "https://github.com/sebastianbergmann/php-timer/",
+            "keywords": [
+                "timer"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+                "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:16:10+00:00"
+        },
+        {
+            "name": "phpunit/phpunit",
+            "version": "9.5.25",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/phpunit.git",
+                "reference": "3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d",
+                "reference": "3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/instantiator": "^1.3.1",
+                "ext-dom": "*",
+                "ext-json": "*",
+                "ext-libxml": "*",
+                "ext-mbstring": "*",
+                "ext-xml": "*",
+                "ext-xmlwriter": "*",
+                "myclabs/deep-copy": "^1.10.1",
+                "phar-io/manifest": "^2.0.3",
+                "phar-io/version": "^3.0.2",
+                "php": ">=7.3",
+                "phpunit/php-code-coverage": "^9.2.13",
+                "phpunit/php-file-iterator": "^3.0.5",
+                "phpunit/php-invoker": "^3.1.1",
+                "phpunit/php-text-template": "^2.0.3",
+                "phpunit/php-timer": "^5.0.2",
+                "sebastian/cli-parser": "^1.0.1",
+                "sebastian/code-unit": "^1.0.6",
+                "sebastian/comparator": "^4.0.8",
+                "sebastian/diff": "^4.0.3",
+                "sebastian/environment": "^5.1.3",
+                "sebastian/exporter": "^4.0.5",
+                "sebastian/global-state": "^5.0.1",
+                "sebastian/object-enumerator": "^4.0.3",
+                "sebastian/resource-operations": "^3.0.3",
+                "sebastian/type": "^3.2",
+                "sebastian/version": "^3.0.2"
+            },
+            "suggest": {
+                "ext-soap": "*",
+                "ext-xdebug": "*"
+            },
+            "bin": [
+                "phpunit"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "9.5-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/Framework/Assert/Functions.php"
+                ],
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "The PHP Unit Testing framework.",
+            "homepage": "https://phpunit.de/",
+            "keywords": [
+                "phpunit",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.25"
+            },
+            "funding": [
+                {
+                    "url": "https://phpunit.de/sponsors.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-09-25T03:44:45+00:00"
+        },
+        {
+            "name": "sebastian/cli-parser",
+            "version": "1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/cli-parser.git",
+                "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+                "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for parsing CLI options",
+            "homepage": "https://github.com/sebastianbergmann/cli-parser",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+                "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:08:49+00:00"
+        },
+        {
+            "name": "sebastian/code-unit",
+            "version": "1.0.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit.git",
+                "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+                "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/code-unit",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:08:54+00:00"
+        },
+        {
+            "name": "sebastian/code-unit-reverse-lookup",
+            "version": "2.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+                "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+                "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Looks up which function or method a line of code belongs to",
+            "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:30:19+00:00"
+        },
+        {
+            "name": "sebastian/comparator",
+            "version": "4.0.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/comparator.git",
+                "reference": "fa0f136dd2334583309d32b62544682ee972b51a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a",
+                "reference": "fa0f136dd2334583309d32b62544682ee972b51a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/diff": "^4.0",
+                "sebastian/exporter": "^4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@2bepublished.at"
+                }
+            ],
+            "description": "Provides the functionality to compare PHP values for equality",
+            "homepage": "https://github.com/sebastianbergmann/comparator",
+            "keywords": [
+                "comparator",
+                "compare",
+                "equality"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/comparator/issues",
+                "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-09-14T12:41:17+00:00"
+        },
+        {
+            "name": "sebastian/complexity",
+            "version": "2.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/complexity.git",
+                "reference": "739b35e53379900cc9ac327b2147867b8b6efd88"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88",
+                "reference": "739b35e53379900cc9ac327b2147867b8b6efd88",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.7",
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for calculating the complexity of PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/complexity",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/complexity/issues",
+                "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T15:52:27+00:00"
+        },
+        {
+            "name": "sebastian/diff",
+            "version": "4.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/diff.git",
+                "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d",
+                "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3",
+                "symfony/process": "^4.2 || ^5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Kore Nordmann",
+                    "email": "mail@kore-nordmann.de"
+                }
+            ],
+            "description": "Diff implementation",
+            "homepage": "https://github.com/sebastianbergmann/diff",
+            "keywords": [
+                "diff",
+                "udiff",
+                "unidiff",
+                "unified diff"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/diff/issues",
+                "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:10:38+00:00"
+        },
+        {
+            "name": "sebastian/environment",
+            "version": "5.1.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/environment.git",
+                "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7",
+                "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-posix": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides functionality to handle HHVM/PHP environments",
+            "homepage": "http://www.github.com/sebastianbergmann/environment",
+            "keywords": [
+                "Xdebug",
+                "environment",
+                "hhvm"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/environment/issues",
+                "source": "https://github.com/sebastianbergmann/environment/tree/5.1.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-04-03T09:37:03+00:00"
+        },
+        {
+            "name": "sebastian/exporter",
+            "version": "4.0.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/exporter.git",
+                "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
+                "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "ext-mbstring": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
+                }
+            ],
+            "description": "Provides the functionality to export PHP variables for visualization",
+            "homepage": "https://www.github.com/sebastianbergmann/exporter",
+            "keywords": [
+                "export",
+                "exporter"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/exporter/issues",
+                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-09-14T06:03:37+00:00"
+        },
+        {
+            "name": "sebastian/global-state",
+            "version": "5.0.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/global-state.git",
+                "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2",
+                "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/object-reflector": "^2.0",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "ext-dom": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-uopz": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Snapshotting of global state",
+            "homepage": "http://www.github.com/sebastianbergmann/global-state",
+            "keywords": [
+                "global state"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/global-state/issues",
+                "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-02-14T08:28:10+00:00"
+        },
+        {
+            "name": "sebastian/lines-of-code",
+            "version": "1.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+                "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc",
+                "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.6",
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for counting the lines of code in PHP source code",
+            "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+                "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-11-28T06:42:11+00:00"
+        },
+        {
+            "name": "sebastian/object-enumerator",
+            "version": "4.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+                "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+                "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/object-reflector": "^2.0",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+            "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+                "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:12:34+00:00"
+        },
+        {
+            "name": "sebastian/object-reflector",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-reflector.git",
+                "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+                "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Allows reflection of object attributes, including inherited and non-public ones",
+            "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+                "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:14:26+00:00"
+        },
+        {
+            "name": "sebastian/recursion-context",
+            "version": "4.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/recursion-context.git",
+                "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172",
+                "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                }
+            ],
+            "description": "Provides functionality to recursively process PHP variables",
+            "homepage": "http://www.github.com/sebastianbergmann/recursion-context",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:17:30+00:00"
+        },
+        {
+            "name": "sebastian/resource-operations",
+            "version": "3.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/resource-operations.git",
+                "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
+                "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides a list of PHP built-in functions that operate on resources",
+            "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/resource-operations/issues",
+                "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:45:17+00:00"
+        },
+        {
+            "name": "sebastian/type",
+            "version": "3.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/type.git",
+                "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e",
+                "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.2-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the types of the PHP type system",
+            "homepage": "https://github.com/sebastianbergmann/type",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/type/issues",
+                "source": "https://github.com/sebastianbergmann/type/tree/3.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-09-12T14:47:03+00:00"
+        },
+        {
+            "name": "sebastian/version",
+            "version": "3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/version.git",
+                "reference": "c6c1022351a901512170118436c764e473f6de8c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+                "reference": "c6c1022351a901512170118436c764e473f6de8c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+            "homepage": "https://github.com/sebastianbergmann/version",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/version/issues",
+                "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:39:44+00:00"
+        },
+        {
+            "name": "theseer/tokenizer",
+            "version": "1.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/theseer/tokenizer.git",
+                "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e",
+                "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlwriter": "*",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+            "support": {
+                "issues": "https://github.com/theseer/tokenizer/issues",
+                "source": "https://github.com/theseer/tokenizer/tree/1.2.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-07-28T10:34:58+00:00"
+        }
+    ],
+    "packages-dev": [],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": [],
+    "prefer-stable": false,
+    "prefer-lowest": false,
+    "platform": [],
+    "platform-dev": [],
+    "plugin-api-version": "2.2.0"
+}
diff --git a/attestation/php/phpunit.xml b/attestation/php/phpunit.xml
new file mode 100644 (file)
index 0000000..9b62e56
--- /dev/null
@@ -0,0 +1,8 @@
+<?xml version="1.0" ?>
+<phpunit bootstrap="vendor/autoload.php" colors="true">
+       <testsuites>
+               <testsuite name="Base application tests">
+                       <directory>./tests</directory>
+               </testsuite>
+       </testsuites>
+</phpunit>
diff --git a/attestation/php/src/ActivationChallenge.php b/attestation/php/src/ActivationChallenge.php
new file mode 100644 (file)
index 0000000..72023b1
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+declare(strict_types=1);
+
+namespace TPMAttestation;
+
+/**
+ * Stub for the class provided by the extension.
+ */
+class ActivationChallenge
+{
+    private readonly string $Secret;
+    private readonly string $EncryptedSecret;
+    private readonly string $Credential;
+
+    public function getSecret(): string
+    {
+        throw new \RuntimeException("tpm_attestation PHP extension is not loaded");
+
+        return $this->Secret;
+    }
+
+    public function toJson(): array
+    {
+        throw new \RuntimeException("tpm_attestation PHP extension is not loaded");
+
+        return [
+            'EncryptedSecret' => $this->EncryptedSecret,
+            'Credential' => $this->Credential,
+        ];
+    }
+}
diff --git a/attestation/php/src/AttestationParameters.php b/attestation/php/src/AttestationParameters.php
new file mode 100644 (file)
index 0000000..e234626
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+declare(strict_types=1);
+
+namespace TPMAttestation;
+
+/**
+ * Stub for the class provided by the extension.
+ */
+class AttestationParameters
+{
+    private readonly string $Public;
+    private readonly bool $UseTCSDActivationFormat;
+    private readonly string $CreateData;
+    private readonly string $CreateAttestation;
+    private readonly string $CreateSignature;
+
+    public function __construct(string $Public, bool $UseTCSDActivationFormat, string $CreateData, string $CreateAttestation, string $CreateSignature)
+    {
+        throw new \RuntimeException("tpm_attestation PHP extension is not loaded");
+
+        $this->Public = $Public;
+        $this->UseTCSDActivationFormat = $UseTCSDActivationFormat;
+        $this->CreateData = $CreateData;
+        $this->CreateAttestation = $CreateAttestation;
+        $this->CreateSignature = $CreateSignature;
+    }
+
+    public static function fromJson(array $input): AttestationParameters
+    {
+        throw new \RuntimeException("tpm_attestation PHP extension is not loaded");
+
+        assert(
+            'array_key_exists("Public", $input) && is_string($input["Public"])',
+            new \InvalidArgumentException('Input argument "Public" must be a string.'),
+        );
+        assert(
+            'array_key_exists("UseTCSDActivationFormat", $input) && is_bool($input["UseTCSDActivationFormat"])',
+            new \InvalidArgumentException('Input argument "UseTCSDActivationFormat" must be a bool.'),
+        );
+        assert(
+            'array_key_exists("CreateData", $input) && is_string($input["CreateData"])',
+            new \InvalidArgumentException('Input argument "CreateData" must be a string.'),
+        );
+        assert(
+            'array_key_exists("CreateAttestation", $input) && is_string($input["CreateAttestation"])',
+            new \InvalidArgumentException('Input argument "CreateAttestation" must be a string.'),
+        );
+        assert(
+            'array_key_exists("CreateSignature", $input) && is_string($input["CreateSignature"])',
+            new \InvalidArgumentException('Input argument "CreateSignature" must be a string.'),
+        );
+
+        return new static(
+            $input['Public'],
+            $input['UseTCSDActivationFormat'],
+            $input['CreateData'],
+            $input['CreateAttestation'],
+            $input['CreateSignature'],
+        );
+    }
+
+    public function GetActivationChallenge(int $TPMVersion, string $EndorsementKey): ActivationChallenge
+    {
+        throw new \RuntimeException("tpm_attestation PHP extension is not loaded");
+    }
+}
diff --git a/attestation/php/src/PlatformParameters.php b/attestation/php/src/PlatformParameters.php
new file mode 100644 (file)
index 0000000..d21d06e
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+declare(strict_types=1);
+
+namespace TPMAttestation;
+
+/**
+ * Stub for the class provided by the extension.
+ */
+class PlatformParameters
+{
+    private readonly int $TPMVersion;
+    private readonly string $Public;
+    private readonly array $Quotes;
+    private readonly array $PCRs;
+    private readonly string $Event;
+
+    public static function fromJson(array $input): PlatformParameters
+    {
+        throw new \RuntimeException("tpm_attestation PHP extension is not loaded");
+    }
+
+    public function AttestPlatform(string $nonce): string
+    {
+        throw new \RuntimeException("tpm_attestation PHP extension is not loaded");
+    }
+}
diff --git a/attestation/php/tests/ActivationChallengeTest.php b/attestation/php/tests/ActivationChallengeTest.php
new file mode 100644 (file)
index 0000000..0b5d4d9
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+declare(strict_types=1);
+
+namespace Test\TPMAttestation;
+
+use PHPUnit\Framework\TestCase;
+use TPMAttestation;
+
+class ActivationChallengeTest extends TestCase
+{
+    private const TEST_ATTESTATION_PARAMETERS = [
+        "Public" => "AAEACwAFAHIAAAAQABQACwgAAAAAAAEAvF9ozm7YHprZ7Kn07RmkGY4j0y2Tp8DcFoRHy/hRYgn71J7ekpeNURzzRqOW8FC32kkI8YM8Spar6Kuoha/WIeV8WpsjQWakPWvGsgklQ6n+bLNw21Wtsr32FWDoeSrVtYqZdxhl4sjvhsNyCyZCx42+M50PSK7wacblmIWL77jWBxKXOH9wSXGJUhMdwcGaxR3ssSq9LoMTpB4xCkeGaCZlDFOp2EIwqYQ+8ynY6nib52zk6YsdQhYDEoKufzB1NHY9tSbs4BzLnPXCuOoUc1x/j59YoWp6P07qqjgtVGaBZeJgSwTZDsGVIbuF0GnmuMaqBzww8NLrkdrP+foJmw==",
+        "UseTCSDActivationFormat" => false,
+        "CreateData" => "AAAAAAAg47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFUBAAsAIgALvz8Av3L699ylBtsyYu0leFCYir+27LSTjHaZy7tD0cMAIgALPl5I4xDjvW0rzuEHrMzXniTMm7xEyjcUVzdOni5TvqoAAA==",
+        "CreateAttestation" => "/1RDR4AaACIACzUBlsmEH4dLGdPbwG2gRgY+lTpBZFbUaB0T0vHJ1hB/AAAAAAACAH30yCC1fl866T6QAXVTLCCw87SUACIAC6z9ARzEd7TT+RyehMhlm9iCXPv5tydzKpm/Pj4beMbgACDvzHq6AIWsfFBAnCSOuez5miCsGSPKuD+nRU/L41sgBQ==",
+        "CreateSignature" => "ABQACwEAl2+9kjGvoyY9Ex0veWcaZnx5DyrYFenx1+4H1PRc38CD+mUy1WALdnsxaOa9Isua+8v9xmzGsA8TVUzPp//vmT9QIQyakzPDoEh/zWwgvG39OEWZI8PSy4Ury2m2X1f4G+hwJnjcKAkfN4/D8VtZvAqo5smZ2TB/AdbETbcnPCb3V+o74WUWkoNVawBffUHPnrEDTFQiDmm5ZYSHbFWn13tY/wLaraOMI9oQuyQNsnjzA4JuLlaofjF8yMBIka2p8Di6Kk3WqrhwXt9tr0Ud3LFm+xer66edJ5TrtH2Qqu1VL20galvTjIisIdPXnT6Y7tx7/Pn25gEXflPAFNa1Mg=="
+    ];
+
+    private const TEST_ENDORSEMENT_KEY = "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAmXMGD+hiG/OP7D7+DxKRlN1Y2xtb2OZ+i0RrVio0pYfmGieCS0Fh\ngi8G4ZEiOZfqs7dZS4Xcr7Ab2AtEPM3GctyQU1z8g9wdmW9SENWKcfyoicuZD84x\nxCKwV/QWfBmTnNTCrByS3WZ26XRcnSJg81bLOLENKI+grpjP2fi9Eihpx9lcyloH\nwJR8Hf2/9q/NdgFta6s8L9v0B8zT/ucsrOrpCenRfJpR3fntq8j0d2K68wgkkX+I\ntDWwimce4spEzgf5OKBM29UpaLM+artKDLO8/mO8g9C7FlremUkjQgrW1VBL+AeL\n/rEEMOKTxTDQqTqFXVMZ0wdjodEIFSBnhQIDAQAB\n-----END RSA PUBLIC KEY-----\n";
+
+    public function testAttestationParameters_Construct()
+    {
+        $ap = new TPMAttestation\AttestationParameters(
+            base64_decode(self::TEST_ATTESTATION_PARAMETERS['Public']),
+            self::TEST_ATTESTATION_PARAMETERS['UseTCSDActivationFormat'],
+            base64_decode(self::TEST_ATTESTATION_PARAMETERS['CreateData']),
+            base64_decode(self::TEST_ATTESTATION_PARAMETERS['CreateAttestation']),
+            base64_decode(self::TEST_ATTESTATION_PARAMETERS['CreateSignature']),
+        );
+
+        $this->assertTrue(true, "Ensure no crashes/errors constructing AttestationParameters");
+    }
+
+    public function testAttestationParameters_FromJson()
+    {
+        $ap = TPMAttestation\AttestationParameters::fromJson(self::TEST_ATTESTATION_PARAMETERS);
+
+        $this->assertTrue(true, "Ensure no crashes/errors calling AttestationParameters::fromJson");
+    }
+
+    public function testAttestationParameters_GetActivationChallenge()
+    {
+        $ap = TPMAttestation\AttestationParameters::fromJson(self::TEST_ATTESTATION_PARAMETERS);
+
+        $challenge = $ap->GetActivationChallenge(
+            TPMVersion: 2,
+            EndorsementKey: self::TEST_ENDORSEMENT_KEY,
+        );
+
+        $this->assertInstanceOf(
+            'TPMAttestation\\ActivationChallenge',
+            $challenge,
+            "Ensure GetActivationChallenge returns an ActivationChallenge",
+        );
+
+        $this->assertIsString(
+            $challenge->getSecret(),
+            "Ensure [ActivationChallenge]->getSecret() returns a string",
+        );
+
+        $this->assertEquals(
+            32,
+            strlen(base64_decode($challenge->getSecret())),
+            "Ensure [ActivationChallenge]->getSecret() returns a base64 string that decodes to 32 bytes",
+        );
+
+        $challengeJson = $challenge->toJson();
+
+        $this->assertIsArray(
+            $challengeJson,
+            "Ensure [ActivationChallenge]->toJson() returns an array",
+        );
+
+        $this->assertArrayHasKey(
+            "EncryptedSecret",
+            $challengeJson,
+            "Ensure the array returned from [ActivationChallenge]->toJson() contains the key \"EncryptedSecret\"",
+        );
+        $this->assertArrayHasKey(
+            "Credential",
+            $challengeJson,
+            "Ensure the array returned from [ActivationChallenge]->toJson() contains the key \"Credential\"",
+        );
+
+        $this->assertNotEmpty(
+            $challengeJson['EncryptedSecret'],
+            "Ensure the \"EncryptedSecret\" key in the array returned from [ActivationChallenge]->toJson() is not an empty value",
+        );
+
+        $this->assertNotEmpty(
+            $challengeJson['Credential'],
+            "Ensure the \"Credential\" key in the array returned from [ActivationChallenge]->toJson() is not an empty value",
+        );
+    }
+}
diff --git a/attestation/php/tests/AttestPlatformTest.php b/attestation/php/tests/AttestPlatformTest.php
new file mode 100644 (file)
index 0000000..d6db938
--- /dev/null
@@ -0,0 +1,174 @@
+<?php
+declare(strict_types=1);
+
+namespace Test\TPMAttestation;
+
+use PHPUnit\Framework\TestCase;
+use TPMAttestation;
+
+class AttestPlatformTest extends TestCase
+{
+    public function testFromJson()
+    {
+        $pp = TPMAttestation\PlatformParameters::fromJson(self::TEST_PLATFORM_PARAMETERS);
+
+        $this->assertTrue(
+            true,
+            "Ensure PlatformParameters::fromJson() does not crash",
+        );
+    }
+
+    public function testAttestPlatform()
+    {
+        $pp = TPMAttestation\PlatformParameters::fromJson(self::TEST_PLATFORM_PARAMETERS);
+
+        $response = $pp->AttestPlatform(self::TEST_NONCE);
+
+        $this->assertTrue(
+            true,
+            "Ensure [PlatformParameters]->AttestPlatform(nonce) does not crash",
+        );
+
+        $this->assertIsString(
+            $response,
+            "Ensure [PlatformParameters]->AttestPlatform(nonce) returns a string",
+        );
+    }
+
+    private const TEST_NONCE = "nUBAaMJTv3qezSAwU0eaLz17+qbZHJQ7RpSj3BH+UuY=";
+
+    private const TEST_PLATFORM_PARAMETERS = [
+        "TPMVersion" => 2,
+        "Public" => "AAEACwAFAHIAAAAQABQACwgAAAAAAAEAvF9ozm7YHprZ7Kn07RmkGY4j0y2Tp8DcFoRHy/hRYgn71J7ekpeNURzzRqOW8FC32kkI8YM8Spar6Kuoha/WIeV8WpsjQWakPWvGsgklQ6n+bLNw21Wtsr32FWDoeSrVtYqZdxhl4sjvhsNyCyZCx42+M50PSK7wacblmIWL77jWBxKXOH9wSXGJUhMdwcGaxR3ssSq9LoMTpB4xCkeGaCZlDFOp2EIwqYQ+8ynY6nib52zk6YsdQhYDEoKufzB1NHY9tSbs4BzLnPXCuOoUc1x/j59YoWp6P07qqjgtVGaBZeJgSwTZDsGVIbuF0GnmuMaqBzww8NLrkdrP+foJmw==",
+        "Quotes" => [
+            [
+                "Version" => 2,
+                "Quote" => "/1RDR4AYACIACzUBlsmEH4dLGdPbwG2gRgY+lTpBZFbUaB0T0vHJ1hB/ACCdQEBowlO/ep7NIDBTR5ovPXv6ptkclDtGlKPcEf5S5gAAAAIAfgeDILV+XzrpPpABdVMsILDztJQAAAABAAsD////ACChyQuEISoiKHhZgA1qkbz10Pd4zWxtFpgW7cqlhAt5tA==",
+                "Signature" => "ABQACwEABv2XOyTPOT47AAgNnQf/c0MYmuKg8XUpPvu3bs/RkAJVIzVRJzTp7SZjY2MRgtzhDB4FFulkc14wxffBAAnpscRPST9lt08U+CuaOOJ2h+i8dBZopjGp47uO5xpIAjde8YDxwaM06vBeq7d2JbBmw7yDotpIOr9PwUJTNjFrcO+W2wRWRbDxhrNNIK2sarw150RlJaXqooO2wMaJ6RLEXtdextAw2sw3mE5wo3G/ZWF3Jsnxjmvc2UImyCamfygZq2FsvJe3NIf2LH7RNMdJ1DqS4IkcAQczhP6+uygxZbZXaB4qR1/UInN+Zz0iB0IcTBXf6JfF+yamGSlVacAJyQ=="
+            ]
+        ],
+        "PCRs" => [
+            [
+                "Index" => 0,
+                "Digest" => "C1UuJAxpdqe6QDRHdnOiqM6+UPtT6cdj1sz8Gxh72w8=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 1,
+                "Digest" => "crvGVY+Kb4sSQspNeQSZFijDtURU66ohOn1JGqqWRVM=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 2,
+                "Digest" => "h53sTS6fjal84A/XYd+yKBS5Kb/Eac80l526s3HsQS4=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 3,
+                "Digest" => "PUWM/lXMA+ofRD8VYr7sjfUcdeFKn8+acjShPxmOeWk=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 4,
+                "Digest" => "Ugu0qGz8kaEGo4JzPuEeUi6oDvRURX3KzoiLsMF5900=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 5,
+                "Digest" => "dTpuqel+2R+t9ov7H8bozbDtbWhnT0n1D066RrTrMDk=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 6,
+                "Digest" => "PUWM/lXMA+ofRD8VYr7sjfUcdeFKn8+acjShPxmOeWk=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 7,
+                "Digest" => "Zcr43R4Op6Y0e2NdKzeck7mhNR7cKvw+zacA5TTrMGg=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 8,
+                "Digest" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 9,
+                "Digest" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 10,
+                "Digest" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 11,
+                "Digest" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 12,
+                "Digest" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 13,
+                "Digest" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 14,
+                "Digest" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 15,
+                "Digest" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 16,
+                "Digest" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 17,
+                "Digest" => "//////////////////////////////////////////8=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 18,
+                "Digest" => "//////////////////////////////////////////8=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 19,
+                "Digest" => "//////////////////////////////////////////8=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 20,
+                "Digest" => "//////////////////////////////////////////8=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 21,
+                "Digest" => "//////////////////////////////////////////8=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 22,
+                "Digest" => "//////////////////////////////////////////8=",
+                "DigestAlg" => 5
+            ],
+            [
+                "Index" => 23,
+                "Digest" => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+                "DigestAlg" => 5
+            ]
+        ],
+        "EventLog" => "AAAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACEAAABTcGVjIElEIEV2ZW50MDMAAAAAAAACaQIBAAAACwAgAAAAAAAACAAAAAEAAAALANaBg3s7j1/Whb+tBCynjmiE4Q4cjyctvM0v0QvnOKf6EAAAAAUrEKfH2WVBgUAq3elK9jwAAAAACgAAgAEAAAALAMIg63LJISnaQV3Iscvm3BD6CyXI2rbWSCxfjDQlM19kOgAAAClGdihGNjQ5RkMyRC1DMEU2LTQyNjItQUQ1MS0wQ0U2NEY3NjQyOUYpAADw4/8AAAAAABAAAAAAAAAAAAAACgAAgAEAAAALAHrZXcGI+0sKY50tovsY8/dgt9pn05fHyHIYE8CVAK5IGAAAAAdGVl9NQUlOAPCW/wAAAAAAAE0AAAAAAAcAAAABAACAAQAAAAsAEVqoJ9vM+0TSFq2ez9pWvepiC4YKlL7Vt6J7uhxNAtg1AAAAYd/ki8qT0hGqDQDgmAMrjAoAAAAAAAAAAQAAAAAAAABTAGUAYwB1AHIAZQBCAG8AbwB0AAAHAAAAAQAAgAEAAAALAN6nuAq1Oj2qok1cxGxk4fqf/QNzn5Cq29jAhnxKW0iQJAAAAGHf5IvKk9IRqg0A4JgDK4wCAAAAAAAAAAAAAAAAAAAAUABLAAcAAAABAACAAQAAAAsA5nDhIfzr1HO4vEG7gBMB/B2a+jOQTwb3FJt08SxHpo8mAAAAYd/ki8qT0hGqDQDgmAMrjAMAAAAAAAAAAAAAAAAAAABLAEUASwAHAAAAAQAAgAEAAAALALr4mjzKzlJ1DF8BKDUeBCKkFZehrf1QgiqjY7nRJOp8JAAAAMuyGdc6PZZFo7za0A5nZW8CAAAAAAAAAAAAAAAAAAAAZABiAAcAAAABAACAAQAAAAsAn3W2gjv/avECSk4gNnGc3VSNPLwr8d6OfvTQ7QH5S/kmAAAAy7IZ1zo9lkWjvNrQDmdlbwMAAAAAAAAAAAAAAAAAAABkAGIAeAACAAAABAAAgAEAAAALACAiLxQAjKtymiTP3UtYCGXMA4mG8SU44ZujvTllIRfQYAAAABiAJ7gAAAAAQN8DAAAAAAAAAAAAAAAAAEAAAAAAAAAAAgEMANBBAwoAAAAAAQEGAAIBAQEGAAAAAQEGAAACAQEGAAAABAgYAAAAAAA4JAQAAAAAAP+tBQAAAAAAf/8EAAIAAAAEAACAAQAAAAsAICIvFACMq3KaJM/dS1gIZcwDiYbxJTjhm6O9OWUhF9BgAAAAGMAfuAAAAABA3wMAAAAAAAAAAAAAAAAAQAAAAAAAAAACAQwA0EEDCgAAAAABAQYAAgEBAQYAAAABAQYAAAIBAQYAAQAECBgAAAAAADgkBAAAAAAA/60FAAAAAAB//wQAAgAAAAQAAIABAAAACwDROp0WRGEQzyLSkGXul1JhU3Bq6mgnsBQvDgQtcAst5lQAAAAY0Bm4AAAAADAIAgAAAAAAAAAAAAAAAAA0AAAAAAAAAAIBDADQQQMKAAAAAAEBBgABAwEBBgAAAAQIGAAAAAAAUO4AAAAAAAD//QEAAAAAAH//BAABAAAACwAAgAEAAAALAMWz18LdF038SIoFomhwnzcEEvwcIqXPI4PQq4R6ltLOLQAAAAxTbWJpb3NUYWJsZQABAAAAAAAAAEQV/fKUlyxKmS7lu88g45QAYMK9AAAAAAEAAAACAACAAQAAAAsAdXRZK/2SUxsZKg10+iMww421l6ZGC7S3EPxXJqPveA02AAAAYd/ki8qT0hGqDQDgmAMrjAkAAAAAAAAABAAAAAAAAABCAG8AbwB0AE8AcgBkAGUAcgANAAAAAQAAAAIAAIABAAAACwAdDBXeKzqKXtU6u+bDfL7gweCMCM1l7whmz26JvNTg9dgAAABh3+SLypPSEaoNAOCYAyuMCAAAAAAAAACoAAAAAAAAAEIAbwBvAHQAMAAwADAARAABAAAAdABXAGkAbgBkAG8AdwBzACAAQgBvAG8AdAAgAE0AYQBuAGEAZwBlAHIAAAAEASoAAQAAAAAIAAAAAAAAAKAAAAAAAABIg0lyTHYJTo6XtLqK+sX+AgIEBEYAXABFAEYASQBcAE0ASQBDAFIATwBTAE8ARgBUAFwAQgBPAE8AVABcAEIATwBPAFQATQBHAEYAVwAuAEUARgBJAAAAf/8EAAAAQk8BAAAAAgAAgAEAAAALAD1YICWU0ftRzMa4uS/NmYJ+f5YX0fUAj9dGfXVhi1tKXAEAAGHf5IvKk9IRqg0A4JgDK4wIAAAAAAAAACwBAAAAAAAAQgBvAG8AdAAwADAAMAAwAAAAAAB0AFcAaQBuAGQAbwB3AHMAIABCAG8AbwB0ACAATQBhAG4AYQBnAGUAcgAAAAQBKgACAAAAAKAPAAAAAAAAGAMAAAAAAD1ISwTCBEdJkirOelJg2NcCAgQERgBcAEUARgBJAFwATQBJAEMAUgBPAFMATwBGAFQAXABCAE8ATwBUAFwAQgBPAE8AVABNAEcARgBXAC4ARQBGAEkAAAB//wQAV0lORE9XUwABAAAAiAAAAHgAAABCAEMARABPAEIASgBFAEMAVAA9AHsAOQBkAGUAYQA4ADYAMgBjAC0ANQBjAGQAZAAtADQAZQA3ADAALQBhAGMAYwAxAC0AZgAzADIAYgAzADQANABkADQANwA5ADUAfQAAAGUAAQAAABAAAAAEAAAAf/8EAAQAAAAHAACAAQAAAAsAPWdytPhO1HWV1yosTF/9FfW7csdQf+JvKq7ixp1WM7ooAAAAQ2FsbGluZyBFRkkgQXBwbGljYXRpb24gZnJvbSBCb290IE9wdGlvbgAAAAAEAAAAAQAAAAsA3z9hmASpL9tAVxktxD3XSOp3itxSvEmM6AUkwBS4ERkEAAAAAAAAAAEAAAAEAAAAAQAAAAsA3z9hmASpL9tAVxktxD3XSOp3itxSvEmM6AUkwBS4ERkEAAAAAAAAAAIAAAAEAAAAAQAAAAsA3z9hmASpL9tAVxktxD3XSOp3itxSvEmM6AUkwBS4ERkEAAAAAAAAAAMAAAAEAAAAAQAAAAsA3z9hmASpL9tAVxktxD3XSOp3itxSvEmM6AUkwBS4ERkEAAAAAAAAAAQAAAAEAAAAAQAAAAsA3z9hmASpL9tAVxktxD3XSOp3itxSvEmM6AUkwBS4ERkEAAAAAAAAAAUAAAAEAAAAAQAAAAsA3z9hmASpL9tAVxktxD3XSOp3itxSvEmM6AUkwBS4ERkEAAAAAAAAAAYAAAAEAAAAAQAAAAsA3z9hmASpL9tAVxktxD3XSOp3itxSvEmM6AUkwBS4ERkEAAAAAAAAAAcAAAAEAAAAAQAAAAsA3z9hmASpL9tAVxktxD3XSOp3itxSvEmM6AUkwBS4ERkEAAAAAAAAAAUAAAAGAACAAQAAAAsAM2TfBzWh8Z22gN2Jm/i8RZwHjduKFvufTYcOCFrAlE/kAQAARUZJIFBBUlQAAAEAXAAAAGeu+S4AAAAAAQAAAAAAAACvEp47AAAAACIAAAAAAAAAjhKeOwAAAADhFsZynpQRSL0UcTu/+iHoAgAAAAAAAACAAAAAgAAAALdTBqADAAAAAAAAAChzKsEf+NIRuksAoMk+yTtIg0lyTHYJTo6XtLqK+sX+AAgAAAAAAAD/pwAAAAAAAAAAAAAAAAAAZQBmAGkAXwBzAHkAcwB0AGUAbQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAArz3GD4OEckeOeT1p2Ed95Lt3+lC3L+VPkX3G/fcySuYAqAAAAAAAAP+nCAAAAAAAAAAAAAAAAABhAHIAYwBoAF8AYgBvAG8AdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB509bmB/XCRKI8I48qPfkowdSexdzQMUy0raq767VDXQCoCAAAAAAAjhKeOwAAAAAAAAAAAAAAAGEAcgBjAGgAXwBjAHIAeQBwAHQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAADAACAAQAAAAsA3PpbTjjnZJYSu82CxRaFUgmR1Bxw4puy5atpjNE4fmG8AAAAGED5tgAAAAAA4AEAAAAAAAAAAAAAAAAAnAAAAAAAAAACAQwA0EEDCgAAAAABAQYAAQEBAQYAAAADFxAAAQAAAAAlOFOBshmCBAEqAAEAAAAACAAAAAAAAACgAAAAAAAASINJckx2CU6Ol7S6ivrF/gICBARGAFwARQBGAEkAXABNAEkAQwBSAE8AUwBPAEYAVABcAEIATwBPAFQAXABCAE8ATwBUAE0ARwBGAFcALgBFAEYASQAAAH//BAAFAAAABwAAgAEAAAALANgEPWt7ha01jrO2rmqHOrfvI6JjUsXcT6pa7trPXrQbHQAAAEV4aXQgQm9vdCBTZXJ2aWNlcyBJbnZvY2F0aW9uBQAAAAcAAIABAAAACwC1T3VCy9hyqBqdneqDmyuNdHx+vV6mYVxA9C9EptvroCgAAABFeGl0IEJvb3QgU2VydmljZXMgUmV0dXJuZWQgd2l0aCBTdWNjZXNz"
+    ];
+}
diff --git a/attestation/rpc_client/Makefile b/attestation/rpc_client/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/attestation/rpc_client/main.go b/attestation/rpc_client/main.go
new file mode 100644 (file)
index 0000000..aad5985
--- /dev/null
@@ -0,0 +1,42 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "fmt"
+       "os/signal"
+       "syscall"
+
+       "go.fuhry.dev/runtime/attestation/internal/attestation"
+       "go.fuhry.dev/runtime/grpc"
+       "go.fuhry.dev/runtime/mtls"
+       attest_pb "go.fuhry.dev/runtime/proto/service/attest"
+)
+
+func main() {
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       flag.Parse()
+
+       clientId := mtls.DefaultIdentity()
+       serverId := mtls.NewServiceIdentity("attest")
+       client, err := grpc.NewGrpcClient(ctx, serverId, clientId)
+       if err != nil {
+               panic(err)
+       }
+
+       conn, err := client.Conn()
+       if err != nil {
+               panic(err)
+       }
+
+       defer conn.Close()
+       attestCl := attest_pb.NewAttestClient(conn)
+
+       nonce, err := attestation.AttestHostGrpc(ctx, attestCl)
+       if err != nil {
+               panic(err)
+       }
+
+       fmt.Println(nonce)
+}
diff --git a/attestation/rpc_server/Makefile b/attestation/rpc_server/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/attestation/rpc_server/attest_acl.yaml b/attestation/rpc_server/attest_acl.yaml
new file mode 100644 (file)
index 0000000..582eda6
--- /dev/null
@@ -0,0 +1,3 @@
+DEFAULT:
+  - service: attest
+  - user: '*'
diff --git a/attestation/rpc_server/main.go b/attestation/rpc_server/main.go
new file mode 100644 (file)
index 0000000..5945a5a
--- /dev/null
@@ -0,0 +1,43 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "os/signal"
+       "syscall"
+
+       "go.fuhry.dev/runtime/attestation/internal/attestation"
+       "go.fuhry.dev/runtime/grpc"
+       "go.fuhry.dev/runtime/mtls"
+       attest_pb "go.fuhry.dev/runtime/proto/service/attest"
+
+       google_grpc "google.golang.org/grpc"
+)
+
+func main() {
+       var err error
+
+       flag.Parse()
+
+       serverIdentity := mtls.DefaultIdentity()
+       s, err := grpc.NewGrpcServer(serverIdentity)
+       if err != nil {
+               panic(err)
+       }
+
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       err = s.PublishAndServe(ctx, func(s *google_grpc.Server) {
+               attest_pb.RegisterAttestServer(s, attestation.NewAttestationServer())
+       })
+       if err != nil {
+               panic(err)
+       }
+       defer s.Stop()
+
+       <-ctx.Done()
+}
+
+func init() {
+       mtls.SetDefaultIdentity("attest")
+}
diff --git a/constants/constants.go b/constants/constants.go
new file mode 100644 (file)
index 0000000..05d1e7f
--- /dev/null
@@ -0,0 +1,15 @@
+package constants
+
+var (
+       RootDomain        = "fuhry.dev"
+       DefaultRegion     = "hq"
+       DefaultHostDomain = DefaultRegion + "." + RootDomain
+       SDDomain          = "v." + RootDomain
+       WebServicesDomain = RootDomain
+       MachinesHost      = "machines." + WebServicesDomain
+       MachinesMqttTopic = "machines/events"
+
+       RootCAName           = "FooCorp Root"
+       IntCAName            = "FooCorp Intermediate mTLS"
+       DeviceTrustTokenName = "FooCorp Device Trust"
+)
diff --git a/echo/client/Makefile b/echo/client/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/echo/client/main.go b/echo/client/main.go
new file mode 100644 (file)
index 0000000..98063ec
--- /dev/null
@@ -0,0 +1,52 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "os/signal"
+       "syscall"
+
+       "go.fuhry.dev/runtime/grpc"
+       "go.fuhry.dev/runtime/mtls"
+       echo_pb "go.fuhry.dev/runtime/proto/service/echo"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+func main() {
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       flag.Parse()
+       logger := log.Default().WithPrefix("EchoClient")
+
+       clientId := mtls.DefaultIdentity()
+       serverId := mtls.NewServiceIdentity("echo")
+       client, err := grpc.NewGrpcClient(ctx, serverId, clientId)
+       if err != nil {
+               logger.Panic(err)
+       }
+
+       conn, err := client.Conn()
+       if err != nil {
+               logger.Panic(err)
+       }
+
+       defer conn.Close()
+       echoCl := echo_pb.NewEchoClient(conn)
+
+       req := &echo_pb.EchoRequest{Message: "hello world"}
+       logger.Noticef("client sending Echo: %+v", req)
+       result, err := echoCl.Echo(ctx, req)
+       if err != nil {
+               logger.Panic(err)
+       }
+
+       logger.Noticef("server replied: %s", result.Message)
+
+       logger.Infof("sending a GreetRequest")
+       greetResult, err := echoCl.Greet(ctx, &echo_pb.GreetRequest{})
+       if err != nil {
+               logger.Panic(err)
+       }
+
+       logger.Noticef("server replied: %s", greetResult.Message)
+}
diff --git a/echo/server.go b/echo/server.go
new file mode 100644 (file)
index 0000000..bafcf50
--- /dev/null
@@ -0,0 +1,45 @@
+package echo
+
+import (
+       "context"
+       "fmt"
+
+       "golang.org/x/text/cases"
+       "golang.org/x/text/language"
+       "google.golang.org/grpc/peer"
+
+       grpc_lib "go.fuhry.dev/runtime/grpc"
+       echo_pb "go.fuhry.dev/runtime/proto/service/echo"
+)
+
+type EchoServer struct {
+       echo_pb.UnimplementedEchoServer
+}
+
+func NewEchoServer() echo_pb.EchoServer {
+       return &EchoServer{}
+}
+
+func (s *EchoServer) Echo(ctx context.Context, req *echo_pb.EchoRequest) (*echo_pb.EchoReply, error) {
+       reply := &echo_pb.EchoReply{
+               Message: cases.Title(language.English).String(req.Message),
+       }
+
+       return reply, nil
+}
+
+func (s *EchoServer) Greet(ctx context.Context, req *echo_pb.GreetRequest) (*echo_pb.GreetReply, error) {
+       peer, ok := peer.FromContext(ctx)
+       if !ok {
+               return nil, fmt.Errorf("provided context did not contain peer anything")
+       }
+
+       spiffe, err := grpc_lib.PeerIdentity(peer)
+       if err != nil {
+               return nil, err
+       }
+
+       return &echo_pb.GreetReply{
+               Message: fmt.Sprintf("Hello, %s!", spiffe),
+       }, nil
+}
diff --git a/echo/server/Makefile b/echo/server/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/echo/server/echo_acl.yaml b/echo/server/echo_acl.yaml
new file mode 100644 (file)
index 0000000..4be2982
--- /dev/null
@@ -0,0 +1,10 @@
+DEFAULT:
+  - service: echo
+
+fuhry.runtime.service.echo.Echo/Echo:
+  - user: '*'
+  - service: echo 
+
+fuhry.runtime.service.echo.Echo/Greet:
+  - service: '*'
+  - user: '*'
diff --git a/echo/server/main.go b/echo/server/main.go
new file mode 100644 (file)
index 0000000..a4b30fd
--- /dev/null
@@ -0,0 +1,43 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "os/signal"
+       "syscall"
+
+       "go.fuhry.dev/runtime/echo"
+       "go.fuhry.dev/runtime/grpc"
+       "go.fuhry.dev/runtime/mtls"
+       echo_pb "go.fuhry.dev/runtime/proto/service/echo"
+
+       google_grpc "google.golang.org/grpc"
+)
+
+func main() {
+       var err error
+
+       flag.Parse()
+
+       serverIdentity := mtls.DefaultIdentity()
+       s, err := grpc.NewGrpcServer(serverIdentity)
+       if err != nil {
+               panic(err)
+       }
+
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       err = s.PublishAndServe(ctx, func(s *google_grpc.Server) {
+               echo_pb.RegisterEchoServer(s, echo.NewEchoServer())
+       })
+       if err != nil {
+               panic(err)
+       }
+       defer s.Stop()
+
+       <-ctx.Done()
+}
+
+func init() {
+       mtls.SetDefaultIdentity("echo")
+}
diff --git a/go.mod b/go.mod
new file mode 100644 (file)
index 0000000..46e2c5c
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,108 @@
+module go.fuhry.dev/runtime
+
+go 1.19
+
+require (
+       github.com/google/certificate-transparency-go v1.1.4
+       github.com/google/go-attestation v0.4.3
+       github.com/google/go-tpm v0.3.3 // indirect
+       github.com/google/go-tspi v0.2.1-0.20190423175329-115dea689aad // indirect
+       golang.org/x/crypto v0.14.0
+       golang.org/x/sys v0.13.0 // indirect
+)
+
+require (
+       github.com/ThalesIgnite/crypto11 v1.2.5
+       github.com/distribution/distribution/v3 v3.0.0-20230621170613-87b280718d38
+       github.com/eclipse/paho.mqtt.golang v1.4.2
+       github.com/go-kit/log v0.2.1
+       github.com/godbus/dbus/v5 v5.1.0
+       github.com/gorilla/websocket v1.5.0
+       github.com/hashicorp/golang-lru v0.5.4
+       github.com/keybase/go-keychain v0.0.0-20230523030712-b5615109f100
+       github.com/mdlayher/apcupsd v0.0.0-20230802135538-48f5030bcd58
+       github.com/miekg/dns v1.1.50
+       github.com/prometheus/client_golang v1.14.0
+       github.com/prometheus/exporter-toolkit v0.8.1
+       github.com/quic-go/quic-go v0.39.0
+       github.com/sirupsen/logrus v1.9.0
+       github.com/urfave/cli/v2 v2.23.5
+       go.etcd.io/etcd/client/v3 v3.5.5
+       go.fuhry.dev/fsnotify v1.7.2
+       go.fuhry.dev/grpc-quic v0.1.2
+       golang.org/x/exp v0.0.0-20221205204356-47842c84f3db
+       golang.org/x/sync v0.3.0
+       gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
+       gopkg.in/yaml.v3 v3.0.1
+       howett.net/plist v1.0.0
+)
+
+require (
+       github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect
+       github.com/BurntSushi/toml v1.2.1 // indirect
+       github.com/beorn7/perks v1.0.1 // indirect
+       github.com/cespare/xxhash/v2 v2.2.0 // indirect
+       github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
+       github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba // indirect
+       github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
+       github.com/docker/go-metrics v0.0.1 // indirect
+       github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 // indirect
+       github.com/felixge/httpsnoop v1.0.1 // indirect
+       github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
+       github.com/go-logfmt/logfmt v0.5.1 // indirect
+       github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
+       github.com/gomodule/redigo v1.8.2 // indirect
+       github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
+       github.com/gorilla/handlers v1.5.1 // indirect
+       github.com/gorilla/mux v1.8.0 // indirect
+       github.com/jpillora/backoff v1.0.0 // indirect
+       github.com/klauspost/compress v1.16.5 // indirect
+       github.com/kr/pretty v0.3.1 // indirect
+       github.com/kr/text v0.2.0 // indirect
+       github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
+       github.com/miekg/pkcs11 v1.1.1 // indirect
+       github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
+       github.com/onsi/ginkgo/v2 v2.9.5 // indirect
+       github.com/opencontainers/go-digest v1.0.0 // indirect
+       github.com/opencontainers/image-spec v1.0.2 // indirect
+       github.com/pkg/errors v0.9.1 // indirect
+       github.com/prometheus/client_model v0.5.0 // indirect
+       github.com/prometheus/common v0.37.0 // indirect
+       github.com/prometheus/procfs v0.8.0 // indirect
+       github.com/quic-go/qtls-go1-20 v0.3.4 // indirect
+       github.com/rogpeppe/go-internal v1.9.0 // indirect
+       github.com/russross/blackfriday/v2 v2.1.0 // indirect
+       github.com/stretchr/testify v1.8.4 // indirect
+       github.com/thales-e-security/pool v0.0.2 // indirect
+       github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
+       go.uber.org/mock v0.3.0 // indirect
+       golang.org/x/mod v0.11.0 // indirect
+       golang.org/x/oauth2 v0.10.0 // indirect
+       golang.org/x/term v0.13.0 // indirect
+       golang.org/x/tools v0.10.0 // indirect
+       google.golang.org/appengine v1.6.7 // indirect
+       google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 // indirect
+       google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect
+       gopkg.in/yaml.v2 v2.4.0 // indirect
+)
+
+require (
+       github.com/coreos/go-semver v0.3.0 // indirect
+       github.com/coreos/go-systemd/v22 v22.4.0 // indirect
+       github.com/go-ldap/ldap/v3 v3.4.4
+       github.com/gogo/protobuf v1.3.2 // indirect
+       github.com/golang/protobuf v1.5.3 // indirect
+       github.com/hashicorp/golang-lru/v2 v2.0.2
+       go.etcd.io/etcd/api/v3 v3.5.5 // indirect
+       go.etcd.io/etcd/client/pkg/v3 v3.5.5
+       go.uber.org/atomic v1.9.0
+       go.uber.org/multierr v1.8.0 // indirect
+       go.uber.org/zap v1.21.0 // indirect
+       golang.org/x/net v0.17.0 // indirect
+       golang.org/x/text v0.13.0
+       google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 // indirect
+       google.golang.org/grpc v1.58.3
+       google.golang.org/protobuf v1.31.0
+)
+
+replace github.com/keybase/go-keychain => github.com/fuhry/go-keychain v0.0.0-20231123062916-83673b79739d
diff --git a/go.sum b/go.sum
new file mode 100644 (file)
index 0000000..c156bf5
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,998 @@
+bitbucket.org/creachadair/shell v0.0.6/go.mod h1:8Qqi/cYk7vPnsOePHroKXDJYmb5x7ENhtiFtfZq8K+M=
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
+cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
+cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
+cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
+cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
+cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
+cloud.google.com/go v0.60.0/go.mod h1:yw2G51M9IfRboUH61Us8GqCeF1PzPblB823Mn2q2eAU=
+cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
+cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
+cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
+cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
+cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
+cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
+cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
+cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
+cloud.google.com/go/pubsub v1.5.0/go.mod h1:ZEwJccE3z93Z2HWvstpri00jOg7oO4UZDtKhwDwqF0w=
+cloud.google.com/go/spanner v1.7.0/go.mod h1:sd3K2gZ9Fd0vMPLXzeCrF6fq4i63Q7aTLW/lBIfBkIk=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
+cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
+cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+contrib.go.opencensus.io/exporter/stackdriver v0.13.4/go.mod h1:aXENhDJ1Y4lIg4EUaVTwzvYETVNZk10Pu26tevFKLUc=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8 h1:d+pBUmsteW5tM87xmVXHZ4+LibHRFn40SPAoZJOg2ak=
+github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU=
+github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
+github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
+github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
+github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
+github.com/Masterminds/sprig v2.15.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
+github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
+github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/ThalesIgnite/crypto11 v1.2.5 h1:1IiIIEqYmBvUYFeMnHqRft4bwf/O36jryEUpY+9ef8E=
+github.com/ThalesIgnite/crypto11 v1.2.5/go.mod h1:ILDKtnCKiQ7zRoNxcp36Y1ZR8LBPmR2E23+wTQe/MlE=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
+github.com/antihax/optional v0.0.0-20180407024304-ca021399b1a6/go.mod h1:V8iCPQYkqmusNa815XgQio277wI47sdRh1dUOLdyC6Q=
+github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ=
+github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
+github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
+github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
+github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
+github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
+github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
+github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20190620071333-e64a0ec8b42a/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/coreos/go-systemd/v22 v22.4.0 h1:y9YHcjnjynCd/DVbg5j9L/33jQM3MxJlbj/zWskzfGU=
+github.com/coreos/go-systemd/v22 v22.4.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
+github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/cyphar/filepath-securejoin v0.2.3 h1:YX6ebbZCZP7VkM3scTTokDgBL2TY741X51MTk3ycuNI=
+github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba h1:p6poVbjHDkKa+wtC8frBMwQtT3BmqGYBjzMwJ63tuR4=
+github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/distribution/distribution/v3 v3.0.0-20230621170613-87b280718d38 h1:zasJGKkPeS7071ifIgt0OVr7pShqedu5tRiAat8sWQg=
+github.com/distribution/distribution/v3 v3.0.0-20230621170613-87b280718d38/go.mod h1:+fqBJ4vPYo4Uu1ZE4d+bUtTLRXfdSL3NvCZIZ9GHv58=
+github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8=
+github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
+github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
+github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
+github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 h1:ZClxb8laGDf5arXfYcAtECDFgAgHklGI8CxgjHnXKJ4=
+github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
+github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/eclipse/paho.mqtt.golang v1.4.2 h1:66wOzfUHSSI1zamx7jR6yMEI5EuHnT1G6rNA5PM12m4=
+github.com/eclipse/paho.mqtt.golang v1.4.2/go.mod h1:JGt0RsEwEX+Xa/agj90YJ9d9DH2b7upDZMK9HRbFvCA=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
+github.com/envoyproxy/protoc-gen-validate v0.0.14/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
+github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fuhry/go-keychain v0.0.0-20231123062916-83673b79739d h1:2Ow4x25aoCCeuc77bpZKsQtgnVEr2A2qqg26VtjDgD0=
+github.com/fuhry/go-keychain v0.0.0-20231123062916-83673b79739d/go.mod h1:phb4Vcwy5vWTWputEGnbcsrrSNviOcQBj6Yz9PQGLwc=
+github.com/fullstorydev/grpcurl v1.6.0/go.mod h1:ZQ+ayqbKMJNhzLmbpCiurTVlaK2M/3nqZCxaQ2Ze/sM=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
+github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
+github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
+github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
+github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
+github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs=
+github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI=
+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
+github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
+github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
+github.com/go-redis/redis v6.15.8+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
+github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
+github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
+github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
+github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k=
+github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg=
+github.com/google/certificate-transparency-go v1.1.1/go.mod h1:FDKqPvSXawb2ecErVRrD+nfy23RCzyl7eqVCEmlT1Zs=
+github.com/google/certificate-transparency-go v1.1.4 h1:hCyXHDbtqlr/lMXU0D4WgbalXL0Zk4dSWWMbPV8VrqY=
+github.com/google/certificate-transparency-go v1.1.4/go.mod h1:D6lvbfwckhNrbM9WVl1EVeMOyzC19mpIjMOI4nxBHtQ=
+github.com/google/go-attestation v0.3.2/go.mod h1:N0ADdnY0cr7eLJyZ75o8kofGGTUF2XrZTJuTPo5acwk=
+github.com/google/go-attestation v0.4.3 h1:hHhPfym1TZm88L7sWmdc/moikHt80ls6mEiU+QvhRvk=
+github.com/google/go-attestation v0.4.3/go.mod h1:7L6MpeaeEmJVJHpr/5cCrOE0SjNA2aFLfJF1Og0AJS8=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-tpm v0.1.2-0.20190725015402-ae6dd98980d4/go.mod h1:H9HbmUG2YgV/PHITkO7p6wxEEj/v5nlsVWIwumwH2NI=
+github.com/google/go-tpm v0.3.0/go.mod h1:iVLWvrPp/bHeEkxTFi9WG6K9w0iy2yIszHwZGHPbzAw=
+github.com/google/go-tpm v0.3.2/go.mod h1:j71sMBTfp3X5jPHz852ZOfQMUOf65Gb/Th8pRmp7fvg=
+github.com/google/go-tpm v0.3.3 h1:P/ZFNBZYXRxc+z7i5uyd8VP7MaDteuLZInzrH2idRGo=
+github.com/google/go-tpm v0.3.3/go.mod h1:9Hyn3rgnzWF9XBWVk6ml6A6hNkbWjNFlDQL51BeghL4=
+github.com/google/go-tpm-tools v0.0.0-20190906225433-1614c142f845/go.mod h1:AVfHadzbdzHo54inR2x1v640jdi1YSi3NauM2DUsxk0=
+github.com/google/go-tpm-tools v0.2.0/go.mod h1:npUd03rQ60lxN7tzeBJreG38RvWwme2N1reF/eeiBk4=
+github.com/google/go-tpm-tools v0.2.1/go.mod h1:npUd03rQ60lxN7tzeBJreG38RvWwme2N1reF/eeiBk4=
+github.com/google/go-tpm-tools v0.3.1 h1:AFlmenDrIe0WU5AvpbfGFOLprTJTg/fCwmTyFdDEjbM=
+github.com/google/go-tpm-tools v0.3.1/go.mod h1:PSg+r5hSZI5tP3X7LBQx2sW1VSZUqZHBSrKyDqrB21U=
+github.com/google/go-tspi v0.2.1-0.20190423175329-115dea689aad h1:LnpS22S8V1HqbxjveESGAazHhi6BX9SwI2Rij7qZcXQ=
+github.com/google/go-tspi v0.2.1-0.20190423175329-115dea689aad/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200507031123-427632fa3b1c/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
+github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/trillian v1.3.11/go.mod h1:0tPraVHrSDkA3BO6vKX67zgLXs6SsOAbHEivX+9mPgw=
+github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/gordonklaus/ineffassign v0.0.0-20200309095847-7953dde2c7bf/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
+github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
+github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
+github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
+github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
+github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/grpc-ecosystem/grpc-gateway v1.12.1/go.mod h1:8XEsbTttt/W+VvjtQhLACqCisSPWTxCZ7sBRjU6iH9c=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
+github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
+github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
+github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
+github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
+github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
+github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
+github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hashicorp/golang-lru/v2 v2.0.2 h1:Dwmkdr5Nc/oBiXgJS3CDHNhJtIHkuZ3DZF5twqnfBdU=
+github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
+github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
+github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
+github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
+github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
+github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/imdario/mergo v0.3.4/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/jhump/protoreflect v1.6.1/go.mod h1:RZQ/lnuN+zqeRVpQigTwO6o0AJUkxbnSnpuG7toUTG4=
+github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
+github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/jonboulle/clockwork v0.2.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
+github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
+github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/juju/ratelimit v1.0.1/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
+github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
+github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/letsencrypt/pkcs11key/v4 v4.0.0/go.mod h1:EFUvBDay26dErnNb70Nd0/VW3tJiIbETBPTl9ATXQag=
+github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
+github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/mdlayher/apcupsd v0.0.0-20230802135538-48f5030bcd58 h1:e1AuZg7Lk0WSy8OiFaoLV+gXIUH1+Bg3tcYLLQJCZBs=
+github.com/mdlayher/apcupsd v0.0.0-20230802135538-48f5030bcd58/go.mod h1:ngUsvRNfxdlJb0cHAlP6xDmCDJGJhXPKAH0ExDdDAU0=
+github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
+github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
+github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
+github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
+github.com/miekg/pkcs11 v1.0.3-0.20190429190417-a667d056470f/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
+github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
+github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
+github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
+github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
+github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
+github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
+github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
+github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
+github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/mwitkow/go-proto-validators v0.0.0-20180403085117-0950a7990007/go.mod h1:m2XC9Qq0AlmmVksL6FktJCdTYyLk7V3fKyp0sl1yWQo=
+github.com/mwitkow/go-proto-validators v0.2.0/go.mod h1:ZfA1hW+UH/2ZHOWvQ3HnQaU0DtnpXu850MZiy+YUgcc=
+github.com/nishanths/predeclared v0.0.0-20190419143655-18a43bb90ffc/go.mod h1:62PewwiQTlm/7Rj+cxVYqZvDIUc+JjZq6GHAC1fsObQ=
+github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
+github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
+github.com/olekukonko/tablewriter v0.0.2/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
+github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
+github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
+github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
+github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
+github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
+github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
+github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
+github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
+github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
+github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
+github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
+github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
+github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
+github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
+github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
+github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
+github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
+github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
+github.com/prometheus/exporter-toolkit v0.8.1 h1:TpKt8z55q1zF30BYaZKqh+bODY0WtByHDOhDA2M9pEs=
+github.com/prometheus/exporter-toolkit v0.8.1/go.mod h1:00shzmJL7KxcsabLWcONwpyNEuWhREOnFqZW7vadFS0=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
+github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
+github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
+github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
+github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
+github.com/pseudomuto/protoc-gen-doc v1.3.2/go.mod h1:y5+P6n3iGrbKG+9O04V5ld71in3v/bX88wUwgt+U8EA=
+github.com/pseudomuto/protokit v0.2.0/go.mod h1:2PdH30hxVHsup8KpBTOXTBeMVhJZVio3Q8ViKSAXT0Q=
+github.com/quic-go/qtls-go1-20 v0.3.4 h1:MfFAPULvst4yoMgY9QmtpYmfij/em7O8UUi+bNVm7Cg=
+github.com/quic-go/qtls-go1-20 v0.3.4/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
+github.com/quic-go/quic-go v0.39.0 h1:AgP40iThFMY0bj8jGxROhw3S0FMGa8ryqsmi9tBH3So=
+github.com/quic-go/quic-go v0.39.0/go.mod h1:T09QsDQWjLiQ74ZmacDfqZmhY/NLnw5BC40MANNNZ1Q=
+github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
+github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
+github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
+github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
+github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
+github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
+github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
+github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
+github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
+github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
+github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
+github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v0.0.0-20170130113145-4d4bfba8f1d1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
+github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg=
+github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20200427203606-3cfed13b9966/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
+github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
+github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
+github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
+github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
+github.com/urfave/cli/v2 v2.23.5 h1:xbrU7tAYviSpqeR3X4nEFWUdB/uDZ6DE+HxmRU7Xtyw=
+github.com/urfave/cli/v2 v2.23.5/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
+github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
+github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
+github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
+github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
+github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
+go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
+go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
+go.etcd.io/etcd v0.0.0-20200513171258-e048e166ab9c/go.mod h1:xCI7ZzBfRuGgBXyXO6yfWfDmlWd35khcWpUa4L0xI/k=
+go.etcd.io/etcd/api/v3 v3.5.5 h1:BX4JIbQ7hl7+jL+g+2j5UAr0o1bctCm6/Ct+ArBGkf0=
+go.etcd.io/etcd/api/v3 v3.5.5/go.mod h1:KFtNaxGDw4Yx/BA4iPPwevUTAuqcsPxzyX8PHydchN8=
+go.etcd.io/etcd/client/pkg/v3 v3.5.5 h1:9S0JUVvmrVl7wCF39iTQthdaaNIiAaQbmK75ogO6GU8=
+go.etcd.io/etcd/client/pkg/v3 v3.5.5/go.mod h1:ggrwbk069qxpKPq8/FKkQ3Xq9y39kbFR4LnKszpRXeQ=
+go.etcd.io/etcd/client/v3 v3.5.5 h1:q++2WTJbUgpQu4B6hCuT7VkdwaTP7Qz6Daak3WzbrlI=
+go.etcd.io/etcd/client/v3 v3.5.5/go.mod h1:aApjR4WGlSumpnJ2kloS75h6aHUmAyaPLjHMxpc7E7c=
+go.fuhry.dev/fsnotify v1.7.2 h1:jPBFFKaJKUv8kSl66IHkkbBmV499/y1qqjHmecApbtg=
+go.fuhry.dev/fsnotify v1.7.2/go.mod h1:/nwTYd9m6GEeyIewNJFg3ikw+GsMe3EwpzlI5wB0Cz4=
+go.fuhry.dev/grpc-quic v0.1.2 h1:wJsr1rtkDxcX4fBaZDlRodBnvl2PIw9001EQR0zFbys=
+go.fuhry.dev/grpc-quic v0.1.2/go.mod h1:82aBkv1Q2C2sxlEY+MAaHFDmmrjl6K7660Ou4h+aJdw=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
+go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
+go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
+go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
+go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo=
+go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
+go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
+go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
+go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
+go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
+go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
+go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
+go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
+go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
+go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
+golang.org/x/crypto v0.0.0-20180501155221-613d6eafa307/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
+golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
+golang.org/x/exp v0.0.0-20221205204356-47842c84f3db h1:D/cFflL63o2KSLJIwjlcIt8PR064j/xsmdEJL/YvY/o=
+golang.org/x/exp v0.0.0-20221205204356-47842c84f3db/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
+golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
+golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
+golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210316092937-0b90fd5c4c48/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210629170331-7dc0b73dc9fb/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191010075000-0337d82405ff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
+golang.org/x/tools v0.0.0-20200426102838-f3a5411a4c3b/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200626171337-aa94e735be7f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200630154851-b2d8b0336632/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200706234117-b22de6825cf7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
+golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.10.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
+google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20181107211654-5fc9ac540362/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20190927181202-20e1ac93f88c/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
+google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
+google.golang.org/genproto v0.0.0-20200626011028-ee7919e894b5/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200707001353-8e8330bf89df/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
+google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 h1:Z0hjGZePRE0ZBWotvtrwxFNrNE9CUAGtplaDK5NNI/g=
+google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98/go.mod h1:S7mY02OqCJTD0E1OiQy1F72PWFB4bZJ87cAtLPYgDR0=
+google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 h1:FmF5cCW94Ij59cfpoLiwTgodWmm60eEV0CjlsVg2fuw=
+google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM=
+google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
+google.golang.org/grpc v1.29.0/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
+google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
+google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
+google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
+google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
+google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ=
+google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
+gopkg.in/cheggaaa/pb.v1 v1.0.28/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
+gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.6/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
+howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
+rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
+sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
diff --git a/grpc/acl/acl_yaml.go b/grpc/acl/acl_yaml.go
new file mode 100644 (file)
index 0000000..53ed632
--- /dev/null
@@ -0,0 +1,143 @@
+package acl
+
+import (
+       "fmt"
+       "net/url"
+       "os"
+       "path"
+       "strings"
+
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/utils/log"
+       "gopkg.in/yaml.v3"
+)
+
+// Interface ACLChecker
+type ACLChecker interface {
+       Check(string, *url.URL) error
+}
+
+type aclEntry struct {
+       User    string `yaml:"user"`
+       Service string `yaml:"service"`
+       Domain  string `yaml:"domain"`
+}
+
+// Type aclYaml defines the YAML structure for an ACL. It works as follows:
+//
+// identity.FooService:
+//   - class: Domain
+//     principal: .example.com
+//   - class: User
+//     principal: joe
+//   - class: Service
+//     principal: foo
+//
+// "identity" = mTLS identity the server is running as
+// "class" - one of the IdentityClass constants (Domain, User or Service)
+// "principal" - the thing to allow
+type aclYaml struct {
+       Services map[string][]*aclEntry `yaml:",inline"`
+}
+
+var aclSearchPaths = []string{
+       ".",
+       "/etc/runtime/grpc",
+}
+
+func TryLoadAcl(serverId mtls.Identity) ACLChecker {
+       logger := log.WithPrefix("ACLChecker")
+       for _, dir := range aclSearchPaths {
+               path := path.Join(dir, serverId.Name()+"_acl.yaml")
+               if ay, err := loadAclFromPath(path); err == nil {
+                       logger.V(1).Infof("loaded ACLs from %s", path)
+                       return ay
+               }
+       }
+
+       logger.V(1).Infof("using default ACLs for server %s", serverId.Name())
+       return &aclYaml{
+               Services: map[string][]*aclEntry{
+                       "DEFAULT": {
+                               {
+                                       Service: serverId.Name(),
+                               },
+                       },
+               },
+       }
+}
+
+func loadAclFromPath(path string) (*aclYaml, error) {
+       contents, err := os.ReadFile(path)
+       if err != nil {
+               return nil, err
+       }
+
+       ay := &aclYaml{}
+       err = yaml.Unmarshal(contents, ay)
+       if err != nil {
+               return nil, err
+       }
+
+       return ay, nil
+}
+
+func (ay *aclYaml) Check(method string, spiffe *url.URL) error {
+       logger := log.WithPrefix("ACLChecker")
+
+       slices := strings.Split(method, "/")
+       if len(slices) != 3 {
+               return fmt.Errorf("method name does not have exactly 3 components: %s [%d]", method, len(slices))
+       }
+
+       service, rpc := slices[1], slices[2]
+       var entries []*aclEntry
+       keys := []string{
+               service + "/" + rpc,
+               service,
+               "DEFAULT",
+       }
+       for _, k := range keys {
+               if e, ok := ay.Services[k]; ok {
+                       entries = e
+                       logger.V(2).Debugf("Using ACL entries from definition: %s", k)
+                       break
+               }
+       }
+
+       if entries == nil {
+               return fmt.Errorf("method %q is not covered by acl, denying by default to client %s", method, spiffe)
+       }
+
+       for i, entry := range entries {
+               if entry.Service != "" && entry.User != "" {
+                       return fmt.Errorf("ACLs: entry %d: error: service and user are mutually exclusive", i)
+               }
+
+               if entry.Domain != "" {
+                       if strings.HasPrefix(entry.Domain, ".") && !strings.HasSuffix(spiffe.Host, entry.Domain) {
+                               continue
+                       }
+                       if !strings.HasPrefix(entry.Domain, ".") && spiffe.Host != entry.Domain {
+                               continue
+                       }
+               }
+
+               if entry.Service == "*" && strings.HasPrefix(spiffe.Path, "/service/") {
+                       // nothing, allow this case
+               } else if entry.Service != "" && spiffe.Path != fmt.Sprintf("/service/%s", entry.Service) {
+                       continue
+               }
+
+               if entry.User == "*" && strings.HasPrefix(spiffe.Path, "/user/") {
+                       // nothing, allow this case
+               } else if entry.User != "" && spiffe.Path != fmt.Sprintf("/user/%s", entry.User) {
+                       continue
+               }
+
+               logger.V(2).Infof("accepting identity %s for method %q based on ACL entry %+v", spiffe, method, entry)
+               return nil
+       }
+
+       return fmt.Errorf("no matching entry found, denying method %s to client %s", method, spiffe)
+}
diff --git a/grpc/client.go b/grpc/client.go
new file mode 100644 (file)
index 0000000..e904b30
--- /dev/null
@@ -0,0 +1,74 @@
+package grpc
+
+import (
+       "context"
+       "fmt"
+
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/sd"
+       "google.golang.org/grpc"
+)
+
+type Client struct {
+       ctx      context.Context
+       serverId mtls.Identity
+       clientId mtls.Identity
+       watcher  *sd.SDWatcher
+       connFac  ConnectionFactory
+}
+
+func NewGrpcClient(ctx context.Context, serverId, clientId mtls.Identity) (*Client, error) {
+       etcdc, err := sd.NewDefaultEtcdClient()
+       if err != nil {
+               panic(err)
+       }
+
+       w := &sd.SDWatcher{
+               Service:    serverId.Name(),
+               EtcdClient: etcdc,
+               Protocol:   sd.ProtocolGRPC,
+       }
+
+       cl := &Client{
+               ctx:      ctx,
+               serverId: serverId,
+               clientId: clientId,
+               watcher:  w,
+               connFac:  NewDefaultConnectionFactory(),
+       }
+
+       return cl, nil
+}
+
+func (c *Client) Conn() (*grpc.ClientConn, error) {
+       addrs, err := c.watcher.GetAddrs(c.ctx)
+       if err != nil {
+               return nil, err
+       }
+
+       tc, err := c.clientId.TlsConfig(c.ctx)
+       if err != nil {
+               return nil, err
+       }
+       tc.ServerName = addrs[0].Hostname
+       verifier := mtls.NewPeerNameVerifier()
+       verifier.AllowFrom(mtls.Service, c.serverId.Name())
+       err = verifier.ConfigureClient(tc)
+       if err != nil {
+               return nil, err
+       }
+       creds := grpc.WithTransportCredentials(c.connFac.NewCredentials(tc))
+       dialer := grpc.WithContextDialer(c.connFac.NewDialer(tc))
+       opts := []grpc.DialOption{
+               creds,
+               dialer,
+       }
+
+       target := fmt.Sprintf("%s:%d", addrs[0].Hostname, addrs[0].Port)
+       conn, err := grpc.DialContext(c.ctx, target, opts...)
+       if err != nil {
+               return nil, err
+       }
+
+       return conn, nil
+}
diff --git a/grpc/conn_base.go b/grpc/conn_base.go
new file mode 100644 (file)
index 0000000..ef76db6
--- /dev/null
@@ -0,0 +1,53 @@
+package grpc
+
+import (
+       "context"
+       "crypto/tls"
+       "flag"
+       "net"
+
+       "go.fuhry.dev/runtime/utils/log"
+       "google.golang.org/grpc/credentials"
+)
+
+type createTransportFunc func() ConnectionFactory
+
+var supportedTransports map[string]createTransportFunc
+var defaultTransport string
+
+type ContextDialer func(ctx context.Context, address string) (net.Conn, error)
+
+type ConnectionFactory interface {
+       NewCredentials(tlsConfig *tls.Config) credentials.TransportCredentials
+       NewListener(port uint16, tlsConfig *tls.Config) (net.Listener, error)
+       NewDialer(tlsConfig *tls.Config) ContextDialer
+}
+
+func NewDefaultConnectionFactory() ConnectionFactory {
+       if !flag.Parsed() {
+               flag.Parse()
+       }
+
+       if createFunc, ok := supportedTransports[defaultTransport]; ok {
+               return createFunc()
+       }
+
+       log.Panicf("transport %q is not supported", defaultTransport)
+       panic("")
+}
+
+func init() {
+       flag.StringVar(&defaultTransport, "grpc.transport", "tcp", "transport for gRPC server and client (tcp or quic)")
+}
+
+func RegisterTransport(transportName string, createFunc createTransportFunc) {
+       if supportedTransports == nil {
+               supportedTransports = make(map[string]createTransportFunc, 0)
+       }
+
+       if _, ok := supportedTransports[transportName]; ok {
+               log.Panicf("transport %q is already registered", transportName)
+       }
+
+       supportedTransports[transportName] = createFunc
+}
diff --git a/grpc/conn_quic.go b/grpc/conn_quic.go
new file mode 100644 (file)
index 0000000..99d16df
--- /dev/null
@@ -0,0 +1,43 @@
+package grpc
+
+import (
+       "crypto/tls"
+       "fmt"
+       "net"
+
+       "google.golang.org/grpc/credentials"
+
+       "github.com/quic-go/quic-go"
+       grpc_quic "go.fuhry.dev/grpc-quic"
+)
+
+type QUICConnectionFactory struct{}
+
+func (cf *QUICConnectionFactory) NewCredentials(tlsConfig *tls.Config) credentials.TransportCredentials {
+       tlsConfig.NextProtos = []string{"grpc-quic"}
+       return grpc_quic.NewCredentials(tlsConfig)
+}
+
+func (cf *QUICConnectionFactory) NewListener(port uint16, tlsConfig *tls.Config) (net.Listener, error) {
+       udpListener, err := net.ListenPacket("udp", fmt.Sprintf(":%d", port))
+       if err != nil {
+               return nil, err
+       }
+
+       quicListener, err := quic.Listen(udpListener, tlsConfig, nil)
+       if err != nil {
+               return nil, err
+       }
+
+       listener := grpc_quic.Listen(*quicListener)
+
+       return listener, nil
+}
+
+func (cf *QUICConnectionFactory) NewDialer(tlsConfig *tls.Config) ContextDialer {
+       return grpc_quic.NewQuicDialer(tlsConfig)
+}
+
+func init() {
+       RegisterTransport("quic", func() ConnectionFactory { return &QUICConnectionFactory{} })
+}
diff --git a/grpc/conn_tcp.go b/grpc/conn_tcp.go
new file mode 100644 (file)
index 0000000..048b26a
--- /dev/null
@@ -0,0 +1,54 @@
+package grpc
+
+import (
+       "context"
+       "crypto/tls"
+       "fmt"
+       "net"
+
+       "google.golang.org/grpc/credentials"
+)
+
+type TCPConnectionFactory struct {
+       Dialer *net.Dialer
+
+       tlsConfig *tls.Config
+}
+
+func (cf *TCPConnectionFactory) NewCredentials(tlsConfig *tls.Config) credentials.TransportCredentials {
+       cf.ingestTlsConfig(tlsConfig)
+
+       return credentials.NewTLS(cf.tlsConfig)
+}
+
+func (cf *TCPConnectionFactory) NewListener(port uint16, tlsConfig *tls.Config) (net.Listener, error) {
+       listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
+       if err != nil {
+               return nil, err
+       }
+       return listener, nil
+}
+
+func (cf *TCPConnectionFactory) NewDialer(tlsConfig *tls.Config) ContextDialer {
+       return func(ctx context.Context, address string) (net.Conn, error) {
+               if cf.Dialer == nil {
+                       cf.Dialer = &net.Dialer{}
+               }
+               return cf.Dialer.DialContext(ctx, "tcp", address)
+       }
+}
+
+func (cf *TCPConnectionFactory) ingestTlsConfig(tlsConfig *tls.Config) {
+       if cf.tlsConfig != nil {
+               return
+       }
+
+       localTlsConfig := tlsConfig.Clone()
+       localTlsConfig.NextProtos = []string{"h2"}
+
+       cf.tlsConfig = localTlsConfig
+}
+
+func init() {
+       RegisterTransport("tcp", func() ConnectionFactory { return &TCPConnectionFactory{} })
+}
diff --git a/grpc/context.go b/grpc/context.go
new file mode 100644 (file)
index 0000000..897e345
--- /dev/null
@@ -0,0 +1,58 @@
+package grpc
+
+import (
+       "context"
+       "fmt"
+
+       "go.fuhry.dev/runtime/utils/log"
+       "google.golang.org/grpc/peer"
+)
+
+type Session interface {
+       Get(key any) (value any, ok bool)
+       Set(key, value any)
+}
+
+type session struct {
+       storage map[any]any
+}
+
+type tSessionKey int
+
+const (
+       kSession tSessionKey = iota
+       kServer
+)
+
+func SessionFromContext(ctx context.Context) Session {
+       peer, ok := peer.FromContext(ctx)
+       if !ok {
+               return nil
+       }
+
+       if peerSpiffe, err := PeerIdentity(peer); err == nil {
+               sessionKey := fmt.Sprintf("%s:%s:%s", peerSpiffe.String(), peer.Addr.Network(), peer.Addr.String())
+               log.Default().V(3).Debugf("peer session key: %s", sessionKey)
+               server := ctx.Value(kServer).(*Server)
+               if session, ok := server.sessions.Get(sessionKey); ok {
+                       return session
+               }
+
+               session := &session{
+                       storage: make(map[any]any, 0),
+               }
+               server.sessions.Add(sessionKey, session)
+               return session
+       }
+
+       return nil
+}
+
+func (s *session) Get(key any) (value any, ok bool) {
+       value, ok = s.storage[key]
+       return
+}
+
+func (s *session) Set(key, value any) {
+       s.storage[key] = value
+}
diff --git a/grpc/server.go b/grpc/server.go
new file mode 100644 (file)
index 0000000..94b74be
--- /dev/null
@@ -0,0 +1,220 @@
+package grpc
+
+import (
+       "context"
+       "crypto/tls"
+       "flag"
+       "fmt"
+       "math/rand"
+       "net/url"
+
+       lru "github.com/hashicorp/golang-lru/v2"
+       grpc_quic "go.fuhry.dev/grpc-quic"
+       "go.fuhry.dev/runtime/grpc/acl"
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/mtls/certutil"
+       "go.fuhry.dev/runtime/sd"
+       "go.fuhry.dev/runtime/utils/hostname"
+       "go.fuhry.dev/runtime/utils/log"
+       "google.golang.org/grpc"
+       "google.golang.org/grpc/codes"
+       "google.golang.org/grpc/credentials"
+       "google.golang.org/grpc/peer"
+       "google.golang.org/grpc/status"
+)
+
+type Server struct {
+       grpcServer *grpc.Server
+       identity   mtls.Identity
+       publisher  *sd.SDPublisher
+       port       uint16
+       verifier   mtls.MTLSPeerVerifier
+       acl        acl.ACLChecker
+       log        *log.Logger
+       sessions   *lru.Cache[string, *session]
+       connFac    ConnectionFactory
+}
+
+var defaultPort *uint
+
+func RandomPort() uint {
+       return uint(1025 + (uint(rand.Int()) % (65535 - 1025)))
+}
+
+func NewGrpcServer(id mtls.Identity) (*Server, error) {
+       if !flag.Parsed() {
+               panic("cannot start grpc services before flags are parsed")
+       }
+
+       return NewGrpcServerWithPort(id, uint16(*defaultPort))
+}
+
+func NewGrpcServerWithPort(id mtls.Identity, port uint16) (*Server, error) {
+       etcdc, err := sd.NewDefaultEtcdClient()
+       if err != nil {
+               panic(err)
+       }
+
+       pub := &sd.SDPublisher{
+               Regions: []string{
+                       hostname.RegionName(),
+               },
+               Service:       id.Name(),
+               Protocol:      sd.ProtocolGRPC,
+               EtcdClient:    etcdc,
+               AdvertisePort: port,
+       }
+
+       // We want client certificate verification turned on, but no filtering on the
+       // client name to be done at handshake time - ACLs are enforced by handleConnection
+       // which calls out to acl_yaml.
+       cv := mtls.NewPeerNameVerifier()
+       cv.AllowFrom(mtls.All)
+
+       sessionsLru, err := lru.New[string, *session](1024)
+       if err != nil {
+               return nil, err
+       }
+
+       server := &Server{
+               identity:  id,
+               publisher: pub,
+               port:      port,
+               acl:       acl.TryLoadAcl(id),
+               verifier:  cv,
+               log:       log.WithPrefix(fmt.Sprintf("grpcServer:%s", id.Name())),
+               sessions:  sessionsLru,
+               connFac:   NewDefaultConnectionFactory(),
+       }
+
+       return server, nil
+}
+
+func (s *Server) PublishAndServe(ctx context.Context, callback func(*grpc.Server)) error {
+       s.log.Noticef("starting %s service on port %d", s.identity.Name(), s.port)
+
+       tc, err := s.identity.TlsConfig(ctx)
+       if err != nil {
+               return err
+       }
+       tc.MinVersion = tls.VersionTLS13
+       tc.MaxVersion = tls.VersionTLS13
+
+       err = s.verifier.ConfigureServer(tc)
+       if err != nil {
+               return err
+       }
+
+       opts := make([]grpc.ServerOption, 0)
+
+       txCreds := s.connFac.NewCredentials(tc)
+       listener, err := s.connFac.NewListener(s.port, tc)
+       if err != nil {
+               return err
+       }
+       creds := grpc.Creds(txCreds)
+       opts = append(opts, creds)
+
+       usi := grpc.ChainUnaryInterceptor(s.handleConnection)
+       opts = append(opts, usi)
+
+       ssi := grpc.ChainStreamInterceptor(s.handleStreamConnection)
+       opts = append(opts, ssi)
+
+       grpcServer := grpc.NewServer(opts...)
+
+       callback(grpcServer)
+
+       go grpcServer.Serve(listener)
+       s.grpcServer = grpcServer
+
+       err = s.publisher.Publish(ctx)
+       if err != nil {
+               grpcServer.Stop()
+               return err
+       }
+
+       s.log.Infof("%s server started", s.identity.Name())
+
+       return nil
+}
+
+func (s *Server) Stop() {
+       s.publisher.Unpublish()
+       if s.grpcServer != nil {
+               s.grpcServer.GracefulStop()
+               s.grpcServer = nil
+       }
+}
+
+func (s *Server) handleConnection(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+       peer, ok := peer.FromContext(ctx)
+       if !ok {
+               return nil, status.Errorf(codes.PermissionDenied, "client did not authenticate")
+       }
+       spiffe, err := PeerIdentity(peer)
+       if err != nil {
+               return nil, err
+       }
+
+       if err := s.acl.Check(info.FullMethod, spiffe); err != nil {
+               return nil, status.Errorf(codes.PermissionDenied, err.Error())
+       }
+
+       serverCtx := context.WithValue(ctx, kServer, s)
+
+       return handler(serverCtx, req)
+}
+
+func (s *Server) handleStreamConnection(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
+       ctx := ss.Context()
+       peer, ok := peer.FromContext(ctx)
+       if !ok {
+               return status.Errorf(codes.PermissionDenied, "client did not authenticate")
+       }
+       spiffe, err := PeerIdentity(peer)
+       if err != nil {
+               return err
+       }
+
+       if err := s.acl.Check(info.FullMethod, spiffe); err != nil {
+               return status.Errorf(codes.PermissionDenied, err.Error())
+       }
+
+       return handler(srv, ss)
+}
+
+func PeerIdentity(peer *peer.Peer) (*url.URL, error) {
+       if peer.AuthInfo == nil {
+               return nil, status.Errorf(codes.PermissionDenied, "no AuthInfo present in peer information")
+       }
+
+       var tlsState tls.ConnectionState
+
+       switch ai := peer.AuthInfo.(type) {
+       case credentials.TLSInfo:
+               if ai.SPIFFEID != nil {
+                       return ai.SPIFFEID, nil
+               }
+
+               tlsState = ai.State
+       case *grpc_quic.Info:
+               conn := ai.Conn()
+               tlsState = conn.(*grpc_quic.Conn).TLSState()
+       default:
+               return nil, status.Errorf(codes.PermissionDenied, "unhandled type of peer.AuthInfo: %T", peer.AuthInfo)
+       }
+
+       if len(tlsState.PeerCertificates) > 0 {
+               cert := tlsState.PeerCertificates[0]
+               if url := certutil.SpiffeUrlFromCertificate(cert); url != nil {
+                       return url, nil
+               }
+       }
+
+       return nil, status.Errorf(codes.PermissionDenied, "could not determine your SPIFFEID from your certificate")
+}
+
+func init() {
+       defaultPort = flag.Uint("grpc.port", RandomPort(), "port for server to listen on")
+}
diff --git a/ldap/health_exporter/Makefile b/ldap/health_exporter/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/ldap/health_exporter/main.go b/ldap/health_exporter/main.go
new file mode 100644 (file)
index 0000000..4d3da0f
--- /dev/null
@@ -0,0 +1,177 @@
+package main
+
+import (
+       "context"
+       "os"
+       "os/signal"
+       "syscall"
+       "time"
+
+       exporter "go.fuhry.dev/runtime/ldap"
+       "go.fuhry.dev/runtime/mtls"
+
+       log "github.com/sirupsen/logrus"
+       "github.com/urfave/cli/v2"
+       "github.com/urfave/cli/v2/altsrc"
+       "golang.org/x/sync/errgroup"
+)
+
+const (
+       promAddr          = "promAddr"
+       ldapNet           = "ldapNet"
+       ldapAddr          = "ldapAddr"
+       ldapUser          = "ldapUser"
+       ldapPass          = "ldapPass"
+       mtlsId            = "mtlsId"
+       interval          = "interval"
+       metrics           = "metrPath"
+       jsonLog           = "jsonLog"
+       webCfgFile        = "webCfgFile"
+       config            = "config"
+       replicationObject = "replicationObject"
+)
+
+func main() {
+       flags := []cli.Flag{
+               altsrc.NewStringFlag(&cli.StringFlag{
+                       Name:    promAddr,
+                       Value:   ":9330",
+                       Usage:   "Bind address for Prometheus HTTP metrics server",
+                       EnvVars: []string{"PROM_ADDR"},
+               }),
+               altsrc.NewStringFlag(&cli.StringFlag{
+                       Name:    metrics,
+                       Value:   "/metrics",
+                       Usage:   "Path on which to expose Prometheus metrics",
+                       EnvVars: []string{"METRICS_PATH"},
+               }),
+               altsrc.NewStringFlag(&cli.StringFlag{
+                       Name:    ldapNet,
+                       Value:   "tcp",
+                       Usage:   "Network of OpenLDAP server",
+                       EnvVars: []string{"LDAP_NET"},
+               }),
+               altsrc.NewStringFlag(&cli.StringFlag{
+                       Name:    ldapAddr,
+                       Value:   "localhost:389",
+                       Usage:   "Address and port of OpenLDAP server",
+                       EnvVars: []string{"LDAP_ADDR"},
+               }),
+               altsrc.NewStringFlag(&cli.StringFlag{
+                       Name:    ldapUser,
+                       Usage:   "OpenLDAP bind username (optional)",
+                       EnvVars: []string{"LDAP_USER"},
+               }),
+               altsrc.NewStringFlag(&cli.StringFlag{
+                       Name:    ldapPass,
+                       Usage:   "OpenLDAP bind password (optional)",
+                       EnvVars: []string{"LDAP_PASS"},
+               }),
+               altsrc.NewStringFlag(&cli.StringFlag{
+                       Name:    mtlsId,
+                       Usage:   "mTLS identity from /etc/ssl/mtls",
+                       EnvVars: []string{"MTLS_ID"},
+               }),
+               altsrc.NewDurationFlag(&cli.DurationFlag{
+                       Name:    interval,
+                       Value:   30 * time.Second,
+                       Usage:   "Scrape interval",
+                       EnvVars: []string{"INTERVAL"},
+               }),
+               altsrc.NewStringFlag(&cli.StringFlag{
+                       Name:    webCfgFile,
+                       Usage:   "Prometheus metrics web config `FILE` (optional)",
+                       EnvVars: []string{"WEB_CFG_FILE"},
+               }),
+               altsrc.NewBoolFlag(&cli.BoolFlag{
+                       Name:    jsonLog,
+                       Value:   false,
+                       Usage:   "Output logs in JSON format",
+                       EnvVars: []string{"JSON_LOG"},
+               }),
+               altsrc.NewStringSliceFlag(&cli.StringSliceFlag{
+                       Name:  replicationObject,
+                       Usage: "Object to watch replication upon",
+               }),
+               &cli.StringFlag{
+                       Name:  config,
+                       Usage: "Optional configuration from a `YAML_FILE`",
+               },
+       }
+       app := &cli.App{
+               Name:            "openldap_exporter",
+               Usage:           "Export OpenLDAP metrics to Prometheus",
+               Before:          altsrc.InitInputSourceWithContext(flags, optionalYamlSourceFunc(config)),
+               Version:         exporter.GetVersion(),
+               HideHelpCommand: true,
+               Flags:           flags,
+               Action:          runMain,
+       }
+       if err := app.Run(os.Args); err != nil {
+               log.WithError(err).Fatal("service failed")
+       }
+       log.Info("service stopped")
+}
+
+func optionalYamlSourceFunc(flagFileName string) func(context *cli.Context) (altsrc.InputSourceContext, error) {
+       return func(c *cli.Context) (altsrc.InputSourceContext, error) {
+               filePath := c.String(flagFileName)
+               if filePath != "" {
+                       return altsrc.NewYamlSourceFromFile(filePath)
+               }
+               return &altsrc.MapInputSource{}, nil
+       }
+}
+
+func runMain(c *cli.Context) error {
+       if c.Bool(jsonLog) {
+               log.SetFormatter(&log.JSONFormatter{})
+       } else {
+               log.SetFormatter(&log.TextFormatter{})
+       }
+       log.Info("service starting")
+
+       server := exporter.NewMetricsServer(
+               c.String(promAddr),
+               c.String(metrics),
+               c.String(webCfgFile),
+       )
+
+       scraper := &exporter.Scraper{
+               Net:  c.String(ldapNet),
+               Addr: c.String(ldapAddr),
+               User: c.String(ldapUser),
+               Pass: c.String(ldapPass),
+               Mtls: mtls.NewServiceIdentity(c.String(mtlsId)),
+               Tick: c.Duration(interval),
+               Sync: c.StringSlice(replicationObject),
+       }
+
+       ctx, cancel := context.WithCancel(context.Background())
+       var group errgroup.Group
+       group.Go(func() error {
+               defer cancel()
+               return server.Start()
+       })
+       group.Go(func() error {
+               defer cancel()
+               scraper.Start(ctx)
+               return nil
+       })
+       group.Go(func() error {
+               defer func() {
+                       cancel()
+                       server.Stop()
+               }()
+               signalChan := make(chan os.Signal, 1)
+               signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
+               select {
+               case <-signalChan:
+                       log.Info("shutdown received")
+                       return nil
+               case <-ctx.Done():
+                       return nil
+               }
+       })
+       return group.Wait()
+}
diff --git a/ldap/health_exporter/systemd/ldap-health-exporter@.service b/ldap/health_exporter/systemd/ldap-health-exporter@.service
new file mode 100644 (file)
index 0000000..78dfc55
--- /dev/null
@@ -0,0 +1,14 @@
+[Unit]
+Description=Monitor LDAP server health on %i
+Wants=systemd-networkd-wait-online.service
+After=systemd-networkd-wait-online.service
+
+[Service]
+Type=simple
+User=slapmon
+ExecStart=/usr/bin/ldap-health-exporter --webCfgFile /etc/prometheus-node-exporter/web-config.yml --ldapAddr %i:636 --mtlsId slapmon --interval=30s
+
+[Install]
+Alias=ldap-health-exporter@%i.service
+WantedBy=multi-user.target
+
diff --git a/ldap/scraper.go b/ldap/scraper.go
new file mode 100644 (file)
index 0000000..bdad83f
--- /dev/null
@@ -0,0 +1,346 @@
+package openldap_exporter
+
+import (
+       "context"
+       "crypto/tls"
+       "fmt"
+       "strconv"
+       "strings"
+       "time"
+
+       ldap "github.com/go-ldap/ldap/v3"
+       "github.com/prometheus/client_golang/prometheus"
+       log "github.com/sirupsen/logrus"
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/mtls/certutil"
+)
+
+const (
+       baseDN    = "cn=Monitor"
+       opsBaseDN = "cn=Operations,cn=Monitor"
+
+       monitorCounterObject = "monitorCounterObject"
+       monitorCounter       = "monitorCounter"
+
+       monitoredObject = "monitoredObject"
+       monitoredInfo   = "monitoredInfo"
+
+       monitorOperation   = "monitorOperation"
+       monitorOpCompleted = "monitorOpCompleted"
+
+       monitorReplicationFilter = "contextCSN"
+       monitorReplication       = "monitorReplication"
+
+       threadsBaseDN = "cn=Threads,cn=Monitor"
+)
+
+type query struct {
+       baseDN       string
+       searchFilter string
+       searchAttr   string
+       metric       prometheus.Collector
+       setData      func([]*ldap.Entry, *query)
+}
+
+var (
+       monitoredObjectCounter = prometheus.NewGaugeVec(
+               prometheus.GaugeOpts{
+                       Subsystem: "openldap",
+                       Name:      "monitored_object",
+                       Help:      help(baseDN, objectClass(monitoredObject), monitoredInfo),
+               },
+               []string{"dn"},
+       )
+       monitorCounterObjectGauge = prometheus.NewGaugeVec(
+               prometheus.GaugeOpts{
+                       Subsystem: "openldap",
+                       Name:      "monitor_counter_object",
+                       Help:      help(baseDN, objectClass(monitorCounterObject), monitorCounter),
+               },
+               []string{"dn"},
+       )
+       monitorOperationCounter = prometheus.NewGaugeVec(
+               prometheus.GaugeOpts{
+                       Subsystem: "openldap",
+                       Name:      "monitor_operation",
+                       Help:      help(opsBaseDN, objectClass(monitorOperation), monitorOpCompleted),
+               },
+               []string{"dn"},
+       )
+       bindCounter = prometheus.NewCounterVec(
+               prometheus.CounterOpts{
+                       Subsystem: "openldap",
+                       Name:      "bind",
+                       Help:      "successful vs unsuccessful ldap bind attempts",
+               },
+               []string{"result"},
+       )
+       dialCounter = prometheus.NewCounterVec(
+               prometheus.CounterOpts{
+                       Subsystem: "openldap",
+                       Name:      "dial",
+                       Help:      "successful vs unsuccessful ldap dial attempts",
+               },
+               []string{"result"},
+       )
+       scrapeCounter = prometheus.NewCounterVec(
+               prometheus.CounterOpts{
+                       Subsystem: "openldap",
+                       Name:      "scrape",
+                       Help:      "successful vs unsuccessful ldap scrape attempts",
+               },
+               []string{"result"},
+       )
+       monitorReplicationGauge = prometheus.NewGaugeVec(
+               prometheus.GaugeOpts{
+                       Subsystem: "openldap",
+                       Name:      "monitor_replication",
+                       Help:      help(baseDN, monitorReplication),
+               },
+               []string{"id", "type"},
+       )
+       queries = []*query{
+               {
+                       baseDN:       baseDN,
+                       searchFilter: objectClass(monitoredObject),
+                       searchAttr:   monitoredInfo,
+                       metric:       monitoredObjectCounter,
+                       setData:      setValue,
+               },
+               {
+                       baseDN:       baseDN,
+                       searchFilter: objectClass(monitorCounterObject),
+                       searchAttr:   monitorCounter,
+                       metric:       monitorCounterObjectGauge,
+                       setData:      setValue,
+               },
+               {
+                       baseDN:       opsBaseDN,
+                       searchFilter: objectClass(monitorOperation),
+                       searchAttr:   monitorOpCompleted,
+                       metric:       monitorOperationCounter,
+                       setData:      setValue,
+               },
+               {
+                       baseDN:       opsBaseDN,
+                       searchFilter: objectClass(monitorOperation),
+                       searchAttr:   monitorOpCompleted,
+                       metric:       monitorOperationCounter,
+                       setData:      setValue,
+               },
+       }
+)
+
+func init() {
+       prometheus.MustRegister(
+               monitoredObjectCounter,
+               monitorCounterObjectGauge,
+               monitorOperationCounter,
+               monitorReplicationGauge,
+               scrapeCounter,
+               bindCounter,
+               dialCounter,
+       )
+}
+
+func help(msg ...string) string {
+       return strings.Join(msg, " ")
+}
+
+func objectClass(name string) string {
+       return fmt.Sprintf("(objectClass=%v)", name)
+}
+
+func setValue(entries []*ldap.Entry, q *query) {
+       for _, entry := range entries {
+               val := entry.GetAttributeValue(q.searchAttr)
+               if val == "" {
+                       // not every entry will have this attribute
+                       continue
+               }
+               num, err := strconv.ParseFloat(val, 64)
+               if err != nil {
+                       // some of these attributes are not numbers
+                       continue
+               }
+               if gauge, ok := q.metric.(*prometheus.GaugeVec); ok {
+                       gauge.WithLabelValues(entry.DN).Set(num)
+               } else if counter, ok := q.metric.(*prometheus.CounterVec); ok {
+                       counter.WithLabelValues(entry.DN).Add(num)
+               }
+       }
+}
+
+func setReplicationValue(entries []*ldap.Entry, q *query) {
+       for _, entry := range entries {
+               val := entry.GetAttributeValue(q.searchAttr)
+               if val == "" {
+                       // not every entry will have this attribute
+                       continue
+               }
+               fields := log.Fields{
+                       "filter": q.searchFilter,
+                       "attr":   q.searchAttr,
+                       "value":  val,
+               }
+               valueBuffer := strings.Split(val, "#")
+               gt, err := time.Parse("20060102150405.999999Z", valueBuffer[0])
+               if err != nil {
+                       log.WithFields(fields).WithError(err).Warn("unexpected gt value")
+                       continue
+               }
+               count, err := strconv.ParseFloat(valueBuffer[1], 64)
+               if err != nil {
+                       log.WithFields(fields).WithError(err).Warn("unexpected count value")
+                       continue
+               }
+               sid := valueBuffer[2]
+               mod, err := strconv.ParseFloat(valueBuffer[3], 64)
+               if err != nil {
+                       log.WithFields(fields).WithError(err).Warn("unexpected mod value")
+                       continue
+               }
+               if gauge, ok := q.metric.(*prometheus.GaugeVec); ok {
+                       gauge.WithLabelValues(sid, "gt").Set(float64(gt.Unix()))
+                       gauge.WithLabelValues(sid, "count").Set(count)
+                       gauge.WithLabelValues(sid, "mod").Set(mod)
+               } else if counter, ok := q.metric.(*prometheus.CounterVec); ok {
+                       counter.WithLabelValues(sid, "gt").Add(float64(gt.Unix()))
+                       counter.WithLabelValues(sid, "count").Add(count)
+                       counter.WithLabelValues(sid, "mod").Add(mod)
+               }
+       }
+}
+
+type Scraper struct {
+       Net      string
+       Addr     string
+       User     string
+       Pass     string
+       Mtls     mtls.Identity
+       Tick     time.Duration
+       LdapSync []string
+       log      log.FieldLogger
+       Sync     []string
+
+       tlsConfig *tls.Config
+}
+
+func (s *Scraper) addReplicationQueries() {
+       for _, q := range s.Sync {
+               queries = append(queries,
+                       &query{
+                               baseDN:       q,
+                               searchFilter: objectClass("*"),
+                               searchAttr:   monitorReplicationFilter,
+                               metric:       monitorReplicationGauge,
+                               setData:      setReplicationValue,
+                       },
+               )
+       }
+}
+
+func (s *Scraper) Start(ctx context.Context) {
+       s.log = log.WithField("component", "scraper")
+       s.setTlsConfig(ctx)
+       s.addReplicationQueries()
+       address := fmt.Sprintf("%s://%s", s.Net, s.Addr)
+       s.log.WithField("addr", address).Info("starting monitor loop")
+       ticker := time.NewTicker(s.Tick)
+       defer ticker.Stop()
+       s.scrape()
+       for {
+               select {
+               case <-ticker.C:
+                       s.scrape()
+               case <-ctx.Done():
+                       return
+               }
+       }
+}
+
+func (s *Scraper) setTlsConfig(ctx context.Context) {
+       var tlsConfig *tls.Config
+       if !s.Mtls.IsValid() {
+               s.log.Error(fmt.Sprintf("mtls identity %q is invalid", s.Mtls.Name()))
+               return
+       }
+       tlsConfig, err := s.Mtls.TlsConfig(ctx)
+       if err != nil {
+               s.log.WithError(err).Error("generating tls config failed")
+               return
+       }
+       if tlsConfig.RootCAs != nil {
+               systemCertificates, err := certutil.LoadCertificatesFromPEM("/etc/ssl/certs/ca-certificates.crt")
+               if err != nil {
+                       s.log.WithError(err).Error("loading system CA certificate store failed")
+                       return
+               }
+               for _, cert := range systemCertificates {
+                       tlsConfig.RootCAs.AddCert(cert)
+               }
+       }
+
+       s.tlsConfig = tlsConfig
+}
+
+func (s *Scraper) scrape() {
+       var conn *ldap.Conn
+       var err error
+       if s.tlsConfig != nil {
+               conn, err = ldap.DialTLS(s.Net, s.Addr, s.tlsConfig)
+       } else {
+               conn, err = ldap.Dial(s.Net, s.Addr)
+       }
+       if err != nil {
+               s.log.WithError(err).Error("dial failed")
+               dialCounter.WithLabelValues("fail").Inc()
+               return
+       }
+       dialCounter.WithLabelValues("ok").Inc()
+       defer (func() {
+               if conn != nil {
+                       conn.Close()
+               }
+       })()
+
+       if s.Mtls.Name() != "" {
+               err = conn.ExternalBind()
+               if err != nil {
+                       s.log.WithError(err).Error("external bind failed")
+                       dialCounter.WithLabelValues("fail").Inc()
+                       return
+               }
+               bindCounter.WithLabelValues("ok").Inc()
+       } else if s.User != "" && s.Pass != "" {
+               err = conn.Bind(s.User, s.Pass)
+               if err != nil {
+                       s.log.WithError(err).Error("bind failed")
+                       bindCounter.WithLabelValues("fail").Inc()
+                       return
+               }
+               bindCounter.WithLabelValues("ok").Inc()
+       }
+
+       scrapeRes := "ok"
+       for _, q := range queries {
+               if err = scrapeQuery(conn, q); err != nil {
+                       s.log.WithError(err).WithField("filter", q.searchFilter).Warn("query failed")
+                       scrapeRes = "fail"
+               }
+       }
+       scrapeCounter.WithLabelValues(scrapeRes).Inc()
+}
+
+func scrapeQuery(conn *ldap.Conn, q *query) error {
+       req := ldap.NewSearchRequest(
+               q.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
+               q.searchFilter, []string{q.searchAttr}, nil,
+       )
+       sr, err := conn.Search(req)
+       if err != nil {
+               return err
+       }
+       q.setData(sr.Entries, q)
+       return nil
+}
diff --git a/ldap/server.go b/ldap/server.go
new file mode 100644 (file)
index 0000000..1b8c467
--- /dev/null
@@ -0,0 +1,103 @@
+package openldap_exporter
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "net/http"
+       "time"
+
+       kitlog "github.com/go-kit/log"
+       "github.com/prometheus/client_golang/prometheus/promhttp"
+       "github.com/prometheus/exporter-toolkit/web"
+       log "github.com/sirupsen/logrus"
+)
+
+var commit string
+var tag string
+
+func GetVersion() string {
+       return fmt.Sprintf("%s (%s)", tag, commit)
+}
+
+type Server struct {
+       server     *http.Server
+       flagConfig *web.FlagConfig
+       logger     log.FieldLogger
+}
+
+func NewMetricsServer(bindAddr, metricsPath, tlsConfigPath string) *Server {
+       mux := http.NewServeMux()
+       mux.Handle(metricsPath, promhttp.Handler())
+       mux.HandleFunc("/version", showVersion)
+       systemdSocket := false
+       return &Server{
+               server: &http.Server{Addr: bindAddr, Handler: mux},
+               flagConfig: &web.FlagConfig{
+                       WebListenAddresses: &[]string{bindAddr},
+                       WebSystemdSocket:   &systemdSocket,
+                       WebConfigFile:      &tlsConfigPath,
+               },
+               logger: log.WithField("component", "server"),
+       }
+}
+
+func showVersion(w http.ResponseWriter, r *http.Request) {
+       if r.Method != "GET" {
+               w.WriteHeader(http.StatusMethodNotAllowed)
+               return
+       }
+       w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+       fmt.Fprintln(w, GetVersion())
+}
+
+func (s *Server) Start() error {
+       s.logger.WithField("addr", s.server.Addr).Info("starting http listener")
+       err := web.ListenAndServe(s.server, s.flagConfig, kitlog.LoggerFunc(s.adaptor))
+       if errors.Is(err, http.ErrServerClosed) {
+               return nil
+       }
+       return err
+}
+
+func (s *Server) Stop() {
+       ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+       s.server.Shutdown(ctx)
+       cancel()
+}
+
+func (s *Server) adaptor(kvs ...interface{}) error {
+       if len(kvs) == 0 {
+               return nil
+       }
+       if len(kvs)%2 != 0 {
+               kvs = append(kvs, nil)
+       }
+       fields := log.Fields{}
+       for i := 0; i < len(kvs); i += 2 {
+               key := fmt.Sprint(kvs[i])
+               fields[key] = kvs[i+1]
+       }
+       var msg string
+       if val, ok := fields["msg"]; ok {
+               delete(fields, "msg")
+               msg = fmt.Sprint(val)
+       }
+       var level string
+       if val, ok := fields["level"]; ok {
+               delete(fields, "level")
+               level = fmt.Sprint(val)
+       }
+       ll := s.logger.WithFields(fields)
+       switch level {
+       case "error":
+               ll.Error(msg)
+       case "warn":
+               ll.Warn(msg)
+       case "debug":
+               ll.Debug(msg)
+       default:
+               ll.Info(msg)
+       }
+       return nil
+}
diff --git a/machines/client.go b/machines/client.go
new file mode 100644 (file)
index 0000000..baffa99
--- /dev/null
@@ -0,0 +1,149 @@
+package machines
+
+import (
+       "bytes"
+       "context"
+       "encoding/json"
+       "errors"
+       "flag"
+       "fmt"
+       "io"
+       "net/http"
+       "strings"
+
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+var (
+       defaultMachinesApiUrl                            = "https://" + constants.MachinesHost + "/api/"
+       defaultMachinesEventsUrl                         = "wss://" + constants.MachinesHost + "/events"
+       defaultMachinesApiScopes                         = "read"
+       machinesApiUrl, machinesEventsUrl                string
+       machinesOauthClientId, machinesOauthClientSecret string
+       machinesApiScopes                                string
+)
+
+type MachinesClient interface {
+       APICall(route string, data interface{}, response any) error
+       NewEventListener(ctx context.Context) (chan MachinesMqttEvent, error)
+}
+
+type machinesClient struct {
+       MachinesClient
+
+       client    *http.Client
+       baseUrl   string
+       eventsUrl string
+       logger    *log.Logger
+}
+
+func init() {
+       flag.StringVar(&machinesApiUrl, "machines.api-url", defaultMachinesApiUrl, "URL to the Machines API")
+       flag.StringVar(&machinesEventsUrl, "machines.events-url", defaultMachinesEventsUrl, "Machines MQTT WebSocket URL")
+       flag.StringVar(&machinesOauthClientId, "machines.client-id", "", "OAuth client ID for the Machines API")
+       flag.StringVar(&machinesOauthClientSecret, "machines.client-secret", "", "Client secret for the Machines API")
+       flag.StringVar(&machinesApiScopes, "machines.scopes", defaultMachinesApiScopes, "comma-separated list of OAuth scopes for the Machines API")
+}
+
+func NewDefaultMachinesClient(scopes ...string) (*machinesClient, error) {
+       if !flag.Parsed() {
+               return nil, errors.New("flags have not been parsed yet")
+       }
+
+       if len(scopes) == 0 {
+               scopes = strings.Split(machinesApiScopes, ",")
+       }
+
+       if machinesOauthClientId == "" || machinesOauthClientSecret == "" {
+               return nil, errors.New("cannot create default machines client: client id or client secret not provided on command line")
+       }
+
+       return NewMachinesClient(machinesApiUrl, machinesEventsUrl, machinesOauthClientId, machinesOauthClientSecret, scopes)
+}
+
+func NewMachinesClient(apiUrl, eventsUrl, clientId, clientSecret string, scopes []string) (*machinesClient, error) {
+       httpClient := &http.Client{}
+       apiUrl = strings.TrimRight(apiUrl, "/")
+       err := SetupOAuthClient(httpClient,
+               fmt.Sprintf("%s/oauth/token", apiUrl),
+               clientId, clientSecret, scopes)
+
+       if err != nil {
+               return nil, err
+       }
+
+       mc := &machinesClient{
+               client:    httpClient,
+               baseUrl:   apiUrl,
+               eventsUrl: eventsUrl,
+               logger:    log.WithPrefix("MachinesClient"),
+       }
+
+       return mc, nil
+}
+
+func (mc *machinesClient) APICall(route string, data interface{}, response any) error {
+       var body io.Reader
+
+       method := "GET"
+       if data != nil {
+               method = "POST"
+               switch td := data.(type) {
+               case string:
+                       body = bytes.NewReader([]byte(td))
+               case []byte:
+                       body = bytes.NewReader(td)
+               default:
+                       bodyJson, err := json.MarshalIndent(data, "", "  ")
+                       if err != nil {
+                               return err
+                       }
+                       body = bytes.NewReader(bodyJson)
+               }
+       }
+
+       route = strings.TrimLeft(route, "/")
+
+       req, err := http.NewRequest(method, fmt.Sprintf("%s/%s", mc.baseUrl, route), body)
+       if err != nil {
+               return err
+       }
+
+       if body != nil {
+               req.Header.Set("content-type", "application/json")
+       }
+
+       resp, err := mc.client.Do(req)
+       if err != nil {
+               return err
+       }
+
+       if resp.StatusCode != http.StatusOK {
+               return NewInvalidHttpResponseError(resp)
+       }
+
+       respBody, err := io.ReadAll(resp.Body)
+       if err != nil {
+               return err
+       }
+
+       err = json.Unmarshal(respBody, response)
+       if err != nil {
+               return fmt.Errorf("error decoding JSON body:\n=====BEGIN JSON=====\n%s\n=====END JSON=====\nerror: %v", respBody, err)
+       }
+
+       return nil
+}
+
+func NewInvalidHttpResponseError(resp *http.Response) error {
+       var bodyStr string
+       body, err := io.ReadAll(resp.Body)
+       if err == nil {
+               bodyStr = string(body)
+       } else {
+               bodyStr = fmt.Sprintf("[error reading body: %v]", err)
+       }
+
+       return fmt.Errorf("received invalid HTTP response with status %d (%s): %s", resp.StatusCode, resp.Status, bodyStr)
+}
diff --git a/machines/event_monitor/Makefile b/machines/event_monitor/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/machines/event_monitor/main.go b/machines/event_monitor/main.go
new file mode 100644 (file)
index 0000000..c79325a
--- /dev/null
@@ -0,0 +1,45 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "os"
+       "os/signal"
+       "syscall"
+
+       "go.fuhry.dev/runtime/machines"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+func main() {
+       flag.Parse()
+       logger := log.WithPrefix("main")
+
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       client, err := machines.NewDefaultMachinesClient("host.attestation.client")
+       if err != nil {
+               logger.Panic(err)
+       }
+
+       ectx, _ := context.WithCancel(ctx)
+
+       eventsChan, err := client.NewEventListener(ectx)
+
+       if err != nil {
+               logger.Panic(err)
+       }
+
+       logger.Info("successfully created event monitor, listening for events")
+mainLoop:
+       for {
+               select {
+               case evt := <-eventsChan:
+                       logger.Noticef("got event with thing=%s, action=%s, tags=<%+v>\n", evt.ItemType, evt.Event, evt.Tags)
+               case <-ctx.Done():
+                       break mainLoop
+               }
+       }
+
+       os.Exit(0)
+}
diff --git a/machines/event_watcher.go b/machines/event_watcher.go
new file mode 100644 (file)
index 0000000..8a5db9e
--- /dev/null
@@ -0,0 +1,111 @@
+package machines
+
+import (
+       "context"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "net/url"
+
+       mqtt "github.com/eclipse/paho.mqtt.golang"
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type MachinesMqttEvent struct {
+       ItemType string            `json:"thing"`
+       Event    string            `json:"event"`
+       Tags     map[string]string `json:"tags"`
+}
+
+func (mc *machinesClient) NewEventListener(ctx context.Context) (chan MachinesMqttEvent, error) {
+       mqttLogger := mc.logger.AppendPrefix(".mqtt")
+
+       mqtt.DEBUG = mqttLogger.WithLevel(log.DEBUG).V(3)
+       mqtt.WARN = mqttLogger.WithLevel(log.WARNING)
+       mqtt.ERROR = mqttLogger.WithLevel(log.ERROR)
+       mqtt.CRITICAL = mqttLogger.WithLevel(log.CRITICAL)
+
+       oauthClient := mc.client.Transport.(*oauthClient)
+       mqttOpts := mqtt.NewClientOptions()
+
+       mqttOpts.Order = false
+       mc.logger.V(1).Infof("attempting to connect to Machines MQTT server at %s", mc.eventsUrl)
+       eventsUrl, err := url.Parse(mc.eventsUrl)
+       if err != nil {
+               return nil, fmt.Errorf("events url %q is invalid: %v", mc.eventsUrl, err)
+       }
+
+       updateCreds := func(mqttOpts *mqtt.ClientOptions) error {
+               accessToken, err := oauthClient.getAccessToken()
+               if err != nil {
+                       return err
+               }
+
+               mc.logger.V(2).Debugf("setting username/password to %s/%s", log.Redact(accessToken), "x")
+               mqttOpts.Username = accessToken
+               mqttOpts.Password = "x"
+
+               return nil
+       }
+
+       if err = updateCreds(mqttOpts); err != nil {
+               return nil, err
+       }
+
+       mqttOpts.Servers = append(mqttOpts.Servers, eventsUrl)
+
+       mqttOpts.OnReconnecting = func(client mqtt.Client, opts *mqtt.ClientOptions) {
+               updateCreds(opts)
+       }
+
+       client := mqtt.NewClient(mqttOpts)
+       t := client.Connect()
+
+       select {
+       case <-t.Done():
+               if err = t.Error(); err != nil {
+                       return nil, err
+               }
+       case <-ctx.Done():
+               return nil, context.Canceled
+       }
+
+       if !client.IsConnected() {
+               return nil, errors.New("somehow we are still not connected??")
+       }
+
+       msgChan := make(chan MachinesMqttEvent)
+
+       handler := func(client mqtt.Client, msg mqtt.Message) {
+               msg.Ack()
+
+               mc.logger.V(2).Debugf("got raw mqtt msg: %s", msg.Payload())
+
+               obj := MachinesMqttEvent{}
+               if err := json.Unmarshal(msg.Payload(), &obj); err == nil {
+                       msgChan <- obj
+               }
+       }
+
+       t = client.Subscribe(constants.MachinesMqttTopic, byte(0), handler)
+       select {
+       case <-t.Done():
+               break
+       case <-ctx.Done():
+               close(msgChan)
+               return nil, context.Canceled
+       }
+
+       go (func() {
+               <-ctx.Done()
+
+               t := client.Unsubscribe(constants.MachinesMqttTopic)
+               t.Wait()
+
+               close(msgChan)
+               client.Disconnect(0)
+       })()
+
+       return msgChan, nil
+}
diff --git a/machines/oauth2.go b/machines/oauth2.go
new file mode 100644 (file)
index 0000000..bdd740b
--- /dev/null
@@ -0,0 +1,223 @@
+package machines
+
+import (
+       "bytes"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "net/http"
+       "os"
+       "path"
+       "strings"
+       "time"
+
+       "go.fuhry.dev/runtime/utils/hashset"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type oauthClientCredentials_AccessTokenRequest struct {
+       GrantType    string `json:"grant_type"`
+       Scope        string `json:"scope"`
+       ClientId     string `json:"client_id"`
+       ClientSecret string `json:"client_secret"`
+}
+
+type oauthClientCredentials_AccessTokenReply struct {
+       TokenType   string `json:"token_type"`
+       AccessToken string `json:"access_token"`
+       ClientId    string `json:"client_id"`
+       ExpiresIn   int64  `json:"expires_in"`
+       Scope       string `json:"scope"`
+}
+
+type oauthClientCredentials_Store struct {
+       TokenEndpoint string                                  `json:"token_endpoint"`
+       ExpiresAt     int64                                   `json:"expires_at"`
+       Token         oauthClientCredentials_AccessTokenReply `json:"token"`
+}
+
+type oauthAuthParams struct {
+       tokenEndpoint string
+       clientId      string
+       clientSecret  string
+       scope         *hashset.HashSet[string]
+}
+
+type oauthClient struct {
+       *http.Transport
+
+       client *http.Client
+       store  *oauthClientCredentials_Store
+       params *oauthAuthParams
+       log    *log.Logger
+}
+
+func SetupOAuthClient(client *http.Client, tokenEndpoint string, clientId string, clientSecret string, scope []string) error {
+       clientCopy := *client
+       client.Transport = &oauthClient{
+               client: &clientCopy,
+               params: &oauthAuthParams{
+                       tokenEndpoint: tokenEndpoint,
+                       clientId:      clientId,
+                       clientSecret:  clientSecret,
+                       scope:         hashset.FromSlice(scope),
+               },
+               log: log.WithPrefix("oauth2"),
+       }
+
+       return nil
+}
+
+func (oc *oauthClient) RoundTrip(request *http.Request) (*http.Response, error) {
+       accessToken, err := oc.getAccessToken()
+       if err != nil {
+               oc.log.Errorf("access token retrieval failed, because: %v\n", err)
+               return nil, err
+       }
+
+       request.Header.Set("authorization", fmt.Sprintf("Bearer %s", accessToken))
+
+       return oc.client.Do(request)
+}
+
+func (oc *oauthClient) credentialCachePath() string {
+       return path.Join(os.TempDir(), fmt.Sprintf("machines-auth-%d.json", os.Getuid()))
+}
+
+func (oc *oauthClient) getAccessToken() (string, error) {
+       accessToken, err := oc.loadAccessToken()
+       if err != nil {
+               oc.log.V(1).Infof("getting a new access token, because: %v\n", err)
+               accessToken, err = oc.renewAccessToken()
+               if err != nil {
+                       oc.log.Criticalf("access token retrieval failed, because: %v\n", err)
+                       return "", err
+               }
+       }
+
+       return accessToken, nil
+}
+
+func (oc *oauthClient) loadAccessToken() (string, error) {
+       now := time.Now()
+
+       // if the client's store in memory has an expired access token, reread it from
+       // the disk
+       if oc.store != nil {
+               if oc.store.ExpiresAt >= now.Unix() {
+                       oc.store = nil
+               }
+       }
+       if oc.store == nil {
+               err := oc.readCredentialCacheFromDisk()
+               if err != nil {
+                       return "", err
+               }
+       }
+
+       return oc.store.Token.AccessToken, nil
+}
+
+func (oc *oauthClient) renewAccessToken() (string, error) {
+       req := oauthClientCredentials_AccessTokenRequest{
+               GrantType:    "client_credentials",
+               ClientId:     oc.params.clientId,
+               ClientSecret: oc.params.clientSecret,
+               Scope:        strings.Join(oc.params.scope.AsSlice(), " "),
+       }
+
+       reqBody, err := json.Marshal(req)
+       if err != nil {
+               return "", err
+       }
+
+       reader := bytes.NewReader(reqBody)
+       httpReq, err := http.NewRequest("POST", oc.params.tokenEndpoint, reader)
+       if err != nil {
+               return "", err
+       }
+
+       httpReq.Header.Set("content-type", "application/json")
+
+       response, err := oc.client.Do(httpReq)
+       if err != nil {
+               return "", err
+       }
+
+       if response.StatusCode != http.StatusOK {
+               return "", NewInvalidHttpResponseError(response)
+       }
+
+       body, err := io.ReadAll(response.Body)
+       if err != nil {
+               return "", err
+       }
+
+       resp := &oauthClientCredentials_AccessTokenReply{}
+       err = json.Unmarshal(body, resp)
+       if err != nil {
+               return "", err
+       }
+
+       err = oc.storeCredentials(resp)
+       if err != nil {
+               return "", err
+       }
+
+       return resp.AccessToken, nil
+}
+
+func (oc *oauthClient) readCredentialCacheFromDisk() error {
+       ccPath := oc.credentialCachePath()
+
+       contents, err := os.ReadFile(ccPath)
+       if err != nil {
+               return err
+       }
+
+       store := oauthClientCredentials_Store{}
+
+       err = json.Unmarshal(contents, &store)
+       if err != nil {
+               return err
+       }
+
+       now := time.Now()
+       if store.ExpiresAt <= now.Unix() {
+               return errors.New("access token has expired")
+       }
+
+       tokenScope := hashset.FromSlice(strings.Split(store.Token.Scope, " "))
+       reqScope := oc.params.scope.Dup()
+       reqScope.Diff(tokenScope)
+       if reqScope.Len() > 0 {
+               return fmt.Errorf("cannot use stored token: missing scopes: \"%s\"",
+                       strings.Join(reqScope.AsSlice(), "\", \""))
+       }
+
+       oc.store = &store
+       return nil
+}
+
+func (oc *oauthClient) storeCredentials(resp *oauthClientCredentials_AccessTokenReply) error {
+       now := time.Now()
+       store := oauthClientCredentials_Store{
+               TokenEndpoint: oc.params.tokenEndpoint,
+               ExpiresAt:     now.Unix() + resp.ExpiresIn,
+               Token:         *resp,
+       }
+
+       storeBytes, err := json.MarshalIndent(store, "", "  ")
+       if err != nil {
+               return err
+       }
+
+       path := oc.credentialCachePath()
+       err = os.WriteFile(path, storeBytes, 0600)
+       if err != nil {
+               return err
+       }
+
+       return nil
+}
diff --git a/machines/types.go b/machines/types.go
new file mode 100644 (file)
index 0000000..8402d03
--- /dev/null
@@ -0,0 +1,143 @@
+package machines
+
+import (
+       "encoding/hex"
+       "net"
+       "time"
+)
+
+type Timestamp uint64
+type IPString string
+type HexEncoded string
+
+type WithUUID struct {
+       ID string `json:"id"`
+}
+
+type WithCalculatedName struct {
+       CalculatedName string `json:"__name__"`
+}
+
+type Host struct {
+       *WithUUID
+       *WithCalculatedName
+
+       Name            string    `json:"name"`
+       Owner           *User     `json:"owner,omitempty"`
+       Role            string    `json:"role"`
+       OS              string    `json:"os"`
+       CreatedAt       Timestamp `json:"created_at"`
+       LastSeen        Timestamp `json:"last_seen"`
+       LastSeenIface   *Iface    `json:"last_seen_iface"`
+       LastSeenIfaceID string    `json:"last_seen_iface"`
+       Interfaces      []*Iface  `json:"interfaces"`
+}
+
+type User struct {
+       *WithUUID
+       *WithCalculatedName
+
+       Authorizer string    `json:"authorizer"`
+       Principal  string    `json:"principal"`
+       CreatedAt  Timestamp `json:"created_at"`
+       LastSeen   Timestamp `json:"last_seen"`
+       Abilities  []string  `json:"abilities"`
+       Flags      []string  `json:"flags"`
+}
+
+type Iface struct {
+       *WithUUID
+       *WithCalculatedName
+
+       Host            *Host          `json:"host"`
+       HostID          string         `json:"host"`
+       Name            string         `json:"name"`
+       MediaType       string         `json:"type"`
+       HardwareAddress string         `json:"hardware_address"`
+       LastIPv4        IPString       `json:"last_inet4"`
+       LastIPv6        IPString       `json:"last_inet6"`
+       LastSeen        Timestamp      `json:"last_seen"`
+       NameScrubbed    string         `json:"name_scrubbed"`
+       Reservations    []*Reservation `json:"reservations"`
+}
+
+type Reservation struct {
+       *WithUUID
+       *WithCalculatedName
+
+       Iface         *Iface   `json:"iface"`
+       IfaceID       string   `json:"iface"`
+       AddressFamily string   `json:"af"`
+       Address       IPString `json:"address"`
+       Domain        *Domain  `json:"domain"`
+       Range         *Range   `json:"range"`
+}
+
+type Domain struct {
+       *WithUUID
+       *WithCalculatedName
+
+       Name   string `json:"name"`
+       Site   *Site  `json:"site"`
+       SiteID string `json:"site"`
+       VlanID uint   `json:"vlan_id"`
+
+       IPv4Address       IPString `json:"inet4_address"`
+       IPv4PrefixLength  uint8    `json:"inet4_prefixlen"`
+       IPv4RouterAddress IPString `json:"inet4_routeraddr"`
+
+       IPv6Address       IPString `json:"inet6_address"`
+       IPv6PrefixLength  uint8    `json:"inet6_prefixlen"`
+       IPv6RouterAddress IPString `json:"inet6_routeraddr"`
+
+       PXEServerIPv4   IPString `json:"pxe4_server"`
+       PXEServerIPv6   IPString `json:"pxe6_server"`
+       PXEFilenameBIOS string   `json:"pxe_filename_bios"`
+       PXEFilenameUEFI string   `json:"pxe_filename_uefi"`
+
+       Features           []string `json:"features"`
+       DefaultRange       *Range
+       ReverseDNSZoneIPv4 string     `json:"inet4_reverse_zone"`
+       ReverseDNSZoneIPv6 string     `json:"inet6_reverse_zone"`
+       GuestSeedStr       HexEncoded `json:"guest_seed"`
+       GuestPassword      string     `json:"guest_password"`
+}
+
+type Range struct {
+       *WithUUID
+       *WithCalculatedName
+}
+
+type Site struct {
+       *WithUUID
+
+       Name string `json:"name"`
+}
+
+type EndorsementKey struct {
+       Found          bool `json:"found"`
+       EndorsementKey struct {
+               PEM         string `json:"pem"`
+               Type        string `json:"type"`
+               Fingerprint struct {
+                       SHA1   HexEncoded `json:"sha1"`
+                       SHA256 HexEncoded `json:"sha256"`
+               } `json:"fingerprint"`
+       } `json:"endorsement_key"`
+}
+
+func (t Timestamp) AsTime() time.Time {
+       return time.Unix(int64(t), 0)
+}
+
+func (ip IPString) AsIP() net.IP {
+       return net.ParseIP(string(ip))
+}
+
+func (h HexEncoded) AsBytes() []byte {
+       ba, err := hex.DecodeString(string(h))
+       if err != nil {
+               return nil
+       }
+       return ba
+}
diff --git a/metrics/apcups_exporter/Makefile b/metrics/apcups_exporter/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/metrics/apcups_exporter/main.go b/metrics/apcups_exporter/main.go
new file mode 100644 (file)
index 0000000..0fa6b6e
--- /dev/null
@@ -0,0 +1,77 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "os/signal"
+       "syscall"
+       "time"
+
+       "github.com/mdlayher/apcupsd"
+       "go.fuhry.dev/runtime/metrics/metricbus/mbclient"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type apcMetrics struct {
+       lineVoltage, loadPercentage, secondsLeft, chargePct, powerConsumption, state mbclient.GaugeMetric
+       timeOnBattery                                                                mbclient.CounterMetric
+       stateMap                                                                     map[string]mbclient.GaugeMetric
+}
+
+func main() {
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+       flag.Parse()
+
+       svc := mbclient.NewService(ctx)
+
+       apcupsd, err := apcupsd.Dial("tcp", "127.0.0.1:3551")
+       if err != nil {
+               log.Panic(err)
+       }
+       defer apcupsd.Close()
+
+       metrics := apcMetrics{
+               lineVoltage:      svc.DefineGauge("apcups_line_voltage", "AC mains line voltage"),
+               loadPercentage:   svc.DefineGauge("apcups_load_pct", "Percentage of total load capacity used on the UPS"),
+               secondsLeft:      svc.DefineGauge("apcups_battery_seconds_remaining", "UPS seconds of battery power remaining"),
+               chargePct:        svc.DefineGauge("apcups_battery_charge_percentage", "UPS battery charge percentage"),
+               powerConsumption: svc.DefineGauge("apcups_power_consumption", "UPS estimate of current watt draw"),
+               state:            svc.DefineGauge("apcups_power_state", "UPS power source", "state"),
+               timeOnBattery:    svc.DefineCounter("apcups_battery_used_time", "UPS total time spent on battery"),
+               stateMap:         make(map[string]mbclient.GaugeMetric, 0),
+       }
+
+       ticker := time.NewTicker(5 * time.Second)
+
+       log.Default().Infof("starting apcups_exporter")
+       defer svc.FlushAndWait()
+       for {
+               select {
+               case <-ticker.C:
+                       status, err := apcupsd.Status()
+                       if err != nil {
+                               log.Default().Errorf("error getting status from apcupsd: %v", err)
+                               continue
+                       }
+                       log.Default().V(1).Infof("UPS status: state=%s, line voltage: %.1f, load %% %.1f, seconds left %.0f, charge pct %.1f",
+                               status.Status, status.LineVoltage, status.LoadPercent, status.TimeLeft.Seconds(), status.BatteryChargePercent)
+                       metrics.lineVoltage.Set(status.LineVoltage)
+                       metrics.loadPercentage.Set(status.LoadPercent)
+                       metrics.secondsLeft.Set(status.TimeLeft.Seconds())
+                       metrics.chargePct.Set(status.BatteryChargePercent)
+                       metrics.powerConsumption.Set(float64(status.NominalPower) * (status.LoadPercent / 100))
+                       if _, ok := metrics.stateMap[status.Status]; !ok {
+                               metrics.stateMap[status.Status] = metrics.state.WithLabelValues(mbclient.KV{"state": status.Status})
+                       }
+                       for state, gauge := range metrics.stateMap {
+                               if state == status.Status {
+                                       gauge.Set(1)
+                               } else {
+                                       gauge.Set(0)
+                               }
+                       }
+               case <-ctx.Done():
+                       return
+               }
+       }
+}
diff --git a/metrics/metricbus/PROTOCOL.md b/metrics/metricbus/PROTOCOL.md
new file mode 100644 (file)
index 0000000..ed155c6
--- /dev/null
@@ -0,0 +1,58 @@
+# metrics bus
+
+**Purpose:** to allow individual runtime processes/services (and potentially others) to send
+metrics up to Prometheus without each one having to declare an individual metrics listener and
+scraper.
+
+**Methodology:** run a single metric collector and listener per host. Individual services use a
+push-based model to publish their metrics.
+
+## Transport
+
+Uses godbus on the system bus. The service identity is `dev.fuhry.runtime.metrics.MetricCollector`.
+
+## Methods
+
+### `Hello(string serviceName, string instanceDiscriminator) (string cookie)`
+
+Notifies the collector of the existence of a service (or an instance of a service).
+
+By convention, client libraries are expected to set `serviceName` to the basename of the executable
+running the service.
+
+The "instance discriminator" allows for instantiated services (that is, services for which multiple
+instances are run or may be run) to publish metrics independently.
+
+If a service with the given serviceName + instanceDiscriminator tuple is already registered by the
+collector, the collector will assume that the service instance exited abnormally and note this in
+the `metricbus_collector_ungraceful_restarts` counter.
+
+Returns a string containing a cookie which will be used by the client library for all future
+events for the process. Conventionally, this cookie will be a numeric connection ID which atomically
+increments for each connection, but clients must treat it as an opaque string value.
+
+### `Goodbye(string cookie)`
+
+Notifies the collector that a service is gracefully shutting down. Client implementations should
+automatically call this when they shut down (i.e. via canceling a context) to avoid incrementing the
+ungraceful_restarts or ungraceful_exits counters.
+
+### `Declare(string serviceCookie, uint16 metricType, string metricName, []string labels) (uint metricIndex)`
+
+Declares a new metric. metricType is one of:
+
+* `CounterMetric`
+* `GaugeMetric`
+
+All metrics have a `_task` label containing the service name.
+
+If the `serviceCookie` represents an instantiated service, the metric will also have an `_instance`
+label populated with the instance name.
+
+### `Post(string serviceCookie, []struct{uint64, float64, []string} values)`
+
+Post metric values. Clients **must** call `Post()` at an interval of 5 seconds or less; services not seen in >10 seconds are purged from memory.
+
+In the case of a gauge, the value of the gauge will be set to the specified value.
+
+In the case of a counter, the counter will be incremented by the specified value.
diff --git a/metrics/metricbus/constants.go b/metrics/metricbus/constants.go
new file mode 100644 (file)
index 0000000..3bdf05e
--- /dev/null
@@ -0,0 +1,91 @@
+package metricbus
+
+import (
+       "fmt"
+
+       "github.com/godbus/dbus/v5"
+)
+
+type MetricType uint
+
+type CollectorError uint
+
+const (
+       CounterMetric MetricType = iota
+       GaugeMetric
+)
+
+const (
+       ErrUnspecified CollectorError = iota
+       ErrServiceCookieNotFound
+       ErrMetricNotFound
+)
+
+const DbusServiceName = "dev.fuhry.runtime.metrics.MetricCollector.v1"
+const DbusServicePath = "/dev/fuhry/runtime/metrics/MetricCollector"
+const SingletonInstanceDiscriminator = "GLOBAL"
+
+type KV map[string]string
+
+func (kv KV) Equals(other KV) bool {
+       if len(kv) != len(other) {
+               return false
+       }
+
+       for k, v := range kv {
+               ov, ok := other[k]
+               if !ok || ov != v {
+                       return false
+               }
+       }
+
+       return true
+}
+
+func (mt MetricType) String() string {
+       switch mt {
+       case CounterMetric:
+               return "counter"
+       case GaugeMetric:
+               return "gauge"
+       }
+
+       panic(fmt.Errorf("unknown metrictype value: %d", uint(mt)))
+}
+
+func (err CollectorError) Error() string {
+       switch err {
+       case ErrUnspecified:
+               return DbusServiceName + ".Error.Unspecified"
+       case ErrServiceCookieNotFound:
+               return DbusServiceName + ".Error.ServiceCookieNotFound"
+       case ErrMetricNotFound:
+               return DbusServiceName + ".Error.MetricNotFound"
+       default:
+               return DbusServiceName + fmt.Sprintf(".Error.Unknown.%d", uint(err))
+       }
+}
+
+// DbusError implements
+func (err CollectorError) DbusError() (string, []interface{}) {
+       return err.Error(), []interface{}{}
+}
+
+func (err CollectorError) DbusErrorS() *dbus.Error {
+       name, body := err.DbusError()
+       return &dbus.Error{
+               Name: name,
+               Body: body,
+       }
+}
+
+func (err CollectorError) Equals(other error) bool {
+       if other == nil {
+               return false
+       }
+       if dbe, ok := other.(dbus.Error); ok {
+               return dbe.Name == err.Error()
+       }
+
+       return false
+}
diff --git a/metrics/metricbus/dbus-1/dev.fuhry.runtime.metrics.conf b/metrics/metricbus/dbus-1/dev.fuhry.runtime.metrics.conf
new file mode 100644 (file)
index 0000000..8978141
--- /dev/null
@@ -0,0 +1,33 @@
+<?xml version="1.0"?> <!--*-nxml-*-->
+<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
+        "https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+
+<busconfig>
+    <policy user="node_exporter">
+        <allow own="dev.fuhry.runtime.metrics.MetricCollector.v1" />
+        <allow send_destination="dev.fuhry.runtime.metrics.MetricCollector.v1" />
+        <allow receive_sender="dev.fuhry.runtime.metrics.MetricCollector.v1" />
+    </policy>
+
+    <policy context="default">
+        <allow send_destination="dev.fuhry.runtime.metrics.MetricCollector.v1"
+            send_interface="dev.fuhry.runtime.metrics.MetricCollector.v1"
+            send_member="Ping" />
+
+        <allow send_destination="dev.fuhry.runtime.metrics.MetricCollector.v1"
+            send_interface="dev.fuhry.runtime.metrics.MetricCollector.v1"
+            send_member="Hello" />
+        
+        <allow send_destination="dev.fuhry.runtime.metrics.MetricCollector.v1"
+            send_interface="dev.fuhry.runtime.metrics.MetricCollector.v1"
+            send_member="Goodbye" />
+        
+        <allow send_destination="dev.fuhry.runtime.metrics.MetricCollector.v1"
+            send_interface="dev.fuhry.runtime.metrics.MetricCollector.v1"
+            send_member="Declare" />
+        
+        <allow send_destination="dev.fuhry.runtime.metrics.MetricCollector.v1"
+            send_interface="dev.fuhry.runtime.metrics.MetricCollector.v1"
+            send_member="Post" />
+    </policy>
+</busconfig>
diff --git a/metrics/metricbus/internal/dbus_interface.go b/metrics/metricbus/internal/dbus_interface.go
new file mode 100644 (file)
index 0000000..d5fffd0
--- /dev/null
@@ -0,0 +1,52 @@
+package internal
+
+import (
+       "flag"
+
+       "github.com/godbus/dbus/v5"
+       "github.com/godbus/dbus/v5/introspect"
+       "go.fuhry.dev/runtime/metrics/metricbus"
+)
+
+var useSessionBus bool
+
+func init() {
+       flag.BoolVar(&useSessionBus, "metricbus.session-bus", false, "use the D-Bus session bus instead of the system bus")
+}
+
+func DbusConn() (*dbus.Conn, error) {
+       if useSessionBus {
+               return dbus.ConnectSessionBus()
+       }
+       return dbus.ConnectSystemBus()
+}
+
+const dbusInterface = `
+<node>
+       <interface name="` + metricbus.DbusServiceName + `">
+               <method name="Ping">
+               </method>
+               <method name="Hello">
+                       <arg name="service_name" direction="in" type="s" />
+                       <arg name="instance_discriminator" direction="in" type="s" />
+                       <arg name="cookie" direction="out" type="s" />
+               </method>
+               <method name="Goodbye">
+                       <arg name="cookie" direction="in" type="s" />
+               </method>
+               <method name="Declare">
+                       <arg name="cookie" direction="in" type="s" />
+                       <arg name="metric_type" direction="in" type="u" />
+                       <arg name="metric_name" direction="in" type="s" />
+                       <arg name="metric_help" direction="in" type="s" />
+                       <arg name="labels" direction="in" type="a{s}" />
+                       <arg name="metric_id" direction="out" type="u" />
+               </method>
+               <method name="Post">
+                       <arg name="cookie" direction="in" type="s" />
+                       <arg name="values" direction="in" type="a{(dda{s})}" />
+               </method>
+       </interface>
+       ` + introspect.IntrospectDataString + `
+</node>
+`
diff --git a/metrics/metricbus/internal/server.go b/metrics/metricbus/internal/server.go
new file mode 100644 (file)
index 0000000..8c51d60
--- /dev/null
@@ -0,0 +1,515 @@
+package internal
+
+import (
+       "context"
+       "crypto/tls"
+       "errors"
+       "flag"
+       "fmt"
+       "net"
+       "net/http"
+       "strconv"
+       "strings"
+       "sync"
+       "time"
+
+       "github.com/godbus/dbus/v5"
+       "github.com/godbus/dbus/v5/introspect"
+       "go.fuhry.dev/runtime/metrics/metricbus"
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/utils/hashset"
+       "go.fuhry.dev/runtime/utils/hostname"
+       "go.fuhry.dev/runtime/utils/log"
+       "go.uber.org/atomic"
+)
+
+type metaMetric struct {
+       metricId uint64
+       servicer *mbServicer
+}
+
+type mbMetaMetrics struct {
+       ungracefulShutdowns *metaMetric
+       ungracefulRestarts  *metaMetric
+}
+
+type mbMetricValue struct {
+       value  *atomic.Float64
+       labels []string
+}
+
+type mbMetricValueArg struct {
+       MetricId    uint64
+       Value       float64
+       LabelValues []string
+}
+
+type mbMetric struct {
+       metricType  metricbus.MetricType
+       name        string
+       help        string
+       valueMu     sync.Mutex
+       validLabels []string
+       values      []*mbMetricValue
+}
+
+type mbService struct {
+       name              string
+       instance          string
+       lastPost          time.Time
+       deregisterPending bool
+
+       metrics map[uint64]*mbMetric
+       nextId  *atomic.Uint64
+}
+
+type mbServicer struct {
+       log         *log.Logger
+       serviceLock sync.RWMutex
+       services    map[uint64]*mbService
+       serviceSet  *hashset.HashSet[string]
+       nextId      *atomic.Uint64
+
+       metaCookie  string
+       metaMetrics mbMetaMetrics
+}
+
+type mbServer struct {
+       log         *log.Logger
+       ctx         context.Context
+       servicer    *mbServicer
+       httpServer  *http.Server
+       httpMux     *http.ServeMux
+       listenOn    *net.TCPAddr
+       startStopMu sync.Mutex
+}
+
+var defaultMetricBusHttpServerPort uint
+
+func init() {
+       flag.UintVar(&defaultMetricBusHttpServerPort, "metricbus.server.port", 7082, "https server port to listen on")
+}
+
+func NewMetricBusServer() (*mbServer, error) {
+       if !flag.Parsed() {
+               return nil, errors.New("flags have not been parsed, cannot determine server port")
+       }
+
+       return NewMetricBusServerWithPort(defaultMetricBusHttpServerPort)
+}
+
+func makeTlsListener(tcpAddr *net.TCPAddr, ctx context.Context) (net.Listener, error) {
+       mtlsId := mtls.DefaultIdentity()
+
+       netListener, err := net.ListenTCP("tcp", tcpAddr)
+       if err != nil {
+               return nil, err
+       }
+       tlsc, err := mtlsId.TlsConfig(ctx)
+       if err != nil {
+               return nil, err
+       }
+       cv := mtls.NewPeerNameVerifier()
+       err = cv.ConfigureServer(tlsc)
+       if err != nil {
+               return nil, err
+       }
+       cv.AllowFrom(mtls.Service, "prometheus")
+       cv.AllowFrom(mtls.User, "dan")
+
+       return tls.NewListener(netListener, tlsc), nil
+}
+
+func NewMetricBusServerWithPort(port uint) (*mbServer, error) {
+       tcpAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf(":%d", port))
+       if err != nil {
+               return nil, err
+       }
+
+       logger := log.WithPrefix("metricbus.server")
+
+       servicer, err := newMetricBusServicer(logger)
+       if err != nil {
+               return nil, err
+       }
+
+       handler := http.NewServeMux()
+       handler.HandleFunc("/metrics", servicer.metricsToString)
+       server := &mbServer{
+               log:     logger,
+               httpMux: handler,
+               httpServer: &http.Server{
+                       Handler: handler,
+               },
+               listenOn: tcpAddr,
+               servicer: servicer,
+       }
+
+       server.log.V(1).Infof("Server configured to listen on %s", tcpAddr.String())
+
+       return server, nil
+}
+
+func (s *mbServer) Start(ctx context.Context) error {
+       s.startStopMu.Lock()
+       defer s.startStopMu.Unlock()
+
+       s.ctx = ctx
+       // startup dbus server
+       dbusConn, err := DbusConn()
+       if err != nil {
+               return err
+       }
+
+       s.log.V(2).Notice("Connected to D-Bus")
+
+       dbusConn.Export(s.servicer, metricbus.DbusServicePath, metricbus.DbusServiceName)
+       dbusConn.Export(introspect.Introspectable(dbusInterface), metricbus.DbusServicePath, "org.fredesktop.DBus.Introspectable")
+
+       reply, err := dbusConn.RequestName(metricbus.DbusServiceName, dbus.NameFlagDoNotQueue)
+       if err != nil {
+               return err
+       }
+       if reply != dbus.RequestNameReplyPrimaryOwner {
+               return errors.New("dbus name already taken")
+       }
+
+       s.log.V(2).Noticef("Successfully registered D-Bus interface: %s", metricbus.DbusServiceName)
+
+       // startup http server
+       s.log.V(2).Notice("Starting TLS server")
+       listener, err := makeTlsListener(s.listenOn, ctx)
+       if err != nil {
+               return err
+       }
+
+       go s.httpServer.Serve(listener)
+       go (func() {
+               shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+
+               defer s.startStopMu.Unlock()
+               defer cancel()
+               defer dbusConn.Close()
+               defer s.httpServer.Shutdown(shutdownCtx)
+
+               ticker := time.NewTicker(1 * time.Second)
+               for {
+                       select {
+                       case <-ticker.C:
+                               s.servicer.sweepDeadServices()
+                       case <-s.ctx.Done():
+                               s.startStopMu.Lock()
+                               return
+                       }
+               }
+       })()
+
+       s.log.Notice("Server started")
+
+       return nil
+}
+
+func newMetricBusServicer(log *log.Logger) (*mbServicer, error) {
+       s := &mbServicer{
+               log:        log.AppendPrefix(".servicer"),
+               services:   make(map[uint64]*mbService, 0),
+               serviceSet: hashset.NewHashSet[string](),
+               nextId:     atomic.NewUint64(0),
+       }
+
+       metaCookie, err := s.Hello("metricbus_collector", "")
+       if err != nil {
+               return nil, err
+       }
+       s.metaCookie = metaCookie
+       ungracefulShutdownsMetricId, err := s.Declare(s.metaCookie, metricbus.CounterMetric, "metricbus_collector_ungraceful_shutdowns", "Tracks the number of un-graceful shutdowns (services which have not posted metrics in >10s)", []string{"service", "instance"})
+       if err != nil {
+               return nil, err
+       }
+       ungracefulRestartsMetricId, err := s.Declare(s.metaCookie, metricbus.CounterMetric, "metricbus_collector_ungraceful_restarts", "Tracks the number of un-graceful restarts (services which declared a name/discriminator pair that was already taken)", []string{"service", "instance"})
+       if err != nil {
+               return nil, err
+       }
+
+       s.metaMetrics = mbMetaMetrics{
+               ungracefulShutdowns: &metaMetric{
+                       metricId: ungracefulShutdownsMetricId,
+                       servicer: s,
+               },
+               ungracefulRestarts: &metaMetric{
+                       metricId: ungracefulRestartsMetricId,
+                       servicer: s,
+               },
+       }
+
+       return s, nil
+}
+
+func (s *mbServicer) metricsToString(w http.ResponseWriter, r *http.Request) {
+       w.Header().Set("content-type", "text/plain; version=0.0.4; charset=utf-8")
+
+       for cookie, service := range s.services {
+               for _, metric := range service.metrics {
+                       metricName := metric.name // fmt.Sprintf("%s_%s", service.name, metric.name)
+                       fmt.Fprintf(
+                               w,
+                               "# HELP %s %s\n",
+                               metricName,
+                               metric.help,
+                       )
+                       fmt.Fprintf(
+                               w,
+                               "# TYPE %s %s\n",
+                               metricName,
+                               metric.metricType.String(),
+                       )
+
+                       baseLabelValues := []string{
+                               fmt.Sprintf("%s=%q", "_task", service.name),
+                               fmt.Sprintf("%s=%q", "_host", hostname.Fqdn()),
+                       }
+                       if service.instance != metricbus.SingletonInstanceDiscriminator {
+                               baseLabelValues = append(baseLabelValues, fmt.Sprintf("%s=%s", "_instance", service.instance))
+                       }
+
+                       for _, value := range metric.values {
+                               labels := make([]string, len(baseLabelValues))
+                               copy(labels, baseLabelValues)
+
+                               for i, labelName := range metric.validLabels {
+                                       if value.labels[i] != "" {
+                                               labels = append(labels, fmt.Sprintf("%s=%q", labelName, value.labels[i]))
+                                       }
+                               }
+
+                               fmt.Fprintf(
+                                       w,
+                                       "%s{%s} %.2f\n",
+                                       metricName,
+                                       strings.Join(labels, ","),
+                                       value.value.Load(),
+                               )
+                       }
+
+                       fmt.Fprintf(w, "\n")
+               }
+
+               if service.deregisterPending {
+                       s.log.Infof("deregistering dead service %s/%s with cookie value %d", service.name, service.instance, cookie)
+                       delete(s.services, cookie)
+               }
+       }
+}
+
+func (s *mbServicer) Ping() *dbus.Error {
+       return nil
+}
+
+func (s *mbServicer) Hello(serviceName string, instanceDiscriminator string) (string, *dbus.Error) {
+       s.serviceLock.Lock()
+       defer s.serviceLock.Unlock()
+
+       if instanceDiscriminator == "" {
+               instanceDiscriminator = metricbus.SingletonInstanceDiscriminator
+       }
+       serviceKey := fmt.Sprintf("%s/%s", serviceName, instanceDiscriminator)
+       if s.serviceSet.Contains(serviceKey) {
+               s.log.Warnf("detected an ungraceful restart for service %s", serviceKey)
+               s.metaMetrics.ungracefulRestarts.Set(1.0, metricbus.KV{
+                       "service":  serviceName,
+                       "instance": instanceDiscriminator,
+               })
+       }
+
+       id := s.nextId.Inc()
+
+       s.services[id] = &mbService{
+               name:     serviceName,
+               instance: instanceDiscriminator,
+               lastPost: time.Now(),
+               metrics:  make(map[uint64]*mbMetric, 0),
+               nextId:   atomic.NewUint64(0),
+       }
+       s.serviceSet.Add(serviceKey)
+
+       s.log.Noticef("service %s registered", serviceKey)
+
+       return fmt.Sprintf("%d", id), nil
+}
+
+func (s *mbServicer) Goodbye(cookie string) *dbus.Error {
+       s.serviceLock.Lock()
+       defer s.serviceLock.Unlock()
+
+       serviceIndex, err := strconv.ParseUint(cookie, 10, 64)
+       if err != nil {
+               return metricbus.ErrServiceCookieNotFound.DbusErrorS()
+       }
+
+       if _, ok := s.services[serviceIndex]; !ok {
+               return &dbus.ErrMsgNoObject
+       }
+
+       serviceKey := fmt.Sprintf("%s/%s", s.services[serviceIndex].name, s.services[serviceIndex].instance)
+
+       s.services[serviceIndex].deregisterPending = true
+       s.serviceSet.Del(serviceKey)
+
+       s.log.Noticef("service %s deregistered gracefully", serviceKey)
+
+       return nil
+}
+
+func (s *mbServicer) Declare(serviceCookie string, metricType metricbus.MetricType, metricName string, metricHelp string, labels []string) (uint64, *dbus.Error) {
+       s.serviceLock.RLock()
+       defer s.serviceLock.RUnlock()
+
+       service, err := s.serviceFromCookie(serviceCookie)
+       if err != nil {
+               return 0, metricbus.ErrServiceCookieNotFound.DbusErrorS()
+       }
+       metricId := service.nextId.Inc()
+
+       service.metrics[metricId] = &mbMetric{
+               metricType:  metricType,
+               name:        metricName,
+               help:        metricHelp,
+               validLabels: labels,
+               values:      make([]*mbMetricValue, 0),
+       }
+
+       s.log.Noticef("service %s/%s declared %s metric with id %d: %s", service.name, service.instance, metricType.String(), metricId, metricName)
+
+       return metricId, nil
+}
+
+func (s *mbServicer) Post(serviceCookie string, values []mbMetricValueArg) *dbus.Error {
+       s.serviceLock.RLock()
+       defer s.serviceLock.RUnlock()
+
+       service, err := s.serviceFromCookie(serviceCookie)
+       if err != nil {
+               return metricbus.ErrServiceCookieNotFound.DbusErrorS()
+       }
+
+       s.log.V(2).Debugf("service %q posted update: %+v", service.name, values)
+
+       service.lastPost = time.Now()
+
+       for _, value := range values {
+               metric, ok := service.metrics[value.MetricId]
+               if !ok {
+                       return metricbus.ErrMetricNotFound.DbusErrorS()
+               }
+
+               err = metric.post(value.Value, value.LabelValues)
+               if err != nil {
+                       return dbus.MakeFailedError(err)
+               }
+       }
+
+       return nil
+}
+
+func (s *mbServicer) serviceFromCookie(serviceCookie string) (*mbService, error) {
+       serviceIndex, err := strconv.ParseUint(serviceCookie, 10, 64)
+       if err != nil {
+               return nil, fmt.Errorf("failed to parse service cookie as uint64: %s", serviceCookie)
+       }
+
+       if service, ok := s.services[serviceIndex]; ok {
+               return service, nil
+       }
+
+       return nil, fmt.Errorf("failed to lookup service with cookie %s", serviceCookie)
+}
+
+func (s *mbServicer) sweepDeadServices() {
+       s.serviceLock.Lock()
+       defer s.serviceLock.Unlock()
+
+       for c, service := range s.services {
+               if fmt.Sprintf("%d", c) == s.metaCookie {
+                       continue
+               }
+               if service.deregisterPending {
+                       continue
+               }
+
+               if service.lastPost.Add(10 * time.Second).Before(time.Now()) {
+                       s.log.Warnf("service %s/%s (cookie %d) not seen since %s, recording unsuccessful shutdown",
+                               service.name,
+                               service.instance,
+                               c,
+                               service.lastPost)
+
+                       s.metaMetrics.ungracefulShutdowns.Set(1.0, metricbus.KV{
+                               "service":  service.name,
+                               "instance": service.instance,
+                       })
+
+                       serviceKey := fmt.Sprintf("%s/%s", service.name, service.instance)
+                       s.serviceSet.Del(serviceKey)
+                       s.services[c].deregisterPending = true
+               }
+       }
+}
+
+func (m *mbMetric) post(val float64, labelValues []string) error {
+       m.valueMu.Lock()
+       defer m.valueMu.Unlock()
+
+       if len(labelValues) != len(m.validLabels) {
+               return fmt.Errorf("value for metric %s does not contain the correct number of label values (%d)", m.name, len(m.validLabels))
+       }
+
+       for _, mv := range m.values {
+               if mv.LabelsEqual(labelValues) {
+                       switch m.metricType {
+                       case metricbus.CounterMetric:
+                               mv.value.Add(val)
+                       case metricbus.GaugeMetric:
+                               mv.value.Store(val)
+                       default:
+                               log.Default().WithPrefix("metricbus.server.mbMetric").Panicf("metric %s: unknown metric type: %d", m.name, m.metricType)
+                       }
+
+                       return nil
+               }
+       }
+
+       valueEntry := &mbMetricValue{
+               value:  atomic.NewFloat64(val),
+               labels: labelValues,
+       }
+       m.values = append(m.values, valueEntry)
+
+       return nil
+}
+
+func (v *mbMetricValue) LabelsEqual(values []string) bool {
+       // NOTE: will cause a panic if lengths are not pre-checked!
+       for i := range v.labels {
+               if v.labels[i] != values[i] {
+                       return false
+               }
+       }
+
+       return true
+}
+
+func (m *metaMetric) Set(val float64, labels metricbus.KV) {
+       service, err := m.servicer.serviceFromCookie(m.servicer.metaCookie)
+       if err != nil {
+               return
+       }
+       metric := service.metrics[m.metricId]
+       labelValues := make([]string, len(metric.validLabels))
+       for i, label := range metric.validLabels {
+               if v, ok := labels[label]; ok {
+                       labelValues[i] = v
+               }
+       }
+
+       metric.post(val, labelValues)
+}
diff --git a/metrics/metricbus/mbclient/client.go b/metrics/metricbus/mbclient/client.go
new file mode 100644 (file)
index 0000000..d2a1145
--- /dev/null
@@ -0,0 +1,269 @@
+package mbclient
+
+import (
+       "context"
+       "flag"
+       "fmt"
+       "os"
+       "path"
+       "strings"
+       "sync"
+       "time"
+
+       "go.fuhry.dev/runtime/metrics/metricbus"
+       "go.fuhry.dev/runtime/utils/log"
+       "go.uber.org/atomic"
+)
+
+type KV = metricbus.KV
+
+type MetricBusService struct {
+       name     string
+       instance string
+
+       log *log.Logger
+
+       client         metricBusLowLevelConnection
+       ctx            context.Context
+       childCtx       context.Context
+       childCtxCancel context.CancelFunc
+
+       eventQ  []metricValue
+       metrics map[string]*metricMetadata
+
+       mu sync.Mutex
+
+       serviceCookie string
+}
+
+var defaultMetricBusServiceName string
+var defaultMetricBusServiceDiscriminator string
+
+func init() {
+       taskName := strings.ReplaceAll(path.Base(os.Args[0]), "-", "_")
+       flag.StringVar(&defaultMetricBusServiceName, "metricbus.client.service-name", taskName, "service name to use for publishing metrics")
+       flag.StringVar(&defaultMetricBusServiceDiscriminator, "metricbus.client.service-discriminator", "", "discriminator to use by default for publishing metrics")
+}
+
+func NewService(ctx context.Context) *MetricBusService {
+       return NewServiceWithDiscriminator(ctx, defaultMetricBusServiceDiscriminator)
+}
+
+func NewServiceWithDiscriminator(ctx context.Context, instanceDiscriminator string) *MetricBusService {
+       childCtx, cancel := context.WithCancel(context.Background())
+       svc := &MetricBusService{
+               name:     defaultMetricBusServiceName,
+               instance: instanceDiscriminator,
+
+               log:     log.WithPrefix(fmt.Sprintf("metricbus.client[%s]", defaultMetricBusServiceName)),
+               eventQ:  make([]metricValue, 0),
+               metrics: make(map[string]*metricMetadata, 0),
+
+               client:         newLowLevelClient(childCtx),
+               ctx:            ctx,
+               childCtx:       childCtx,
+               childCtxCancel: cancel,
+       }
+
+       go svc.loop()
+
+       svc.tryRegister()
+
+       return svc
+}
+func (s *MetricBusService) FlushAndWait() {
+       if s.childCtx.Err() == context.Canceled || s.serviceCookie == "" {
+               return
+       }
+
+       s.Flush()
+       <-s.childCtx.Done()
+
+       // wait on the mutex to be released
+       s.mu.Lock()
+       defer s.mu.Unlock()
+}
+
+func (s *MetricBusService) DefineCounter(metricName, descr string, labelNames ...string) CounterMetric {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       bm := &baseMetric{
+               metricMetadata: &metricMetadata{
+                       s:          s,
+                       name:       metricName,
+                       help:       descr,
+                       metricType: metricbus.CounterMetric,
+                       labelNames: labelNames,
+               },
+               labelValues: make([]string, len(labelNames)),
+       }
+
+       if err := bm.EnsureDeclared(); err != nil {
+               s.log.Warn("failed to declare metric, will retry when publishing: ", err)
+       }
+
+       s.metrics[metricName] = bm.metricMetadata
+
+       return &counterMetric{
+               baseMetric: bm,
+       }
+}
+
+func (s *MetricBusService) DefineGauge(metricName, descr string, labelNames ...string) GaugeMetric {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       bm := &baseMetric{
+               metricMetadata: &metricMetadata{
+                       s:          s,
+                       name:       metricName,
+                       help:       descr,
+                       metricType: metricbus.GaugeMetric,
+                       labelNames: labelNames,
+               },
+               labelValues: make([]string, len(labelNames)),
+       }
+
+       if err := bm.EnsureDeclared(); err != nil {
+               s.log.Warn("failed to declare metric, will retry when publishing: ", err)
+       }
+
+       s.metrics[metricName] = bm.metricMetadata
+
+       return &gaugeMetric{
+               baseMetric: bm,
+       }
+}
+
+func (s *MetricBusService) setLocalStateDeregistered() {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       s.serviceCookie = ""
+
+       // clear metric IDs so that these are recreated
+       for _, metric := range s.metrics {
+               metric.metricId = 0
+       }
+}
+
+func (s *MetricBusService) tryRegister() {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       if s.serviceCookie != "" {
+               return
+       }
+
+       cookie, err := s.client.Hello(s.name, s.instance)
+       if err != nil {
+               s.log.Warn("failed to register service with MetricBus server: ", err)
+       }
+
+       s.serviceCookie = cookie
+}
+
+func (s *MetricBusService) Flush() {
+       var err error
+
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+flushRestart:
+       for _, m := range s.metrics {
+               err = m.EnsureDeclared()
+               if err != nil {
+                       if metricbus.ErrServiceCookieNotFound.Equals(err) {
+                               goto handleServerRestarted
+                       }
+
+                       s.log.Warn("failed to declare metric: ", err)
+               }
+       }
+
+       for i := range s.eventQ {
+               ev := &s.eventQ[i]
+
+               baseMetric := s.metrics[ev.metricName]
+               ev.MetricId = baseMetric.metricId
+               ev.ExportValue = ev.value.Load()
+       }
+
+       err = s.client.Post(s.serviceCookie, s.eventQ)
+       if err != nil {
+               if metricbus.ErrServiceCookieNotFound.Equals(err) {
+                       goto handleServerRestarted
+               }
+               s.log.Warn("failed to post events, queue will remain unflushed: ", err)
+               return
+       }
+
+       s.eventQ = make([]metricValue, 0)
+       return
+
+handleServerRestarted:
+       s.log.Notice("Server was restarted! Resetting local state and re-registering with collector.")
+
+       s.mu.Unlock()
+       s.setLocalStateDeregistered()
+       s.tryRegister()
+       s.mu.Lock()
+
+       goto flushRestart
+}
+
+func (s *MetricBusService) deregister() {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       if s.serviceCookie == "" {
+               return
+       }
+
+       err := s.client.Goodbye(s.serviceCookie)
+       if err != nil {
+               s.log.Warn("failed to deregister service from metric collector: ", err)
+       }
+
+       s.childCtxCancel()
+}
+
+func (s *MetricBusService) loop() {
+       ticker := time.NewTicker(1 * time.Second)
+
+       for {
+               select {
+               case <-s.ctx.Done():
+                       s.deregister()
+                       return
+               case <-ticker.C:
+                       s.Flush()
+               }
+       }
+}
+
+func (s *MetricBusService) event(metricName string, labelValues []string) *metricValue {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       if metricName == "" {
+               s.log.Panic("empty metric names are not permitted")
+       }
+
+       mv := metricValue{
+               metricName:  metricName,
+               LabelValues: labelValues,
+       }
+
+       for _, qmv := range s.eventQ {
+               if qmv.Equals(mv) {
+                       return &qmv
+               }
+       }
+
+       mv.value = atomic.NewFloat64(0.0)
+
+       s.eventQ = append(s.eventQ, mv)
+       return &s.eventQ[len(s.eventQ)-1]
+}
diff --git a/metrics/metricbus/mbclient/conn.go b/metrics/metricbus/mbclient/conn.go
new file mode 100644 (file)
index 0000000..80e8e93
--- /dev/null
@@ -0,0 +1,167 @@
+package mbclient
+
+import (
+       "context"
+       "fmt"
+       "sync"
+
+       "github.com/godbus/dbus/v5"
+       "go.fuhry.dev/runtime/metrics/metricbus"
+       mbinternal "go.fuhry.dev/runtime/metrics/metricbus/internal"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+var globalDbusConn *dbus.Conn
+var globalDbusConnOnce sync.Once
+
+var globalDbusMetricBusObj dbus.BusObject
+var globalDbusMetricBusObjMu sync.Mutex
+
+const metricBusApiPrefix = metricbus.DbusServiceName
+
+type metricBusLowLevelClient struct {
+       ctx context.Context
+}
+
+func newDbusConnection(ctx context.Context) (*dbus.Conn, error) {
+       conn, err := mbinternal.DbusConn()
+       if err != nil {
+               return nil, err
+       }
+
+       go (func() {
+               <-ctx.Done()
+               conn.Close()
+       })()
+
+       return conn, nil
+}
+
+func mustGlobalDbusConn(ctx context.Context) *dbus.Conn {
+       globalDbusConnOnce.Do(func() {
+               conn, err := newDbusConnection(ctx)
+               if err != nil {
+                       log.Panic(err)
+               }
+               if conn == nil {
+                       log.Panic("dbus session connection is nil, but so was err??")
+               }
+
+               globalDbusConn = conn
+       })
+
+       return globalDbusConn
+}
+
+func metricBusDbusObject(ctx context.Context) (dbus.BusObject, error) {
+       globalDbusMetricBusObjMu.Lock()
+       defer globalDbusMetricBusObjMu.Unlock()
+
+       if globalDbusMetricBusObj == nil {
+               conn := mustGlobalDbusConn(ctx)
+               globalDbusMetricBusObj = conn.Object(metricBusApiPrefix, metricbus.DbusServicePath)
+
+               if globalDbusMetricBusObj == nil {
+                       log.Default().Errorf("failed to get BusObject")
+                       return nil, fmt.Errorf("failed to get BusObject")
+               }
+       }
+
+       call := globalDbusMetricBusObj.Call(metricBusApiPrefix+".Ping", dbus.Flags(0))
+       if call.Err != nil {
+               log.Default().Error("failed to ping MetricBus server: ", call.Err)
+               globalDbusMetricBusObj = nil
+               return nil, call.Err
+       }
+
+       return globalDbusMetricBusObj, nil
+}
+
+func newLowLevelClient(ctx context.Context) metricBusLowLevelConnection {
+       return &metricBusLowLevelClient{
+               ctx: ctx,
+       }
+}
+
+func (c *metricBusLowLevelClient) Ping() error {
+       obj, err := metricBusDbusObject(c.ctx)
+       if err != nil {
+               return err
+       }
+
+       result := obj.Call(metricBusApiPrefix+".Ping", dbus.Flags(0))
+       if result.Err != nil {
+               return result.Err
+       }
+
+       return nil
+}
+
+func (c *metricBusLowLevelClient) Hello(serviceName, instanceDiscriminator string) (serviceCookie string, err error) {
+       obj, err := metricBusDbusObject(c.ctx)
+       if err != nil {
+               return "", err
+       }
+
+       result := obj.Call(metricBusApiPrefix+".Hello", dbus.Flags(0), serviceName, instanceDiscriminator)
+       if result.Err != nil {
+               return "", result.Err
+       }
+
+       result.Store(&serviceCookie)
+       return serviceCookie, nil
+}
+
+func (c *metricBusLowLevelClient) Goodbye(serviceCookie string) error {
+       obj, err := metricBusDbusObject(c.ctx)
+       if err != nil {
+               return err
+       }
+
+       result := obj.Call(metricBusApiPrefix+".Goodbye", dbus.Flags(0), serviceCookie)
+       if result.Err != nil {
+               return result.Err
+       }
+
+       return nil
+}
+
+func (c *metricBusLowLevelClient) Declare(serviceCookie string, metricType metricbus.MetricType, metricName string, metricHelp string, labels ...string) (metricId uint64, err error) {
+       obj, err := metricBusDbusObject(c.ctx)
+       if err != nil {
+               return 0, err
+       }
+
+       result := obj.Call(metricBusApiPrefix+".Declare",
+               dbus.Flags(0),
+               serviceCookie,
+               uint16(metricType),
+               metricName,
+               metricHelp,
+               labels)
+
+       if result.Err != nil {
+               return 0, result.Err
+       }
+
+       result.Store(&metricId)
+       return metricId, nil
+}
+
+func (c *metricBusLowLevelClient) Post(serviceCookie string, values []metricValue) error {
+       obj, err := metricBusDbusObject(c.ctx)
+       if err != nil {
+               return err
+       }
+
+       result := obj.Call(metricBusApiPrefix+".Post",
+               dbus.Flags(0),
+               serviceCookie,
+               values)
+
+       if result.Err != nil {
+               return result.Err
+       }
+
+       return nil
+}
diff --git a/metrics/metricbus/mbclient/example/Makefile b/metrics/metricbus/mbclient/example/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/metrics/metricbus/mbclient/example/main.go b/metrics/metricbus/mbclient/example/main.go
new file mode 100644 (file)
index 0000000..9f9a72f
--- /dev/null
@@ -0,0 +1,43 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "math/rand"
+       "os/signal"
+       "syscall"
+       "time"
+
+       "go.fuhry.dev/runtime/metrics/metricbus/mbclient"
+)
+
+func main() {
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+       flag.Parse()
+
+       svc := mbclient.NewServiceWithDiscriminator(ctx, "merr")
+
+       counter := svc.DefineCounter("test_count", "test counter", "labelone", "labeltwo")
+       gauge := svc.DefineGauge("test_gauge", "test gauge", "label_a", "label_b")
+
+       counterLabelSetOne := counter.WithLabelValues(mbclient.KV{"labelone": "foo", "labeltwo": "bar"})
+       counterLabelSetTwo := counter.WithLabelValues(mbclient.KV{"labelone": "baz", "labeltwo": "quux"})
+
+       gaugeLabelSetOne := gauge.WithLabelValues(mbclient.KV{"label_a": "one", "label_b": "two"})
+       gaugeLabelSetTwo := gauge.WithLabelValues(mbclient.KV{"label_a": "three", "label_b": "four"})
+
+       ticker := time.NewTicker(5 * time.Second)
+
+       defer svc.FlushAndWait()
+       for {
+               select {
+               case <-ticker.C:
+                       counterLabelSetOne.Add(rand.Float64())
+                       counterLabelSetTwo.Add(rand.Float64())
+                       gaugeLabelSetOne.Set(rand.Float64())
+                       gaugeLabelSetTwo.Set(rand.Float64())
+               case <-ctx.Done():
+                       return
+               }
+       }
+}
diff --git a/metrics/metricbus/mbclient/intf.go b/metrics/metricbus/mbclient/intf.go
new file mode 100644 (file)
index 0000000..8b5ccdb
--- /dev/null
@@ -0,0 +1,34 @@
+package mbclient
+
+import (
+       "go.fuhry.dev/runtime/metrics/metricbus"
+       "go.uber.org/atomic"
+)
+
+type metricValue struct {
+       MetricId    uint64
+       ExportValue float64
+       LabelValues []string
+
+       metricName string
+       value      *atomic.Float64
+}
+
+type metricBusLowLevelConnection interface {
+       Ping() error
+       Hello(serviceName, instance string) (string, error)
+       Goodbye(cookie string) error
+       Declare(serviceCookie string, metricType metricbus.MetricType, metricName string, metricHelp string, labels ...string) (uint64, error)
+       Post(serviceCookie string, values []metricValue) error
+}
+
+type CounterMetric interface {
+       WithLabelValues(metricbus.KV) CounterMetric
+       Add(float64)
+}
+
+type GaugeMetric interface {
+       WithLabelValues(metricbus.KV) GaugeMetric
+       Set(float64)
+       Reset()
+}
diff --git a/metrics/metricbus/mbclient/metrics.go b/metrics/metricbus/mbclient/metrics.go
new file mode 100644 (file)
index 0000000..45bd0c5
--- /dev/null
@@ -0,0 +1,117 @@
+package mbclient
+
+import (
+       "go.fuhry.dev/runtime/metrics/metricbus"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type metricMetadata struct {
+       s          *MetricBusService
+       name       string
+       help       string
+       metricType metricbus.MetricType
+       labelNames []string
+       metricId   uint64
+}
+
+type baseMetric struct {
+       *metricMetadata
+       labelValues []string
+}
+
+type counterMetric struct {
+       *baseMetric
+}
+
+type gaugeMetric struct {
+       *baseMetric
+}
+
+func (v metricValue) Equals(other metricValue) bool {
+       if v.metricName != other.metricName {
+               return false
+       }
+       for i := range v.LabelValues {
+               if v.LabelValues[i] != other.LabelValues[i] {
+                       return false
+               }
+       }
+
+       return true
+}
+
+func (m *metricMetadata) EnsureDeclared() error {
+       if m.metricId != 0 {
+               return nil
+       }
+
+       metricId, err := m.s.client.Declare(
+               m.s.serviceCookie,
+               m.metricType,
+               m.name,
+               m.help,
+               m.labelNames...,
+       )
+       if err != nil {
+               return err
+       }
+
+       log.Default().WithPrefix("metricbus.mbclient").V(1).Debugf("declared metric %q with id %d",
+               m.name, metricId)
+
+       m.metricId = metricId
+
+       return nil
+}
+
+func (m *baseMetric) WithLabelValues(kv metricbus.KV) *baseMetric {
+       newMetric := &baseMetric{
+               metricMetadata: m.metricMetadata,
+               labelValues:    make([]string, len(m.labelNames)),
+       }
+
+       n := len(kv)
+       for i, k := range m.labelNames {
+               if val, ok := kv[k]; ok {
+                       newMetric.labelValues[i] = val
+                       n--
+               }
+       }
+
+       if n > 0 {
+               log.Default().Panicf("metric got kv with unrecognized labels: %+v, valid labels: %+v",
+                       kv, m.labelNames)
+       }
+
+       return newMetric
+}
+
+func (m *counterMetric) Add(val float64) {
+       qent := m.s.event(m.name, m.labelValues)
+       qent.value.Add(val)
+}
+
+func (m *counterMetric) WithLabelValues(kv metricbus.KV) CounterMetric {
+       newMetric := &counterMetric{
+               baseMetric: m.baseMetric.WithLabelValues(kv),
+       }
+
+       return newMetric
+}
+
+func (m *gaugeMetric) Set(val float64) {
+       qent := m.s.event(m.name, m.labelValues)
+       qent.value.Store(val)
+}
+
+func (m *gaugeMetric) Reset() {
+       m.Set(0.0)
+}
+
+func (m *gaugeMetric) WithLabelValues(kv metricbus.KV) GaugeMetric {
+       newMetric := &gaugeMetric{
+               baseMetric: m.baseMetric.WithLabelValues(kv),
+       }
+
+       return newMetric
+}
diff --git a/metrics/metricbus/mbserver/Makefile b/metrics/metricbus/mbserver/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/metrics/metricbus/mbserver/main.go b/metrics/metricbus/mbserver/main.go
new file mode 100644 (file)
index 0000000..237116c
--- /dev/null
@@ -0,0 +1,31 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "os/signal"
+       "syscall"
+
+       mbinternal "go.fuhry.dev/runtime/metrics/metricbus/internal"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+func main() {
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+       serverCtx, cancel := context.WithCancel(context.Background())
+
+       flag.Parse()
+
+       mbs, err := mbinternal.NewMetricBusServer()
+       if err != nil {
+               log.Panic(err)
+       }
+
+       err = mbs.Start(serverCtx)
+       if err != nil {
+               log.Panic(err)
+       }
+
+       <-ctx.Done()
+       cancel()
+}
diff --git a/metrics/metricbus/systemd/system/metric-collector.service b/metrics/metricbus/systemd/system/metric-collector.service
new file mode 100644 (file)
index 0000000..caff109
--- /dev/null
@@ -0,0 +1,12 @@
+[Unit]
+Description=Metric Collector
+
+[Service]
+Type=dbus
+User=node_exporter
+BusName=dev.fuhry.runtime.metrics.MetricCollector.v1
+ExecStart=/usr/bin/metricbus-collector -mtls.id=node-exporter
+
+[Install]
+Alias=dev.fuhry.runtime.metrics.MetricCollector.service
+WantedBy=default.target
diff --git a/mtls/certutil/certutil.go b/mtls/certutil/certutil.go
new file mode 100644 (file)
index 0000000..3e1bc99
--- /dev/null
@@ -0,0 +1,155 @@
+package certutil
+
+import (
+       "crypto"
+       "crypto/x509"
+       "encoding/asn1"
+       "encoding/pem"
+       "fmt"
+       "net/url"
+       "os"
+       "strings"
+)
+
+var oidSubjectAltName = asn1.ObjectIdentifier{2, 5, 29, 17}
+
+func LoadCertificatesFromPEM(path string) ([]*x509.Certificate, error) {
+       var pemBlock *pem.Block
+
+       certs := make([]*x509.Certificate, 0)
+       contents, err := os.ReadFile(path)
+       if err != nil {
+               return nil, err
+       }
+
+       for {
+               pemBlock, contents = pem.Decode(contents)
+               if pemBlock == nil {
+                       return certs, nil
+               }
+
+               if pemBlock.Type == "CERTIFICATE" {
+                       cert, err := x509.ParseCertificate(pemBlock.Bytes)
+                       if err != nil {
+                               return nil, err
+                       }
+
+                       certs = append(certs, cert)
+               }
+       }
+}
+
+func LoadPrivateKeyFromPEM(path string) (crypto.PrivateKey, error) {
+       contents, err := os.ReadFile(path)
+       if err != nil {
+               return nil, err
+       }
+
+       return LoadPrivateKeyFromPEMBytes(contents)
+}
+
+func LoadPrivateKeyFromPEMBytes(contents []byte) (crypto.PrivateKey, error) {
+       pemBlock, _ := pem.Decode(contents)
+       if pemBlock == nil {
+               return nil, fmt.Errorf("file contents do not contain PEM")
+       }
+
+       switch pemBlock.Type {
+       case "RSA PRIVATE KEY":
+               parsed, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
+               if err != nil {
+                       return nil, err
+               }
+               return parsed, nil
+       case "EC PRIVATE KEY":
+               parsed, err := x509.ParseECPrivateKey(pemBlock.Bytes)
+               if err != nil {
+                       return nil, err
+               }
+               return parsed, nil
+       case "PRIVATE KEY":
+               parsed, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes)
+               if err != nil {
+                       return nil, err
+               }
+               return parsed, nil
+       }
+
+       return nil, fmt.Errorf("unsupported PEM block type: %s", pemBlock.Type)
+}
+
+func genericizePrivateKeyFunc[T crypto.PrivateKey](parser func([]byte) (T, error)) func([]byte) (crypto.PrivateKey, error) {
+       return func(input []byte) (crypto.PrivateKey, error) {
+               result, err := parser(input)
+               if err != nil {
+                       return nil, err
+               }
+               return result, nil
+       }
+}
+
+func LoadPrivateKeyFromBytes(contents []byte) (crypto.PrivateKey, error) {
+       parsers := []func([]byte) (crypto.PrivateKey, error){
+               genericizePrivateKeyFunc(x509.ParsePKCS1PrivateKey),
+               genericizePrivateKeyFunc(x509.ParseECPrivateKey),
+               genericizePrivateKeyFunc(x509.ParsePKCS8PrivateKey),
+       }
+
+       for _, parser := range parsers {
+               if parsed, err := parser(contents); err != nil {
+                       return parsed, nil
+               }
+       }
+
+       return nil, fmt.Errorf("failed to decode arbitrary bytes to any supported private key type")
+}
+
+func SpiffeUrlFromCertificate(cert *x509.Certificate) *url.URL {
+       if spiffe, err := url.Parse(cert.Subject.CommonName); err == nil && spiffe.Scheme == "spiffe" {
+               return spiffe
+       }
+
+       for _, ext := range cert.Extensions {
+               if ext.Critical || !ext.Id.Equal(oidSubjectAltName) {
+                       continue
+               }
+
+               values := make([]asn1.RawValue, 0)
+               _, err := asn1.Unmarshal(ext.Value, &values)
+               if err == nil {
+                       for _, rawValue := range values {
+                               if rawValue.Class == 2 && rawValue.Tag == 6 {
+                                       san := string(rawValue.Bytes)
+                                       url, err := url.Parse(san)
+                                       if err == nil && url.Scheme == "spiffe" {
+                                               return url
+                                       }
+                               }
+                       }
+
+                       continue
+               }
+
+               san := string(ext.Value)
+
+               if !strings.HasPrefix(san, "URI:") {
+                       continue
+               }
+
+               url, err := url.Parse(san[4:])
+               if err == nil && url.Scheme == "spiffe" {
+                       return url
+               }
+
+       }
+
+       return nil
+}
+
+func Fingerprint(cert *x509.Certificate, hash crypto.Hash) []byte {
+       hasher := hash.New()
+       defer hasher.Reset()
+       dest := make([]byte, 0)
+       hasher.Write(cert.Raw)
+       return hasher.Sum(dest)
+}
diff --git a/mtls/fsnotify/fsnotify.go b/mtls/fsnotify/fsnotify.go
new file mode 100644 (file)
index 0000000..367906c
--- /dev/null
@@ -0,0 +1,142 @@
+package fsnotify
+
+import (
+       "path/filepath"
+       "sync"
+
+       "go.fuhry.dev/fsnotify"
+       "go.fuhry.dev/runtime/utils/hashset"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type NotifyFunc = func(string, fsnotify.Op)
+type Op = fsnotify.Op
+
+var (
+       Write  = fsnotify.Write
+       Remove = fsnotify.Remove
+       Create = fsnotify.Create
+       Rename = fsnotify.Rename
+       Chmod  = fsnotify.Chmod
+       Close  = fsnotify.Close
+)
+
+var startWatcherMu sync.Mutex
+var gWatcher *fsnotify.Watcher
+var logger *log.Logger
+var pendingWrites *hashset.HashSet[string]
+var watched *hashset.HashSet[string]
+var watchHandlers map[string][]NotifyFunc
+
+func startWatcher() error {
+       startWatcherMu.Lock()
+       defer startWatcherMu.Unlock()
+       if gWatcher != nil {
+               return nil
+       }
+
+       logger = log.Default().WithPrefix("mtls/fsnotify")
+       watchHandlers = make(map[string][]NotifyFunc, 0)
+       pendingWrites = hashset.NewHashSet[string]()
+       watched = hashset.NewHashSet[string]()
+       watcher, err := fsnotify.NewWatcher()
+       if err != nil {
+               log.Panicf("failed to start new global watcher")
+       }
+
+       gWatcher = watcher
+       go watcherLoop()
+       return nil
+}
+
+func addWatch(path string) error {
+       startWatcherMu.Lock()
+       defer startWatcherMu.Unlock()
+
+       if watched.Contains(path) {
+               return nil
+       }
+
+       err := gWatcher.Add(path)
+       if err != nil {
+               return err
+       }
+       watched.Add(path)
+       return nil
+}
+
+func watcherLoop() {
+       defer gWatcher.Close()
+
+       for {
+               select {
+               case event := <-gWatcher.Events:
+                       if event.Op == 0 || event.Name == "" {
+                               continue
+                       }
+                       handleEvent(event)
+               case err := <-gWatcher.Errors:
+                       if err != nil {
+                               logger.Error(err)
+                       }
+               }
+       }
+}
+
+func handleEvent(event fsnotify.Event) {
+       if handlers, ok := watchHandlers[event.Name]; ok {
+
+               if event.Has(Create) {
+                       addWatch(event.Name)
+               } else if event.Has(Remove) {
+                       // remove watchers from deleted files.
+                       // we will still be notified if the file is re-created, via the watch
+                       // on the file's parent directory
+                       gWatcher.Remove(event.Name)
+               }
+
+               if event.Op == Write {
+                       pendingWrites.Add(event.Name)
+                       return
+               } else if event.Op == Close {
+                       if !pendingWrites.Contains(event.Name) {
+                               return
+                       }
+
+                       pendingWrites.Del(event.Name)
+               }
+
+               for _, handler := range handlers {
+                       handler(event.Name, event.Op)
+               }
+       }
+}
+
+func NotifyPath(path string, callback NotifyFunc) error {
+       err := startWatcher()
+       if err != nil {
+               return err
+       }
+
+       logger.V(1).Debugf("adding watcher on file %s", path)
+       err = addWatch(path)
+       if err != nil {
+               return err
+       }
+
+       if _, ok := watchHandlers[path]; !ok {
+               watchHandlers[path] = make([]NotifyFunc, 0)
+       }
+       watchHandlers[path] = append(watchHandlers[path], callback)
+
+       dirPath := filepath.Dir(path)
+       if dirPath != path {
+               logger.V(2).Debugf("adding watcher to parent directory %s", dirPath)
+               err = addWatch(dirPath)
+               if err != nil {
+                       logger.Warnf("failed to add watcher for %s parent directory %s: %v", path, dirPath, err)
+               }
+       }
+
+       return nil
+}
diff --git a/mtls/identity.go b/mtls/identity.go
new file mode 100644 (file)
index 0000000..451cd60
--- /dev/null
@@ -0,0 +1,358 @@
+package mtls
+
+import (
+       "crypto/tls"
+       "flag"
+       "fmt"
+       "os/user"
+       "strings"
+       "time"
+
+       "go.fuhry.dev/runtime/mtls/certutil"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type PrincipalClass int
+
+const (
+       InvalidPrincipal PrincipalClass = iota
+       ServicePrincipal
+       UserPrincipal
+       SSLCertificatePrincipal
+)
+
+func (c PrincipalClass) String() string {
+       switch c {
+       case ServicePrincipal:
+               return "service"
+       case UserPrincipal:
+               return "user"
+       case SSLCertificatePrincipal:
+               return "tls"
+       }
+
+       panic("invalid PrincipalClass")
+}
+
+const (
+       defaultDefaultIdentity = "host"
+)
+
+var (
+       defaultMtlsIdentity string
+       logger              *log.Logger
+)
+
+type Identity interface {
+       CertificateProvider
+
+       Name() string
+       Class() PrincipalClass
+       Equals(Identity) bool
+       IsValid() bool
+}
+
+type substantiatedIdentity struct {
+       CertificateProvider
+}
+
+type identityLoaderFunc func(name string) (CertificateProvider, error)
+
+type identityDriver struct {
+       name string
+       load identityLoaderFunc
+}
+
+var identityDrivers []*identityDriver
+
+func registerIdentityDriver(name string, load identityLoaderFunc) {
+       driver := &identityDriver{
+               name: name,
+               load: load,
+       }
+
+       identityDrivers = append(identityDrivers, driver)
+}
+
+func (id *substantiatedIdentity) Name() string {
+       cert, err := id.CertificateProvider.LeafCertificate()
+       if err != nil {
+               log.Fatalf("substantiatedIdentity failed to get certificate: %v", err)
+       }
+
+       spiffe := certutil.SpiffeUrlFromCertificate(cert)
+       if spiffe != nil {
+               parts := strings.Split(spiffe.Path, "/")
+               if len(parts) != 3 {
+                       log.Fatalf("spiffe url invalid: path must have exactly 3 parts: %s", spiffe.Path)
+               }
+               switch parts[1] {
+               case "service":
+                       return parts[2]
+               case "user":
+                       return parts[2]
+               default:
+                       log.Fatalf("spiffe url invalid: unknown idClass: %s", parts[1])
+               }
+       }
+
+       log.Fatalf("unable to get spiffe identity from certificate: none found in subject or SAN")
+       return ""
+}
+
+func (id *substantiatedIdentity) Class() PrincipalClass {
+       cert, err := id.CertificateProvider.LeafCertificate()
+       if err != nil {
+               log.Fatalf("substantiatedIdentity failed to get certificate: %v", err)
+       }
+
+       spiffe := certutil.SpiffeUrlFromCertificate(cert)
+       if spiffe != nil {
+               parts := strings.Split(spiffe.Path, "/")
+               if len(parts) != 3 {
+                       log.Fatalf("spiffe url invalid: path must have exactly 3 parts: %s", spiffe.Path)
+               }
+               switch parts[1] {
+               case "service":
+                       return ServicePrincipal
+               case "user":
+                       return UserPrincipal
+               default:
+                       log.Fatalf("spiffe url invalid: unknown idClass: %s", parts[1])
+               }
+       }
+
+       log.Fatal("unable to get spiffe identity from certificate: none found in subject or SAN")
+       return InvalidPrincipal
+}
+
+func (id *substantiatedIdentity) IsValid() bool {
+       return identityIsValid(id.CertificateProvider)
+}
+
+func identityIsValid(id certificatePrimitive) bool {
+       cert, err := id.LeafCertificate()
+       if err != nil {
+               return false
+       }
+
+       if time.Now().Before(cert.NotBefore) || time.Now().After(cert.NotAfter) {
+               return false
+       }
+
+       pkey, err := id.PrivateKey()
+       if err != nil || pkey == nil {
+               return false
+       }
+
+       return true
+}
+
+func (id *substantiatedIdentity) Equals(other Identity) bool {
+       return identityEquals(id, other)
+}
+
+func identityEquals(a Identity, b Identity) bool {
+       return a.Name() == b.Name() && a.Class() == b.Class()
+}
+
+type stubIdentity struct {
+       *inaccessibleCertificate
+
+       name string
+       cls  PrincipalClass
+}
+
+func (id *stubIdentity) Name() string {
+       return id.name
+}
+
+func (id *stubIdentity) Class() PrincipalClass {
+       return id.cls
+}
+
+func (id *stubIdentity) Equals(other Identity) bool {
+       return identityEquals(id, other)
+}
+
+func (id *stubIdentity) IsValid() bool {
+       return false
+}
+
+func NewServiceIdentity(service string) Identity {
+       for _, driver := range identityDrivers {
+               logger.V(1).Infof("trying driver %s to load service identity %s", driver.name, service)
+               identity, err := driver.load(service)
+
+               if err == nil {
+                       subst := &substantiatedIdentity{
+                               CertificateProvider: identity,
+                       }
+                       logger.V(2).Infof("driver %s reports it loaded identity %s:%s", driver.name, subst.Class().String(), subst.Name())
+
+                       if subst.Name() == service && subst.Class() == ServicePrincipal {
+                               logger.V(1).Noticef("successfully loaded %s(%s) with driver %s", subst.Class().String(), subst.Name(), driver.name)
+                               return subst
+                       } else {
+                               logger.V(2).Warnf(
+                                       "driver %s successfully loaded a certificate, but it doesn't match what "+
+                                               "we expected: class %s (expected)/%s (got); name %s (expected)/%s (got)",
+                                       driver.name, ServicePrincipal.String(), subst.Class().String(),
+                                       service, subst.Name())
+                       }
+               } else {
+                       logger.V(2).Warnf("driver %s failed to load service identity %s: %+v", driver.name, service, err)
+               }
+       }
+
+       return &stubIdentity{
+               inaccessibleCertificate: &inaccessibleCertificate{},
+
+               name: service,
+               cls:  ServicePrincipal,
+       }
+}
+
+func NewUserIdentity(username string) Identity {
+       for _, driver := range identityDrivers {
+               logger.V(1).Infof("trying driver %s to load service identity %s", driver.name, username)
+               identity, err := driver.load(username)
+               if err == nil {
+                       subst := &substantiatedIdentity{
+                               CertificateProvider: identity,
+                       }
+                       logger.V(2).Infof("driver %s reports it loaded identity %s:%s", driver.name, subst.Class().String(), subst.Name())
+
+                       if subst.Name() == username && subst.Class() == UserPrincipal {
+                               logger.V(1).Noticef("successfully loaded %s(%s) with driver %s", subst.Class().String(), subst.Name(), driver.name)
+                               return subst
+                       } else {
+                               logger.V(2).Warnf(
+                                       "driver %s successfully loaded a certificate, but it doesn't match what "+
+                                               "we expected: class %s (expected)/%s (got); name %s (expected)/%s (got)",
+                                       driver.name, ServicePrincipal.String(), subst.Class().String(),
+                                       username, subst.Name())
+                       }
+               } else {
+                       logger.V(2).Warnf("driver %s failed to load service identity %s: %+v", driver.name, username, err)
+               }
+       }
+
+       return &stubIdentity{
+               inaccessibleCertificate: &inaccessibleCertificate{},
+
+               name: username,
+               cls:  UserPrincipal,
+       }
+}
+
+func NewDefaultUserIdentity() (Identity, error) {
+       user, err := user.Current()
+       if err != nil {
+               return nil, err
+       }
+
+       return NewUserIdentity(user.Username), nil
+}
+
+type substantiatedSslCertificate struct {
+       CertificateProvider
+
+       certName string
+}
+
+func (id *substantiatedSslCertificate) Name() string {
+       return id.certName
+}
+
+func (id *substantiatedSslCertificate) Class() PrincipalClass {
+       return SSLCertificatePrincipal
+}
+
+func (id *substantiatedSslCertificate) IsValid() bool {
+       return identityIsValid(id.CertificateProvider)
+}
+
+func (id *substantiatedSslCertificate) Equals(other Identity) bool {
+       return identityEquals(id, other)
+}
+
+func NewSSLCertificate(certName string) Identity {
+       fileId, err := LoadSSLCertificateFromFilesystem(certName)
+       if err == nil {
+               subst := &substantiatedSslCertificate{
+                       CertificateProvider: fileId,
+                       certName:            certName,
+               }
+
+               if subst.Name() == certName && subst.Class() == SSLCertificatePrincipal {
+                       return subst
+               }
+       }
+
+       return &stubIdentity{
+               inaccessibleCertificate: &inaccessibleCertificate{},
+
+               name: certName,
+               cls:  SSLCertificatePrincipal,
+       }
+}
+
+func init() {
+       flag.StringVar(&defaultMtlsIdentity, "mtls.id", defaultMtlsIdentity, "mTLS identity to use when not overridden by the application")
+
+       // identityCache = make(map[string]*serviceIdentity, 0)
+
+       logger = log.Default().WithPrefix("mtls")
+}
+
+func SetDefaultIdentity(ident string) {
+       defaultMtlsIdentity = ident
+}
+
+// DefaultIdentity returns the Identity specified in the `-mtls.id` argument to the executable.
+func DefaultIdentity() Identity {
+       if !flag.Parsed() {
+               panic("cannot get default identity before flags are parsed")
+       }
+
+       if defaultMtlsIdentity == "" {
+               userId, err := NewDefaultUserIdentity()
+               if err == nil && userId.IsValid() {
+                       leafCert, _ := userId.LeafCertificate()
+                       log.Default().Infof("found valid user certificate, using identity: %s", leafCert.Subject)
+                       return userId
+               } else {
+                       log.Default().V(2).Debugf("couldn't load a user identity: err: %+v", err)
+               }
+
+               return NewServiceIdentity(defaultDefaultIdentity)
+       }
+
+       return NewServiceIdentity(defaultMtlsIdentity)
+}
+
+func IdentityFromTLSConnectionState(state *tls.ConnectionState) (Identity, error) {
+       if state == nil {
+               return nil, fmt.Errorf("connectionState is nil")
+       }
+
+       for _, cert := range state.PeerCertificates {
+               spiffe := certutil.SpiffeUrlFromCertificate(cert)
+               if spiffe != nil {
+                       parts := strings.Split(spiffe.Path, "/")
+                       if len(parts) != 3 {
+                               return nil, fmt.Errorf("spiffe url invalid: path must have exactly 3 parts: %s", spiffe.Path)
+                       }
+                       switch parts[1] {
+                       case "service":
+                               return NewServiceIdentity(parts[2]), nil
+                       case "user":
+                               return NewUserIdentity(parts[2]), nil
+                       default:
+                               return nil, fmt.Errorf("spiffe url invalid: unknown idClass: %s", parts[1])
+                       }
+               }
+       }
+       return nil, fmt.Errorf("could not get spiffe url from any provided peer certificate")
+}
diff --git a/mtls/pkcs11.go b/mtls/pkcs11.go
new file mode 100644 (file)
index 0000000..285367a
--- /dev/null
@@ -0,0 +1,100 @@
+package mtls
+
+import (
+       "crypto"
+       "crypto/tls"
+       "fmt"
+       "os"
+       "sync"
+
+       "github.com/ThalesIgnite/crypto11"
+       "go.fuhry.dev/runtime/constants"
+)
+
+const (
+       deviceTrustObjectLabel = "Device Identity"
+)
+
+var pkcs11ModulePaths = []string{
+       "/usr/lib/pkcs11/libtpm2_pkcs11.so",
+       "/usr/lib/x86_64-linux-gnu/pkcs11/libtpm2_pkcs11.so",
+}
+
+var crypto11Config *crypto11.Config = &crypto11.Config{
+       TokenLabel:        constants.DeviceTrustTokenName,
+       LoginNotSupported: true,
+}
+
+var (
+       p11Global      *p11
+       p11GlobalError error
+       p11GlobalOnce  sync.Once
+)
+
+type p11 struct {
+       cHandle *crypto11.Context
+}
+
+func getGlobalP11() (*p11, error) {
+       p11GlobalOnce.Do(func() {
+               p11Global, p11GlobalError = NewP11()
+       })
+
+       return p11Global, p11GlobalError
+}
+
+func NewP11() (*p11, error) {
+       for _, p := range pkcs11ModulePaths {
+               if _, err := os.Stat(p); err == nil {
+                       crypto11Config.Path = p
+               }
+       }
+       if crypto11Config.Path == "" {
+               return nil, fmt.Errorf("unable to stat tpm2 pkcs11 module at any known path: %v", pkcs11ModulePaths)
+       }
+
+       ctx, err := crypto11.Configure(crypto11Config)
+       if err != nil {
+               return nil, err
+       }
+
+       p := &p11{
+               cHandle: ctx,
+       }
+
+       return p, nil
+}
+
+func (p *p11) Close() {
+       p.cHandle.Close()
+}
+
+func (p *p11) GetCertificate() (*tls.Certificate, error) {
+       cert, err := p.cHandle.FindCertificate(nil, []byte(deviceTrustObjectLabel), nil)
+       if err != nil {
+               return nil, err
+       }
+
+       privateKey, err := p.GetPrivateKey()
+       if err != nil {
+               return nil, err
+       }
+
+       tlsCert := &tls.Certificate{
+               Certificate: [][]byte{
+                       cert.Raw,
+               },
+               PrivateKey: privateKey,
+               Leaf:       cert,
+       }
+       return tlsCert, nil
+}
+
+func (p *p11) GetPrivateKey() (crypto.Signer, error) {
+       kp, err := p.cHandle.FindKeyPair(nil, []byte(deviceTrustObjectLabel))
+       if err != nil {
+               return nil, fmt.Errorf("while getting private key handle: %v", err)
+       }
+
+       return kp, nil
+}
diff --git a/mtls/provider_file.go b/mtls/provider_file.go
new file mode 100644 (file)
index 0000000..b8abba9
--- /dev/null
@@ -0,0 +1,508 @@
+package mtls
+
+import (
+       "context"
+       "crypto"
+       "crypto/tls"
+       "crypto/x509"
+       "flag"
+       "fmt"
+       "os"
+       "path"
+       "sync"
+
+       "go.fuhry.dev/runtime/mtls/certutil"
+       "go.fuhry.dev/runtime/mtls/fsnotify"
+)
+
+type FileBackedCertificate struct {
+       LeafPath          string
+       IntermediatesPath string
+       PrivateKeyPath    string
+       RootPath          string
+
+       mu sync.Mutex
+
+       tlsConfig *tls.Config
+       leaf      *x509.Certificate
+       ints      []*x509.Certificate
+       pkey      crypto.PrivateKey
+       root      *x509.Certificate
+}
+
+type fileBackedRoots struct {
+       RootPath          string
+       IntermediatesPath string
+
+       ints []*x509.Certificate
+       root []*x509.Certificate
+
+       mu       sync.Mutex
+       initOnce sync.Once
+}
+
+const (
+       defaultMtlsRootPath = "/etc/ssl/mtls"
+)
+
+var (
+       mtlsRootPaths   = []string{defaultMtlsRootPath}
+       sslCertsBaseDir = "/etc/ssl/private"
+
+       defaultRootCAFile         string
+       defaultIntermediateCAFile string
+)
+
+func LoadServiceIdentityFromFilesystem(serviceIdentity string) (*FileBackedCertificate, error) {
+       var lastErr error
+       for _, path := range mtlsRootPaths {
+               c, err := newFileBackedCertificateFromBaseDir(path, serviceIdentity)
+               if err == nil {
+                       return c, nil
+               }
+               lastErr = err
+       }
+
+       return nil, lastErr
+}
+
+func newFileBackedCertificateFromBaseDir(mtlsRootPath string, serviceIdentity string) (*FileBackedCertificate, error) {
+       certDirectory := path.Join(mtlsRootPath, serviceIdentity)
+
+       leafPath := path.Join(certDirectory, "cert.pem")
+       chainPath := path.Join(certDirectory, "chain.pem")
+       keyPath := path.Join(certDirectory, "privkey.pem")
+       rootPath := path.Join(mtlsRootPath, "rootca.pem")
+
+       for _, file := range []string{leafPath, chainPath, keyPath, rootPath} {
+               if err := fileExistsAndIsReadable(file); err != nil {
+                       return nil, err
+               }
+       }
+
+       return &FileBackedCertificate{
+               LeafPath:          leafPath,
+               IntermediatesPath: chainPath,
+               PrivateKeyPath:    keyPath,
+               RootPath:          rootPath,
+       }, nil
+}
+
+func LoadUserIdentityFromFilesystem() (*FileBackedCertificate, error) {
+       fullChainPath, ok := os.LookupEnv("STEP_PERSONAL_CERTIFICATE")
+       if !ok {
+               return nil, fmt.Errorf("failed to get user certificate path from env STEP_PERSONAL_CERTIFICATE")
+       }
+
+       keyPath, ok := os.LookupEnv("STEP_PERSONAL_PRIVATE_KEY")
+       if !ok {
+               return nil, fmt.Errorf("failed to get user private key path from env STEP_PERSONAL_PRIVATE_KEY")
+       }
+
+       rootPath := path.Join(defaultMtlsRootPath, "rootca.pem")
+
+       for _, file := range []string{fullChainPath, keyPath, rootPath} {
+               if err := fileExistsAndIsReadable(file); err != nil {
+                       return nil, err
+               }
+       }
+
+       return &FileBackedCertificate{
+               LeafPath:          fullChainPath,
+               IntermediatesPath: fullChainPath,
+               PrivateKeyPath:    keyPath,
+               RootPath:          rootPath,
+       }, nil
+}
+
+func LoadSSLCertificateFromFilesystem(certName string) (*FileBackedCertificate, error) {
+       certDirectory := path.Join(defaultMtlsRootPath, certName)
+
+       leafPath := path.Join(certDirectory, "cert.pem")
+       chainPath := path.Join(certDirectory, "chain.pem")
+       keyPath := path.Join(certDirectory, "privkey.pem")
+       // FIXME!!
+       rootPath := "/etc/ssl/certs/ISRG_Root_X1.pem"
+
+       for _, file := range []string{leafPath, chainPath, keyPath, rootPath} {
+               if err := fileExistsAndIsReadable(file); err != nil {
+                       return nil, err
+               }
+       }
+
+       return &FileBackedCertificate{
+               LeafPath:          leafPath,
+               IntermediatesPath: chainPath,
+               PrivateKeyPath:    keyPath,
+               RootPath:          rootPath,
+       }, nil
+}
+
+func (c *FileBackedCertificate) LeafCertificate() (*x509.Certificate, error) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+
+       if c.leaf != nil {
+               return c.leaf, nil
+       }
+
+       leaf, err := c.tryLoadLeaf()
+       if err != nil {
+               return nil, err
+       }
+
+       c.leaf = leaf
+       return leaf, nil
+}
+
+func (c *FileBackedCertificate) tryLoadLeaf() (*x509.Certificate, error) {
+       certs, err := certutil.LoadCertificatesFromPEM(c.LeafPath)
+       if err != nil {
+               return nil, err
+       }
+
+       for _, cert := range certs {
+               if cert.IsCA {
+                       continue
+               }
+
+               return cert, nil
+       }
+
+       return nil, fmt.Errorf("leaf certificate path %q contains no end-entity certificates", c.LeafPath)
+}
+
+func (c *FileBackedCertificate) PrivateKey() (crypto.PrivateKey, error) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+
+       if c.pkey != nil {
+               return c.pkey, nil
+       }
+
+       pkey, err := c.tryLoadPrivateKey()
+       if err != nil {
+               return nil, err
+       }
+
+       c.pkey = pkey
+       return c.pkey, nil
+}
+
+func (c *FileBackedCertificate) tryLoadPrivateKey() (crypto.PrivateKey, error) {
+       return certutil.LoadPrivateKeyFromPEM(c.PrivateKeyPath)
+}
+
+func (c *FileBackedCertificate) RootCertificate() (*x509.Certificate, error) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+
+       if c.root != nil {
+               return c.root, nil
+       }
+
+       root, err := c.tryLoadRoot()
+       if err != nil {
+               return nil, err
+       }
+
+       c.root = root
+       return root, nil
+}
+
+func (c *FileBackedCertificate) tryLoadRoot() (*x509.Certificate, error) {
+       certs, err := certutil.LoadCertificatesFromPEM(c.RootPath)
+       if err != nil {
+               return nil, err
+       }
+
+       for _, cert := range certs {
+               if cert.IsCA && cert.Issuer.ToRDNSequence().String() == cert.Subject.ToRDNSequence().String() {
+                       return cert, nil
+               }
+       }
+
+       return nil, fmt.Errorf("failed to find any self-signed certificates in root certificate file %q", c.RootPath)
+}
+
+func (c *FileBackedCertificate) IntermediateCertificates() ([]*x509.Certificate, error) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+
+       if c.ints != nil {
+               return c.ints, nil
+       }
+
+       newInts, err := c.tryLoadIntermediates()
+       if err != nil {
+               return nil, err
+       }
+
+       c.ints = newInts
+       return c.ints, nil
+}
+
+func (c *FileBackedCertificate) tryLoadIntermediates() ([]*x509.Certificate, error) {
+       certs, err := certutil.LoadCertificatesFromPEM(c.IntermediatesPath)
+       if err != nil {
+               return nil, err
+       }
+       newInts := make([]*x509.Certificate, 0)
+
+       for _, cert := range certs {
+               if !cert.IsCA {
+                       continue
+               }
+
+               newInts = append(newInts, cert)
+       }
+
+       // don't check length of newInts here - sometimes there may not be any
+       return newInts, nil
+}
+
+func (r *fileBackedRoots) init() {
+       r.initOnce.Do(func() {
+               fsnotify.NotifyPath(r.RootPath, r.notifyEvent)
+               fsnotify.NotifyPath(r.IntermediatesPath, r.notifyEvent)
+       })
+}
+
+func (r *fileBackedRoots) RootCertificates() ([]*x509.Certificate, error) {
+       r.mu.Lock()
+       defer r.mu.Unlock()
+
+       r.init()
+
+       if r.root != nil {
+               return r.root, nil
+       }
+
+       root, err := r.tryLoadRoots()
+       if err != nil {
+               return nil, err
+       }
+
+       r.root = root
+       return r.root, nil
+}
+
+func (r *fileBackedRoots) tryLoadRoots() ([]*x509.Certificate, error) {
+       certs, err := certutil.LoadCertificatesFromPEM(r.RootPath)
+       if err != nil {
+               return nil, err
+       }
+
+       for _, cert := range certs {
+               if cert.IsCA && cert.Issuer.String() == cert.Subject.String() {
+                       return []*x509.Certificate{cert}, nil
+               }
+       }
+
+       return nil, fmt.Errorf("failed to find any self-signed certificates in root certificate file %q", r.RootPath)
+}
+
+func (r *fileBackedRoots) IntermediateCertificates() ([]*x509.Certificate, error) {
+       r.mu.Lock()
+       defer r.mu.Unlock()
+
+       r.init()
+
+       if r.ints != nil {
+               return r.ints, nil
+       }
+
+       newInts, err := r.tryLoadIntermediates()
+       if err != nil {
+               return nil, err
+       }
+
+       r.ints = newInts
+       return r.ints, nil
+}
+
+func (r *fileBackedRoots) tryLoadIntermediates() ([]*x509.Certificate, error) {
+       certs, err := certutil.LoadCertificatesFromPEM(r.IntermediatesPath)
+       if err != nil {
+               return nil, err
+       }
+       newInts := make([]*x509.Certificate, 0)
+
+       for _, cert := range certs {
+               if !cert.IsCA {
+                       continue
+               }
+
+               newInts = append(newInts, cert)
+       }
+
+       // don't check length of newInts here - sometimes there may not be any
+       return newInts, nil
+}
+
+func (r *fileBackedRoots) notifyEvent(filePath string, op fsnotify.Op) {
+       if op.Has(fsnotify.Remove) {
+               return
+       }
+
+       r.mu.Lock()
+       defer r.mu.Unlock()
+
+       switch filePath {
+       case r.IntermediatesPath:
+               ints, err := r.tryLoadIntermediates()
+               if err == nil {
+                       logger.Infof("detected change to intermediate certificates %s, reloaded", r.IntermediatesPath)
+                       r.ints = ints
+               } else {
+                       logger.Warningf("intermediate certificates %s changed but unable to reload: %v", r.IntermediatesPath, err)
+               }
+       case r.RootPath:
+               root, err := r.tryLoadRoots()
+               if err == nil {
+                       logger.Infof("detected change to root certificate %s, reloaded", r.RootPath)
+                       r.root = root
+               } else {
+                       logger.Warningf("root certificate %s changed but unable to reload: %v", r.RootPath, err)
+               }
+       }
+}
+
+func (c *FileBackedCertificate) TlsConfig(ctx context.Context) (*tls.Config, error) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+
+       if c.tlsConfig != nil {
+               return c.tlsConfig, nil
+       }
+
+       fsnotify.NotifyPath(c.LeafPath, c.notifyEvent)
+       fsnotify.NotifyPath(c.IntermediatesPath, c.notifyEvent)
+       fsnotify.NotifyPath(c.PrivateKeyPath, c.notifyEvent)
+       fsnotify.NotifyPath(c.RootPath, c.notifyEvent)
+
+       c.tlsConfig = &tls.Config{
+               GetCertificate:       c.GetCertificate,
+               GetClientCertificate: c.GetClientCertificate,
+       }
+
+       return c.tlsConfig, nil
+}
+
+func (c *FileBackedCertificate) GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+       return c.newTlsCertificate()
+}
+
+func (c *FileBackedCertificate) GetClientCertificate(reqInfo *tls.CertificateRequestInfo) (*tls.Certificate, error) {
+       return c.newTlsCertificate()
+}
+
+func (c *FileBackedCertificate) newTlsCertificate() (*tls.Certificate, error) {
+       return newTlsCertificate(c)
+}
+
+func (c *FileBackedCertificate) NewDialContextFunc() DialContextFunc {
+       return newDialContextFunc(c)
+}
+
+func (c *FileBackedCertificate) notifyEvent(filePath string, op fsnotify.Op) {
+       if op.Has(fsnotify.Remove) {
+               return
+       }
+
+       c.mu.Lock()
+       defer c.mu.Unlock()
+
+       switch filePath {
+       case c.LeafPath:
+               cert, err := c.tryLoadLeaf()
+               if err == nil {
+                       logger.Infof("detected change to leaf certificate %s, reloaded", c.LeafPath)
+                       c.leaf = cert
+               } else {
+                       logger.Warningf("leaf certificate %s changed but unable to reload: %v", c.LeafPath, err)
+               }
+       case c.IntermediatesPath:
+               ints, err := c.tryLoadIntermediates()
+               if err == nil {
+                       logger.Infof("detected change to intermediate certificates %s, reloaded", c.IntermediatesPath)
+                       c.ints = ints
+               } else {
+                       logger.Warningf("intermediate certificates %s changed but unable to reload: %v", c.IntermediatesPath, err)
+               }
+       case c.PrivateKeyPath:
+               pkey, err := c.tryLoadPrivateKey()
+               if err == nil {
+                       logger.Infof("detected change to private key %s, reloaded", c.PrivateKeyPath)
+                       c.pkey = pkey
+               } else {
+                       logger.Warningf("private key %s changed but unable to reload: %v", c.PrivateKeyPath, err)
+               }
+       case c.RootPath:
+               root, err := c.tryLoadRoot()
+               if err == nil {
+                       logger.Infof("detected change to root certificate %s, reloaded", c.RootPath)
+                       c.root = root
+               } else {
+                       logger.Warningf("root certificate %s changed but unable to reload: %v", c.RootPath, err)
+               }
+       }
+}
+
+func fileExistsAndIsReadable(path string) error {
+       stat, err := os.Stat(path)
+       if err != nil {
+               return err
+       }
+       if !stat.Mode().IsRegular() {
+               return fmt.Errorf("%q is not a regular file", path)
+       }
+       fp, err := os.Open(path)
+       if err != nil {
+               return err
+       }
+       fp.Close()
+
+       return nil
+}
+
+func appendMtlsCertificateDir(path string) error {
+       stat, err := os.Stat(path)
+       if err != nil {
+               return err
+       }
+       if !stat.IsDir() {
+               return fmt.Errorf("mTLS certificate path is not a directory: %q", path)
+       }
+
+       mtlsRootPaths = append(mtlsRootPaths, path)
+       return nil
+}
+
+func init() {
+       defaultRootCAFile = fmt.Sprintf("%s/rootca.pem", defaultMtlsRootPath)
+       defaultIntermediateCAFile = fmt.Sprintf("%s/ca.pem", defaultMtlsRootPath)
+
+       defaultFileBackedRoots := &fileBackedRoots{}
+
+       if homeDir := os.Getenv("HOME"); homeDir != "" {
+               userMtlsPath := path.Join(homeDir, ".cache", "mtls")
+               appendMtlsCertificateDir(userMtlsPath)
+       }
+
+       flag.StringVar(&defaultFileBackedRoots.RootPath, "mtls.root-ca", defaultRootCAFile, "root CA file for verifying mTLS connections")
+       flag.StringVar(&defaultFileBackedRoots.IntermediatesPath, "mtls.intermediate-ca", defaultIntermediateCAFile, "intermediate CA file for verifying TLS connections")
+
+       flag.StringVar(&sslCertsBaseDir, "tls.certs-dir", sslCertsBaseDir, "directory to look under for public-site SSL certificates (NOT mTLS certs)")
+       flag.Func("mtls.certs-dir", "additional directory to search for mTLS certificates", appendMtlsCertificateDir)
+
+       registerIdentityDriver("file_service_global", func(serviceName string) (CertificateProvider, error) {
+               return LoadServiceIdentityFromFilesystem(serviceName)
+       })
+       registerIdentityDriver("file_user_home", func(_ string) (CertificateProvider, error) {
+               return LoadUserIdentityFromFilesystem()
+       })
+       registerRootDriver("file_etc_mtls", func() (rootsPrimitive, error) {
+               return defaultFileBackedRoots, nil
+       })
+}
diff --git a/mtls/provider_interface.go b/mtls/provider_interface.go
new file mode 100644 (file)
index 0000000..f42f122
--- /dev/null
@@ -0,0 +1,66 @@
+package mtls
+
+import (
+       "context"
+       "crypto"
+       "crypto/tls"
+       "crypto/x509"
+       "errors"
+       "net"
+)
+
+type DialContextFunc func(context.Context, string, string) (net.Conn, error)
+
+type CertificateProvider interface {
+       certificatePrimitive
+
+       TlsConfig(context.Context) (*tls.Config, error)
+       NewDialContextFunc() DialContextFunc
+}
+
+type rootsPrimitive interface {
+       RootCertificates() ([]*x509.Certificate, error)
+       IntermediateCertificates() ([]*x509.Certificate, error)
+}
+
+type certificatePrimitive interface {
+       RootCertificate() (*x509.Certificate, error)
+       IntermediateCertificates() ([]*x509.Certificate, error)
+
+       LeafCertificate() (*x509.Certificate, error)
+       PrivateKey() (crypto.PrivateKey, error)
+
+       newTlsCertificate() (*tls.Certificate, error)
+}
+
+type inaccessibleCertificate struct{}
+
+var ErrCertificateInaccessible = errors.New("requested certificate is inaccessible")
+
+func (c *inaccessibleCertificate) LeafCertificate() (*x509.Certificate, error) {
+       return nil, ErrCertificateInaccessible
+}
+
+func (c *inaccessibleCertificate) IntermediateCertificates() ([]*x509.Certificate, error) {
+       return nil, ErrCertificateInaccessible
+}
+
+func (c *inaccessibleCertificate) RootCertificate() (*x509.Certificate, error) {
+       return nil, ErrCertificateInaccessible
+}
+
+func (c *inaccessibleCertificate) PrivateKey() (crypto.PrivateKey, error) {
+       return nil, ErrCertificateInaccessible
+}
+
+func (c *inaccessibleCertificate) newTlsCertificate() (*tls.Certificate, error) {
+       return nil, ErrCertificateInaccessible
+}
+
+func (c *inaccessibleCertificate) TlsConfig(ctx context.Context) (*tls.Config, error) {
+       return nil, ErrCertificateInaccessible
+}
+
+func (c *inaccessibleCertificate) NewDialContextFunc() DialContextFunc {
+       return newDialContextFunc(c)
+}
diff --git a/mtls/provider_keychain_macos.go b/mtls/provider_keychain_macos.go
new file mode 100644 (file)
index 0000000..104f642
--- /dev/null
@@ -0,0 +1,458 @@
+//go:build darwin
+
+package mtls
+
+import (
+       "bytes"
+       "context"
+       "crypto"
+       "crypto/ecdsa"
+       "crypto/rsa"
+       "crypto/tls"
+       "crypto/x509"
+       "encoding/base64"
+       "encoding/hex"
+       "errors"
+       "fmt"
+       "io"
+       "strings"
+       "time"
+
+       "github.com/keybase/go-keychain"
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/mtls/certutil"
+       "go.fuhry.dev/runtime/utils/hashset"
+       "go.fuhry.dev/runtime/utils/log"
+       "go.fuhry.dev/runtime/utils/stringmatch"
+)
+
+type macosKeychainCertificate struct {
+       certificatePrimitive
+
+       ints []*x509.Certificate
+       root *x509.Certificate
+       leaf *x509.Certificate
+       pkey crypto.PrivateKey
+}
+
+type macosKeychainRoots struct{}
+
+type kcCertResult struct {
+       cert *x509.Certificate
+       skid []byte
+}
+
+type kcCryptoPrivateKey interface {
+       Public() crypto.PublicKey
+       Equals(crypto.PrivateKey) bool
+}
+
+type kcCryptoPublicKey interface {
+       Equals(crypto.PublicKey) bool
+}
+
+type kcCryptoSigner struct {
+       pub    crypto.PublicKey
+       keyRef *keychain.KeyRef
+}
+
+type kcSignerOpts struct {
+       hash crypto.Hash
+}
+
+func (kso *kcSignerOpts) HashFunc() crypto.Hash {
+       return kso.hash
+}
+
+var kcLogger *log.Logger
+
+func init() {
+       kcLogger = log.WithPrefix("mtls.macOSKeychain")
+
+       registerIdentityDriver("macos_keychain", NewCertificateFromMacKeychain)
+}
+
+func NewCertificateFromMacKeychain(principal string) (CertificateProvider, error) {
+       root, err := getMtlsRootFromMacKeychain()
+       if err != nil {
+               return nil, err
+       }
+       kcLogger.V(2).Debugf("loaded root cert from keychain: %s", root.Subject.String())
+
+       ints, err := getMtlsIntermediatesFromMacKeychain()
+       if err != nil {
+               return nil, err
+       }
+
+       for _, c := range ints {
+               kcLogger.V(2).Debugf("loaded intermediate cert from keychain: %s", c.Subject.String())
+       }
+
+       leaves, err := getLeafCertificatesFromKeychainMatchingPrincipal(ServicePrincipal, principal)
+       if err != nil {
+               return nil, err
+       }
+
+       leaf, pkey, err := findCertificateAndPrivateKeyMatchingKeyPairInKeychain(leaves)
+       if err != nil {
+               return nil, err
+       }
+
+       c := &macosKeychainCertificate{
+               root: root,
+               ints: ints,
+               leaf: leaf,
+               pkey: pkey,
+       }
+
+       return c, nil
+}
+
+func (c *macosKeychainCertificate) LeafCertificate() (*x509.Certificate, error) {
+       return c.leaf, nil
+}
+
+func (c *macosKeychainCertificate) IntermediateCertificates() ([]*x509.Certificate, error) {
+       return c.ints, nil
+}
+
+func (c *macosKeychainCertificate) RootCertificate() (*x509.Certificate, error) {
+       return c.root, nil
+}
+
+func (c *macosKeychainCertificate) PrivateKey() (crypto.PrivateKey, error) {
+       return c.pkey, nil
+}
+
+func (c *macosKeychainCertificate) newTlsCertificate() (*tls.Certificate, error) {
+       return newTlsCertificate(c)
+}
+
+func (c *macosKeychainCertificate) NewDialContextFunc() DialContextFunc {
+       return newDialContextFunc(c)
+}
+
+func (c *macosKeychainCertificate) TlsConfig(ctx context.Context) (*tls.Config, error) {
+       tc := &tls.Config{
+               GetCertificate:       c.GetCertificate,
+               GetClientCertificate: c.GetClientCertificate,
+       }
+
+       return tc, nil
+}
+
+func (c *macosKeychainCertificate) GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+       return c.newTlsCertificate()
+}
+
+func (c *macosKeychainCertificate) GetClientCertificate(reqInfo *tls.CertificateRequestInfo) (*tls.Certificate, error) {
+       return c.newTlsCertificate()
+}
+
+func getMtlsIntermediatesFromMacKeychain() ([]*x509.Certificate, error) {
+       certs, err := findCertificatesInKeychainWithLabel(stringmatch.Prefix(constants.IntCAName))
+       if err != nil {
+               return nil, err
+       }
+
+       ints := make([]*x509.Certificate, 0)
+       dupes := hashset.NewHashSet[string]()
+       for _, cert := range certs {
+               if cert.IsCA && cert.Subject.String() != cert.Issuer.String() {
+                       fpr := hex.EncodeToString(certutil.Fingerprint(cert, crypto.SHA256))
+                       if dupes.Contains(fpr) {
+                               continue
+                       }
+                       dupes.Add(fpr)
+                       ints = append(ints, cert)
+               }
+       }
+
+       return ints, nil
+}
+
+func getMtlsRootFromMacKeychain() (*x509.Certificate, error) {
+       certs, err := findCertificatesInKeychainWithLabel(stringmatch.Contains(constants.RootCAName))
+       if err != nil {
+               return nil, err
+       }
+
+       for _, cert := range certs {
+               if cert.IsCA && cert.Issuer.String() == cert.Subject.String() {
+                       return cert, nil
+               }
+       }
+
+       return nil, errors.New("failed to find any root certificates in keychain")
+}
+
+func findCertificatesInKeychainWithLabel(match stringmatch.StringMatcher) ([]*x509.Certificate, error) {
+       parser := func(res *keychain.QueryResult) (*x509.Certificate, error) {
+               return x509.ParseCertificate(res.Data)
+       }
+       return searchKeychain(match, keychain.SecClassCertificate, parser)
+}
+
+func findCertificateAndPrivateKeyMatchingKeyPairInKeychain(leaves []*kcCertResult) (*x509.Certificate, crypto.Signer, error) {
+       matches := make([]stringmatch.StringMatcher, 0)
+       for _, leaf := range leaves {
+               matches = append(matches, stringmatch.Exact(leaf.cert.Subject.CommonName))
+       }
+       match := stringmatch.Or(matches...)
+
+       type matchingPair struct {
+               cert *x509.Certificate
+               pkey crypto.Signer
+       }
+
+       pairs, err := searchKeychain(
+               match,
+               keychain.SecClassCryptoKey,
+               func(res *keychain.QueryResult) (*matchingPair, error) {
+                       if !res.HasKey {
+                               return nil, fmt.Errorf("result does not contain a key")
+                       }
+                       if len(res.KeyLabel) != 20 {
+                               return nil, fmt.Errorf("KeyLabel not populated")
+                       }
+
+                       for _, leaf := range leaves {
+                               if len(leaf.skid) != 20 {
+                                       continue
+                               }
+                               if bytes.Equal(res.KeyLabel, leaf.skid) {
+                                       kcLogger.V(3).Noticef("found key %q matching desired subjectKeyId %s", res.Label, strings.ToUpper(hex.EncodeToString(leaf.skid)))
+
+                                       kcs := &kcCryptoSigner{
+                                               pub:    leaf.cert.PublicKey,
+                                               keyRef: res.Key,
+                                       }
+
+                                       return &matchingPair{
+                                               cert: leaf.cert,
+                                               pkey: kcs,
+                                       }, nil
+                               }
+                       }
+
+                       return nil, fmt.Errorf("failed to identify any private key/certificate pairs")
+               })
+
+       if err != nil {
+               return nil, nil, err
+       }
+
+       if len(pairs) < 1 {
+               return nil, nil, fmt.Errorf("no certificate/private key matching pairs found")
+       }
+
+       pair := pairs[0]
+       return pair.cert, pair.pkey, nil
+}
+
+// Public implements crypto.Decrypter and crypto.Signer.
+func (kcs *kcCryptoSigner) Public() crypto.PublicKey {
+       if kcs.pub != nil {
+               return kcs.pub
+       }
+
+       pub, err := kcs.keyRef.Public()
+       if err != nil {
+               kcLogger.Panicf("failed to convert keychain key to public key: %v", err)
+               return nil
+       }
+
+       if rsaPub, err := x509.ParsePKCS1PublicKey(pub); err == nil {
+               kcs.pub = rsaPub
+               return rsaPub
+       }
+       if pkixPub, err := x509.ParsePKIXPublicKey(pub); err == nil {
+               kcs.pub = pkixPub
+               return pkixPub
+       }
+
+       out := bytes.NewBufferString("")
+       encoder := base64.NewEncoder(base64.StdEncoding, out)
+       encoder.Write(pub)
+       pubB64 := out.String()
+       kcLogger.Panicf("public key was exported from keychain successfully, but couldn't be parsed: %s", pubB64)
+       return nil
+}
+
+// Sign implements crypto.Signer.
+func (kcs *kcCryptoSigner) Sign(randReader io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
+       alg, err := kcs.getSignerAlgo(opts)
+       if err != nil {
+               return nil, err
+       }
+       signed, err := kcs.keyRef.SignWithAlgorithm(digest, alg)
+       if err != nil {
+               kcLogger.Errorf("while signing using keychain provider: ", err)
+               return nil, err
+       }
+
+       return signed, nil
+}
+
+// Decrypt implements crypto.Decrypter.
+func (kcs *kcCryptoSigner) Decrypt(rand io.Reader, msg []byte, opts crypto.DecrypterOpts) ([]byte, error) {
+       dec, err := kcs.keyRef.Decrypt(msg, kcs.Public())
+       if err != nil {
+               kcLogger.Error(err)
+               return nil, err
+       }
+       kcLogger.Debugf("decrypt ok: %+v", dec)
+       return dec, nil
+}
+
+func (kcs *kcCryptoSigner) getSignerAlgo(opts crypto.SignerOpts) (keychain.SecKeyAlgorithm, error) {
+       var err error
+       var algo keychain.SecKeyAlgorithm
+       hash := opts.HashFunc()
+       switch kcs.Public().(type) {
+       case *ecdsa.PublicKey:
+               switch hash {
+               case crypto.SHA1:
+                       algo = keychain.ECDSASignatureDigestX962SHA1
+               case crypto.SHA256:
+                       algo = keychain.ECDSASignatureDigestX962SHA256
+               case crypto.SHA384:
+                       algo = keychain.ECDSASignatureDigestX962SHA384
+               case crypto.SHA512:
+                       algo = keychain.ECDSASignatureDigestX962SHA512
+               default:
+                       err = fmt.Errorf("unsupported hash")
+               }
+       case *rsa.PublicKey:
+               switch opts.(type) {
+               case *rsa.PSSOptions:
+                       switch hash {
+                       case crypto.SHA256:
+                               algo = keychain.RSASignatureDigestPSSSHA256
+                       case crypto.SHA384:
+                               algo = keychain.RSASignatureDigestPSSSHA384
+                       case crypto.SHA512:
+                               algo = keychain.RSASignatureDigestPSSSHA512
+                       default:
+                               err = fmt.Errorf("unsupported hash")
+                       }
+               default:
+                       switch hash {
+                       case crypto.SHA1:
+                               algo = keychain.RSASignatureDigestPKCS1v15SHA1
+                       case crypto.SHA256:
+                               algo = keychain.RSASignatureDigestPKCS1v15SHA256
+                       case crypto.SHA384:
+                               algo = keychain.RSASignatureDigestPKCS1v15SHA384
+                       case crypto.SHA512:
+                               algo = keychain.RSASignatureDigestPKCS1v15SHA512
+                       default:
+                               err = fmt.Errorf("unsupported hash")
+                       }
+               }
+       default:
+               err = fmt.Errorf("unsupported key type: %T", kcs.Public())
+       }
+
+       return algo, err
+}
+
+func getLeafCertificatesFromKeychainMatchingPrincipal(class PrincipalClass, principal string) ([]*kcCertResult, error) {
+       matcher := stringmatch.And(
+               stringmatch.Prefix("spiffe://"),
+               stringmatch.Suffix(fmt.Sprintf("/%s/%s", class.String(), principal)),
+       )
+
+       return searchKeychain(
+               matcher,
+               keychain.SecClassCertificate,
+               func(res *keychain.QueryResult) (*kcCertResult, error) {
+                       if len(res.SubjectKeyIdentifier) != 20 {
+                               return nil, fmt.Errorf("skid not populated for this certificate")
+                       }
+                       cert, err := x509.ParseCertificate(res.Data)
+                       if err != nil {
+                               return nil, err
+                       }
+                       if cert.IsCA {
+                               return nil, fmt.Errorf("certificate has CA:TRUE flag set")
+                       }
+                       now := time.Now()
+                       if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
+                               return nil, fmt.Errorf("certificate has expired or is not yet valid")
+                       }
+                       kcLogger.V(3).Debugf(
+                               "appending certificate with SubjectKeyIdentifier %s",
+                               strings.ToUpper(hex.EncodeToString(res.SubjectKeyIdentifier)))
+
+                       cr := &kcCertResult{
+                               cert: cert,
+                               skid: bytes.Clone(res.SubjectKeyIdentifier),
+                       }
+                       return cr, nil
+               })
+}
+
+func searchKeychain[T any](match stringmatch.StringMatcher, secClass keychain.SecClass, parser func(*keychain.QueryResult) (T, error)) ([]T, error) {
+       query := keychain.NewItem()
+
+       if int(secClass) != 0 {
+               query.SetSecClass(secClass)
+       }
+       query.SetReturnAttributes(true)
+       switch secClass {
+       case keychain.SecClassCryptoKey, keychain.SecClassIdentity:
+               query.SetReturnRef(true)
+               query.SetReturnData(false)
+       default:
+               query.SetReturnRef(false)
+               query.SetReturnData(true)
+       }
+       query.SetMatchLimit(keychain.MatchLimitAll)
+
+       items, err := keychain.QueryItem(query)
+       if err != nil {
+               return nil, fmt.Errorf("while attempting to load mTLS root certificate from keychain: %v", err)
+       }
+
+       results := make([]T, 0)
+       for _, res := range items {
+               if !match.Match(res.Label) {
+                       continue
+               }
+
+               parsed, err := parser(&res)
+               if err != nil {
+                       kcLogger.V(2).Warningf("found matching item %q in keychain, but failed to parse: %v", res.Label, err)
+                       continue
+               }
+
+               kcLogger.V(2).Infof("successfully parsed keychain item %q as %T", res.Label, parsed)
+               results = append(results, parsed)
+       }
+
+       if len(results) == 0 {
+               return nil, fmt.Errorf("no items with the label %+v could be found", match)
+       }
+
+       return results, nil
+}
+
+func (kcr *macosKeychainRoots) RootCertificates() ([]*x509.Certificate, error) {
+       root, err := getMtlsRootFromMacKeychain()
+       if err != nil {
+               return nil, err
+       }
+
+       return []*x509.Certificate{root}, nil
+}
+
+func (kcr *macosKeychainRoots) IntermediateCertificates() ([]*x509.Certificate, error) {
+       return getMtlsIntermediatesFromMacKeychain()
+}
+
+func init() {
+       registerRootDriver("macos_keychain", func() (rootsPrimitive, error) {
+               return &macosKeychainRoots{}, nil
+       })
+}
diff --git a/mtls/provider_shared.go b/mtls/provider_shared.go
new file mode 100644 (file)
index 0000000..183e017
--- /dev/null
@@ -0,0 +1,46 @@
+package mtls
+
+import (
+       "context"
+       "crypto/tls"
+       "net"
+)
+
+func newTlsCertificate(id certificatePrimitive) (*tls.Certificate, error) {
+       leafCertificate, err := id.LeafCertificate()
+       if err != nil {
+               return nil, err
+       }
+       privateKey, err := id.PrivateKey()
+       if err != nil {
+               return nil, err
+       }
+       intermediates, err := id.IntermediateCertificates()
+       if err != nil {
+               return nil, err
+       }
+       rawCerts := make([][]byte, 0)
+       rawCerts = append(rawCerts, leafCertificate.Raw)
+       for _, c := range intermediates {
+               rawCerts = append(rawCerts, c.Raw)
+       }
+
+       return &tls.Certificate{
+               Certificate: rawCerts,
+               PrivateKey:  privateKey,
+               Leaf:        leafCertificate,
+       }, nil
+}
+
+func newDialContextFunc(id CertificateProvider) DialContextFunc {
+       dcf := func(ctx context.Context, network, addr string) (net.Conn, error) {
+               c, err := id.TlsConfig(ctx)
+               if err != nil {
+                       return nil, err
+               }
+
+               return tls.Dial(network, addr, c)
+       }
+
+       return dcf
+}
diff --git a/mtls/provider_tpm2_pkcs11.go b/mtls/provider_tpm2_pkcs11.go
new file mode 100644 (file)
index 0000000..cbec7a8
--- /dev/null
@@ -0,0 +1,115 @@
+//go:build linux
+
+package mtls
+
+import (
+       "context"
+       "crypto"
+       "crypto/tls"
+       "crypto/x509"
+       "fmt"
+       "path"
+
+       "go.fuhry.dev/runtime/mtls/certutil"
+)
+
+type TPMBackedCertificate struct {
+       certificatePrimitive
+
+       p11 *p11
+}
+
+func NewTPMBackedCertificate() (*TPMBackedCertificate, error) {
+       pkcs, err := getGlobalP11()
+       if err != nil {
+               return nil, err
+       }
+
+       cert := &TPMBackedCertificate{
+               p11: &p11{
+                       cHandle: pkcs.cHandle,
+               },
+       }
+
+       return cert, nil
+}
+
+func (c *TPMBackedCertificate) LeafCertificate() (*x509.Certificate, error) {
+       tlsCert, err := c.p11.GetCertificate()
+       if err != nil {
+               return nil, err
+       }
+
+       return tlsCert.Leaf, nil
+}
+
+func (c *TPMBackedCertificate) IntermediateCertificates() ([]*x509.Certificate, error) {
+       caFile := path.Join(defaultMtlsRootPath, "ca.pem")
+
+       certs, err := certutil.LoadCertificatesFromPEM(caFile)
+       if err != nil {
+               return nil, err
+       }
+
+       ints := make([]*x509.Certificate, 0)
+       for _, cert := range certs {
+               if cert.IsCA {
+                       ints = append(ints, cert)
+               }
+       }
+
+       return ints, nil
+}
+
+func (c *TPMBackedCertificate) PrivateKey() (crypto.PrivateKey, error) {
+       pkey, err := c.p11.GetPrivateKey()
+       if err != nil {
+               return nil, err
+       }
+
+       return pkey, nil
+}
+
+func (c *TPMBackedCertificate) RootCertificate() (*x509.Certificate, error) {
+       caFile := path.Join(defaultMtlsRootPath, "ca.pem")
+
+       certs, err := certutil.LoadCertificatesFromPEM(caFile)
+       if err != nil {
+               return nil, err
+       }
+
+       for _, cert := range certs {
+               if cert.IsCA && cert.Issuer.String() == cert.Subject.String() {
+                       return cert, nil
+               }
+       }
+
+       return nil, fmt.Errorf("failed to find any self-signed ca certificates in %s", caFile)
+}
+
+func (c *TPMBackedCertificate) getCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+       return newTlsCertificate(c)
+}
+
+func (c *TPMBackedCertificate) getClientCertificate(reqInfo *tls.CertificateRequestInfo) (*tls.Certificate, error) {
+       return newTlsCertificate(c)
+}
+
+func (c *TPMBackedCertificate) TlsConfig(ctx context.Context) (*tls.Config, error) {
+       tlsConfig := &tls.Config{
+               GetCertificate:       c.getCertificate,
+               GetClientCertificate: c.getClientCertificate,
+       }
+
+       return tlsConfig, nil
+}
+
+func (c *TPMBackedCertificate) NewDialContextFunc() DialContextFunc {
+       return newDialContextFunc(c)
+}
+
+func init() {
+       registerIdentityDriver("tpm2-pkcs11", func(_ string) (CertificateProvider, error) {
+               return NewTPMBackedCertificate()
+       })
+}
diff --git a/mtls/verify_names.go b/mtls/verify_names.go
new file mode 100644 (file)
index 0000000..d1d180b
--- /dev/null
@@ -0,0 +1,269 @@
+package mtls
+
+import (
+       "crypto/tls"
+       "crypto/x509"
+       "fmt"
+       "regexp"
+       "strings"
+
+       "go.fuhry.dev/runtime/utils/hashset"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type IdentityClass uint
+
+type remoteIdentity struct {
+       class  IdentityClass
+       domain string
+       princ  string
+}
+
+const (
+       Domain IdentityClass = iota
+       Service
+       User
+       All
+)
+
+const (
+       exprSpiffeServiceIdentity = `^spiffe://(?P<domain>[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)/service/(?P<principal>[a-z0-9_-]+)$`
+       exprSpiffeUserIdentity    = `^spiffe://(?P<domain>[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)/user/(?P<principal>[a-z0-9_-]+)$`
+       exprMtlsInternalIdenitty  = `^(?P<principal>[A-Za-z0-9_-]+)\.(?P<domain>[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)\.mtls\.internal$`
+)
+
+func (c IdentityClass) String() string {
+       switch c {
+       case Domain:
+               return "domain"
+       case Service:
+               return "service"
+       case User:
+               return "user"
+       case All:
+               return "all"
+       }
+
+       panic("invalid value for IdentityClass")
+}
+
+var (
+       spiffeServiceIdentity, spiffeUserIdentity, mtlsInternalIdentity *regexp.Regexp
+)
+
+func init() {
+       spiffeServiceIdentity = regexp.MustCompile(exprSpiffeServiceIdentity)
+       spiffeUserIdentity = regexp.MustCompile(exprSpiffeUserIdentity)
+       mtlsInternalIdentity = regexp.MustCompile(exprMtlsInternalIdenitty)
+}
+
+// MTLSPeerVerifier validates SPIFFE CNs and SANs of peer certificates, and facilitates
+// configuration of conventional certificate validation (chain, notbefore/notafter, key
+// usage, etc.) against the mTLS CA.
+//
+// It will not configure the local identity's certificates, private keys, etc. - for that,
+// use `Identity.TlsConfig()`.
+type MTLSPeerVerifier interface {
+       // ConfigureServer modifies a tls.Config struct to require client certificates, and verify
+       // the client certificate using the custom chain and name verifiers.
+       ConfigureServer(*tls.Config) error
+
+       // ConfigureClient modifies a tls.Config struct to verify server certificates against the
+       // mTLS root CA.
+       ConfigureClient(*tls.Config) error
+
+       // AllowFrom allow-lists one or more peer identities of a given type.
+       //
+       // Specify the IdentityClass "Any" to allow all clients.
+       //
+       // If IdentityClass is Service or User, the variadic arguments are the service names or
+       // usernames that are permitted to connect.
+       AllowFrom(IdentityClass, ...string)
+
+       // VerifyPeerCert conforms to the function prototype for `tls.Config.VerifyConnection`.
+       //
+       // This function is called after VerifyPeerCertificate completes without error.
+       VerifyPeerCert(*x509.Certificate, x509.VerifyOptions) error
+}
+
+type mtlsPeerVerifier struct {
+       MTLSPeerVerifier
+
+       allowedPrincipals map[IdentityClass]*hashset.HashSet[string]
+       log               *log.Logger
+}
+
+func NewPeerNameVerifier() MTLSPeerVerifier {
+       cv := &mtlsPeerVerifier{
+               allowedPrincipals: make(map[IdentityClass]*hashset.HashSet[string]),
+               log:               log.WithPrefix("MTLSPeerVerifier"),
+       }
+
+       return cv
+}
+
+func (cv *mtlsPeerVerifier) ConfigureServer(tlsConfig *tls.Config) error {
+       vo, err := NewMTLSVerifyOpts()
+       if err != nil {
+               return err
+       }
+       tlsConfig.VerifyPeerCertificate = NewVerifyMTLSPeerCertificateFuncWithOpts(vo)
+       tlsConfig.InsecureSkipVerify = true
+       tlsConfig.VerifyConnection = cv.verifyConnectionFunc(vo)
+       tlsConfig.ClientCAs = vo.Roots
+       tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
+
+       return nil
+}
+
+func (cv *mtlsPeerVerifier) ConfigureClient(tlsConfig *tls.Config) error {
+       vo, err := NewMTLSVerifyOpts()
+       if err != nil {
+               return err
+       }
+       tlsConfig.InsecureSkipVerify = true
+       tlsConfig.VerifyPeerCertificate = NewVerifyMTLSPeerCertificateFuncWithOpts(vo)
+       tlsConfig.VerifyConnection = cv.verifyConnectionFunc(vo)
+
+       return nil
+}
+
+func (cv *mtlsPeerVerifier) verifyConnectionFunc(verifyOpts x509.VerifyOptions) func(tls.ConnectionState) error {
+       return func(cs tls.ConnectionState) error {
+               if len(cs.PeerCertificates) < 1 {
+                       return fmt.Errorf("no peer certificate provided")
+               }
+
+               peerCert := cs.PeerCertificates[0]
+
+               return cv.VerifyPeerCert(peerCert, verifyOpts)
+       }
+}
+
+func (cv *mtlsPeerVerifier) VerifyPeerCert(peerCert *x509.Certificate, verifyOpts x509.VerifyOptions) error {
+       _, err := peerCert.Verify(verifyOpts)
+       if err != nil {
+               return err
+       }
+       err = cv.checkName(peerCert.Subject.CommonName)
+       if err == nil {
+               cv.log.V(2).Infof("accepted peer certificate with CN = %q", peerCert.Subject.CommonName)
+               return nil
+       }
+       cv.log.V(2).Infof("did not accept peer certificate CN %q: %v", peerCert.Subject.CommonName, err)
+
+       for _, dnsName := range peerCert.DNSNames {
+               err = cv.checkName(dnsName)
+               if err == nil {
+                       cv.log.V(1).Infof("accepted peer certificate with SAN = DNS:%q", dnsName)
+                       return nil
+               }
+               cv.log.V(2).Infof("did not accept peer certificate dnsName %q: %v", dnsName, err)
+       }
+
+       for _, url := range peerCert.URIs {
+               err = cv.checkName(url.String())
+               if err == nil {
+                       cv.log.V(1).Infof("accepted peer certificate with SAN = URI:%q", url.String())
+                       return nil
+               }
+               cv.log.V(2).Infof("did not accept peer certificate URI %q: %v", url.String(), err)
+       }
+
+       cv.log.V(2).Errorf("rejected connection because no names were allowed")
+       return fmt.Errorf("none of the names in this certificate are allowed")
+}
+
+func (cv *mtlsPeerVerifier) AllowFrom(class IdentityClass, principals ...string) {
+       if len(principals) < 1 && class != All {
+               return
+       }
+       hs, ok := cv.allowedPrincipals[class]
+       if !ok {
+               cv.allowedPrincipals[class] = hashset.NewHashSet[string]()
+               hs = cv.allowedPrincipals[class]
+       }
+       for _, princ := range principals {
+               hs.Add(princ)
+       }
+}
+
+func (cv *mtlsPeerVerifier) checkName(name string) error {
+       id, err := parseName(name)
+       if err != nil {
+               return err
+       }
+       cv.log.V(3).Debugf("parsed name %q to %+v (class=%s)", name, id, id.class.String())
+
+       if _, ok := cv.allowedPrincipals[All]; ok {
+               return nil
+       }
+
+       if allowedDomains, ok := cv.allowedPrincipals[Domain]; ok {
+               if allowedDomains.Contains(id.domain) {
+                       cv.log.V(3).Debugf("domain %q exactly matched allowlist", id.domain)
+                       return nil
+               }
+               domainOk := false
+               for _, val := range allowedDomains.AsSlice() {
+                       if val[0] != '.' {
+                               continue
+                       }
+                       if strings.HasSuffix(id.domain, val) {
+                               cv.log.V(3).Debugf("domain %q matched suffix rule %q", id.domain, val)
+                               domainOk = true
+                               break
+                       }
+               }
+               if !domainOk {
+                       return fmt.Errorf("trust domain %q is not allowed to authenticate to this service", id.domain)
+               }
+       }
+
+       if allowedPrincipals, ok := cv.allowedPrincipals[id.class]; ok {
+               if !allowedPrincipals.Contains(id.princ) {
+                       return fmt.Errorf("principal %q is not allowed to authenticate to this service", id.princ)
+               }
+
+               cv.log.V(3).Debugf("principal %q matched allowlist", id.princ)
+               return nil
+       }
+
+       return fmt.Errorf("principals of this type are not allowed")
+}
+
+func parseName(name string) (*remoteIdentity, error) {
+       exps := []struct {
+               expr  *regexp.Regexp
+               class IdentityClass
+       }{
+               {
+                       expr:  spiffeServiceIdentity,
+                       class: Service,
+               },
+               {
+                       expr:  mtlsInternalIdentity,
+                       class: Service,
+               },
+               {
+                       expr:  spiffeUserIdentity,
+                       class: User,
+               },
+       }
+
+       for _, e := range exps {
+               if !e.expr.MatchString(name) {
+                       continue
+               }
+
+               parts := e.expr.FindStringSubmatch(name)
+               iDomain, iPrinc := e.expr.SubexpIndex("domain"), e.expr.SubexpIndex("principal")
+               return &remoteIdentity{
+                       class:  e.class,
+                       domain: parts[iDomain],
+                       princ:  parts[iPrinc],
+               }, nil
+       }
+
+       return nil, fmt.Errorf("cannot understand CN/SAN %q as an mTLS identity", name)
+}
diff --git a/mtls/verify_roots.go b/mtls/verify_roots.go
new file mode 100644 (file)
index 0000000..1a95101
--- /dev/null
@@ -0,0 +1,207 @@
+package mtls
+
+import (
+       "crypto/x509"
+       "errors"
+       "sync"
+       "time"
+
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type tlsVerifyPeerCertificatesFunc = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error
+
+type rootDriver struct {
+       name string
+       load func() (rootsPrimitive, error)
+}
+
+func registerRootDriver(name string, load func() (rootsPrimitive, error)) {
+       if rootsDrivers == nil {
+               rootsDrivers = make([]*rootDriver, 0)
+       }
+
+       rd := &rootDriver{
+               name: name,
+               load: load,
+       }
+
+       rootsDrivers = append(rootsDrivers, rd)
+}
+
+var (
+       rootsDrivers []*rootDriver
+
+       mtlsVerifyOpts     *x509.VerifyOptions
+       mtlsVerifyOptsOnce sync.Once
+
+       ErrCertificateParseFailed                    = errors.New("failed to parse a certificate that was presented in the handshake")
+       ErrMultipleCertificatesPresented             = errors.New("peer presented multiple leaf certificates")
+       ErrNoCertificatePresented                    = errors.New("peer did not present a certificate")
+       ErrCertificateNotYetValid                    = errors.New("certificate is not yet valid")
+       ErrCertificateExpired                        = errors.New("certificate has expired")
+       ErrCertificateHasUnhandledCriticalExtensions = errors.New("certificate has unhandled critical extensions")
+       ErrBasicConstraintsInvalid                   = errors.New("leaf certificate basic constraints are invalid")
+       ErrCertificateCAFlagSet                      = errors.New("leaf certificate has CA:TRUE flag in basicConstraints")
+)
+
+func selectRoots() ([]*x509.Certificate, []*x509.Certificate, error) {
+       if rootsDrivers == nil {
+               return nil, nil, errors.New("no roots drivers were registered")
+       }
+
+       for _, driver := range rootsDrivers {
+               logger.V(1).Infof("asking roots driver %s for roots and intermediates", driver.name)
+
+               provider, err := driver.load()
+               if err != nil {
+                       logger.V(2).Warningf("driver %s failed to initialize: %v", driver.name, err)
+                       continue
+               }
+
+               root, err := provider.RootCertificates()
+               if err != nil {
+                       logger.V(2).Warningf("driver %s (%T) failed to load root certificate: %v", driver.name, provider, err)
+                       continue
+               }
+
+               ints, err := provider.IntermediateCertificates()
+               if err != nil {
+                       logger.V(2).Warningf("driver %s (%T) failed to load intermediate certificates: %v", driver.name, provider, err)
+                       continue
+               }
+
+               logger.V(1).Noticef("using driver %s (%T) for root and intermediate certificates", driver.name, provider)
+
+               return root, ints, nil
+       }
+
+       return nil, nil, errors.New("no usable roots driver was found")
+}
+
+func newMTLSVerifyOpts() (*x509.VerifyOptions, error) {
+       rootPool := x509.NewCertPool()
+       intermediatePool := x509.NewCertPool()
+
+       root, ints, err := selectRoots()
+       if err != nil {
+               return nil, err
+       }
+
+       for _, cert := range root {
+               rootPool.AddCert(cert)
+       }
+
+       for _, cert := range ints {
+               intermediatePool.AddCert(cert)
+       }
+
+       opts := &x509.VerifyOptions{
+               Intermediates: intermediatePool,
+               Roots:         rootPool,
+               KeyUsages: []x509.ExtKeyUsage{
+                       x509.ExtKeyUsageAny,
+               },
+       }
+
+       return opts, nil
+}
+
+func NewMTLSVerifyOpts() (x509.VerifyOptions, error) {
+       mtlsVerifyOptsOnce.Do(func() {
+               var err error
+               mtlsVerifyOpts, err = newMTLSVerifyOpts()
+               if err != nil {
+                       log.Panicf("failed to load mtls root certificates: %v", err)
+               }
+       })
+
+       verifyOptsClone := x509.VerifyOptions{
+               Intermediates: mtlsVerifyOpts.Intermediates.Clone(),
+               Roots:         mtlsVerifyOpts.Roots.Clone(),
+               KeyUsages:     make([]x509.ExtKeyUsage, 0),
+       }
+
+       verifyOptsClone.KeyUsages = append(verifyOptsClone.KeyUsages, mtlsVerifyOpts.KeyUsages...)
+
+       return verifyOptsClone, nil
+}
+
+func checkLeafCertificateConstraints(leafCert *x509.Certificate) error {
+       if leafCert.NotBefore.After(time.Now()) {
+               return ErrCertificateNotYetValid
+       }
+       if leafCert.NotAfter.Before(time.Now()) {
+               return ErrCertificateExpired
+       }
+       if len(leafCert.UnhandledCriticalExtensions) > 0 {
+               return ErrCertificateHasUnhandledCriticalExtensions
+       }
+       // if !leafCert.BasicConstraintsValid {
+       //      return ErrBasicConstraintsInvalid
+       // }
+       if leafCert.IsCA {
+               return ErrCertificateCAFlagSet
+       }
+       return nil
+}
+
+func verifyMTLSCertificateChain(leafCert *x509.Certificate, intermediates []*x509.Certificate, mtlsVerifyOpts x509.VerifyOptions) error {
+       for _, intCert := range intermediates {
+               mtlsVerifyOpts.Intermediates.AddCert(intCert)
+       }
+
+       chains, err := leafCert.Verify(mtlsVerifyOpts)
+       if err != nil {
+               return err
+       }
+
+       for _, chain := range chains {
+               lastCert := chain[0]
+               logger.Debugf("checking constraints on leaf certificate: %+v", lastCert.Subject.String())
+               if err := checkLeafCertificateConstraints(lastCert); err != nil {
+                       return err
+               }
+       }
+
+       return nil
+}
+
+func NewVerifyMTLSPeerCertificateFunc() (tlsVerifyPeerCertificatesFunc, error) {
+       vo, err := NewMTLSVerifyOpts()
+       if err != nil {
+               return nil, err
+       }
+
+       return NewVerifyMTLSPeerCertificateFuncWithOpts(vo), nil
+}
+
+func NewVerifyMTLSPeerCertificateFuncWithOpts(vo x509.VerifyOptions) tlsVerifyPeerCertificatesFunc {
+       return func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
+               var leafCert *x509.Certificate
+
+               intermediates := make([]*x509.Certificate, 0)
+
+               for _, rawCert := range rawCerts {
+                       cert, err := x509.ParseCertificate(rawCert)
+                       if err == nil {
+                               if cert.IsCA {
+                                       intermediates = append(intermediates, cert)
+                               } else {
+                                       if leafCert != nil {
+                                               return ErrMultipleCertificatesPresented
+                                       }
+                                       leafCert = cert
+                               }
+                       } else {
+                               return ErrCertificateParseFailed
+                       }
+               }
+
+               if leafCert == nil {
+                       return ErrNoCertificatePresented
+               }
+
+               return verifyMTLSCertificateChain(leafCert, intermediates, vo)
+       }
+}
diff --git a/mtls/verify_tool/Makefile b/mtls/verify_tool/Makefile
new file mode 100644 (file)
index 0000000..1d7d424
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(GOSRC:.go=)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: %.go
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/mtls/verify_tool/main.go b/mtls/verify_tool/main.go
new file mode 100644 (file)
index 0000000..26f4c27
--- /dev/null
@@ -0,0 +1,89 @@
+package main
+
+import (
+       "crypto/x509"
+       "encoding/pem"
+       "flag"
+       "io"
+       "os"
+       "strings"
+
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type flagStringSlice []string
+
+func (s *flagStringSlice) String() string {
+       return strings.Join([]string(*s), ", ")
+}
+
+func (s *flagStringSlice) Set(value string) error {
+       *s = append(*s, value)
+       return nil
+}
+
+func main() {
+       var services, users, domains flagStringSlice
+
+       var certPath string
+       var certContents []byte
+       var certParsed *x509.Certificate
+       var err error
+
+       flag.Var(&services, "service", "List of services to allow")
+       flag.Var(&users, "user", "List of users to allow")
+       flag.Var(&domains, "domain", "List of domains to allow")
+       flag.StringVar(&certPath, "certificate", "", "client certificate presented; if omitted, reads from standard input")
+
+       flag.Parse()
+
+       if certPath == "" {
+               certContents, err = io.ReadAll(os.Stdin)
+               if err != nil {
+                       log.Fatal(err)
+               }
+       } else {
+               certContents, err = os.ReadFile(certPath)
+               if err != nil {
+                       log.Fatal(err)
+               }
+       }
+
+       pemValue, rest := pem.Decode(certContents)
+       if pemValue != nil {
+               if pemValue.Type != "CERTIFICATE" {
+                       log.Fatal("PEM-encoded input is not a certificate")
+               }
+               certParsed, err = x509.ParseCertificate(pemValue.Bytes)
+               if err != nil {
+                       log.Fatal("failed to parse the provided certificate", err)
+               }
+       } else {
+               certParsed, err = x509.ParseCertificate(rest)
+               if err != nil {
+                       log.Fatal("failed to parse the provided certificate", err)
+               }
+       }
+
+       log.Default().Debugf("parsed certificate with subject: %+v", certParsed.Subject)
+
+       cv := mtls.NewPeerNameVerifier()
+       cv.AllowFrom(mtls.Service, services...)
+       cv.AllowFrom(mtls.User, users...)
+       cv.AllowFrom(mtls.Domain, domains...)
+
+       verifyOpts, err := mtls.NewMTLSVerifyOpts()
+       if err != nil {
+               log.Default().Warn("creating verify opts: ", err)
+               os.Exit(1)
+       }
+
+       err = cv.VerifyPeerCert(certParsed, verifyOpts)
+       if err != nil {
+               log.Default().Warn("certificate validation failed: ", err)
+               os.Exit(1)
+       }
+
+       os.Exit(0)
+}
diff --git a/net/dns/dns_cache.go b/net/dns/dns_cache.go
new file mode 100644 (file)
index 0000000..4a3a6fa
--- /dev/null
@@ -0,0 +1,145 @@
+package dns
+
+import (
+       "context"
+       "fmt"
+       "math/rand"
+       "sync"
+       "time"
+
+       lru "github.com/hashicorp/golang-lru"
+       "github.com/miekg/dns"
+)
+
+var dnsCache *lru.Cache
+var dnsCacheInit sync.Once
+
+func ResolveDualStack(hostname string) (string, string, error) {
+       var msg *dns.Msg
+       var err error
+
+       dnsCacheInit.Do(func() {
+               var err error
+               dnsCache, err = lru.New(256)
+               if err != nil {
+                       panic(fmt.Errorf("failed to create lru cache: %v", err))
+               }
+       })
+
+       hostname = dns.Fqdn(hostname)
+
+       if entry, ok := dnsCache.Get(hostname); ok {
+               if msg, ok = entry.(*dns.Msg); !ok {
+                       return "", "", fmt.Errorf("lru cache is corrupt: expected entry to be *dns.Msg, got %T", entry)
+               }
+       } else {
+               msg, err = doDualStackQuery(hostname)
+               if err != nil {
+                       return "", "", err
+               }
+
+               dnsCache.Add(hostname, msg)
+       }
+
+       var (
+               ip4 string
+               ip6 string
+       )
+
+       for _, rr := range msg.Answer {
+               switch a := rr.(type) {
+               case *dns.A:
+                       ip4 = a.A.String()
+               case *dns.AAAA:
+                       ip6 = a.AAAA.String()
+               }
+       }
+
+       return ip4, ip6, nil
+}
+
+func doDualStackQuery(hostname string) (*dns.Msg, error) {
+       var msg *dns.Msg
+
+       cc, err := dns.ClientConfigFromFile("/etc/resolv.conf")
+       if err != nil {
+               return nil, err
+       }
+
+       for _, qtype := range []uint16{dns.TypeA, dns.TypeAAAA} {
+               query := &dns.Msg{
+                       MsgHdr: dns.MsgHdr{
+                               Id:                dns.Id(),
+                               RecursionDesired:  true,
+                               AuthenticatedData: true,
+                       },
+                       Question: make([]dns.Question, 1),
+                       Extra: []dns.RR{
+                               newEDNSCookie(),
+                       },
+               }
+
+               query.Question[0] = dns.Question{
+                       Name:   hostname,
+                       Qtype:  qtype,
+                       Qclass: dns.ClassINET,
+               }
+
+               mu := &sync.Mutex{}
+               done := false
+
+               client := &dns.Client{}
+               resultChan := make(chan *dns.Msg, len(cc.Servers))
+               defer (func() {
+                       mu.Lock()
+                       done = true
+                       close(resultChan)
+                       mu.Unlock()
+               })()
+
+               ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+               defer cancel()
+
+               for _, server := range cc.Servers {
+                       go (func() {
+                               m, _, err := client.Exchange(query, server+":53")
+                               if m != nil && err == nil {
+                                       mu.Lock()
+                                       defer mu.Unlock()
+                                       if !done {
+                                               resultChan <- m
+                                       }
+                               }
+                       })()
+               }
+
+               select {
+               case m := <-resultChan:
+                       if msg == nil {
+                               msg = m
+                       } else {
+                               msg.Answer = append(msg.Answer, m.Answer...)
+                       }
+               case <-ctx.Done():
+                       return nil, fmt.Errorf("resolving name %s timed out", hostname)
+               }
+       }
+
+       return msg, nil
+}
+
+func newEDNSCookie() dns.RR {
+       clientCookie := fmt.Sprintf("%016x", rand.Uint64())
+       return &dns.OPT{
+               Hdr: dns.RR_Header{
+                       Name:   ".",
+                       Rrtype: dns.TypeOPT,
+               },
+               Option: []dns.EDNS0{
+                       &dns.EDNS0_COOKIE{
+                               Code:   dns.EDNS0COOKIE,
+                               Cookie: clientCookie,
+                       },
+               },
+       }
+}
diff --git a/proto/service/attest/Makefile b/proto/service/attest/Makefile
new file mode 100644 (file)
index 0000000..501225c
--- /dev/null
@@ -0,0 +1,14 @@
+PROTO_SRCS := $(wildcard *.proto)
+PROTO_GO_OUTPUT := $(PROTO_SRCS:.proto=.pb.go) $(PROTO_SRCS:.proto=_grpc.pb.go)
+
+$(PROTO_GO_OUTPUT): $(PROTO_SRCS)
+       protoc --go_out=. --go_opt=paths=source_relative \
+               --go-grpc_out=. --go-grpc_opt=paths=source_relative \
+               $(PROTO_SRCS)
+
+pb_go: $(PROTO_GO_OUTPUT)
+
+all: pb_go
+
+clean:
+       rm -fv $(PROTO_GO_OUTPUT)
diff --git a/proto/service/attest/attest_server.pb.go b/proto/service/attest/attest_server.pb.go
new file mode 100644 (file)
index 0000000..276a999
--- /dev/null
@@ -0,0 +1,853 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+//     protoc-gen-go v1.30.0
+//     protoc        v4.25.1
+// source: attest_server.proto
+
+package attest
+
+import (
+       protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+       protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+       reflect "reflect"
+       sync "sync"
+)
+
+const (
+       // Verify that this generated code is sufficiently up-to-date.
+       _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+       // Verify that runtime/protoimpl is sufficiently up-to-date.
+       _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type PCR_DigestAlg int32
+
+const (
+       PCR_INVALID_DIGEST PCR_DigestAlg = 0
+       PCR_SHA1           PCR_DigestAlg = 1
+       PCR_SHA256         PCR_DigestAlg = 2
+       PCR_SHA384         PCR_DigestAlg = 3
+)
+
+// Enum value maps for PCR_DigestAlg.
+var (
+       PCR_DigestAlg_name = map[int32]string{
+               0: "INVALID_DIGEST",
+               1: "SHA1",
+               2: "SHA256",
+               3: "SHA384",
+       }
+       PCR_DigestAlg_value = map[string]int32{
+               "INVALID_DIGEST": 0,
+               "SHA1":           1,
+               "SHA256":         2,
+               "SHA384":         3,
+       }
+)
+
+func (x PCR_DigestAlg) Enum() *PCR_DigestAlg {
+       p := new(PCR_DigestAlg)
+       *p = x
+       return p
+}
+
+func (x PCR_DigestAlg) String() string {
+       return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (PCR_DigestAlg) Descriptor() protoreflect.EnumDescriptor {
+       return file_attest_server_proto_enumTypes[0].Descriptor()
+}
+
+func (PCR_DigestAlg) Type() protoreflect.EnumType {
+       return &file_attest_server_proto_enumTypes[0]
+}
+
+func (x PCR_DigestAlg) Number() protoreflect.EnumNumber {
+       return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use PCR_DigestAlg.Descriptor instead.
+func (PCR_DigestAlg) EnumDescriptor() ([]byte, []int) {
+       return file_attest_server_proto_rawDescGZIP(), []int{1, 0}
+}
+
+type Quote struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       TpmVersion uint32 `protobuf:"varint,1,opt,name=tpm_version,json=Version,proto3" json:"tpm_version,omitempty"`
+       Quote      []byte `protobuf:"bytes,2,opt,name=quote,json=Quote,proto3" json:"quote,omitempty"`
+       Signature  []byte `protobuf:"bytes,3,opt,name=signature,json=Signature,proto3" json:"signature,omitempty"`
+}
+
+func (x *Quote) Reset() {
+       *x = Quote{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_attest_server_proto_msgTypes[0]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *Quote) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Quote) ProtoMessage() {}
+
+func (x *Quote) ProtoReflect() protoreflect.Message {
+       mi := &file_attest_server_proto_msgTypes[0]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use Quote.ProtoReflect.Descriptor instead.
+func (*Quote) Descriptor() ([]byte, []int) {
+       return file_attest_server_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Quote) GetTpmVersion() uint32 {
+       if x != nil {
+               return x.TpmVersion
+       }
+       return 0
+}
+
+func (x *Quote) GetQuote() []byte {
+       if x != nil {
+               return x.Quote
+       }
+       return nil
+}
+
+func (x *Quote) GetSignature() []byte {
+       if x != nil {
+               return x.Signature
+       }
+       return nil
+}
+
+type PCR struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       Index     uint32        `protobuf:"varint,1,opt,name=index,json=Index,proto3" json:"index,omitempty"`
+       Digest    []byte        `protobuf:"bytes,2,opt,name=digest,json=Digest,proto3" json:"digest,omitempty"`
+       DigestAlg PCR_DigestAlg `protobuf:"varint,3,opt,name=digest_alg,json=DigestAlg,proto3,enum=fuhry.runtime.service.attest.PCR_DigestAlg" json:"digest_alg,omitempty"`
+}
+
+func (x *PCR) Reset() {
+       *x = PCR{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_attest_server_proto_msgTypes[1]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *PCR) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PCR) ProtoMessage() {}
+
+func (x *PCR) ProtoReflect() protoreflect.Message {
+       mi := &file_attest_server_proto_msgTypes[1]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use PCR.ProtoReflect.Descriptor instead.
+func (*PCR) Descriptor() ([]byte, []int) {
+       return file_attest_server_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *PCR) GetIndex() uint32 {
+       if x != nil {
+               return x.Index
+       }
+       return 0
+}
+
+func (x *PCR) GetDigest() []byte {
+       if x != nil {
+               return x.Digest
+       }
+       return nil
+}
+
+func (x *PCR) GetDigestAlg() PCR_DigestAlg {
+       if x != nil {
+               return x.DigestAlg
+       }
+       return PCR_INVALID_DIGEST
+}
+
+type AttestationParameters struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       Public                  []byte `protobuf:"bytes,1,opt,name=public,json=Public,proto3" json:"public,omitempty"`
+       UseTcsdActivationFormat bool   `protobuf:"varint,2,opt,name=use_tcsd_activation_format,json=UseTCSDActivationFormat,proto3" json:"use_tcsd_activation_format,omitempty"`
+       CreateData              []byte `protobuf:"bytes,3,opt,name=create_data,json=CreateData,proto3" json:"create_data,omitempty"`
+       CreateAttestation       []byte `protobuf:"bytes,4,opt,name=create_attestation,json=CreateAttestation,proto3" json:"create_attestation,omitempty"`
+       CreateSignature         []byte `protobuf:"bytes,5,opt,name=create_signature,json=CreateSignature,proto3" json:"create_signature,omitempty"`
+}
+
+func (x *AttestationParameters) Reset() {
+       *x = AttestationParameters{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_attest_server_proto_msgTypes[2]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *AttestationParameters) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AttestationParameters) ProtoMessage() {}
+
+func (x *AttestationParameters) ProtoReflect() protoreflect.Message {
+       mi := &file_attest_server_proto_msgTypes[2]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use AttestationParameters.ProtoReflect.Descriptor instead.
+func (*AttestationParameters) Descriptor() ([]byte, []int) {
+       return file_attest_server_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *AttestationParameters) GetPublic() []byte {
+       if x != nil {
+               return x.Public
+       }
+       return nil
+}
+
+func (x *AttestationParameters) GetUseTcsdActivationFormat() bool {
+       if x != nil {
+               return x.UseTcsdActivationFormat
+       }
+       return false
+}
+
+func (x *AttestationParameters) GetCreateData() []byte {
+       if x != nil {
+               return x.CreateData
+       }
+       return nil
+}
+
+func (x *AttestationParameters) GetCreateAttestation() []byte {
+       if x != nil {
+               return x.CreateAttestation
+       }
+       return nil
+}
+
+func (x *AttestationParameters) GetCreateSignature() []byte {
+       if x != nil {
+               return x.CreateSignature
+       }
+       return nil
+}
+
+type PlatformParameters struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       TpmVersion uint32   `protobuf:"varint,1,opt,name=tpm_version,json=TPMVersion,proto3" json:"tpm_version,omitempty"`
+       Public     []byte   `protobuf:"bytes,2,opt,name=public,json=Public,proto3" json:"public,omitempty"`
+       Quotes     []*Quote `protobuf:"bytes,3,rep,name=quotes,json=Quotes,proto3" json:"quotes,omitempty"`
+       Pcrs       []*PCR   `protobuf:"bytes,4,rep,name=pcrs,json=PCRs,proto3" json:"pcrs,omitempty"`
+       EventLog   []byte   `protobuf:"bytes,5,opt,name=event_log,json=EventLog,proto3" json:"event_log,omitempty"`
+}
+
+func (x *PlatformParameters) Reset() {
+       *x = PlatformParameters{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_attest_server_proto_msgTypes[3]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *PlatformParameters) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PlatformParameters) ProtoMessage() {}
+
+func (x *PlatformParameters) ProtoReflect() protoreflect.Message {
+       mi := &file_attest_server_proto_msgTypes[3]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use PlatformParameters.ProtoReflect.Descriptor instead.
+func (*PlatformParameters) Descriptor() ([]byte, []int) {
+       return file_attest_server_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *PlatformParameters) GetTpmVersion() uint32 {
+       if x != nil {
+               return x.TpmVersion
+       }
+       return 0
+}
+
+func (x *PlatformParameters) GetPublic() []byte {
+       if x != nil {
+               return x.Public
+       }
+       return nil
+}
+
+func (x *PlatformParameters) GetQuotes() []*Quote {
+       if x != nil {
+               return x.Quotes
+       }
+       return nil
+}
+
+func (x *PlatformParameters) GetPcrs() []*PCR {
+       if x != nil {
+               return x.Pcrs
+       }
+       return nil
+}
+
+func (x *PlatformParameters) GetEventLog() []byte {
+       if x != nil {
+               return x.EventLog
+       }
+       return nil
+}
+
+type GetActivationChallengeRequest struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       TpmVersion            uint32                 `protobuf:"varint,1,opt,name=tpm_version,json=tpmVersion,proto3" json:"tpm_version,omitempty"`
+       AttestationParameters *AttestationParameters `protobuf:"bytes,2,opt,name=attestation_parameters,json=attestationParameters,proto3" json:"attestation_parameters,omitempty"`
+       EndorsementKey        []byte                 `protobuf:"bytes,3,opt,name=endorsement_key,json=endorsementKey,proto3" json:"endorsement_key,omitempty"`
+}
+
+func (x *GetActivationChallengeRequest) Reset() {
+       *x = GetActivationChallengeRequest{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_attest_server_proto_msgTypes[4]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *GetActivationChallengeRequest) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetActivationChallengeRequest) ProtoMessage() {}
+
+func (x *GetActivationChallengeRequest) ProtoReflect() protoreflect.Message {
+       mi := &file_attest_server_proto_msgTypes[4]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetActivationChallengeRequest.ProtoReflect.Descriptor instead.
+func (*GetActivationChallengeRequest) Descriptor() ([]byte, []int) {
+       return file_attest_server_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *GetActivationChallengeRequest) GetTpmVersion() uint32 {
+       if x != nil {
+               return x.TpmVersion
+       }
+       return 0
+}
+
+func (x *GetActivationChallengeRequest) GetAttestationParameters() *AttestationParameters {
+       if x != nil {
+               return x.AttestationParameters
+       }
+       return nil
+}
+
+func (x *GetActivationChallengeRequest) GetEndorsementKey() []byte {
+       if x != nil {
+               return x.EndorsementKey
+       }
+       return nil
+}
+
+type GetActivationChallengeResponse struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       EncryptedSecret []byte `protobuf:"bytes,2,opt,name=encrypted_secret,json=encryptedSecret,proto3" json:"encrypted_secret,omitempty"`
+       Credential      []byte `protobuf:"bytes,3,opt,name=credential,proto3" json:"credential,omitempty"`
+}
+
+func (x *GetActivationChallengeResponse) Reset() {
+       *x = GetActivationChallengeResponse{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_attest_server_proto_msgTypes[5]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *GetActivationChallengeResponse) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetActivationChallengeResponse) ProtoMessage() {}
+
+func (x *GetActivationChallengeResponse) ProtoReflect() protoreflect.Message {
+       mi := &file_attest_server_proto_msgTypes[5]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetActivationChallengeResponse.ProtoReflect.Descriptor instead.
+func (*GetActivationChallengeResponse) Descriptor() ([]byte, []int) {
+       return file_attest_server_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *GetActivationChallengeResponse) GetEncryptedSecret() []byte {
+       if x != nil {
+               return x.EncryptedSecret
+       }
+       return nil
+}
+
+func (x *GetActivationChallengeResponse) GetCredential() []byte {
+       if x != nil {
+               return x.Credential
+       }
+       return nil
+}
+
+type AttestPlatformRequest struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       Nonce              []byte              `protobuf:"bytes,1,opt,name=nonce,proto3" json:"nonce,omitempty"`
+       PlatformParameters *PlatformParameters `protobuf:"bytes,2,opt,name=platform_parameters,json=platformParameters,proto3" json:"platform_parameters,omitempty"`
+}
+
+func (x *AttestPlatformRequest) Reset() {
+       *x = AttestPlatformRequest{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_attest_server_proto_msgTypes[6]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *AttestPlatformRequest) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AttestPlatformRequest) ProtoMessage() {}
+
+func (x *AttestPlatformRequest) ProtoReflect() protoreflect.Message {
+       mi := &file_attest_server_proto_msgTypes[6]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use AttestPlatformRequest.ProtoReflect.Descriptor instead.
+func (*AttestPlatformRequest) Descriptor() ([]byte, []int) {
+       return file_attest_server_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *AttestPlatformRequest) GetNonce() []byte {
+       if x != nil {
+               return x.Nonce
+       }
+       return nil
+}
+
+func (x *AttestPlatformRequest) GetPlatformParameters() *PlatformParameters {
+       if x != nil {
+               return x.PlatformParameters
+       }
+       return nil
+}
+
+type AttestPlatformResponse struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       Nonce []byte `protobuf:"bytes,1,opt,name=nonce,proto3" json:"nonce,omitempty"`
+}
+
+func (x *AttestPlatformResponse) Reset() {
+       *x = AttestPlatformResponse{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_attest_server_proto_msgTypes[7]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *AttestPlatformResponse) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AttestPlatformResponse) ProtoMessage() {}
+
+func (x *AttestPlatformResponse) ProtoReflect() protoreflect.Message {
+       mi := &file_attest_server_proto_msgTypes[7]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use AttestPlatformResponse.ProtoReflect.Descriptor instead.
+func (*AttestPlatformResponse) Descriptor() ([]byte, []int) {
+       return file_attest_server_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *AttestPlatformResponse) GetNonce() []byte {
+       if x != nil {
+               return x.Nonce
+       }
+       return nil
+}
+
+var File_attest_server_proto protoreflect.FileDescriptor
+
+var file_attest_server_proto_rawDesc = []byte{
+       0x0a, 0x13, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2e,
+       0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x1c, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72, 0x75, 0x6e,
+       0x74, 0x69, 0x6d, 0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x74, 0x74,
+       0x65, 0x73, 0x74, 0x22, 0x59, 0x0a, 0x05, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x12, 0x1c, 0x0a, 0x0b,
+       0x74, 0x70, 0x6d, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28,
+       0x0d, 0x52, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75,
+       0x6f, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x51, 0x75, 0x6f, 0x74, 0x65,
+       0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20,
+       0x01, 0x28, 0x0c, 0x52, 0x09, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xc2,
+       0x01, 0x0a, 0x03, 0x50, 0x43, 0x52, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18,
+       0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x16, 0x0a, 0x06,
+       0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x44, 0x69,
+       0x67, 0x65, 0x73, 0x74, 0x12, 0x4a, 0x0a, 0x0a, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0x5f, 0x61,
+       0x6c, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2b, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79,
+       0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+       0x2e, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x50, 0x43, 0x52, 0x2e, 0x44, 0x69, 0x67, 0x65,
+       0x73, 0x74, 0x41, 0x6c, 0x67, 0x52, 0x09, 0x44, 0x69, 0x67, 0x65, 0x73, 0x74, 0x41, 0x6c, 0x67,
+       0x22, 0x41, 0x0a, 0x09, 0x44, 0x69, 0x67, 0x65, 0x73, 0x74, 0x41, 0x6c, 0x67, 0x12, 0x12, 0x0a,
+       0x0e, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x44, 0x49, 0x47, 0x45, 0x53, 0x54, 0x10,
+       0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x48, 0x41, 0x31, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x53,
+       0x48, 0x41, 0x32, 0x35, 0x36, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x48, 0x41, 0x33, 0x38,
+       0x34, 0x10, 0x03, 0x22, 0xe7, 0x01, 0x0a, 0x15, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74,
+       0x69, 0x6f, 0x6e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x16, 0x0a,
+       0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x50,
+       0x75, 0x62, 0x6c, 0x69, 0x63, 0x12, 0x3b, 0x0a, 0x1a, 0x75, 0x73, 0x65, 0x5f, 0x74, 0x63, 0x73,
+       0x64, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x66, 0x6f, 0x72,
+       0x6d, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x17, 0x55, 0x73, 0x65, 0x54, 0x43,
+       0x53, 0x44, 0x41, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6f, 0x72, 0x6d,
+       0x61, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x64, 0x61, 0x74,
+       0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x44,
+       0x61, 0x74, 0x61, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x61, 0x74,
+       0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52,
+       0x11, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69,
+       0x6f, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x69, 0x67,
+       0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x43, 0x72,
+       0x65, 0x61, 0x74, 0x65, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xde, 0x01,
+       0x0a, 0x12, 0x50, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65,
+       0x74, 0x65, 0x72, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x70, 0x6d, 0x5f, 0x76, 0x65, 0x72, 0x73,
+       0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x54, 0x50, 0x4d, 0x56, 0x65,
+       0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x18,
+       0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x12, 0x3b, 0x0a,
+       0x06, 0x71, 0x75, 0x6f, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e,
+       0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x2e, 0x73, 0x65,
+       0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x51, 0x75, 0x6f,
+       0x74, 0x65, 0x52, 0x06, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x73, 0x12, 0x35, 0x0a, 0x04, 0x70, 0x63,
+       0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79,
+       0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+       0x2e, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x50, 0x43, 0x52, 0x52, 0x04, 0x50, 0x43, 0x52,
+       0x73, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x6c, 0x6f, 0x67, 0x18, 0x05,
+       0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x4c, 0x6f, 0x67, 0x22, 0xd5,
+       0x01, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+       0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+       0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x70, 0x6d, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18,
+       0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x74, 0x70, 0x6d, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
+       0x6e, 0x12, 0x6a, 0x0a, 0x16, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+       0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28,
+       0x0b, 0x32, 0x33, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d,
+       0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74,
+       0x2e, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x61, 0x72, 0x61,
+       0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x52, 0x15, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x61, 0x74,
+       0x69, 0x6f, 0x6e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x27, 0x0a,
+       0x0f, 0x65, 0x6e, 0x64, 0x6f, 0x72, 0x73, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x6b, 0x65, 0x79,
+       0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x65, 0x6e, 0x64, 0x6f, 0x72, 0x73, 0x65, 0x6d,
+       0x65, 0x6e, 0x74, 0x4b, 0x65, 0x79, 0x22, 0x71, 0x0a, 0x1e, 0x47, 0x65, 0x74, 0x41, 0x63, 0x74,
+       0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65,
+       0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x65, 0x6e, 0x63, 0x72,
+       0x79, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01,
+       0x28, 0x0c, 0x52, 0x0f, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x53, 0x65, 0x63,
+       0x72, 0x65, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61,
+       0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74,
+       0x69, 0x61, 0x6c, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x22, 0x90, 0x01, 0x0a, 0x15, 0x41, 0x74,
+       0x74, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x52, 0x65, 0x71, 0x75,
+       0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01,
+       0x28, 0x0c, 0x52, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x12, 0x61, 0x0a, 0x13, 0x70, 0x6c, 0x61,
+       0x74, 0x66, 0x6f, 0x72, 0x6d, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73,
+       0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72,
+       0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61,
+       0x74, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x50, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x50, 0x61,
+       0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x52, 0x12, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f,
+       0x72, 0x6d, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x22, 0x2e, 0x0a, 0x16,
+       0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x52, 0x65,
+       0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x18,
+       0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x32, 0x9f, 0x02, 0x0a,
+       0x06, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x12, 0x95, 0x01, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x41,
+       0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e,
+       0x67, 0x65, 0x12, 0x3b, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69,
+       0x6d, 0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x74, 0x74, 0x65, 0x73,
+       0x74, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43,
+       0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+       0x3c, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x2e,
+       0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x47,
+       0x65, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x61, 0x6c,
+       0x6c, 0x65, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12,
+       0x7d, 0x0a, 0x0e, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72,
+       0x6d, 0x12, 0x33, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d,
+       0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74,
+       0x2e, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x52,
+       0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x34, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72,
+       0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61,
+       0x74, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x74, 0x74, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x74,
+       0x66, 0x6f, 0x72, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2b,
+       0x5a, 0x29, 0x67, 0x6f, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x64, 0x65, 0x76, 0x2f, 0x72,
+       0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x73, 0x65, 0x72,
+       0x76, 0x69, 0x63, 0x65, 0x2f, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f,
+       0x74, 0x6f, 0x33,
+}
+
+var (
+       file_attest_server_proto_rawDescOnce sync.Once
+       file_attest_server_proto_rawDescData = file_attest_server_proto_rawDesc
+)
+
+func file_attest_server_proto_rawDescGZIP() []byte {
+       file_attest_server_proto_rawDescOnce.Do(func() {
+               file_attest_server_proto_rawDescData = protoimpl.X.CompressGZIP(file_attest_server_proto_rawDescData)
+       })
+       return file_attest_server_proto_rawDescData
+}
+
+var file_attest_server_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
+var file_attest_server_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
+var file_attest_server_proto_goTypes = []interface{}{
+       (PCR_DigestAlg)(0),                     // 0: fuhry.runtime.service.attest.PCR.DigestAlg
+       (*Quote)(nil),                          // 1: fuhry.runtime.service.attest.Quote
+       (*PCR)(nil),                            // 2: fuhry.runtime.service.attest.PCR
+       (*AttestationParameters)(nil),          // 3: fuhry.runtime.service.attest.AttestationParameters
+       (*PlatformParameters)(nil),             // 4: fuhry.runtime.service.attest.PlatformParameters
+       (*GetActivationChallengeRequest)(nil),  // 5: fuhry.runtime.service.attest.GetActivationChallengeRequest
+       (*GetActivationChallengeResponse)(nil), // 6: fuhry.runtime.service.attest.GetActivationChallengeResponse
+       (*AttestPlatformRequest)(nil),          // 7: fuhry.runtime.service.attest.AttestPlatformRequest
+       (*AttestPlatformResponse)(nil),         // 8: fuhry.runtime.service.attest.AttestPlatformResponse
+}
+var file_attest_server_proto_depIdxs = []int32{
+       0, // 0: fuhry.runtime.service.attest.PCR.digest_alg:type_name -> fuhry.runtime.service.attest.PCR.DigestAlg
+       1, // 1: fuhry.runtime.service.attest.PlatformParameters.quotes:type_name -> fuhry.runtime.service.attest.Quote
+       2, // 2: fuhry.runtime.service.attest.PlatformParameters.pcrs:type_name -> fuhry.runtime.service.attest.PCR
+       3, // 3: fuhry.runtime.service.attest.GetActivationChallengeRequest.attestation_parameters:type_name -> fuhry.runtime.service.attest.AttestationParameters
+       4, // 4: fuhry.runtime.service.attest.AttestPlatformRequest.platform_parameters:type_name -> fuhry.runtime.service.attest.PlatformParameters
+       5, // 5: fuhry.runtime.service.attest.Attest.GetActivationChallenge:input_type -> fuhry.runtime.service.attest.GetActivationChallengeRequest
+       7, // 6: fuhry.runtime.service.attest.Attest.AttestPlatform:input_type -> fuhry.runtime.service.attest.AttestPlatformRequest
+       6, // 7: fuhry.runtime.service.attest.Attest.GetActivationChallenge:output_type -> fuhry.runtime.service.attest.GetActivationChallengeResponse
+       8, // 8: fuhry.runtime.service.attest.Attest.AttestPlatform:output_type -> fuhry.runtime.service.attest.AttestPlatformResponse
+       7, // [7:9] is the sub-list for method output_type
+       5, // [5:7] is the sub-list for method input_type
+       5, // [5:5] is the sub-list for extension type_name
+       5, // [5:5] is the sub-list for extension extendee
+       0, // [0:5] is the sub-list for field type_name
+}
+
+func init() { file_attest_server_proto_init() }
+func file_attest_server_proto_init() {
+       if File_attest_server_proto != nil {
+               return
+       }
+       if !protoimpl.UnsafeEnabled {
+               file_attest_server_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*Quote); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+               file_attest_server_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*PCR); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+               file_attest_server_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*AttestationParameters); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+               file_attest_server_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*PlatformParameters); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+               file_attest_server_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*GetActivationChallengeRequest); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+               file_attest_server_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*GetActivationChallengeResponse); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+               file_attest_server_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*AttestPlatformRequest); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+               file_attest_server_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*AttestPlatformResponse); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+       }
+       type x struct{}
+       out := protoimpl.TypeBuilder{
+               File: protoimpl.DescBuilder{
+                       GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+                       RawDescriptor: file_attest_server_proto_rawDesc,
+                       NumEnums:      1,
+                       NumMessages:   8,
+                       NumExtensions: 0,
+                       NumServices:   1,
+               },
+               GoTypes:           file_attest_server_proto_goTypes,
+               DependencyIndexes: file_attest_server_proto_depIdxs,
+               EnumInfos:         file_attest_server_proto_enumTypes,
+               MessageInfos:      file_attest_server_proto_msgTypes,
+       }.Build()
+       File_attest_server_proto = out.File
+       file_attest_server_proto_rawDesc = nil
+       file_attest_server_proto_goTypes = nil
+       file_attest_server_proto_depIdxs = nil
+}
diff --git a/proto/service/attest/attest_server.proto b/proto/service/attest/attest_server.proto
new file mode 100644 (file)
index 0000000..4557321
--- /dev/null
@@ -0,0 +1,67 @@
+syntax = "proto3";
+
+option go_package = "go.fuhry.dev/runtime/proto/service/attest";
+
+package fuhry.runtime.service.attest;
+
+service Attest {
+    rpc GetActivationChallenge (GetActivationChallengeRequest) returns (GetActivationChallengeResponse) {}
+    rpc AttestPlatform (AttestPlatformRequest) returns (AttestPlatformResponse) {}
+}
+
+message Quote {
+    uint32 tpm_version = 1 [json_name = "Version"];
+    bytes quote = 2 [json_name = "Quote"];
+    bytes signature = 3 [json_name = "Signature"];
+}
+
+message PCR {
+    enum DigestAlg {
+        INVALID_DIGEST = 0;
+        SHA1 = 1;
+        SHA256 = 2;
+        SHA384 = 3;
+    }
+
+    uint32 index = 1 [json_name = "Index"];
+    bytes digest = 2 [json_name = "Digest"];
+    DigestAlg digest_alg = 3 [json_name = "DigestAlg"];
+}
+
+message AttestationParameters {
+    bytes public = 1 [json_name = "Public"];
+    bool use_tcsd_activation_format = 2 [json_name = "UseTCSDActivationFormat"];
+    bytes create_data = 3 [json_name = "CreateData"];
+    bytes create_attestation = 4 [json_name = "CreateAttestation"];
+    bytes create_signature = 5 [json_name = "CreateSignature"];
+}
+
+message PlatformParameters {
+    uint32 tpm_version = 1 [json_name = "TPMVersion"];
+    bytes public = 2 [json_name = "Public"];
+    repeated Quote quotes = 3 [json_name = "Quotes"];
+    repeated PCR pcrs = 4 [json_name = "PCRs"];
+    bytes event_log = 5 [json_name = "EventLog"];
+}
+
+message GetActivationChallengeRequest {
+    uint32 tpm_version = 1;
+    AttestationParameters attestation_parameters = 2;
+    bytes endorsement_key = 3;
+}
+
+message GetActivationChallengeResponse {
+    reserved 1;
+
+    bytes encrypted_secret = 2;
+    bytes credential = 3;
+}
+
+message AttestPlatformRequest {
+    bytes nonce = 1;
+    PlatformParameters platform_parameters = 2;
+}
+
+message AttestPlatformResponse {
+    bytes nonce = 1;
+}
diff --git a/proto/service/attest/attest_server_grpc.pb.go b/proto/service/attest/attest_server_grpc.pb.go
new file mode 100644 (file)
index 0000000..7f41c29
--- /dev/null
@@ -0,0 +1,146 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.3.0
+// - protoc             v4.25.1
+// source: attest_server.proto
+
+package attest
+
+import (
+       context "context"
+       grpc "google.golang.org/grpc"
+       codes "google.golang.org/grpc/codes"
+       status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+const (
+       Attest_GetActivationChallenge_FullMethodName = "/fuhry.runtime.service.attest.Attest/GetActivationChallenge"
+       Attest_AttestPlatform_FullMethodName         = "/fuhry.runtime.service.attest.Attest/AttestPlatform"
+)
+
+// AttestClient is the client API for Attest service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type AttestClient interface {
+       GetActivationChallenge(ctx context.Context, in *GetActivationChallengeRequest, opts ...grpc.CallOption) (*GetActivationChallengeResponse, error)
+       AttestPlatform(ctx context.Context, in *AttestPlatformRequest, opts ...grpc.CallOption) (*AttestPlatformResponse, error)
+}
+
+type attestClient struct {
+       cc grpc.ClientConnInterface
+}
+
+func NewAttestClient(cc grpc.ClientConnInterface) AttestClient {
+       return &attestClient{cc}
+}
+
+func (c *attestClient) GetActivationChallenge(ctx context.Context, in *GetActivationChallengeRequest, opts ...grpc.CallOption) (*GetActivationChallengeResponse, error) {
+       out := new(GetActivationChallengeResponse)
+       err := c.cc.Invoke(ctx, Attest_GetActivationChallenge_FullMethodName, in, out, opts...)
+       if err != nil {
+               return nil, err
+       }
+       return out, nil
+}
+
+func (c *attestClient) AttestPlatform(ctx context.Context, in *AttestPlatformRequest, opts ...grpc.CallOption) (*AttestPlatformResponse, error) {
+       out := new(AttestPlatformResponse)
+       err := c.cc.Invoke(ctx, Attest_AttestPlatform_FullMethodName, in, out, opts...)
+       if err != nil {
+               return nil, err
+       }
+       return out, nil
+}
+
+// AttestServer is the server API for Attest service.
+// All implementations must embed UnimplementedAttestServer
+// for forward compatibility
+type AttestServer interface {
+       GetActivationChallenge(context.Context, *GetActivationChallengeRequest) (*GetActivationChallengeResponse, error)
+       AttestPlatform(context.Context, *AttestPlatformRequest) (*AttestPlatformResponse, error)
+       mustEmbedUnimplementedAttestServer()
+}
+
+// UnimplementedAttestServer must be embedded to have forward compatible implementations.
+type UnimplementedAttestServer struct {
+}
+
+func (UnimplementedAttestServer) GetActivationChallenge(context.Context, *GetActivationChallengeRequest) (*GetActivationChallengeResponse, error) {
+       return nil, status.Errorf(codes.Unimplemented, "method GetActivationChallenge not implemented")
+}
+func (UnimplementedAttestServer) AttestPlatform(context.Context, *AttestPlatformRequest) (*AttestPlatformResponse, error) {
+       return nil, status.Errorf(codes.Unimplemented, "method AttestPlatform not implemented")
+}
+func (UnimplementedAttestServer) mustEmbedUnimplementedAttestServer() {}
+
+// UnsafeAttestServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to AttestServer will
+// result in compilation errors.
+type UnsafeAttestServer interface {
+       mustEmbedUnimplementedAttestServer()
+}
+
+func RegisterAttestServer(s grpc.ServiceRegistrar, srv AttestServer) {
+       s.RegisterService(&Attest_ServiceDesc, srv)
+}
+
+func _Attest_GetActivationChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+       in := new(GetActivationChallengeRequest)
+       if err := dec(in); err != nil {
+               return nil, err
+       }
+       if interceptor == nil {
+               return srv.(AttestServer).GetActivationChallenge(ctx, in)
+       }
+       info := &grpc.UnaryServerInfo{
+               Server:     srv,
+               FullMethod: Attest_GetActivationChallenge_FullMethodName,
+       }
+       handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+               return srv.(AttestServer).GetActivationChallenge(ctx, req.(*GetActivationChallengeRequest))
+       }
+       return interceptor(ctx, in, info, handler)
+}
+
+func _Attest_AttestPlatform_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+       in := new(AttestPlatformRequest)
+       if err := dec(in); err != nil {
+               return nil, err
+       }
+       if interceptor == nil {
+               return srv.(AttestServer).AttestPlatform(ctx, in)
+       }
+       info := &grpc.UnaryServerInfo{
+               Server:     srv,
+               FullMethod: Attest_AttestPlatform_FullMethodName,
+       }
+       handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+               return srv.(AttestServer).AttestPlatform(ctx, req.(*AttestPlatformRequest))
+       }
+       return interceptor(ctx, in, info, handler)
+}
+
+// Attest_ServiceDesc is the grpc.ServiceDesc for Attest service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var Attest_ServiceDesc = grpc.ServiceDesc{
+       ServiceName: "fuhry.runtime.service.attest.Attest",
+       HandlerType: (*AttestServer)(nil),
+       Methods: []grpc.MethodDesc{
+               {
+                       MethodName: "GetActivationChallenge",
+                       Handler:    _Attest_GetActivationChallenge_Handler,
+               },
+               {
+                       MethodName: "AttestPlatform",
+                       Handler:    _Attest_AttestPlatform_Handler,
+               },
+       },
+       Streams:  []grpc.StreamDesc{},
+       Metadata: "attest_server.proto",
+}
diff --git a/proto/service/attest/convert.go b/proto/service/attest/convert.go
new file mode 100644 (file)
index 0000000..7e63cb9
--- /dev/null
@@ -0,0 +1,142 @@
+package attest
+
+import (
+       "crypto"
+       "fmt"
+
+       "github.com/google/go-attestation/attest"
+)
+
+func AttestationParametersFromProto(pb *AttestationParameters) attest.AttestationParameters {
+       return attest.AttestationParameters{
+               Public:                  pb.Public,
+               UseTCSDActivationFormat: pb.UseTcsdActivationFormat,
+               CreateData:              pb.CreateData,
+               CreateAttestation:       pb.CreateAttestation,
+               CreateSignature:         pb.CreateSignature,
+       }
+}
+
+func AttestationParametersToProto(ap attest.AttestationParameters) *AttestationParameters {
+       return &AttestationParameters{
+               Public:                  ap.Public,
+               UseTcsdActivationFormat: ap.UseTCSDActivationFormat,
+               CreateData:              ap.CreateData,
+               CreateAttestation:       ap.CreateAttestation,
+               CreateSignature:         ap.CreateSignature,
+       }
+}
+
+func QuoteFromProto(pb *Quote) attest.Quote {
+       return attest.Quote{
+               Version:   attest.TPMVersion(pb.TpmVersion),
+               Quote:     pb.Quote,
+               Signature: pb.Signature,
+       }
+}
+
+func QuoteToProto(qu attest.Quote) *Quote {
+       return &Quote{
+               TpmVersion: uint32(qu.Version),
+               Quote:      qu.Quote,
+               Signature:  qu.Signature,
+       }
+}
+
+func PCRFromProto(pb *PCR) (*attest.PCR, error) {
+       var alg crypto.Hash
+
+       switch pb.DigestAlg {
+       case PCR_SHA1:
+               alg = crypto.SHA1
+       case PCR_SHA256:
+               alg = crypto.SHA256
+       case PCR_SHA384:
+               alg = crypto.SHA384
+       default:
+               return nil, fmt.Errorf("Unsupported digest alg: %d", pb.DigestAlg)
+       }
+
+       return &attest.PCR{
+               Index:     int(pb.Index),
+               Digest:    pb.Digest,
+               DigestAlg: alg,
+       }, nil
+}
+
+func PCRToProto(ap *attest.PCR) (*PCR, error) {
+       var alg PCR_DigestAlg
+
+       digest := ap.DigestAlg.String()
+       switch digest {
+       case "SHA-1":
+               alg = PCR_SHA1
+       case "SHA-256":
+               alg = PCR_SHA256
+       case "SHA-384":
+               alg = PCR_SHA384
+       default:
+               return nil, fmt.Errorf("Unsupported PCR digest algorithm: %s", digest)
+       }
+
+       return &PCR{
+               Index:     uint32(ap.Index),
+               Digest:    ap.Digest,
+               DigestAlg: alg,
+       }, nil
+}
+
+func PlatformParametersFromProto(pb *PlatformParameters) (*attest.PlatformParameters, error) {
+       pp := &attest.PlatformParameters{
+               TPMVersion: attest.TPMVersion(pb.TpmVersion),
+               Public:     pb.Public,
+               EventLog:   pb.EventLog,
+               Quotes:     make([]attest.Quote, len(pb.Quotes)),
+               PCRs:       make([]attest.PCR, len(pb.Pcrs)),
+       }
+
+       for i, q := range pb.Quotes {
+               pp.Quotes[i] = QuoteFromProto(q)
+       }
+
+       for i, p := range pb.Pcrs {
+               pcr, err := PCRFromProto(p)
+               if err != nil {
+                       return nil, err
+               }
+               pp.PCRs[i] = *pcr
+       }
+
+       return pp, nil
+}
+
+func PlatformParametersToProto(pp *attest.PlatformParameters) (*PlatformParameters, error) {
+       pb := &PlatformParameters{
+               TpmVersion: uint32(pp.TPMVersion),
+               Public:     pp.Public,
+               EventLog:   pp.EventLog,
+               Quotes:     make([]*Quote, len(pp.Quotes)),
+               Pcrs:       make([]*PCR, len(pp.PCRs)),
+       }
+
+       for i, q := range pp.Quotes {
+               pb.Quotes[i] = QuoteToProto(q)
+       }
+
+       for i, p := range pp.PCRs {
+               pcr, err := PCRToProto(&p)
+               if err != nil {
+                       return nil, err
+               }
+               pb.Pcrs[i] = pcr
+       }
+
+       return pb, nil
+}
+
+func (r *GetActivationChallengeResponse) EC() attest.EncryptedCredential {
+       return attest.EncryptedCredential{
+               Credential: r.Credential,
+               Secret:     r.EncryptedSecret,
+       }
+}
diff --git a/proto/service/echo/Makefile b/proto/service/echo/Makefile
new file mode 100644 (file)
index 0000000..cc9ec69
--- /dev/null
@@ -0,0 +1,11 @@
+PROTO_SRCS := $(wildcard *.proto)
+PROTO_GO_OUTPUT := $(PROTO_SRCS:.proto=.pb.go)
+
+$(PROTO_GO_OUTPUT): $(PROTO_SRCS)
+       protoc --go_out=. --go_opt=paths=source_relative \
+               --go-grpc_out=. --go-grpc_opt=paths=source_relative \
+               $(PROTO_SRCS)
+
+pb_go: $(PROTO_GO_OUTPUT)
+
+all: pb_go
\ No newline at end of file
diff --git a/proto/service/echo/echo_server.pb.go b/proto/service/echo/echo_server.pb.go
new file mode 100644 (file)
index 0000000..17772bd
--- /dev/null
@@ -0,0 +1,338 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+//     protoc-gen-go v1.30.0
+//     protoc        v4.25.1
+// source: echo_server.proto
+
+package echo
+
+import (
+       protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+       protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+       reflect "reflect"
+       sync "sync"
+)
+
+const (
+       // Verify that this generated code is sufficiently up-to-date.
+       _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+       // Verify that runtime/protoimpl is sufficiently up-to-date.
+       _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type EchoRequest struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
+}
+
+func (x *EchoRequest) Reset() {
+       *x = EchoRequest{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_echo_server_proto_msgTypes[0]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *EchoRequest) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*EchoRequest) ProtoMessage() {}
+
+func (x *EchoRequest) ProtoReflect() protoreflect.Message {
+       mi := &file_echo_server_proto_msgTypes[0]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use EchoRequest.ProtoReflect.Descriptor instead.
+func (*EchoRequest) Descriptor() ([]byte, []int) {
+       return file_echo_server_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *EchoRequest) GetMessage() string {
+       if x != nil {
+               return x.Message
+       }
+       return ""
+}
+
+type EchoReply struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
+}
+
+func (x *EchoReply) Reset() {
+       *x = EchoReply{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_echo_server_proto_msgTypes[1]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *EchoReply) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*EchoReply) ProtoMessage() {}
+
+func (x *EchoReply) ProtoReflect() protoreflect.Message {
+       mi := &file_echo_server_proto_msgTypes[1]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use EchoReply.ProtoReflect.Descriptor instead.
+func (*EchoReply) Descriptor() ([]byte, []int) {
+       return file_echo_server_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *EchoReply) GetMessage() string {
+       if x != nil {
+               return x.Message
+       }
+       return ""
+}
+
+type GreetRequest struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+}
+
+func (x *GreetRequest) Reset() {
+       *x = GreetRequest{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_echo_server_proto_msgTypes[2]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *GreetRequest) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GreetRequest) ProtoMessage() {}
+
+func (x *GreetRequest) ProtoReflect() protoreflect.Message {
+       mi := &file_echo_server_proto_msgTypes[2]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use GreetRequest.ProtoReflect.Descriptor instead.
+func (*GreetRequest) Descriptor() ([]byte, []int) {
+       return file_echo_server_proto_rawDescGZIP(), []int{2}
+}
+
+type GreetReply struct {
+       state         protoimpl.MessageState
+       sizeCache     protoimpl.SizeCache
+       unknownFields protoimpl.UnknownFields
+
+       Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
+}
+
+func (x *GreetReply) Reset() {
+       *x = GreetReply{}
+       if protoimpl.UnsafeEnabled {
+               mi := &file_echo_server_proto_msgTypes[3]
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               ms.StoreMessageInfo(mi)
+       }
+}
+
+func (x *GreetReply) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GreetReply) ProtoMessage() {}
+
+func (x *GreetReply) ProtoReflect() protoreflect.Message {
+       mi := &file_echo_server_proto_msgTypes[3]
+       if protoimpl.UnsafeEnabled && x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use GreetReply.ProtoReflect.Descriptor instead.
+func (*GreetReply) Descriptor() ([]byte, []int) {
+       return file_echo_server_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *GreetReply) GetMessage() string {
+       if x != nil {
+               return x.Message
+       }
+       return ""
+}
+
+var File_echo_server_proto protoreflect.FileDescriptor
+
+var file_echo_server_proto_rawDesc = []byte{
+       0x0a, 0x11, 0x65, 0x63, 0x68, 0x6f, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2e, 0x70, 0x72,
+       0x6f, 0x74, 0x6f, 0x12, 0x1a, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69,
+       0x6d, 0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x65, 0x63, 0x68, 0x6f, 0x22,
+       0x27, 0x0a, 0x0b, 0x45, 0x63, 0x68, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18,
+       0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+       0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x25, 0x0a, 0x09, 0x45, 0x63, 0x68, 0x6f,
+       0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
+       0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
+       0x0e, 0x0a, 0x0c, 0x47, 0x72, 0x65, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22,
+       0x26, 0x0a, 0x0a, 0x47, 0x72, 0x65, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x18, 0x0a,
+       0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
+       0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0xbd, 0x01, 0x0a, 0x04, 0x45, 0x63, 0x68, 0x6f,
+       0x12, 0x58, 0x0a, 0x04, 0x45, 0x63, 0x68, 0x6f, 0x12, 0x27, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79,
+       0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+       0x2e, 0x65, 0x63, 0x68, 0x6f, 0x2e, 0x45, 0x63, 0x68, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+       0x74, 0x1a, 0x25, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d,
+       0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x65, 0x63, 0x68, 0x6f, 0x2e, 0x45,
+       0x63, 0x68, 0x6f, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x12, 0x5b, 0x0a, 0x05, 0x47, 0x72,
+       0x65, 0x65, 0x74, 0x12, 0x28, 0x2e, 0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72, 0x75, 0x6e, 0x74,
+       0x69, 0x6d, 0x65, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x65, 0x63, 0x68, 0x6f,
+       0x2e, 0x47, 0x72, 0x65, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e,
+       0x66, 0x75, 0x68, 0x72, 0x79, 0x2e, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x2e, 0x73, 0x65,
+       0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x65, 0x63, 0x68, 0x6f, 0x2e, 0x47, 0x72, 0x65, 0x65, 0x74,
+       0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x42, 0x29, 0x5a, 0x27, 0x67, 0x6f, 0x2e, 0x66, 0x75,
+       0x68, 0x72, 0x79, 0x2e, 0x64, 0x65, 0x76, 0x2f, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x2f,
+       0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2f, 0x65, 0x63,
+       0x68, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+       file_echo_server_proto_rawDescOnce sync.Once
+       file_echo_server_proto_rawDescData = file_echo_server_proto_rawDesc
+)
+
+func file_echo_server_proto_rawDescGZIP() []byte {
+       file_echo_server_proto_rawDescOnce.Do(func() {
+               file_echo_server_proto_rawDescData = protoimpl.X.CompressGZIP(file_echo_server_proto_rawDescData)
+       })
+       return file_echo_server_proto_rawDescData
+}
+
+var file_echo_server_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
+var file_echo_server_proto_goTypes = []interface{}{
+       (*EchoRequest)(nil),  // 0: fuhry.runtime.service.echo.EchoRequest
+       (*EchoReply)(nil),    // 1: fuhry.runtime.service.echo.EchoReply
+       (*GreetRequest)(nil), // 2: fuhry.runtime.service.echo.GreetRequest
+       (*GreetReply)(nil),   // 3: fuhry.runtime.service.echo.GreetReply
+}
+var file_echo_server_proto_depIdxs = []int32{
+       0, // 0: fuhry.runtime.service.echo.Echo.Echo:input_type -> fuhry.runtime.service.echo.EchoRequest
+       2, // 1: fuhry.runtime.service.echo.Echo.Greet:input_type -> fuhry.runtime.service.echo.GreetRequest
+       1, // 2: fuhry.runtime.service.echo.Echo.Echo:output_type -> fuhry.runtime.service.echo.EchoReply
+       3, // 3: fuhry.runtime.service.echo.Echo.Greet:output_type -> fuhry.runtime.service.echo.GreetReply
+       2, // [2:4] is the sub-list for method output_type
+       0, // [0:2] is the sub-list for method input_type
+       0, // [0:0] is the sub-list for extension type_name
+       0, // [0:0] is the sub-list for extension extendee
+       0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_echo_server_proto_init() }
+func file_echo_server_proto_init() {
+       if File_echo_server_proto != nil {
+               return
+       }
+       if !protoimpl.UnsafeEnabled {
+               file_echo_server_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*EchoRequest); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+               file_echo_server_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*EchoReply); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+               file_echo_server_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*GreetRequest); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+               file_echo_server_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+                       switch v := v.(*GreetReply); i {
+                       case 0:
+                               return &v.state
+                       case 1:
+                               return &v.sizeCache
+                       case 2:
+                               return &v.unknownFields
+                       default:
+                               return nil
+                       }
+               }
+       }
+       type x struct{}
+       out := protoimpl.TypeBuilder{
+               File: protoimpl.DescBuilder{
+                       GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+                       RawDescriptor: file_echo_server_proto_rawDesc,
+                       NumEnums:      0,
+                       NumMessages:   4,
+                       NumExtensions: 0,
+                       NumServices:   1,
+               },
+               GoTypes:           file_echo_server_proto_goTypes,
+               DependencyIndexes: file_echo_server_proto_depIdxs,
+               MessageInfos:      file_echo_server_proto_msgTypes,
+       }.Build()
+       File_echo_server_proto = out.File
+       file_echo_server_proto_rawDesc = nil
+       file_echo_server_proto_goTypes = nil
+       file_echo_server_proto_depIdxs = nil
+}
diff --git a/proto/service/echo/echo_server.proto b/proto/service/echo/echo_server.proto
new file mode 100644 (file)
index 0000000..5b9b015
--- /dev/null
@@ -0,0 +1,24 @@
+syntax = "proto3";
+
+option go_package = "go.fuhry.dev/runtime/proto/service/echo";
+
+package fuhry.runtime.service.echo;
+
+service Echo {
+    rpc Echo(EchoRequest) returns (EchoReply) {}
+    rpc Greet(GreetRequest) returns (GreetReply) {}
+}
+
+message EchoRequest {
+    string message = 1;
+}
+
+message EchoReply {
+    string message = 1;
+}
+
+message GreetRequest {}
+
+message GreetReply {
+    string message = 1;
+}
diff --git a/proto/service/echo/echo_server_grpc.pb.go b/proto/service/echo/echo_server_grpc.pb.go
new file mode 100644 (file)
index 0000000..e606890
--- /dev/null
@@ -0,0 +1,146 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.3.0
+// - protoc             v4.25.1
+// source: echo_server.proto
+
+package echo
+
+import (
+       context "context"
+       grpc "google.golang.org/grpc"
+       codes "google.golang.org/grpc/codes"
+       status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+const (
+       Echo_Echo_FullMethodName  = "/fuhry.runtime.service.echo.Echo/Echo"
+       Echo_Greet_FullMethodName = "/fuhry.runtime.service.echo.Echo/Greet"
+)
+
+// EchoClient is the client API for Echo service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type EchoClient interface {
+       Echo(ctx context.Context, in *EchoRequest, opts ...grpc.CallOption) (*EchoReply, error)
+       Greet(ctx context.Context, in *GreetRequest, opts ...grpc.CallOption) (*GreetReply, error)
+}
+
+type echoClient struct {
+       cc grpc.ClientConnInterface
+}
+
+func NewEchoClient(cc grpc.ClientConnInterface) EchoClient {
+       return &echoClient{cc}
+}
+
+func (c *echoClient) Echo(ctx context.Context, in *EchoRequest, opts ...grpc.CallOption) (*EchoReply, error) {
+       out := new(EchoReply)
+       err := c.cc.Invoke(ctx, Echo_Echo_FullMethodName, in, out, opts...)
+       if err != nil {
+               return nil, err
+       }
+       return out, nil
+}
+
+func (c *echoClient) Greet(ctx context.Context, in *GreetRequest, opts ...grpc.CallOption) (*GreetReply, error) {
+       out := new(GreetReply)
+       err := c.cc.Invoke(ctx, Echo_Greet_FullMethodName, in, out, opts...)
+       if err != nil {
+               return nil, err
+       }
+       return out, nil
+}
+
+// EchoServer is the server API for Echo service.
+// All implementations must embed UnimplementedEchoServer
+// for forward compatibility
+type EchoServer interface {
+       Echo(context.Context, *EchoRequest) (*EchoReply, error)
+       Greet(context.Context, *GreetRequest) (*GreetReply, error)
+       mustEmbedUnimplementedEchoServer()
+}
+
+// UnimplementedEchoServer must be embedded to have forward compatible implementations.
+type UnimplementedEchoServer struct {
+}
+
+func (UnimplementedEchoServer) Echo(context.Context, *EchoRequest) (*EchoReply, error) {
+       return nil, status.Errorf(codes.Unimplemented, "method Echo not implemented")
+}
+func (UnimplementedEchoServer) Greet(context.Context, *GreetRequest) (*GreetReply, error) {
+       return nil, status.Errorf(codes.Unimplemented, "method Greet not implemented")
+}
+func (UnimplementedEchoServer) mustEmbedUnimplementedEchoServer() {}
+
+// UnsafeEchoServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to EchoServer will
+// result in compilation errors.
+type UnsafeEchoServer interface {
+       mustEmbedUnimplementedEchoServer()
+}
+
+func RegisterEchoServer(s grpc.ServiceRegistrar, srv EchoServer) {
+       s.RegisterService(&Echo_ServiceDesc, srv)
+}
+
+func _Echo_Echo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+       in := new(EchoRequest)
+       if err := dec(in); err != nil {
+               return nil, err
+       }
+       if interceptor == nil {
+               return srv.(EchoServer).Echo(ctx, in)
+       }
+       info := &grpc.UnaryServerInfo{
+               Server:     srv,
+               FullMethod: Echo_Echo_FullMethodName,
+       }
+       handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+               return srv.(EchoServer).Echo(ctx, req.(*EchoRequest))
+       }
+       return interceptor(ctx, in, info, handler)
+}
+
+func _Echo_Greet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+       in := new(GreetRequest)
+       if err := dec(in); err != nil {
+               return nil, err
+       }
+       if interceptor == nil {
+               return srv.(EchoServer).Greet(ctx, in)
+       }
+       info := &grpc.UnaryServerInfo{
+               Server:     srv,
+               FullMethod: Echo_Greet_FullMethodName,
+       }
+       handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+               return srv.(EchoServer).Greet(ctx, req.(*GreetRequest))
+       }
+       return interceptor(ctx, in, info, handler)
+}
+
+// Echo_ServiceDesc is the grpc.ServiceDesc for Echo service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var Echo_ServiceDesc = grpc.ServiceDesc{
+       ServiceName: "fuhry.runtime.service.echo.Echo",
+       HandlerType: (*EchoServer)(nil),
+       Methods: []grpc.MethodDesc{
+               {
+                       MethodName: "Echo",
+                       Handler:    _Echo_Echo_Handler,
+               },
+               {
+                       MethodName: "Greet",
+                       Handler:    _Echo_Greet_Handler,
+               },
+       },
+       Streams:  []grpc.StreamDesc{},
+       Metadata: "echo_server.proto",
+}
diff --git a/rand/range.go b/rand/range.go
new file mode 100644 (file)
index 0000000..e750f7a
--- /dev/null
@@ -0,0 +1,15 @@
+package rand
+
+import (
+       "crypto/rand"
+       "math/big"
+)
+
+func Range(low int64, high int64) int64 {
+       max := big.NewInt(high - low)
+       n, err := rand.Int(rand.Reader, max)
+       if err != nil {
+               panic(err)
+       }
+       return n.Int64() + low
+}
diff --git a/sase/acl.go b/sase/acl.go
new file mode 100644 (file)
index 0000000..dbefd12
--- /dev/null
@@ -0,0 +1,125 @@
+package sase
+
+import (
+       "net"
+)
+
+type ACL interface {
+       Test(req *WebSocketRequest) bool
+       AddRule(...ACLRule)
+
+       Rules() []ACLRule
+       DefaultAction() ACLAction
+}
+
+type acl struct {
+       rules         []ACLRule
+       defaultAction ACLAction
+}
+
+type ACLRule interface {
+       Test(req *WebSocketRequest) ACLAction
+}
+
+type aclMatcher interface {
+       matchesRequest(req *WebSocketRequest) bool
+}
+
+type ACLRuleType uint
+type ACLAction uint
+
+const (
+       ActionContinue ACLAction = iota
+       ActionDeny
+       ActionAllow
+)
+
+const (
+       RuleTypeIPNetwork ACLRuleType = iota
+       RuleTypeHostnamePattern
+       RuleTypeHostnameRegexp
+)
+
+type aclRule struct {
+       ACLRule
+
+       match  []aclMatcher
+       action ACLAction
+}
+
+type aclIPNetworkRule struct {
+       network *net.IPNet
+}
+
+func NewACL(defaultAction ACLAction) *acl {
+       acl := &acl{
+               rules:         make([]ACLRule, 0),
+               defaultAction: defaultAction,
+       }
+
+       return acl
+}
+
+func (acl *acl) AddRule(rule ...ACLRule) {
+       acl.rules = append(acl.rules, rule...)
+}
+
+func (acl *acl) Rules() []ACLRule {
+       return acl.rules
+}
+
+func (acl *acl) DefaultAction() ACLAction {
+       return acl.defaultAction
+}
+
+func (acl *acl) Test(req *WebSocketRequest) bool {
+       for _, rule := range acl.rules {
+               action := rule.Test(req)
+               if action != ActionContinue {
+                       return action == ActionAllow
+               }
+       }
+
+       return acl.defaultAction == ActionAllow
+}
+
+func NewACLRule(action ACLAction, match ...aclMatcher) ACLRule {
+       return &aclRule{
+               action: action,
+               match:  match,
+       }
+}
+
+func (rule *aclRule) Test(req *WebSocketRequest) ACLAction {
+       matches := true
+evalLoop:
+       for _, matcher := range rule.match {
+               if !matcher.matchesRequest(req) {
+                       matches = false
+                       // lazy evaluation
+                       break evalLoop
+               }
+       }
+
+       if matches {
+               return rule.action
+       }
+
+       return ActionContinue
+}
+
+func NewIPNetworkMatcher(net *net.IPNet) aclMatcher {
+       return &aclIPNetworkRule{
+               network: net,
+       }
+}
+
+func (r *aclIPNetworkRule) matchesRequest(req *WebSocketRequest) bool {
+       for _, ip := range req.addr {
+               if r.network.Contains(net.ParseIP(ip.String())) {
+                       return true
+               }
+       }
+
+       return false
+}
diff --git a/sase/acl_test.go b/sase/acl_test.go
new file mode 100644 (file)
index 0000000..ff0ab75
--- /dev/null
@@ -0,0 +1,59 @@
+package sase
+
+import (
+       "net"
+       "testing"
+
+       . "gopkg.in/check.v1"
+)
+
+func Test(t *testing.T) { TestingT(t) }
+
+type ACLSuite struct{}
+
+var _ = Suite(&ACLSuite{})
+
+func (s *ACLSuite) TestIPNetworkRule(c *C) {
+       type testCase struct {
+               expect bool
+               req    WebSocketRequest
+       }
+
+       cases := []testCase{
+               {
+                       expect: true,
+                       req: WebSocketRequest{
+                               network:  "tcp",
+                               hostname: "localhost",
+                               port:     22,
+                               addr: []net.Addr{
+                                       &net.IPAddr{IP: net.ParseIP("127.0.0.1")},
+                                       &net.IPAddr{IP: net.ParseIP("::1")},
+                               },
+                       },
+               },
+               {
+                       expect: false,
+                       req: WebSocketRequest{
+                               network:  "tcp",
+                               hostname: "localhost",
+                               port:     22,
+                               addr: []net.Addr{
+                                       &net.IPAddr{IP: net.ParseIP("10.0.0.1")},
+                                       &net.IPAddr{IP: net.ParseIP("fe80::1")},
+                               },
+                       },
+               },
+       }
+
+       acl := NewACL(ActionDeny)
+
+       _, cidr4, _ := net.ParseCIDR("127.0.0.0/8")
+       _, cidr6, _ := net.ParseCIDR("::1/128")
+       acl.AddRule(NewACLRule(ActionAllow, NewIPNetworkMatcher(cidr4)))
+       acl.AddRule(NewACLRule(ActionAllow, NewIPNetworkMatcher(cidr6)))
+
+       for _, testCase := range cases {
+               c.Assert(acl.Test(&testCase.req), Equals, testCase.expect)
+       }
+}
diff --git a/sase/client.go b/sase/client.go
new file mode 100644 (file)
index 0000000..0ed4f39
--- /dev/null
@@ -0,0 +1,51 @@
+package sase
+
+import (
+       "context"
+       "fmt"
+       "io"
+       "net/url"
+
+       "github.com/gorilla/websocket"
+       "go.fuhry.dev/runtime/mtls"
+)
+
+type Client interface {
+       Connect(*url.URL) (io.ReadWriteCloser, error)
+}
+
+type saseTcpProxyClient struct {
+       Client
+
+       wsClient *websocket.Dialer
+       identity mtls.Identity
+}
+
+func NewClient(id mtls.Identity) (Client, error) {
+       tlsConfig, err := id.TlsConfig(context.TODO())
+       if err != nil {
+               return nil, err
+       }
+       tlsConfig.RootCAs = nil
+       wsClient := &websocket.Dialer{
+               TLSClientConfig: tlsConfig,
+       }
+
+       return &saseTcpProxyClient{
+               identity: id,
+               wsClient: wsClient,
+       }, nil
+}
+
+func (c *saseTcpProxyClient) Connect(url *url.URL) (io.ReadWriteCloser, error) {
+       conn, resp, err := c.wsClient.Dial(url.String(), nil)
+       if err != nil {
+               if resp != nil {
+                       body, _ := io.ReadAll(resp.Body)
+                       return nil, fmt.Errorf("received unexpected response from server: HTTP %d: %s", resp.StatusCode, body)
+               }
+               return nil, err
+       }
+
+       return newWebSocketBinaryReadWriter(conn)
+}
diff --git a/sase/happy_eyeballs.go b/sase/happy_eyeballs.go
new file mode 100644 (file)
index 0000000..9eb789a
--- /dev/null
@@ -0,0 +1,64 @@
+package sase
+
+import (
+       "context"
+       "fmt"
+       "net"
+       "strings"
+       "sync"
+)
+
+func dialHappyEyeballs(ctx context.Context, addrs []net.Addr, port uint16) (net.Conn, error) {
+       dialer := &net.Dialer{}
+       lock := &sync.Mutex{}
+       defer lock.Unlock()
+       errors := make([]error, 0)
+       connChan := make(chan net.Conn, len(addrs))
+       defer close(connChan)
+       errChan := make(chan error, len(addrs))
+       defer close(errChan)
+       done := false
+       defer (func() { done = true })()
+       defer lock.Lock()
+       dialContext, dialCancel := context.WithCancel(ctx)
+
+       dialFunc := func(addr net.Addr) {
+               addr = &net.TCPAddr{
+                       IP:   net.ParseIP(addr.String()),
+                       Port: int(port),
+               }
+               conn, err := dialer.DialContext(dialContext, addr.Network(), addr.String())
+               lock.Lock()
+               defer lock.Unlock()
+               if err != nil {
+                       if !strings.HasSuffix(err.Error(), ": operation was canceled") && !done {
+                               errChan <- err
+                       }
+                       return
+               }
+               if !done {
+                       connChan <- conn
+               }
+       }
+
+       for _, addr := range addrs {
+               go dialFunc(addr)
+       }
+
+       for {
+               select {
+               case conn := <-connChan:
+                       dialCancel()
+                       return conn, nil
+               case err := <-errChan:
+                       errors = append(errors, err)
+                       if len(errors) == len(addrs) {
+                               dialCancel()
+                               return nil, fmt.Errorf("failed to make any connection: %v", errors)
+                       }
+               case <-ctx.Done():
+                       dialCancel()
+                       return nil, context.Canceled
+               }
+       }
+}
diff --git a/sase/machines_networks.go b/sase/machines_networks.go
new file mode 100644 (file)
index 0000000..9baa5f8
--- /dev/null
@@ -0,0 +1,100 @@
+package sase
+
+import (
+       "flag"
+       "fmt"
+       "net"
+
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/machines"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type machinesDomain struct {
+       domainName string
+       cidr4      *net.IPNet
+       cidr6      *net.IPNet
+}
+
+type machinesApi_DomainEntry struct {
+       Name               string `json:"name"`
+       IPv4NetworkAddress string `json:"inet4_address"`
+       IPv4PrefixLength   int    `json:"inet4_prefixlen"`
+       IPv6NetworkAddress string `json:"inet6_address"`
+       IPv6PrefixLength   int    `json:"inet6_prefixlen"`
+}
+
+type machinesApi_DomainResponse []*machinesApi_DomainEntry
+
+var defaultDomains []machinesDomain
+
+func init() {
+       type _domain struct {
+               domainName string
+               cidr4      string
+               cidr6      string
+       }
+
+       unparsedDomains := []_domain{
+               {constants.DefaultHostDomain, "10.0.0.0/22", "fe00:ab00::/64"},
+       }
+
+       defaultDomains = make([]machinesDomain, 0)
+       for _, d := range unparsedDomains {
+               domain := machinesDomain{
+                       domainName: d.domainName,
+                       cidr4:      mustParseIPCidr(d.cidr4),
+                       cidr6:      mustParseIPCidr(d.cidr6),
+               }
+
+               defaultDomains = append(defaultDomains, domain)
+       }
+}
+
+func initNetworksFromMachinesApi(client machines.MachinesClient) {
+       domains := make(machinesApi_DomainResponse, 0)
+       err := client.APICall("domains", nil, &domains)
+       if err != nil {
+               log.Default().Errorf("failed to get domains from the machines api: %v\n", err)
+               return
+       }
+       out := make([]machinesDomain, 0)
+       for _, domain := range domains {
+               xd := machinesDomain{
+                       domainName: domain.Name,
+                       cidr4:      mustParseIPCidr(fmt.Sprintf("%s/%d", domain.IPv4NetworkAddress, domain.IPv4PrefixLength)),
+                       cidr6:      mustParseIPCidr(fmt.Sprintf("%s/%d", domain.IPv6NetworkAddress, domain.IPv6PrefixLength)),
+               }
+               out = append(out, xd)
+       }
+
+       if len(out) > 0 {
+               defaultDomains = out
+       }
+}
+
+func NewCorpNetworkRuleset() []ACLRule {
+       if !flag.Parsed() {
+               log.Fatal("args are not yet parsed, so we cannot load domains")
+       }
+
+       client, err := machines.NewDefaultMachinesClient("domain.list")
+       if err == nil {
+               initNetworksFromMachinesApi(client)
+       }
+       rules := make([]ACLRule, 0)
+       for _, d := range defaultDomains {
+               rules = append(rules, NewACLRule(ActionAllow, NewIPNetworkMatcher(d.cidr4)))
+               rules = append(rules, NewACLRule(ActionAllow, NewIPNetworkMatcher(d.cidr6)))
+       }
+       return rules
+}
+
+func mustParseIPCidr(cidr string) *net.IPNet {
+       _, ipnet, err := net.ParseCIDR(cidr)
+       if err != nil {
+               panic(err)
+       }
+
+       return ipnet
+}
diff --git a/sase/read_writer.go b/sase/read_writer.go
new file mode 100644 (file)
index 0000000..7d2195b
--- /dev/null
@@ -0,0 +1,85 @@
+package sase
+
+import (
+       "context"
+       "io"
+
+       "github.com/gorilla/websocket"
+       "go.fuhry.dev/runtime/utils/generics"
+)
+
+type webSocketBinaryReadWriter struct {
+       io.ReadWriteCloser
+
+       ws     *websocket.Conn
+       curMsg []byte
+}
+
+func newWebSocketBinaryReadWriter(ws *websocket.Conn) (*webSocketBinaryReadWriter, error) {
+       return &webSocketBinaryReadWriter{
+               ws: ws,
+       }, nil
+}
+
+func (rw *webSocketBinaryReadWriter) Read(p []byte) (int, error) {
+       if rw.curMsg == nil {
+               t, msg, err := rw.ws.ReadMessage()
+               if err != nil {
+                       return 0, err
+               }
+
+               if t == websocket.BinaryMessage {
+                       rw.curMsg = msg
+               }
+       }
+
+       if rw.curMsg != nil {
+               n := generics.Min(len(p), len(rw.curMsg))
+               copy(p, rw.curMsg)
+               if n < len(rw.curMsg) {
+                       rw.curMsg = rw.curMsg[n:]
+               } else {
+                       rw.curMsg = nil
+               }
+
+               return n, nil
+       }
+
+       return 0, nil
+}
+
+func (rw *webSocketBinaryReadWriter) Write(p []byte) (int, error) {
+       err := rw.ws.WriteMessage(websocket.BinaryMessage, p)
+       return len(p), err
+}
+
+func (rw *webSocketBinaryReadWriter) Close() error {
+       return rw.ws.Close()
+}
+
+func NewChanReader(reader io.Reader, cancel context.CancelFunc) chan []byte {
+       readChan := make(chan []byte)
+
+       go (func() {
+               defer close(readChan)
+               defer cancel()
+
+               buf := make([]byte, 64*1024)
+               for {
+                       n, err := reader.Read(buf)
+                       if n > 0 && (err == nil || err == io.EOF) {
+                               replyBuf := make([]byte, n)
+                               copy(replyBuf, buf)
+                               readChan <- replyBuf
+                               if err == io.EOF {
+                                       return
+                               }
+                       }
+                       if err != nil {
+                               return
+                       }
+               }
+       })()
+
+       return readChan
+}
diff --git a/sase/ws_proxy.go b/sase/ws_proxy.go
new file mode 100644 (file)
index 0000000..f0c4566
--- /dev/null
@@ -0,0 +1,238 @@
+package sase
+
+import (
+       "bufio"
+       "context"
+       "encoding/json"
+       "fmt"
+       "net"
+       "net/http"
+       "regexp"
+       "strconv"
+       "time"
+
+       "github.com/gorilla/websocket"
+       "go.fuhry.dev/runtime/net/dns"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type WebSocketProxy struct {
+       Server *http.Server
+       ACL    ACL
+}
+
+type WebSocketRequest struct {
+       network  string
+       hostname string
+       port     uint16
+       addr     []net.Addr
+}
+
+type logEntry struct {
+       RemoteAddress,
+       Host,
+       Method,
+       Path string
+
+       StatusCode int
+}
+
+type statusRecorder struct {
+       http.ResponseWriter
+       http.Hijacker
+
+       Status int
+}
+
+var (
+       pathRegexp *regexp.Regexp
+)
+
+var upgrader = &websocket.Upgrader{
+       ReadBufferSize:  1280,
+       WriteBufferSize: 1280,
+}
+
+func init() {
+       pathRegexp = regexp.MustCompile(`^/(?P<network>[a-z]{1,8})/(?P<hostname>[a-z0-9-]+(?:\.[a-z0-9-]+)*)/(?P<port>[0-9]{1,5})$`)
+}
+
+func NewWebSocketProxy(listen string) (*WebSocketProxy, error) {
+       handler := http.NewServeMux()
+       server := &http.Server{
+               Addr:    listen,
+               Handler: newLoggingMiddleware(handler),
+       }
+
+       wsp := &WebSocketProxy{
+               Server: server,
+               ACL:    NewACL(ActionDeny),
+       }
+
+       handler.HandleFunc("/tcp/", wsp.handle)
+       handler.HandleFunc("/_/health", func(w http.ResponseWriter, r *http.Request) {
+               w.Header().Set("content-type", "text/plain")
+               w.WriteHeader(200)
+               fmt.Fprintf(w, "OK\n")
+       })
+
+       return wsp, nil
+}
+
+func (wsp *WebSocketProxy) handle(w http.ResponseWriter, r *http.Request) {
+       path := r.URL.Path
+
+       if !pathRegexp.MatchString(path) {
+               wsp.replyForbidden(w, "invalid path: does not match expected request pattern")
+               return
+       }
+
+       groups := pathRegexp.FindAllStringSubmatch(path, 1)
+       senames := pathRegexp.SubexpNames()
+
+       wsReq := &WebSocketRequest{
+               addr: make([]net.Addr, 0, 2),
+       }
+
+       for _, matches := range groups {
+               for i, match := range matches {
+                       switch senames[i] {
+                       case "network":
+                               wsReq.network = match
+                       case "hostname":
+                               wsReq.hostname = match
+                       case "port":
+                               matchInt, err := strconv.Atoi(match)
+                               if err != nil {
+                                       wsp.replyBadRequest(w, "invalid port: cannot parse as integer")
+                                       return
+                               }
+                               wsReq.port = uint16(matchInt)
+                       }
+               }
+       }
+
+       ipv4, ipv6, err := dns.ResolveDualStack(wsReq.hostname)
+       if err != nil {
+               wsp.replyBadGateway(w, fmt.Sprintf("cannot resolve hostname %q: %v", wsReq.hostname, err))
+               return
+       }
+
+       if ipv4 != "" {
+               wsReq.addr = append(wsReq.addr, &net.IPAddr{IP: net.ParseIP(ipv4)})
+       }
+       if ipv6 != "" {
+               wsReq.addr = append(wsReq.addr, &net.IPAddr{IP: net.ParseIP(ipv6)})
+       }
+
+       if !wsp.ACL.Test(wsReq) {
+               wsp.replyForbidden(w, "ACL denied access to this hostname/IP/port")
+               return
+       }
+
+       reqCtx := r.Context()
+       dialCtx, dialCancel := context.WithTimeout(reqCtx, 5*time.Second)
+       defer dialCancel()
+
+       conn, err := dialHappyEyeballs(dialCtx, wsReq.addr, wsReq.port)
+
+       if err != nil {
+               wsp.replyBadGateway(w, err.Error())
+               return
+       }
+
+       defer conn.Close()
+
+       ws, err := upgrader.Upgrade(w, r, w.Header())
+       if err != nil {
+               wsp.replyServerError(w, err.Error())
+               return
+       }
+
+       wsReadWriter, err := newWebSocketBinaryReadWriter(ws)
+       if err != nil {
+               wsp.replyServerError(w, err.Error())
+               return
+       }
+
+       wsCtx, cancel := context.WithCancel(reqCtx)
+
+       localReader := NewChanReader(wsReadWriter, cancel)
+       remoteReader := NewChanReader(conn, cancel)
+
+mainLoop:
+       for {
+               select {
+               case msg := <-localReader:
+                       conn.Write(msg)
+               case msg := <-remoteReader:
+                       wsReadWriter.Write(msg)
+               case <-wsCtx.Done():
+                       break mainLoop
+               }
+       }
+
+       cancel()
+}
+
+func (wsp *WebSocketProxy) replyBadGateway(w http.ResponseWriter, msg string) {
+       w.Header().Set("content-type", "text/plain")
+       w.WriteHeader(502)
+       fmt.Fprintf(w, "502 Bad Gateway: %s\n", msg)
+}
+
+func (wsp *WebSocketProxy) replyServerError(w http.ResponseWriter, msg string) {
+       w.Header().Set("content-type", "text/plain")
+       w.WriteHeader(500)
+       fmt.Fprintf(w, "500 Internal Server Error: %s\n", msg)
+}
+
+func (wsp *WebSocketProxy) replyForbidden(w http.ResponseWriter, msg string) {
+       w.Header().Set("content-type", "text/plain")
+       w.WriteHeader(403)
+       fmt.Fprintf(w, "403 Forbidden: %s\n", msg)
+}
+
+func (wsp *WebSocketProxy) replyBadRequest(w http.ResponseWriter, msg string) {
+       w.Header().Set("content-type", "text/plain")
+       w.WriteHeader(400)
+       fmt.Fprintf(w, "400 Bad Request: %s\n", msg)
+}
+
+func newLoggingMiddleware(h http.Handler) http.Handler {
+       return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               ws := &statusRecorder{
+                       ResponseWriter: w,
+               }
+               if h, ok := w.(http.Hijacker); ok {
+                       ws.Hijacker = h
+               }
+
+               h.ServeHTTP(ws, r)
+
+               logger := log.Default()
+
+               entry := logEntry{
+                       RemoteAddress: r.RemoteAddr,
+                       Host:          r.Host,
+                       Method:        r.Method,
+                       Path:          r.URL.Path,
+                       StatusCode:    ws.Status,
+               }
+
+               entryJson, err := json.Marshal(entry)
+               if err == nil {
+                       logger.Print(string(entryJson))
+               }
+       })
+}
+
+func (r *statusRecorder) WriteHeader(status int) {
+       r.ResponseWriter.WriteHeader(status)
+       r.Status = status
+}
+
+func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+       r.Status = http.StatusSwitchingProtocols
+       return r.Hijacker.Hijack()
+}
diff --git a/sase/ws_proxy_client/Makefile b/sase/ws_proxy_client/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/sase/ws_proxy_client/main.go b/sase/ws_proxy_client/main.go
new file mode 100644 (file)
index 0000000..69bb6b1
--- /dev/null
@@ -0,0 +1,63 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "fmt"
+       "net/url"
+       "os"
+       "os/signal"
+
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/sase"
+)
+
+func main() {
+       mtls.SetDefaultIdentity("devicetrust")
+
+       flag.Parse()
+
+       url, err := url.Parse(flag.Arg(0))
+
+       if err != nil {
+               flag.Usage()
+               os.Exit(1)
+       }
+
+       if url.Scheme != "wss" {
+               fmt.Fprintln(os.Stderr, "only wss is supported as a url scheme")
+               flag.Usage()
+               os.Exit(1)
+       }
+
+       id := mtls.DefaultIdentity()
+
+       client, err := sase.NewClient(id)
+       if err != nil {
+               panic(err)
+       }
+
+       conn, err := client.Connect(url)
+       if err != nil {
+               panic(err)
+       }
+
+       ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
+
+       remoteChan := sase.NewChanReader(conn, cancel)
+       localChan := sase.NewChanReader(os.Stdin, cancel)
+
+mainLoop:
+       for {
+               select {
+               case msg := <-remoteChan:
+                       os.Stdout.Write(msg)
+               case msg := <-localChan:
+                       conn.Write(msg)
+               case <-ctx.Done():
+                       break mainLoop
+               }
+       }
+
+       cancel()
+}
diff --git a/sase/ws_tcp_proxy/Makefile b/sase/ws_tcp_proxy/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/sase/ws_tcp_proxy/main.go b/sase/ws_tcp_proxy/main.go
new file mode 100644 (file)
index 0000000..89e5057
--- /dev/null
@@ -0,0 +1,89 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "fmt"
+       "os/signal"
+       "strconv"
+       "strings"
+       "syscall"
+       "time"
+
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/rand"
+       "go.fuhry.dev/runtime/sase"
+       "go.fuhry.dev/runtime/sd"
+)
+
+func main() {
+       var addr string
+       var sslCert string
+
+       defaultPort := int(rand.Range(1025, 59999))
+
+       flag.StringVar(&addr, "addr", fmt.Sprintf("[::]:%d", defaultPort), "address to listen on")
+       flag.StringVar(&sslCert, "cert", "", "puppet-managed ssl cert to use; if left blank, TLS is disabled")
+
+       flag.Parse()
+
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       listenPort, err := strconv.Atoi(addr[strings.LastIndex(addr, ":")+1:])
+       if err != nil {
+               panic(err)
+       }
+
+       wsp, err := sase.NewWebSocketProxy(addr)
+       if err != nil {
+               panic(err)
+       }
+
+       wsp.ACL.AddRule(sase.NewCorpNetworkRuleset()...)
+
+       if sslCert != "" {
+               cert := mtls.NewSSLCertificate(sslCert)
+               if !cert.IsValid() {
+                       panic("ssl certificate is not usable")
+               }
+
+               tlsc, err := cert.TlsConfig(ctx)
+               if err != nil {
+                       panic(err)
+               }
+
+               verifier := mtls.NewPeerNameVerifier()
+               verifier.AllowFrom(mtls.Domain, "."+constants.RootDomain)
+               verifier.AllowFrom(mtls.Service, "devicetrust")
+               err = verifier.ConfigureServer(tlsc)
+               if err != nil {
+                       panic(err)
+               }
+
+               wsp.Server.TLSConfig = tlsc
+               go wsp.Server.ListenAndServeTLS("", "")
+       } else {
+               go wsp.Server.ListenAndServe()
+       }
+
+       publisher := &sd.SDPublisher{
+               Protocol:      sd.ProtocolTCP,
+               Service:       "sase-proxy",
+               AdvertisePort: uint16(listenPort),
+       }
+
+       err = publisher.Publish(ctx)
+       if err != nil {
+               panic(err)
+       }
+
+       <-ctx.Done()
+
+       publisher.Unpublish()
+
+       tmo, tmoCancel := context.WithTimeout(context.Background(), 5*time.Second)
+
+       wsp.Server.Shutdown(tmo)
+       tmoCancel()
+}
diff --git a/sd/etcd_factory.go b/sd/etcd_factory.go
new file mode 100644 (file)
index 0000000..f264ed4
--- /dev/null
@@ -0,0 +1,100 @@
+package sd
+
+import (
+       "context"
+       "flag"
+       "fmt"
+       "sync"
+       "time"
+
+       "go.etcd.io/etcd/client/pkg/v3/srv"
+       etcd_client "go.etcd.io/etcd/client/v3"
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/utils/hostname"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+var clientSingleton *etcd_client.Client
+var clientMtx sync.Mutex
+var etcdMtlsId = "etcd-client"
+var etcdDiscoveryDomain string
+var etcdStartupTimeoutMs uint = 15000
+
+func NewDefaultEtcdClient() (*etcd_client.Client, error) {
+       var err error
+
+       clientMtx.Lock()
+       defer clientMtx.Unlock()
+
+       logger := log.WithPrefix("etcd-client")
+
+       id := mtls.NewServiceIdentity(etcdMtlsId)
+
+       if clientSingleton == nil {
+               deadline := time.Now().Add(time.Millisecond * time.Duration(etcdStartupTimeoutMs))
+               for {
+                       if time.Now().After(deadline) && err != nil {
+                               return nil, err
+                       }
+
+                       clientSingleton, err = NewEtcdClient(id)
+                       if err == nil {
+                               break
+                       }
+
+                       logger.Warnf("failed to startup etcd client: %+v", err)
+                       logger.Warn("waiting 1sec before retrying")
+
+                       time.Sleep(1 * time.Second)
+               }
+       }
+
+       return clientSingleton, nil
+}
+
+func NewEtcdClient(id mtls.Identity) (*etcd_client.Client, error) {
+       deadline := time.Now().Add(time.Millisecond * time.Duration(etcdStartupTimeoutMs))
+
+       return NewEtcdClientWithDeadline(
+               id, deadline)
+}
+
+func NewEtcdClientWithDeadline(id mtls.Identity, deadline time.Time) (*etcd_client.Client, error) {
+       var client *etcd_client.Client
+
+       tlsConfig, err := id.TlsConfig(context.Background())
+       if err != nil {
+               return nil, fmt.Errorf("failed to setup client TLS configuration: %v", err)
+       }
+       pnv := mtls.NewPeerNameVerifier()
+       pnv.AllowFrom(mtls.Service, "etcd")
+       pnv.ConfigureClient(tlsConfig)
+
+       clients, err := srv.GetClient("etcd-client", etcdDiscoveryDomain, "")
+       if err != nil {
+               return nil, fmt.Errorf("failed to discover peers: %v", err)
+       }
+
+       dialTimeout := time.Until(deadline)
+
+       clientConfig := etcd_client.Config{
+               DialTimeout: dialTimeout,
+               Endpoints:   clients.Endpoints,
+               TLS:         tlsConfig,
+       }
+
+       // clients.Endpoints is the list of endpoints now
+       client, err = etcd_client.New(clientConfig)
+
+       if err != nil {
+               return nil, fmt.Errorf("failed to create client: %v", err)
+       }
+
+       return client, nil
+}
+
+func init() {
+       flag.StringVar(&etcdMtlsId, "etcd.mtls.id", "etcd-client", "mTLS identity to use for connecting to etcd")
+       flag.UintVar(&etcdStartupTimeoutMs, "etcd.startup-timeout", etcdStartupTimeoutMs, "max timeout (in ms) for etcd startup attempts before failing")
+       flag.StringVar(&etcdDiscoveryDomain, "etcd.srv-domain", hostname.DomainName(), "discovery domain for etcd")
+}
diff --git a/sd/health_exporter/Makefile b/sd/health_exporter/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/sd/health_exporter/main.go b/sd/health_exporter/main.go
new file mode 100644 (file)
index 0000000..6b7c87e
--- /dev/null
@@ -0,0 +1,148 @@
+package main
+
+import (
+       "context"
+       "crypto/tls"
+       "encoding/json"
+       "flag"
+       "fmt"
+       "net"
+       "net/http"
+       "os/signal"
+       "sync"
+       "syscall"
+
+       etcd_client "go.etcd.io/etcd/client/v3"
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/sd"
+)
+
+func main() {
+       var listen string
+       flag.StringVar(&listen, "listen", ":7080", "IP address and port to listen on")
+
+       flag.Parse()
+
+       client, err := sd.NewDefaultEtcdClient()
+       if err != nil {
+               panic(err)
+       }
+       defer client.Close()
+       keys := make(map[string][]byte, 0)
+       keysLock := &sync.RWMutex{}
+
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       handler := http.NewServeMux()
+       server := &http.Server{
+               Handler: handler,
+       }
+
+       handler.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
+               keysLock.RLock()
+               defer keysLock.RUnlock()
+
+               w.Header().Set("content-type", "text/plain; version=0.0.4; charset=utf-8")
+
+               gaugeName := "sd_service_health"
+               fmt.Fprintf(w, "# HELP %s Health of services as monitored by the sdregister helper\n", gaugeName)
+               fmt.Fprintf(w, "# TYPE %s gauge\n", gaugeName)
+
+               for _, serviceJson := range keys {
+                       s := sd.SDHealthReport{}
+
+                       err := json.Unmarshal(serviceJson, &s)
+                       if err != nil {
+                               continue
+                       }
+
+                       metric := 0.0
+                       if s.Up {
+                               metric = 1.0
+                       }
+                       labels := fmt.Sprintf(
+                               "protocol=%q,service=%q,monitor_host=%q,monitor_region=%q",
+                               s.Protocol, s.Service, s.MonitorHost, s.MonitorRegion)
+
+                       if s.Shard != "" {
+                               labels += fmt.Sprintf(",shard_name=%q", s.Shard)
+                       }
+                       if s.ShardRegion != "" {
+                               labels += fmt.Sprintf(",shard_region=%q", s.ShardRegion)
+                       }
+
+                       fmt.Fprintf(w, "%s{%s} %.1f\n", gaugeName, labels, metric)
+               }
+       })
+
+       tcpAddr, err := net.ResolveTCPAddr("tcp", listen)
+       if err != nil {
+               panic(err)
+       }
+       tlsListener, err := makeTlsListener(tcpAddr, ctx)
+       if err != nil {
+               panic(err)
+       }
+       go server.Serve(tlsListener)
+
+       publisher := &sd.SDPublisher{
+               AdvertisePort: tcpAddr.AddrPort().Port(),
+               Protocol:      sd.ProtocolTCP,
+               Service:       "health-exporter",
+       }
+       publisher.Publish(ctx)
+
+       initialKeys, err := client.Get(ctx, "/sd/_health/", etcd_client.WithPrefix())
+       if err != nil {
+               panic(err)
+       }
+       keysLock.Lock()
+       for _, entry := range initialKeys.Kvs {
+               keys[string(entry.Key)] = entry.Value
+       }
+       keysLock.Unlock()
+
+       watcher := client.Watch(ctx, "/sd/_health/", etcd_client.WithPrefix())
+       for {
+               select {
+               case notif := <-watcher:
+                       keysLock.Lock()
+                       for _, event := range notif.Events {
+                               switch event.Type.String() {
+                               case "PUT":
+                                       keys[string(event.Kv.Key)] = event.Kv.Value
+                               case "DELETE":
+                                       k := string(event.Kv.Key)
+                                       delete(keys, k)
+                               }
+                       }
+                       keysLock.Unlock()
+               case <-ctx.Done():
+                       publisher.Unpublish()
+                       server.Shutdown(context.Background())
+                       return
+               }
+       }
+}
+
+func makeTlsListener(tcpAddr *net.TCPAddr, ctx context.Context) (net.Listener, error) {
+       mtlsId := mtls.DefaultIdentity()
+
+       netListener, err := net.ListenTCP("tcp", tcpAddr)
+       if err != nil {
+               return nil, err
+       }
+       tlsc, err := mtlsId.TlsConfig(ctx)
+       if err != nil {
+               return nil, err
+       }
+       cv := mtls.NewPeerNameVerifier()
+       err = cv.ConfigureServer(tlsc)
+       if err != nil {
+               return nil, err
+       }
+       cv.AllowFrom(mtls.Service, "prometheus")
+       cv.AllowFrom(mtls.User, "dan")
+
+       return tls.NewListener(netListener, tlsc), nil
+}
diff --git a/sd/healthcheck.go b/sd/healthcheck.go
new file mode 100644 (file)
index 0000000..fd87d10
--- /dev/null
@@ -0,0 +1,280 @@
+package sd
+
+import (
+       "context"
+       "crypto/tls"
+       "crypto/x509"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "sync"
+       "time"
+
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/mtls/certutil"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type HealthCheckEngine interface {
+       Name() string
+       Load(serviceDef *ServiceDefinition, config []byte) (HealthCheckService, error)
+}
+
+type HealthCheckService interface {
+       Check() error
+       ServiceDefinition() *ServiceDefinition
+       NewPublisher(domain string, regions []string) *SDPublisher
+       NewDefaultPublisher() *SDPublisher
+}
+
+type PublishAddress struct {
+       IPOrHostname string
+       Port         uint16
+}
+
+type Layer4Protocol uint8
+
+const (
+       ProtocolInvalid Layer4Protocol = iota
+       ProtocolTCP
+       ProtocolUDP
+       ProtocolGRPC
+)
+
+var (
+       ErrTLSServerOptionConflict = errors.New(
+               "mTLS related options (\"peer_mtls_id\") are mutually exclusive with \"server_name\" " +
+                       "and \"ca_certificate\"")
+       ErrTLSClientOptionConflict = errors.New(
+               "cannot set client cert or private key when mtls identities are used")
+)
+
+type HealthStatus uint
+
+type ServiceDefinition struct {
+       Engine       string `json:"type"`
+       ServiceName  string `json:"service_name"`
+       PollInterval uint   `json:"poll_interval"`
+}
+
+type HealthChangeEvent struct {
+       Status HealthStatus
+       Error  error
+}
+
+const (
+       ServiceUp HealthStatus = iota
+       ServiceDown
+)
+
+type socketService struct {
+       SocketHost string `json:"socket_host"`
+       SocketPort uint16 `json:"socket_port"`
+}
+
+type tlsService struct {
+       UseTls       bool   `json:"tls"`
+       MtlsIdentity string `json:"mtls_id"`
+       MtlsPeer     string `json:"peer_mtls_id"`
+       ServerName   string `json:"server_name"`
+       ClientCert   string `json:"certificate"`
+       PrivateKey   string `json:"private_key"`
+       CaCert       string `json:"ca_certificate"`
+}
+
+var engines []HealthCheckEngine
+var enginesOnce sync.Once
+
+func (p Layer4Protocol) DNSComponent() string {
+       switch p {
+       case ProtocolTCP:
+               return "_tcp"
+       case ProtocolUDP:
+               return "_udp"
+       case ProtocolGRPC:
+               return "_grpc"
+       default:
+               panic("cannot stringify invalid protocol")
+       }
+}
+
+func (p Layer4Protocol) String() string {
+       return p.DNSComponent()[1:]
+}
+
+func Layer4ProtocolFromString(s string) (Layer4Protocol, error) {
+       switch s {
+       case "udp", "UDP", ProtocolUDP.DNSComponent():
+               return ProtocolUDP, nil
+       case "tcp", "TCP", ProtocolTCP.DNSComponent():
+               return ProtocolTCP, nil
+       case "grpc", "GRPC", "gRPC", ProtocolGRPC.DNSComponent():
+               return ProtocolGRPC, nil
+       default:
+               return ProtocolInvalid, fmt.Errorf("%q is not a valid layer4 protocol id", s)
+       }
+}
+
+func LoadHealthCheck(conf []byte) (HealthCheckService, error) {
+       svc := &ServiceDefinition{}
+       err := json.Unmarshal(conf, svc)
+       if err != nil {
+               return nil, err
+       }
+
+       for _, engine := range engines {
+               if svc.Engine == engine.Name() {
+                       service, err := engine.Load(svc, conf)
+                       if err != nil {
+                               return nil, err
+                       }
+                       return service, nil
+               }
+       }
+
+       return nil, fmt.Errorf("service type %s is unsupported", svc.Engine)
+}
+
+func HealthCheckLoop(ctx context.Context, svc HealthCheckService) <-chan HealthChangeEvent {
+       logger := log.WithPrefix("HealthCheckLoop")
+       ticker := time.NewTicker(time.Second * time.Duration(svc.ServiceDefinition().PollInterval))
+       eventsChan := make(chan HealthChangeEvent)
+
+       go (func() {
+               var lastEvent *HealthChangeEvent
+
+               doHealthCheck := func() {
+                       var event HealthChangeEvent
+                       err := svc.Check()
+                       if err == nil {
+                               logger.V(1).Infof("%T: svc %s is healthy", svc, svc.ServiceDefinition().ServiceName)
+                               event = HealthChangeEvent{
+                                       Status: ServiceUp,
+                                       Error:  nil,
+                               }
+                       } else {
+                               logger.V(1).Infof("%T: svc %s is unhealthy: %v", svc, svc.ServiceDefinition().ServiceName, err)
+                               event = HealthChangeEvent{
+                                       Status: ServiceDown,
+                                       Error:  err,
+                               }
+                       }
+                       if lastEvent == nil || lastEvent.Status != event.Status {
+                               eventsChan <- event
+                       }
+                       lastEvent = &event
+               }
+
+               doHealthCheck()
+
+               for {
+                       select {
+                       case <-ctx.Done():
+                               close(eventsChan)
+                               return
+                       case <-ticker.C:
+                               doHealthCheck()
+                       }
+               }
+       })()
+
+       return eventsChan
+}
+
+func registerEngine(hce HealthCheckEngine) {
+       enginesOnce.Do(func() {
+               engines = make([]HealthCheckEngine, 0)
+       })
+
+       engines = append(engines, hce)
+}
+
+func newTlsConfigForHealthcheckService(ts *tlsService, ss *socketService) (*tls.Config, error) {
+       logger := log.Default().WithPrefix("healthcheck.tls")
+
+       if !ts.UseTls {
+               return nil, nil
+       }
+
+       var c *tls.Config
+       var err error
+
+       if ts.MtlsIdentity != "" {
+               id := mtls.NewServiceIdentity(ts.MtlsIdentity)
+               if !id.IsValid() {
+                       return nil, fmt.Errorf("unable to load mTLS identity %q, check that the certificate and private key are present, up-to-date and accessible", ts.MtlsIdentity)
+               }
+
+               c, err = id.TlsConfig(context.TODO())
+               if err != nil {
+                       return nil, err
+               }
+       } else {
+               c = &tls.Config{}
+       }
+
+       if ts.MtlsPeer != "" {
+               if ts.CaCert != "" || ts.ServerName != "" {
+                       return nil, ErrTLSServerOptionConflict
+               }
+
+               pnv := mtls.NewPeerNameVerifier()
+               pnv.AllowFrom(mtls.Service, ts.MtlsPeer)
+               err = pnv.ConfigureClient(c)
+               if err != nil {
+                       return nil, err
+               }
+       }
+       if ts.CaCert != "" {
+               caCertificates, err := certutil.LoadCertificatesFromPEM(ts.CaCert)
+               if err != nil {
+                       return nil, err
+               }
+               if c.RootCAs == nil {
+                       c.RootCAs = x509.NewCertPool()
+               }
+               for _, cert := range caCertificates {
+                       logger.V(2).Debugf("permit cert: %q", cert.Subject.String())
+                       c.RootCAs.AddCert(cert)
+               }
+       }
+
+       if ts.ClientCert != "" && ts.PrivateKey != "" {
+               if c.GetClientCertificate != nil {
+                       return nil, ErrTLSClientOptionConflict
+               }
+               clientCertificate, err := certutil.LoadCertificatesFromPEM(ts.ClientCert)
+               if err != nil {
+                       return nil, err
+               }
+               clientPrivateKey, err := certutil.LoadPrivateKeyFromPEM(ts.PrivateKey)
+               if err != nil {
+                       return nil, err
+               }
+               if len(clientCertificate) < 1 {
+                       return nil, fmt.Errorf("no certificates found in client certificate path %s", ts.ClientCert)
+               }
+               rawCerts := make([][]byte, 0)
+               for _, c := range clientCertificate {
+                       rawCerts = append(rawCerts, c.Raw)
+               }
+
+               tlsCerts := []tls.Certificate{
+                       {
+                               Certificate: rawCerts,
+                               PrivateKey:  clientPrivateKey,
+                               Leaf:        clientCertificate[0],
+                       },
+               }
+
+               c.Certificates = tlsCerts
+       }
+       if ts.ServerName != "" {
+               c.ServerName = ts.ServerName
+       } else if ss.SocketHost != "" {
+               c.ServerName = ss.SocketHost
+       }
+
+       logger.V(3).Debugf("finished assembling tls.Config: %+v", c)
+       return c, nil
+}
diff --git a/sd/healthcheck_http.go b/sd/healthcheck_http.go
new file mode 100644 (file)
index 0000000..00a54d9
--- /dev/null
@@ -0,0 +1,147 @@
+package sd
+
+import (
+       "encoding/json"
+       "fmt"
+       "net/http"
+       "strings"
+
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/utils/hostname"
+)
+
+type httpHealthCheckEngine struct {
+       HealthCheckEngine
+}
+
+type httpHealthCheckService struct {
+       HealthCheckService
+
+       socketService
+       tlsService
+
+       serviceDef *ServiceDefinition `json:"-"`
+       httpClient *http.Client       `json:"-"`
+
+       HttpHost string `json:"http_host"`
+       Http2    bool   `json:"http2"`
+       Path     string `json:"path"`
+}
+
+func (hce *httpHealthCheckEngine) Name() string {
+       return "http"
+}
+
+func (hce *httpHealthCheckEngine) Load(serviceDef *ServiceDefinition, conf []byte) (HealthCheckService, error) {
+       svc := &httpHealthCheckService{
+               serviceDef: serviceDef,
+       }
+
+       err := json.Unmarshal(conf, svc)
+       if err != nil {
+               return nil, err
+       }
+
+       tlsConfig, err := newTlsConfigForHealthcheckService(&svc.tlsService, &svc.socketService)
+       if err != nil {
+               return nil, err
+       }
+
+       svc.httpClient = &http.Client{
+               Transport: &http.Transport{
+                       ForceAttemptHTTP2: svc.Http2,
+                       TLSClientConfig:   tlsConfig,
+               },
+       }
+
+       return svc, nil
+}
+
+func (hcs *httpHealthCheckService) ServiceDefinition() *ServiceDefinition {
+       return hcs.serviceDef
+}
+
+func (hcs *httpHealthCheckService) NewPublisher(domain string, regions []string) *SDPublisher {
+       _, port := hcs.PublishAddress()
+
+       publisher := &SDPublisher{
+               AdvertiseHost: hcs.SocketHost,
+               AdvertisePort: port,
+               Domain:        domain,
+               Protocol:      ProtocolTCP,
+               Service:       hcs.serviceDef.ServiceName,
+               ShardName:     strings.Split(hcs.SocketHost, ".")[0],
+               Regions:       regions,
+       }
+
+       return publisher
+}
+
+func (hcs *httpHealthCheckService) NewDefaultPublisher() *SDPublisher {
+       domain := constants.SDDomain
+       regions := []string{
+               hostname.RegionName(),
+       }
+
+       return hcs.NewPublisher(domain, regions)
+}
+
+func (hcs *httpHealthCheckService) PublishAddress() (string, uint16) {
+       port := hcs.SocketPort
+       if port == 0 {
+               port = 80
+               if hcs.UseTls {
+                       port = 443
+               }
+       }
+
+       return hcs.SocketHost, port
+}
+
+func (hcs *httpHealthCheckService) Check() error {
+       protocol := "http"
+       var port uint16 = 80
+       if hcs.UseTls {
+               protocol = "https"
+               port = 443
+       }
+       if hcs.SocketPort != 0 {
+               port = hcs.SocketPort
+       }
+       path := hcs.Path
+       if path == "" {
+               path = "/"
+       }
+       if path[0] != '/' {
+               path = "/" + path
+       }
+       url := fmt.Sprintf("%s://%s:%d%s", protocol, hcs.SocketHost, port, path)
+       req, err := http.NewRequest("GET", url, nil)
+       if err != nil {
+               return err
+       }
+
+       if hcs.HttpHost != "" {
+               req.Host = hcs.HttpHost
+       } else if hcs.ServerName != "" && hcs.UseTls {
+               req.Host = hcs.ServerName
+       }
+       req.Header.Set("connection", "close")
+
+       resp, err := hcs.httpClient.Do(req)
+       if err != nil {
+               return err
+       }
+
+       if resp.StatusCode >= 200 && resp.StatusCode < 400 {
+               return nil
+       }
+
+       return fmt.Errorf("http request succeeded, but received unhealthy status code: %d", resp.StatusCode)
+}
+
+func init() {
+       hce := &httpHealthCheckEngine{}
+
+       registerEngine(hce)
+}
diff --git a/sd/healthcheck_ldap.go b/sd/healthcheck_ldap.go
new file mode 100644 (file)
index 0000000..5b54ae0
--- /dev/null
@@ -0,0 +1,143 @@
+package sd
+
+import (
+       "crypto/tls"
+       "encoding/json"
+       "fmt"
+       "strings"
+
+       ldap "github.com/go-ldap/ldap/v3"
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/utils/hostname"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type ldapHealthCheckEngine struct {
+       HealthCheckEngine
+}
+
+type ldapHealthCheckService struct {
+       HealthCheckService
+
+       socketService
+       tlsService
+
+       serviceDef *ServiceDefinition `json:"-"`
+       tlsConfig  *tls.Config        `json:"-"`
+
+       Query string `json:"search_query"`
+}
+
+func (lce *ldapHealthCheckEngine) Name() string {
+       return "ldap"
+}
+
+func (lce *ldapHealthCheckEngine) Load(serviceDef *ServiceDefinition, conf []byte) (HealthCheckService, error) {
+       svc := &ldapHealthCheckService{
+               serviceDef: serviceDef,
+       }
+
+       err := json.Unmarshal(conf, svc)
+       if err != nil {
+               return nil, err
+       }
+
+       tlsConfig, err := newTlsConfigForHealthcheckService(&svc.tlsService, &svc.socketService)
+       if err != nil {
+               return nil, err
+       }
+
+       svc.tlsConfig = tlsConfig
+
+       return svc, nil
+}
+
+func (lcs *ldapHealthCheckService) ServiceDefinition() *ServiceDefinition {
+       return lcs.serviceDef
+}
+
+func (lcs *ldapHealthCheckService) NewPublisher(domain string, regions []string) *SDPublisher {
+       _, port := lcs.PublishAddress()
+
+       publisher := &SDPublisher{
+               AdvertiseHost: lcs.SocketHost,
+               AdvertisePort: port,
+               Domain:        domain,
+               Protocol:      ProtocolTCP,
+               Service:       lcs.serviceDef.ServiceName,
+               ShardName:     strings.Split(lcs.SocketHost, ".")[0],
+               Regions:       regions,
+       }
+
+       return publisher
+}
+
+func (lcs *ldapHealthCheckService) NewDefaultPublisher() *SDPublisher {
+       domain := constants.SDDomain
+       region := []string{
+               hostname.RegionName(),
+       }
+
+       return lcs.NewPublisher(domain, region)
+}
+
+func (lcs *ldapHealthCheckService) PublishAddress() (string, uint16) {
+       port := lcs.SocketPort
+       if port == 0 {
+               port = 389
+               if lcs.UseTls {
+                       port = 636
+               }
+       }
+
+       return lcs.SocketHost, port
+}
+
+func (lcs *ldapHealthCheckService) Check() error {
+       var err error
+       var port uint16 = 389
+       logger := log.WithPrefix("ldapHealthCheckService:" + lcs.SocketHost)
+
+       if lcs.UseTls {
+               port = 636
+       }
+       if lcs.SocketPort != 0 {
+               port = lcs.SocketPort
+       }
+
+       addr := fmt.Sprintf("%s:%d", lcs.SocketHost, port)
+       var conn *ldap.Conn
+
+       if lcs.UseTls {
+               conn, err = ldap.DialTLS("tcp", addr, lcs.tlsConfig)
+               if err != nil {
+                       return err
+               }
+       } else {
+               conn, err = ldap.Dial("tcp", addr)
+               if err != nil {
+                       return err
+               }
+       }
+       defer conn.Close()
+
+       err = conn.ExternalBind()
+       if err != nil {
+               return err
+       }
+
+       whoami, err := conn.WhoAmI([]ldap.Control{})
+       if err != nil {
+               return err
+       }
+
+       logger.V(2).Infof("Successfully bound to LDAP as %s", whoami.AuthzID)
+
+       return nil
+}
+
+func init() {
+       lce := &ldapHealthCheckEngine{}
+
+       registerEngine(lce)
+}
diff --git a/sd/monitor.go b/sd/monitor.go
new file mode 100644 (file)
index 0000000..a50fca8
--- /dev/null
@@ -0,0 +1,145 @@
+package sd
+
+import (
+       "context"
+       "encoding/json"
+       "fmt"
+       "strings"
+       "sync"
+       "time"
+
+       "go.fuhry.dev/runtime/utils/hostname"
+       "go.fuhry.dev/runtime/utils/log"
+
+       etcd_client "go.etcd.io/etcd/client/v3"
+)
+
+type healthCheckingPublisher struct {
+       publisher   *SDPublisher
+       healthcheck HealthCheckService
+       etcdClient  *etcd_client.Client
+       logger      *log.Logger
+}
+
+type SDHealthReport struct {
+       Up            bool
+       Domain        string
+       Shard         string
+       ShardRegion   string
+       Protocol      string
+       Service       string
+       SocketHost    string
+       SocketPort    uint16
+       LastError     string
+       MonitorHost   string
+       MonitorRegion string
+}
+
+type srvRecord struct {
+       Host     string `json:"host"`
+       Priority uint   `json:"priority"`
+       Port     uint16 `json:"port"`
+       TTL      uint   `json:"ttl"`
+}
+
+type addressRecord struct {
+       Host string `json:"host"`
+       TTL  uint   `json:"ttl"`
+}
+
+func (sdp *healthCheckingPublisher) MonitorAndPublish(ctx context.Context, wg *sync.WaitGroup) error {
+       logger := sdp.logger.AppendPrefix(".MonitorAndPublish")
+       ticker := time.NewTicker(30 * time.Second)
+
+       wg.Add(1)
+       defer wg.Done()
+
+       var err error
+
+       leaseApi := etcd_client.NewLease(sdp.etcdClient)
+       statLease, err := leaseApi.Grant(ctx, 60)
+       if err != nil {
+               return err
+       }
+       logger.V(2).Debugf("leaseID: %016x", statLease.ID)
+
+       defer (func() {
+               if statLease != nil {
+                       leaseApi.Revoke(context.Background(), statLease.ID)
+               }
+       })()
+
+       events := HealthCheckLoop(ctx, sdp.healthcheck)
+
+       var lastEvent *HealthChangeEvent
+
+       for {
+               select {
+               case evt := <-events:
+                       lastEvent = &evt
+                       sdp.updateHealth(ctx, statLease, evt)
+                       switch evt.Status {
+                       case ServiceUp:
+                               sdp.publisher.Publish(ctx)
+                       case ServiceDown:
+                               sdp.publisher.Unpublish()
+                       }
+               case <-ticker.C:
+                       kar, err := leaseApi.KeepAliveOnce(ctx, statLease.ID)
+                       if err != nil || (kar != nil && kar.ID == etcd_client.NoLease) {
+                               // our lease got revoked
+                               logger.V(1).Errorf("Our lease disappeared!")
+                               statLease, err = leaseApi.Grant(ctx, 60)
+                               if err != nil {
+                                       return err
+                               }
+                               logger.V(2).Debugf("New leaseID: %016x", statLease.ID)
+                               if lastEvent != nil {
+                                       sdp.updateHealth(ctx, statLease, *lastEvent)
+                               }
+                       }
+               case <-ctx.Done():
+                       return nil
+               }
+       }
+}
+
+func (sdp *healthCheckingPublisher) updateHealth(ctx context.Context, lease *etcd_client.LeaseGrantResponse, evt HealthChangeEvent) {
+       key := fmt.Sprintf("/sd/_health/%016x", lease.ID)
+
+       hr := SDHealthReport{
+               Up:            evt.Status == ServiceUp,
+               Service:       sdp.publisher.Service,
+               SocketHost:    sdp.publisher.AdvertiseHost,
+               SocketPort:    sdp.publisher.AdvertisePort,
+               Domain:        sdp.publisher.Domain,
+               Shard:         sdp.publisher.ShardName,
+               ShardRegion:   strings.Split(sdp.publisher.AdvertiseHost, ".")[1],
+               Protocol:      sdp.healthcheck.ServiceDefinition().Engine,
+               MonitorHost:   hostname.Fqdn(),
+               MonitorRegion: hostname.RegionName(),
+       }
+       if evt.Error != nil {
+               hr.LastError = evt.Error.Error()
+       }
+       j, err := json.Marshal(hr)
+       if err != nil {
+               return
+       }
+
+       sdp.etcdClient.Put(ctx, key, string(j), etcd_client.WithLease(lease.ID))
+}
+
+func NewHealthCheckPublisher(hcs HealthCheckService, domain string, regions []string) (*healthCheckingPublisher, error) {
+       etcd, err := NewDefaultEtcdClient()
+       if err != nil {
+               return nil, err
+       }
+
+       return &healthCheckingPublisher{
+               publisher:   hcs.NewPublisher(domain, regions),
+               healthcheck: hcs,
+               etcdClient:  etcd,
+               logger:      log.WithPrefix(fmt.Sprintf("HealthCheckingPublisher(%s)", hcs.ServiceDefinition().ServiceName)),
+       }, nil
+}
diff --git a/sd/publish.go b/sd/publish.go
new file mode 100644 (file)
index 0000000..d2af708
--- /dev/null
@@ -0,0 +1,338 @@
+package sd
+
+import (
+       "context"
+       "encoding/json"
+       "fmt"
+       "strings"
+       "sync"
+       "time"
+
+       etcd_client "go.etcd.io/etcd/client/v3"
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/net/dns"
+       "go.fuhry.dev/runtime/utils"
+       "go.fuhry.dev/runtime/utils/hostname"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+const (
+       leaseRenewalInterval = 15
+       leaseMaxLifetime     = 30
+)
+
+type SDPublisher struct {
+       AdvertiseHost string
+       AdvertisePort uint16
+       Domain        string
+       Protocol      Layer4Protocol
+       Service       string
+       ShardName     string
+       LocalRegion   string
+       Regions       []string
+       IP4           string
+       IP6           string
+       EtcdClient    *etcd_client.Client
+
+       mtx *sync.Mutex
+       wg  *sync.WaitGroup
+
+       logger *log.Logger
+       leases etcd_client.Lease
+       lease  *etcd_client.LeaseGrantResponse
+       ctx    context.Context
+       cancel context.CancelFunc
+}
+
+type recordToPublish struct {
+       rtype string
+       path  string
+       val   string
+}
+
+func (s *SDPublisher) init() error {
+       if s.mtx == nil {
+               s.mtx = &sync.Mutex{}
+               s.wg = &sync.WaitGroup{}
+       }
+
+       s.mtx.Lock()
+       defer s.mtx.Unlock()
+
+       s.logger = log.WithPrefix(fmt.Sprintf("SDPublish(%s)", s.Service))
+
+       if s.EtcdClient == nil {
+               cl, err := NewDefaultEtcdClient()
+
+               if err != nil {
+                       return err
+               }
+
+               s.EtcdClient = cl
+       }
+
+       s.leases = etcd_client.NewLease(s.EtcdClient)
+
+       errors := make([]string, 0)
+
+       if s.AdvertiseHost == "" {
+               s.AdvertiseHost = hostname.Fqdn()
+       }
+
+       if s.AdvertisePort == 0 {
+               errors = append(errors, "cannot advertise port 0")
+       }
+
+       if s.Service == "" {
+               errors = append(errors, "service name was not set")
+       }
+
+       if s.ShardName == "" {
+               s.ShardName = hostname.Hostname()
+       }
+
+       if s.LocalRegion == "" {
+               s.LocalRegion = hostname.RegionName()
+       }
+
+       if s.Regions == nil || len(s.Regions) == 0 {
+               s.Regions = []string{
+                       hostname.RegionName(),
+               }
+       }
+
+       if s.Domain == "" {
+               s.Domain = constants.SDDomain
+       }
+
+       if s.IP4 == "" || s.IP6 == "" {
+               ip4, ip6, err := dns.ResolveDualStack(s.AdvertiseHost)
+               if err != nil {
+                       return fmt.Errorf("ip4 or ip6 not provided, and we were unable to resolve them")
+               }
+               if s.IP4 == "" {
+                       s.IP4 = ip4
+               }
+               if s.IP6 == "" {
+                       s.IP6 = ip6
+               }
+       }
+
+       if len(errors) > 0 {
+               return fmt.Errorf("errors encountered while initializing SDService:\n  %s", strings.Join(errors, "\n  "))
+       }
+
+       return nil
+}
+
+func (s *SDPublisher) Publish(ctx context.Context) error {
+       err := s.init()
+       if err != nil {
+               return err
+       }
+
+       s.mtx.Lock()
+       defer s.mtx.Unlock()
+
+       if s.lease != nil {
+               // service is already published
+               return nil
+       }
+       s.ctx, s.cancel = context.WithCancel(ctx)
+
+       go s.renewalLoop()
+
+       return nil
+}
+
+func (s *SDPublisher) Unpublish() {
+       err := s.init()
+       if err != nil {
+               panic(err)
+       }
+
+       if s.lease != nil {
+               s.cancel()
+               s.wg.Wait()
+       }
+}
+
+func (s *SDPublisher) publish() error {
+       records := s.recordsToPublish()
+
+       for _, record := range records {
+               s.logger.AppendPrefix(".publish").V(1).Infof("Publishing %s record at %s: %s", record.rtype, record.path, record.val)
+
+               _, err := s.EtcdClient.Put(s.ctx,
+                       record.path, record.val, etcd_client.WithLease(s.lease.ID))
+               if err != nil {
+                       s.leases.Revoke(s.ctx, s.lease.ID)
+                       s.lease = nil
+                       return err
+               }
+       }
+
+       return nil
+}
+
+func (s *SDPublisher) unpublish() {
+       s.mtx.Lock()
+       defer s.mtx.Unlock()
+
+       revokeOk := false
+       if s.lease != nil {
+               _, err := s.leases.Revoke(context.Background(), s.lease.ID)
+               if err == nil {
+                       revokeOk = true
+               }
+       }
+
+       if !revokeOk {
+               records := s.recordsToPublish()
+               for _, record := range records {
+                       s.EtcdClient.Delete(s.ctx, record.path)
+               }
+       }
+
+       s.lease = nil
+       s.ctx = nil
+       s.cancel = nil
+}
+
+func (s *SDPublisher) recordsToPublish() []recordToPublish {
+       records := make([]recordToPublish, 0)
+
+       records = append(records, recordToPublish{
+               rtype: "SRV",
+               path:  s.srvRecordPath(),
+               val:   s.srvRecordJson(s.LocalRegion),
+       })
+
+       records = append(records, recordToPublish{
+               rtype: "A",
+               path:  s.aRecordPath(),
+               val:   s.aRecordJson(),
+       })
+
+       records = append(records, recordToPublish{
+               rtype: "AAAA",
+               path:  s.aaaaRecordPath(),
+               val:   s.aaaaRecordJson(),
+       })
+
+       return records
+}
+
+func (s *SDPublisher) renewalLoop() {
+       ticker := time.NewTicker(leaseRenewalInterval * time.Second)
+       defer ticker.Stop()
+
+       s.wg.Add(1)
+       defer s.wg.Done()
+
+       // renew/grant at the start of execution
+       s.renewalTick()
+
+       for {
+               select {
+               case <-s.ctx.Done():
+                       s.logger.V(1).Infof("Tearing down DNS records for service %s on shard %s", s.Service, s.ShardName)
+                       s.unpublish()
+                       return
+               case <-ticker.C:
+                       s.renewalTick()
+               }
+       }
+}
+
+func (s *SDPublisher) renewalTick() {
+       var err error
+       var kar *etcd_client.LeaseKeepAliveResponse
+
+       kar, err = nil, nil
+       if s.lease != nil {
+               s.logger.V(3).Debugf("Doing keepalive for service %s on %s", s.Service, s.AdvertiseHost)
+               kar, err = s.leases.KeepAliveOnce(s.ctx, s.lease.ID)
+       } else {
+               s.logger.V(1).Debugf("no active lease for service %s on %s, attempting to acquire one", s.Service, s.AdvertiseHost)
+       }
+       if kar == nil || err != nil || (kar != nil && kar.ID == etcd_client.NoLease) {
+               // we lost our lease - get a new one
+               s.lease = nil
+               lease, err := s.leases.Grant(s.ctx, leaseMaxLifetime)
+               if lease == nil || err != nil {
+                       s.logger.Debugf("warning: lost our lease and failed to get a new one. ")
+                       return
+               }
+               s.logger.V(2).Debugf("leaseID: %016x", lease.ID)
+
+               s.lease = lease
+
+               err = s.publish()
+               if err != nil {
+                       s.logger.Warningf("warning: failed to re-publish record after losing our lease: %v", err)
+                       return
+               }
+       }
+}
+
+func (s *SDPublisher) srvRecordPath() string {
+       domainPathComponents := strings.Join(utils.Reverse(strings.Split(s.Domain, ".")), "/")
+       return fmt.Sprintf("/sd/%s/%s/%s/_%s/%s",
+               "dns",
+               domainPathComponents,
+               s.Protocol.DNSComponent(),
+               s.Service,
+               s.ShardName)
+}
+
+func (s *SDPublisher) srvRecordJson(region string) string {
+       prio := uint(5)
+       if region != s.LocalRegion {
+               prio = 10
+       }
+       recordValue, _ := json.Marshal(srvRecord{
+               Host:     s.AdvertiseHost,
+               Port:     s.AdvertisePort,
+               Priority: prio,
+               TTL:      uint(leaseRenewalInterval),
+       })
+
+       return string(recordValue)
+}
+
+func (s *SDPublisher) aRecordPath() string {
+       domainPathComponents := strings.Join(utils.Reverse(strings.Split(s.Domain, ".")), "/")
+       return fmt.Sprintf("/sd/%s/%s/%s/%s/a",
+               "dns",
+               domainPathComponents,
+               s.Service,
+               s.ShardName)
+}
+
+func (s *SDPublisher) aRecordJson() string {
+       recordValue, _ := json.Marshal(addressRecord{
+               Host: s.IP4,
+               TTL:  uint(leaseRenewalInterval),
+       })
+
+       return string(recordValue)
+}
+
+func (s *SDPublisher) aaaaRecordPath() string {
+       domainPathComponents := strings.Join(utils.Reverse(strings.Split(s.Domain, ".")), "/")
+       return fmt.Sprintf("/sd/%s/%s/%s/%s/aaaa",
+               "dns",
+               domainPathComponents,
+               s.Service,
+               s.ShardName)
+}
+
+func (s *SDPublisher) aaaaRecordJson() string {
+       recordValue, _ := json.Marshal(addressRecord{
+               Host: s.IP6,
+               TTL:  uint(leaseRenewalInterval),
+       })
+
+       return string(recordValue)
+}
diff --git a/sd/sd_publish/Makefile b/sd/sd_publish/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/sd/sd_publish/main.go b/sd/sd_publish/main.go
new file mode 100644 (file)
index 0000000..76813c9
--- /dev/null
@@ -0,0 +1,47 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "os/signal"
+       "strings"
+       "syscall"
+
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/sd"
+       "go.fuhry.dev/runtime/utils/hostname"
+)
+
+func main() {
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       svc := &sd.SDPublisher{}
+       var port uint
+       var protocolStr string
+       var regionsStr string
+
+       flag.StringVar(&svc.AdvertiseHost, "host", "", "The DNS name that will be advertised in the SRV record")
+       flag.UintVar(&port, "port", 0, "The port that will be advertised for the service in the SRV record")
+       flag.StringVar(&protocolStr, "proto", "tcp", "Layer 4 protocol the service uses (tcp or udp)")
+       flag.StringVar(&svc.Domain, "domain", constants.SDDomain, "domain name under which services are advertised")
+       flag.StringVar(&regionsStr, "region", hostname.RegionName(), "region(s) in which to publish the record (separate multiple regions with commas)")
+       flag.StringVar(&svc.Service, "service", "", "name of the service, like \"http\", etc.")
+       flag.StringVar(&svc.ShardName, "shard", "", "name of the shard/individual cluster member; default is derived from advertised hostname")
+       flag.Parse()
+       svc.AdvertisePort = uint16(port)
+       svc.Regions = strings.Split(regionsStr, ",")
+       l4p, err := sd.Layer4ProtocolFromString(protocolStr)
+       if err != nil {
+               panic(err)
+       }
+       svc.Protocol = l4p
+
+       err = svc.Publish(ctx)
+       if err != nil {
+               panic(err)
+       }
+
+       <-ctx.Done()
+
+       svc.Unpublish()
+}
diff --git a/sd/sd_register/Makefile b/sd/sd_register/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/sd/sd_register/main.go b/sd/sd_register/main.go
new file mode 100644 (file)
index 0000000..2f85d09
--- /dev/null
@@ -0,0 +1,121 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "fmt"
+       "os"
+       "os/signal"
+       "path"
+       "strings"
+       "sync"
+       "syscall"
+
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/sd"
+       "go.fuhry.dev/runtime/utils/hostname"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type confPaths []string
+
+func (cp *confPaths) String() string {
+       return strings.Join(*cp, ";")
+}
+
+func (cp *confPaths) Set(value string) error {
+       *cp = append(*cp, value)
+       return nil
+}
+
+func main() {
+       logger := log.Default()
+
+       shutdownWaits := &sync.WaitGroup{}
+
+       confs := make(confPaths, 0)
+       var (
+               region string
+               domain string
+       )
+
+       defaultRegion := hostname.RegionName()
+
+       flag.Usage = func() {
+               fmt.Fprintf(flag.CommandLine.Output(),
+                       "%s continually monitors the health of a service, publishes it in "+
+                               "the service\ndirectory as long as it's healthy, and removes registration "+
+                               "when it becomes unhealthy.\n\n"+
+                               "%s also reports service health under the `/sd/_health` prefix, for "+
+                               "exporting\nto prometheus via `sd-health-exporter(1)`.\n\n",
+                       os.Args[0], os.Args[0])
+
+               flag.PrintDefaults()
+       }
+       flag.Var(&confs, "service", "Service definition to health check; may be specified multiple times. If this is a directory, all .json files in this directory will be loaded.")
+       flag.StringVar(&domain, "domain", constants.SDDomain, "domain name to suffix all published services with")
+       flag.StringVar(&region, "region", defaultRegion, "regional shard to publish under; separate multiple shards with commas")
+       flag.Parse()
+
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       if len(confs) < 1 {
+               flag.Usage()
+               os.Exit(1)
+       }
+
+       for _, p := range confs {
+               stat, err := os.Stat(p)
+               if err != nil {
+                       panic(err)
+               }
+               if stat.Mode().IsRegular() {
+                       processAndStart(ctx, p, domain, strings.Split(region, ","), shutdownWaits)
+               } else if stat.Mode().IsDir() {
+                       entries, err := os.ReadDir(p)
+                       if err != nil {
+                               panic(err)
+                       }
+                       for _, e := range entries {
+                               if e.Name()[len(e.Name())-5:] != ".json" {
+                                       continue
+                               }
+
+                               fullPath := path.Join(p, e.Name())
+                               stat, err := os.Stat(fullPath)
+                               if err != nil {
+                                       panic(err)
+                               }
+                               if !stat.Mode().IsRegular() {
+                                       panic(fmt.Errorf("json file is not a regular file: %s", fullPath))
+                               }
+
+                               logger.Infof("loading service file: %s", fullPath)
+
+                               processAndStart(ctx, fullPath, domain, strings.Split(region, ","), shutdownWaits)
+                       }
+               }
+       }
+
+       <-ctx.Done()
+
+       shutdownWaits.Wait()
+}
+
+func processAndStart(ctx context.Context, p, domain string, regions []string, shutdownWaits *sync.WaitGroup) {
+       contents, err := os.ReadFile(p)
+       if err != nil {
+               panic(err)
+       }
+
+       svc, err := sd.LoadHealthCheck(contents)
+       if err != nil {
+               panic(err)
+       }
+
+       publisher, err := sd.NewHealthCheckPublisher(svc, domain, regions)
+       if err != nil {
+               panic(err)
+       }
+       go publisher.MonitorAndPublish(ctx, shutdownWaits)
+}
diff --git a/sd/sd_watcher/Makefile b/sd/sd_watcher/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/sd/sd_watcher/main.go b/sd/sd_watcher/main.go
new file mode 100644 (file)
index 0000000..a88a793
--- /dev/null
@@ -0,0 +1,70 @@
+package main
+
+import (
+       "context"
+       "flag"
+       "fmt"
+       "os"
+       "os/signal"
+       "syscall"
+       "time"
+
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/sd"
+)
+
+func main() {
+       var (
+               domain      string
+               service     string
+               protocolStr string
+       )
+
+       flag.Usage = func() {
+               fmt.Fprintf(flag.CommandLine.Output(),
+                       "%s monitors for changes to the publication of a given service.\n\n",
+                       os.Args[0])
+
+               flag.PrintDefaults()
+       }
+       flag.StringVar(&domain, "domain", constants.SDDomain, "domain name to suffix all published services with")
+       flag.StringVar(&service, "service", "", "name of the service to watch")
+       flag.StringVar(&protocolStr, "proto", "tcp", "Layer 4 protocol the service uses (tcp or udp)")
+       flag.Parse()
+
+       l4p, err := sd.Layer4ProtocolFromString(protocolStr)
+       if err != nil {
+               panic(err)
+       }
+
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       watcher := &sd.SDWatcher{
+               Domain:   domain,
+               Protocol: l4p,
+               Service:  service,
+       }
+
+       ticker := time.NewTicker(15 * time.Second)
+       defer ticker.Stop()
+
+       addrs, err := watcher.GetAddrs(ctx)
+       if err != nil {
+               panic(err)
+       }
+       fmt.Printf("items: %v\n", addrs)
+
+       updates := watcher.WatchUpdates(ctx)
+
+mainLoop:
+       for {
+               select {
+               case addrs = <-updates:
+                       fmt.Printf("items updated: %v\n", addrs)
+               case <-ticker.C:
+                       fmt.Printf("items have not changed: %v\n", addrs)
+               case <-ctx.Done():
+                       break mainLoop
+               }
+       }
+}
diff --git a/sd/systemd/sd-health-exporter.service b/sd/systemd/sd-health-exporter.service
new file mode 100644 (file)
index 0000000..81f2680
--- /dev/null
@@ -0,0 +1,14 @@
+[Unit]
+Description=Export Prometheus metrics for service health
+Wants=systemd-networkd-wait-online.service
+After=systemd-networkd-wait-online.service
+
+[Service]
+Type=simple
+User=node_exporter
+Group=node_exporter
+Environment=MTLS_IDENTITY=node-exporter
+ExecStart=/usr/bin/mtls.id=${MTLS_IDENTITY}
+
+[Install]
+WantedBy=multi-user.target
diff --git a/sd/systemd/sd-register.service b/sd/systemd/sd-register.service
new file mode 100644 (file)
index 0000000..fe525ec
--- /dev/null
@@ -0,0 +1,14 @@
+[Unit]
+Description=Service discovery and health checking for %i
+Wants=systemd-networkd-wait-online.service
+After=systemd-networkd-wait-online.service
+
+[Service]
+Type=simple
+User=nobody
+Group=nobody
+ExecStart=/usr/bin/mtls.id=etcd-client -service /etc/runtime/sd
+
+[Install]
+WantedBy=multi-user.target
+
diff --git a/sd/systemd/sd-register@.service b/sd/systemd/sd-register@.service
new file mode 100644 (file)
index 0000000..79ff522
--- /dev/null
@@ -0,0 +1,14 @@
+[Unit]
+Description=Service discovery and health checking for %i
+Wants=systemd-networkd-wait-online.service
+After=systemd-networkd-wait-online.service
+
+[Service]
+Type=simple
+User=nobody
+Group=nobody
+ExecStart=/usr/bin/sd-register -mtls.id=etcd-client -service /etc/runtime/sd/%i -domain %i
+
+[Install]
+WantedBy=multi-user.target
+
diff --git a/sd/watcher.go b/sd/watcher.go
new file mode 100644 (file)
index 0000000..1e334c7
--- /dev/null
@@ -0,0 +1,225 @@
+package sd
+
+import (
+       "context"
+       "encoding/json"
+       "fmt"
+       "net/url"
+       "strings"
+       "sync"
+
+       etcd_client "go.etcd.io/etcd/client/v3"
+
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/net/dns"
+       "go.fuhry.dev/runtime/utils"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type SDWatcher struct {
+       Region     string
+       Domain     string
+       Protocol   Layer4Protocol
+       Service    string
+       EtcdClient *etcd_client.Client
+
+       logger       *log.Logger
+       starter      *sync.Once
+       lock         *sync.RWMutex
+       wg           *sync.WaitGroup
+       results      []ServiceAddress
+       watchers     chan []ServiceAddress
+       watcherCount int
+}
+
+type ServiceAddress struct {
+       Hostname string
+       Port     uint16
+       IP4      string
+       IP6      string
+       Protocol Layer4Protocol
+       Service  string
+       Shard    string
+
+       key string
+}
+
+func (w *SDWatcher) init() error {
+       if w.Region == "" {
+               w.Region = "dns"
+       }
+
+       if w.Domain == "" {
+               w.Domain = constants.SDDomain
+       }
+
+       // if w.Service == "" {
+       //      return fmt.Errorf("service name must be specified")
+       // }
+
+       if w.EtcdClient == nil {
+               cl, err := NewDefaultEtcdClient()
+               if err != nil {
+                       return err
+               }
+
+               w.EtcdClient = cl
+       }
+
+       if w.lock == nil {
+               w.watchers = make(chan []ServiceAddress, 10)
+               w.starter = &sync.Once{}
+               w.lock = &sync.RWMutex{}
+               w.wg = &sync.WaitGroup{}
+               w.logger = log.WithPrefix("SDWatcher:" + w.Service)
+       }
+
+       return nil
+}
+
+func (w *SDWatcher) GetAddrs(ctx context.Context) ([]ServiceAddress, error) {
+       err := w.init()
+       if err != nil {
+               return nil, err
+       }
+
+       w.starter.Do(func() {
+               go w.watch(ctx)
+
+               w.wg.Add(1)
+               w.wg.Wait()
+       })
+
+       if len(w.results) < 1 {
+               return nil, fmt.Errorf("failed to discover any instances of service %q in domain %q and region %q", w.Service, w.Domain, w.Region)
+       }
+
+       return w.results, nil
+}
+
+func (w *SDWatcher) watch(ctx context.Context) {
+       kvs := make(map[string][]byte, 0)
+
+       w.logger.Infof("Watching for service publications under path %s", w.prefix())
+
+       items, err := w.EtcdClient.Get(ctx, w.prefix(), etcd_client.WithPrefix())
+       if err == nil {
+               for _, kv := range items.Kvs {
+                       kvs[string(kv.Key)] = kv.Value
+               }
+
+               w.publishResults(kvs)
+               w.wg.Done()
+
+       }
+
+       watcher := etcd_client.NewWatcher(w.EtcdClient)
+       keyWatch := watcher.Watch(ctx, w.prefix(), etcd_client.WithPrefix())
+
+       for {
+               select {
+               case items := <-keyWatch:
+                       for _, ev := range items.Events {
+                               if ev.Type.String() == "PUT" {
+                                       w.logger.V(2).Debugf("%s was published", string(ev.Kv.Key))
+                                       kvs[string(ev.Kv.Key)] = ev.Kv.Value
+                               } else if ev.Type.String() == "DELETE" {
+                                       w.logger.V(2).Debugf("%s was deleted", string(ev.Kv.Key))
+                                       delete(kvs, string(ev.Kv.Key))
+                               }
+                       }
+
+                       w.publishResults(kvs)
+               case <-ctx.Done():
+                       return
+               }
+       }
+}
+
+func (w *SDWatcher) publishResults(kvs map[string][]byte) {
+       w.lock.Lock()
+       w.results = w.buildResult(kvs)
+       w.lock.Unlock()
+
+       for i := 0; i < w.watcherCount; i++ {
+               w.watchers <- w.results
+       }
+}
+
+func (w *SDWatcher) WatchUpdates(ctx context.Context) chan []ServiceAddress {
+       w.watcherCount++
+       och := make(chan []ServiceAddress)
+
+       go (func() {
+               defer func() { w.watcherCount-- }()
+
+               for {
+                       select {
+                       case m := <-w.watchers:
+                               och <- m
+                       case <-ctx.Done():
+                               close(och)
+                               return
+                       }
+               }
+       })()
+
+       return och
+}
+
+func (w *SDWatcher) prefix() string {
+       domainComponents := utils.Reverse(strings.Split(w.Domain, "."))
+       domainPath := strings.Join(domainComponents, "/")
+
+       s := fmt.Sprintf("/sd/%s/%s/", w.Region, domainPath)
+       if w.Protocol != ProtocolInvalid {
+               s += fmt.Sprintf("%s/", w.Protocol.DNSComponent())
+               if w.Service != "" {
+                       s += fmt.Sprintf("_%s/", w.Service)
+               }
+       } else {
+               s += "_"
+       }
+       return s
+}
+
+func (w *SDWatcher) buildResult(kvs map[string][]byte) []ServiceAddress {
+       sas := make([]ServiceAddress, 0)
+       record := &srvRecord{}
+
+       for key, value := range kvs {
+               err := json.Unmarshal(value, record)
+               if err != nil {
+                       continue
+               }
+               ip4, ip6, err := dns.ResolveDualStack(record.Host)
+               if err != nil {
+                       continue
+               }
+               s := fmt.Sprintf("/sd/%s/%s/", w.Region, w.Domain)
+               components := strings.Split(key[len(s):], "/")
+               sa := ServiceAddress{
+                       Hostname: record.Host,
+                       Port:     record.Port,
+                       IP4:      ip4,
+                       IP6:      ip6,
+                       key:      key[len(s):],
+               }
+               if len(components) == 3 {
+                       sa.Protocol, _ = Layer4ProtocolFromString(components[0])
+                       sa.Service = components[1][1:]
+                       sa.Shard = components[2]
+               }
+               sas = append(sas, sa)
+       }
+
+       return sas
+}
+
+func (sa ServiceAddress) ToURI() *url.URL {
+       return &url.URL{
+               Scheme: "sd",
+               Host:   sa.Service,
+               Path:   fmt.Sprintf("/%s", sa.Protocol.String()),
+       }
+}
diff --git a/thirdparty/registry/Makefile b/thirdparty/registry/Makefile
new file mode 100644 (file)
index 0000000..bfab546
--- /dev/null
@@ -0,0 +1,14 @@
+GOSRC = $(wildcard *.go)
+GOEXE = $(shell basename `pwd`)
+GOBUILDFLAGS := -buildmode=pie -trimpath
+
+all: $(GOEXE)
+
+clean:
+       rm -fv $(GOEXE)
+
+.PHONY: all clean
+
+$(GOEXE): %: $(GOSRC)
+       go build $(GOBUILDFLAGS) -o $@ $<
+
diff --git a/thirdparty/registry/config.yml b/thirdparty/registry/config.yml
new file mode 100644 (file)
index 0000000..ff32304
--- /dev/null
@@ -0,0 +1,21 @@
+version: 0.1
+log:
+  fields:
+    service: registry
+storage:
+  cache:
+    blobdescriptor: inmemory
+  filesystem:
+    rootdirectory: /var/lib/registry
+http:
+  addr: :5005
+  host: https://registry.example.com/
+  headers:
+    X-Content-Type-Options: [nosniff]
+  draintimeout: 60s
+health:
+  storagedriver:
+    enabled: true
+    interval: 10s
+    threshold: 3
+
diff --git a/thirdparty/registry/main.go b/thirdparty/registry/main.go
new file mode 100644 (file)
index 0000000..592e6a6
--- /dev/null
@@ -0,0 +1,218 @@
+package main
+
+import (
+       "context"
+       "crypto/tls"
+       "flag"
+       "fmt"
+       "net"
+       "net/http"
+       _ "net/http/pprof"
+       "os"
+       "os/signal"
+       "strconv"
+       "syscall"
+
+       "github.com/distribution/distribution/v3/configuration"
+       _ "github.com/distribution/distribution/v3/registry/auth/htpasswd"
+       _ "github.com/distribution/distribution/v3/registry/auth/silly"
+       _ "github.com/distribution/distribution/v3/registry/auth/token"
+       "github.com/distribution/distribution/v3/registry/handlers"
+       _ "github.com/distribution/distribution/v3/registry/proxy"
+       _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
+       _ "github.com/distribution/distribution/v3/registry/storage/driver/oss"
+
+       // proxyproto "github.com/pires/go-proxyproto"
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/grpc"
+       "go.fuhry.dev/runtime/metrics/metricbus/mbclient"
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/sd"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+var logger *log.Logger
+var DefaultSSLCertificate = "registry." + constants.WebServicesDomain
+
+const (
+       privilegedIdentity = "docker-publish"
+)
+
+type registryMetrics struct {
+       requests   mbclient.CounterMetric
+       requestsXX mbclient.CounterMetric
+}
+
+func main() {
+       var port uint
+       var sslCert string
+       var configPath string
+       flag.UintVar(&port, "registry.port", grpc.RandomPort(), "port to listen on")
+       flag.StringVar(&sslCert, "registry.ssl-cert", DefaultSSLCertificate, "ssl certificate to load from secrets")
+       flag.StringVar(&configPath, "registry.config-path", "config.yml", "path to configuration file")
+       flag.Parse()
+       logger = log.WithPrefix("registry")
+       ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+
+       metricsService := mbclient.NewService(ctx)
+       defer metricsService.FlushAndWait()
+
+       metrics := &registryMetrics{
+               requests:   metricsService.DefineCounter("registry_http_requests", "HTTP requests received and processed by the container registry", "response_code"),
+               requestsXX: metricsService.DefineCounter("registry_http_requests_xx", "HTTP requests received and processed by the container registry, broken down by response code class (first digit of status code)", "response_code_class"),
+       }
+
+       config, err := resolveConfiguration([]string{configPath})
+       if err != nil {
+               logger.Fatal(err)
+       }
+
+       tlsListener, err := makeTlsListener(port, sslCert, ctx)
+       if err != nil {
+               logger.Fatal(err)
+       }
+
+       // proxyProtoListener := &proxyproto.Listener{
+       //      Listener:          tlsListener,
+       //      ReadHeaderTimeout: 10 * time.Second,
+       // }
+
+       handler := http.NewServeMux()
+       app := handlers.NewApp(ctx, config)
+       handler.Handle("/", metricsMiddleware(registryAuthMiddleware(app), metrics))
+       handler.HandleFunc("/ready", func(w http.ResponseWriter, req *http.Request) {
+               w.WriteHeader(200)
+               w.Write([]byte("LIVE"))
+       })
+
+       server := &http.Server{
+               Handler: handler,
+       }
+
+       logger.Noticef("will listen on port %d", port)
+
+       publisher := &sd.SDPublisher{
+               AdvertisePort: uint16(port),
+               Protocol:      sd.ProtocolTCP,
+               Service:       "distribution",
+       }
+
+       go (func() {
+               err := server.Serve(tlsListener)
+               if err != nil {
+                       logger.Fatal(err)
+               }
+       })()
+
+       publisher.Publish(ctx)
+
+       <-ctx.Done()
+
+       publisher.Unpublish()
+       if config.HTTP.DrainTimeout > 0 {
+               logger.Notice("shutdown requested, draining for up to ", config.HTTP.DrainTimeout)
+               drainCtx, cancel := context.WithTimeout(context.Background(), config.HTTP.DrainTimeout)
+               server.Shutdown(drainCtx)
+               cancel()
+       }
+}
+
+func resolveConfiguration(args []string) (*configuration.Configuration, error) {
+       var configurationPath string
+
+       if len(args) > 0 {
+               configurationPath = args[0]
+       } else if os.Getenv("REGISTRY_CONFIGURATION_PATH") != "" {
+               configurationPath = os.Getenv("REGISTRY_CONFIGURATION_PATH")
+       }
+
+       if configurationPath == "" {
+               return nil, fmt.Errorf("configuration path unspecified")
+       }
+
+       fp, err := os.Open(configurationPath)
+       if err != nil {
+               return nil, err
+       }
+
+       defer fp.Close()
+
+       config, err := configuration.Parse(fp)
+       if err != nil {
+               return nil, fmt.Errorf("error parsing %s: %v", configurationPath, err)
+       }
+
+       return config, nil
+}
+
+func makeTlsListener(port uint, sslCert string, ctx context.Context) (net.Listener, error) {
+       cert := mtls.NewSSLCertificate(sslCert)
+       tlsConfig, err := cert.TlsConfig(ctx)
+       if err != nil {
+               return nil, err
+       }
+
+       tcpAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf(":%d", port))
+       if err != nil {
+               return nil, err
+       }
+       netListener, err := net.ListenTCP("tcp", tcpAddr)
+       if err != nil {
+               return nil, err
+       }
+
+       cv := mtls.NewPeerNameVerifier()
+       cv.AllowFrom(mtls.Service, privilegedIdentity)
+       cv.AllowFrom(mtls.Service, "docker-pull")
+       // to allow healthchecking and metrics querying
+       cv.AllowFrom(mtls.Service, "prometheus")
+       cv.ConfigureServer(tlsConfig)
+
+       return tls.NewListener(netListener, tlsConfig), nil
+}
+
+func registryAuthMiddleware(next http.Handler) http.Handler {
+       return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+               remoteIdentity, err := mtls.IdentityFromTLSConnectionState(req.TLS)
+               if err != nil {
+                       w.WriteHeader(500)
+                       w.Write([]byte(
+                               fmt.Sprintf("Failed to get identity from TLS connection state: %+v", err)))
+                       return
+               }
+               if req.Method != "GET" && req.Method != "HEAD" && req.Method != "OPTIONS" {
+                       if remoteIdentity.Name() != privilegedIdentity {
+                               w.WriteHeader(401)
+                               w.Write([]byte(
+                                       fmt.Sprintf("Unauthorized: only %s may perform %s requests", privilegedIdentity, req.Method)))
+                       }
+               }
+               next.ServeHTTP(w, req)
+       })
+}
+
+type loggingResponseWriter struct {
+       http.ResponseWriter
+       statusCode int
+}
+
+func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
+       return &loggingResponseWriter{w, 200}
+}
+
+func (lrw *loggingResponseWriter) WriteHeader(code int) {
+       lrw.statusCode = code
+       lrw.ResponseWriter.WriteHeader(code)
+}
+
+func metricsMiddleware(next http.Handler, metrics *registryMetrics) http.Handler {
+       return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+               lrw := newLoggingResponseWriter(w)
+
+               next.ServeHTTP(lrw, req)
+
+               statusCode := strconv.Itoa(lrw.statusCode)
+               metrics.requests.WithLabelValues(mbclient.KV{"response_code": statusCode}).Add(1)
+               metrics.requestsXX.WithLabelValues(mbclient.KV{"response_code_class": statusCode[:1]}).Add(1)
+       })
+}
diff --git a/thirdparty/registry/systemd/docker-registry.service b/thirdparty/registry/systemd/docker-registry.service
new file mode 100644 (file)
index 0000000..430b9fb
--- /dev/null
@@ -0,0 +1,14 @@
+[Unit]
+Description=Docker registry wrapper
+Wants=systemd-networkd-wait-online.service
+After=systemd-networkd-wait-online.service
+
+[Service]
+Type=simple
+User=registry
+Group=registry
+ExecStart=/usr/bin/docker-registry -mtls.id=distribution -registry.port=5005 -registry.config-path=/etc/runtime/docker-registry.yml
+
+[Install]
+WantedBy=multi-user.target
+
diff --git a/utils/ansi/color.go b/utils/ansi/color.go
new file mode 100644 (file)
index 0000000..a4dd395
--- /dev/null
@@ -0,0 +1,44 @@
+package ansi
+
+import (
+       "fmt"
+       "strings"
+)
+
+type Color uint
+
+const Escape byte = 0x1B
+
+const (
+       Reset Color = 0
+       Bold  Color = 1
+
+       Black  Color = 30
+       Red    Color = 31
+       Green  Color = 32
+       Yellow Color = 33
+       Blue   Color = 34
+       Purple Color = 35
+       Cyan   Color = 36
+       White  Color = 37
+
+       BgBlack  Color = 40
+       BgRed    Color = 41
+       BgGreen  Color = 42
+       BgYellow Color = 43
+       BgBlue   Color = 44
+       BgPurple Color = 45
+       BgCyan   Color = 46
+       BgWhite  Color = 47
+)
+
+func String(seq ...Color) string {
+       s := string([]byte{Escape}) + "["
+       seqSlice := make([]string, len(seq))
+       for i, color := range seq {
+               seqSlice[i] = fmt.Sprintf("%d", color)
+       }
+       s += strings.Join(seqSlice, ";") + "m"
+
+       return s
+}
diff --git a/utils/debounce/debounce.go b/utils/debounce/debounce.go
new file mode 100644 (file)
index 0000000..c620a49
--- /dev/null
@@ -0,0 +1,47 @@
+package debounce
+
+import (
+       "context"
+       "time"
+)
+
+type actionFunc func()
+type Debounce struct {
+       action  actionFunc
+       timeout time.Duration
+       ticker  time.Ticker
+       ctx     context.Context
+       cancel  context.CancelFunc
+}
+
+var DefaultTimeout = 50 * time.Millisecond
+
+func New(action actionFunc) *Debounce {
+       return NewWithTimeout(action, DefaultTimeout)
+}
+
+func NewWithTimeout(action actionFunc, timeout time.Duration) *Debounce {
+       return &Debounce{
+               action:  action,
+               timeout: timeout,
+       }
+}
+
+func (d *Debounce) Trigger() {
+       if d.ctx != nil {
+               d.cancel()
+       }
+       d.ctx, d.cancel = context.WithTimeout(context.TODO(), d.timeout)
+       d.ticker = *time.NewTicker(d.timeout)
+
+       go d.maybeAction()
+}
+
+func (d *Debounce) maybeAction() {
+       select {
+       case <-d.ctx.Done():
+               return
+       case <-d.ticker.C:
+               d.action()
+       }
+}
diff --git a/utils/debounce/debounce_test.go b/utils/debounce/debounce_test.go
new file mode 100644 (file)
index 0000000..8a249b3
--- /dev/null
@@ -0,0 +1,75 @@
+package debounce
+
+import (
+       "testing"
+       "time"
+
+       . "gopkg.in/check.v1"
+)
+
+func Test(t *testing.T) { TestingT(t) }
+
+type DebounceSuite struct{}
+
+var _ = Suite(&DebounceSuite{})
+
+func (s *DebounceSuite) TestSingleTrigger(c *C) {
+       var counter uint32 = 0
+       action := func() {
+               counter++
+       }
+
+       d := NewWithTimeout(action, 20*time.Millisecond)
+       d.Trigger()
+
+       c.Assert(counter, Equals, uint32(0))
+       time.Sleep(10 * time.Millisecond)
+       c.Assert(counter, Equals, uint32(0))
+       time.Sleep(15 * time.Millisecond)
+       c.Assert(counter, Equals, uint32(1))
+}
+
+func (s *DebounceSuite) TestMultiTrigger(c *C) {
+       var counter uint32 = 0
+       action := func() {
+               counter++
+       }
+
+       d := NewWithTimeout(action, 20*time.Millisecond)
+       d.Trigger()
+       d.Trigger()
+       d.Trigger()
+
+       c.Assert(counter, Equals, uint32(0))
+       time.Sleep(10 * time.Millisecond)
+       c.Assert(counter, Equals, uint32(0))
+       time.Sleep(15 * time.Millisecond)
+       c.Assert(counter, Equals, uint32(1))
+}
+
+func (s *DebounceSuite) TestDelayTrigger(c *C) {
+       var counter uint32 = 0
+       action := func() {
+               counter++
+       }
+
+       d := NewWithTimeout(action, 20*time.Millisecond)
+
+       d.Trigger()
+       c.Assert(counter, Equals, uint32(0))
+       time.Sleep(10 * time.Millisecond)
+
+       d.Trigger()
+       c.Assert(counter, Equals, uint32(0))
+       time.Sleep(15 * time.Millisecond)
+       c.Assert(counter, Equals, uint32(0))
+
+       d.Trigger()
+       time.Sleep(25 * time.Millisecond)
+       c.Assert(counter, Equals, uint32(1))
+
+       d.Trigger()
+       c.Assert(counter, Equals, uint32(1))
+       time.Sleep(25 * time.Millisecond)
+       c.Assert(counter, Equals, uint32(2))
+}
diff --git a/utils/generics/math.go b/utils/generics/math.go
new file mode 100644 (file)
index 0000000..5d73e06
--- /dev/null
@@ -0,0 +1,36 @@
+package generics
+
+type number interface {
+       ~int | ~int8 | ~int16 | ~int32 | ~int64 |
+               ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
+               ~float32 | ~float64
+}
+
+func Min[Tcomp number](n ...Tcomp) Tcomp {
+       if len(n) < 1 {
+               return Tcomp(0)
+       }
+       m := n[0]
+       for _, i := range n {
+               if i < m {
+                       m = i
+               }
+       }
+
+       return m
+}
+
+func Max[Tcomp number](n ...Tcomp) Tcomp {
+       if len(n) < 1 {
+               return Tcomp(0)
+       }
+
+       m := n[0]
+       for _, i := range n {
+               if i > m {
+                       m = i
+               }
+       }
+
+       return m
+}
diff --git a/utils/hashset/hashset.go b/utils/hashset/hashset.go
new file mode 100644 (file)
index 0000000..7509728
--- /dev/null
@@ -0,0 +1,165 @@
+package hashset
+
+import (
+       "sync"
+)
+
+type Hashable = comparable
+
+type HashSet[TKey Hashable] struct {
+       data map[TKey]interface{}
+       lock *sync.RWMutex
+}
+
+func NewHashSet[TKey Hashable]() *HashSet[TKey] {
+       data := make(map[TKey]interface{}, 0)
+       return &HashSet[TKey]{
+               data: data,
+               lock: &sync.RWMutex{},
+       }
+}
+
+func FromSlice[TKey Hashable](inp []TKey) *HashSet[TKey] {
+       hs := NewHashSet[TKey]()
+       hs.Add(inp...)
+       return hs
+}
+
+func (hs *HashSet[TKey]) Add(items ...TKey) {
+       hs.lock.Lock()
+       defer hs.lock.Unlock()
+
+       for _, e := range items {
+               hs.add(e)
+       }
+}
+
+func (hs *HashSet[TKey]) add(e TKey) {
+       hs.data[e] = nil
+}
+
+func (hs *HashSet[TKey]) Each() chan TKey {
+       hs.lock.RLock()
+       defer hs.lock.RUnlock()
+
+       return hs.each()
+}
+
+func (hs *HashSet[TKey]) each() chan TKey {
+       ch := make(chan TKey)
+
+       go (func() {
+               for k, _ := range hs.data {
+                       ch <- k
+               }
+
+               close(ch)
+       })()
+
+       return ch
+}
+
+func (hs *HashSet[TKey]) Empty() {
+       hs.lock.Lock()
+       defer hs.lock.Unlock()
+
+       hs.empty()
+}
+
+func (hs *HashSet[TKey]) empty() {
+       hs.data = make(map[TKey]interface{}, 0)
+}
+
+func (hs *HashSet[TKey]) Union(other *HashSet[TKey]) {
+       hs.lock.Lock()
+       defer hs.lock.Unlock()
+
+       hs.union(other)
+}
+
+func (hs *HashSet[TKey]) union(other *HashSet[TKey]) {
+       for e := range other.Each() {
+               hs.add(e)
+       }
+}
+
+func (hs *HashSet[TKey]) Diff(other *HashSet[TKey]) {
+       hs.lock.Lock()
+       defer hs.lock.Unlock()
+
+       hs.diff(other)
+}
+
+func (hs *HashSet[TKey]) diff(other *HashSet[TKey]) {
+       for e := range other.Each() {
+               hs.del(e)
+       }
+}
+
+func (hs *HashSet[TKey]) Del(items ...TKey) {
+       hs.lock.Lock()
+       defer hs.lock.Unlock()
+
+       for _, e := range items {
+               hs.del(e)
+       }
+}
+
+func (hs *HashSet[TKey]) del(e TKey) {
+       if _, ok := hs.data[e]; ok {
+               delete(hs.data, e)
+       }
+}
+
+func (hs *HashSet[TKey]) AsSlice() []TKey {
+       hs.lock.RLock()
+       defer hs.lock.RUnlock()
+
+       return hs.asSlice()
+}
+
+func (hs *HashSet[TKey]) asSlice() []TKey {
+       s := make([]TKey, len(hs.data))
+
+       i := 0
+       for k, _ := range hs.data {
+               s[i] = k
+               i++
+       }
+
+       return s
+}
+
+func (hs *HashSet[TKey]) Len() int {
+       hs.lock.RLock()
+       defer hs.lock.RUnlock()
+
+       return hs.len()
+}
+
+func (hs *HashSet[TKey]) len() int {
+       return len(hs.data)
+}
+
+func (hs *HashSet[TKey]) Contains(e TKey) bool {
+       hs.lock.RLock()
+       defer hs.lock.RUnlock()
+
+       return hs.contains(e)
+}
+
+func (hs *HashSet[TKey]) contains(e TKey) bool {
+       _, ok := hs.data[e]
+       return ok
+}
+
+func (hs *HashSet[TKey]) Dup() *HashSet[TKey] {
+       hs.lock.RLock()
+       defer hs.lock.RUnlock()
+
+       return hs.dup()
+}
+
+func (hs *HashSet[TKey]) dup() *HashSet[TKey] {
+       return FromSlice(hs.AsSlice())
+}
diff --git a/utils/hashset/hashset_test.go b/utils/hashset/hashset_test.go
new file mode 100644 (file)
index 0000000..9334672
--- /dev/null
@@ -0,0 +1,103 @@
+package hashset
+
+import (
+       "testing"
+
+       "golang.org/x/exp/slices"
+       . "gopkg.in/check.v1"
+)
+
+func Test(t *testing.T) { TestingT(t) }
+
+type HashSetSuite struct{}
+
+var _ = Suite(&HashSetSuite{})
+
+func (s *HashSetSuite) TestEach(c *C) {
+       items := []string{
+               "a",
+               "b",
+               "c",
+       }
+       hs := NewHashSet[string]()
+
+       hs.Add(items...)
+
+       for e := range hs.Each() {
+               c.Assert(slices.Contains(items, e), Equals, true)
+       }
+       c.Assert(hs.Len(), Equals, 3)
+}
+
+func (s *HashSetSuite) TestAsSlice(c *C) {
+       items := []string{
+               "a",
+               "b",
+               "c",
+       }
+       hs := NewHashSet[string]()
+
+       hs.Add(items...)
+
+       hsSlice := hs.AsSlice()
+
+       for _, e := range items {
+               c.Assert(slices.Contains(hsSlice, e), Equals, true)
+       }
+       c.Assert(hs.Len(), Equals, 3)
+}
+
+func (s *HashSetSuite) TestUnion(c *C) {
+       items_a := []string{
+               "a",
+               "b",
+               "c",
+       }
+
+       items_b := []string{
+               "d",
+               "e",
+               "f",
+       }
+
+       hs_a := NewHashSet[string]()
+       hs_a.Add(items_a...)
+
+       hs_b := NewHashSet[string]()
+       hs_b.Add(items_b...)
+
+       hs_a.Union(hs_b)
+
+       c.Assert(hs_a.Len(), Equals, 6)
+       for e := range hs_a.Each() {
+               c.Assert(slices.Contains(items_a, e) || slices.Contains(items_b, e), Equals, true)
+       }
+}
+
+func (s *HashSetSuite) TestDifference(c *C) {
+       items_a := []string{
+               "a",
+               "b",
+               "c",
+       }
+
+       items_b := []string{
+               "c",
+               "d",
+               "e",
+       }
+
+       hs_a := NewHashSet[string]()
+       hs_a.Add(items_a...)
+
+       hs_b := NewHashSet[string]()
+       hs_b.Add(items_b...)
+
+       hs_a.Diff(hs_b)
+
+       c.Assert(hs_a.Len(), Equals, 2)
+       hsSlice := hs_a.AsSlice()
+       c.Assert(slices.Contains(hsSlice, "a"), Equals, true)
+       c.Assert(slices.Contains(hsSlice, "b"), Equals, true)
+       c.Assert(slices.Contains(hsSlice, "c"), Equals, false)
+}
diff --git a/utils/hostname/hostname.go b/utils/hostname/hostname.go
new file mode 100644 (file)
index 0000000..f99e32d
--- /dev/null
@@ -0,0 +1,83 @@
+//go:build !darwin
+
+package hostname
+
+import (
+       "fmt"
+       "strings"
+       "sync"
+       "syscall"
+)
+
+type i8 = interface {
+       int8 | uint8
+}
+
+var utsname syscall.Utsname
+var utsnameOnce sync.Once
+
+func Hostname() string {
+       return strings.Split(nodeName(), ".")[0]
+}
+
+func DomainName() string {
+       uname := uname()
+
+       domainName := int8ToString(uname.Domainname)
+       if domainName != "(none)" {
+               return domainName
+       }
+
+       nodeName := int8ToString(uname.Nodename)
+       for i, chr := range []byte(nodeName) {
+               if chr == '.' {
+                       return nodeName[i+1:]
+               }
+       }
+
+       err := fmt.Errorf("could not determine domain name from (uname.Nodename=%v) (uname.Domainname=%v)", uname.Nodename, uname.Domainname)
+       panic(err)
+}
+
+func RegionName() string {
+       domain := DomainName()
+
+       if domain == "" {
+               panic("domain string is empty")
+       }
+
+       return strings.Split(domain, ".")[0]
+}
+
+func Fqdn() string {
+       return strings.Join([]string{Hostname(), DomainName()}, ".")
+}
+
+func uname() syscall.Utsname {
+       utsnameOnce.Do(func() {
+               err := syscall.Uname(&utsname)
+               if err != nil {
+                       panic(err)
+               }
+       })
+
+       return utsname
+}
+
+func nodeName() string {
+       uname := uname()
+
+       return int8ToString(uname.Nodename)
+}
+
+func int8ToString[T i8](ba [65]T) string {
+       bytes := make([]byte, 0)
+       for _, b := range ba {
+               if b == T(0) {
+                       break
+               }
+               bytes = append(bytes, byte(b))
+       }
+
+       return string(bytes)
+}
diff --git a/utils/hostname/hostname_macos.go b/utils/hostname/hostname_macos.go
new file mode 100644 (file)
index 0000000..75e07da
--- /dev/null
@@ -0,0 +1,144 @@
+//go:build darwin
+
+package hostname
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "net"
+       "os"
+       "strings"
+       "time"
+
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/utils/log"
+       "howett.net/plist"
+)
+
+const (
+       defaultDomainName = constants.DefaultHostDomain
+
+       systemPreferencesPlist = "/Library/Preferences/SystemConfiguration/preferences.plist"
+)
+
+type systemPrefs struct {
+       System *systemPrefs_System `plist:"System"`
+}
+
+type systemPrefs_System struct {
+       Network *systemPrefs_System_Network `plist:"Network"`
+       System  *systemPrefs_System_System  `plist:"System"`
+}
+
+type systemPrefs_System_Network struct {
+       HostNames *systemPrefs_System_Network_HostNames `plist:"HostNames"`
+}
+
+type systemPrefs_System_Network_HostNames struct {
+       LocalHostName string `plist:"LocalHostName"`
+}
+
+type systemPrefs_System_System struct {
+       ComputerName         string `plist:"ComputerName"`
+       ComputerNameEncoding int    `plist:"ComputerNameEncoding"`
+}
+
+func Hostname() string {
+       fqdn := Fqdn()
+       return strings.Split(fqdn, ".")[0]
+}
+
+func DomainName() string {
+       fqdn := strings.Split(Fqdn(), ".")
+       if len(fqdn) > 1 {
+               return strings.Join(fqdn[1:], ".")
+       }
+       return defaultDomainName
+}
+
+func RegionName() string {
+       return strings.Split(DomainName(), ".")[0]
+}
+
+func Fqdn() string {
+       dnsFqdn, dnsErr := fqdnFromDns()
+       if dnsErr == nil {
+               return maybeAppendDefaultDomainName(dnsFqdn)
+       }
+
+       prefsFqdn, prefsErr := fqdnFromSystemPreferences()
+       if prefsErr == nil {
+               return maybeAppendDefaultDomainName(prefsFqdn)
+       }
+
+       log.Panicf("failed to get fqdn: dns failed because %q and prefs failed because %q", dnsErr.Error(), prefsErr.Error())
+       return ""
+}
+
+func maybeAppendDefaultDomainName(name string) string {
+       parts := strings.Split(name, ".")
+       if len(parts) < 2 {
+               name = fmt.Sprintf("%s.%s", parts[0], defaultDomainName)
+       }
+
+       log.Default().V(1).Noticef("detected system hostname: %q", name)
+
+       return name
+}
+
+func fqdnFromSystemPreferences() (string, error) {
+       fp, err := os.Open(systemPreferencesPlist)
+       if err != nil {
+               return "", err
+       }
+       defer fp.Close()
+
+       decoder := plist.NewDecoder(fp)
+
+       prefs := &systemPrefs{}
+       err = decoder.Decode(prefs)
+       if err != nil {
+               return "", err
+       }
+
+       name := ""
+
+       if n := prefs.System.Network.HostNames.LocalHostName; n != "" {
+               name = n
+       }
+       if n := prefs.System.System.ComputerName; n != "" && (name == "" || name == "localhost" || strings.HasPrefix(name, "localhost.")) {
+               name = n
+       }
+
+       if name == "" {
+               return "", fmt.Errorf("failed to read system hostname from %s: all supported properties came up empty", systemPreferencesPlist)
+       }
+
+       if name == "localhost" || strings.HasPrefix(name, "localhost.") {
+               return "", fmt.Errorf("successfully read hostname from %q, but hostname is 'localhost'", systemPreferencesPlist)
+       }
+
+       return name, nil
+}
+
+func fqdnFromDns() (string, error) {
+       resolver := &net.Resolver{}
+
+       ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+       defer cancel()
+
+       result, err := resolver.LookupAddr(ctx, "127.0.0.1")
+       if err != nil {
+               return "", fmt.Errorf("failed to lookup hostname for 127.0.0.1: %v", err)
+       }
+
+       for _, name := range result {
+               parts := strings.Split(name, ".")
+               if parts[0] != "localhost" {
+                       return parts[0], nil
+               }
+       }
+
+       return "", errors.New("failed to lookup any hostname for 127.0.0.1 that does not resolve to just \"localhost\"")
+}
diff --git a/utils/log/level.go b/utils/log/level.go
new file mode 100644 (file)
index 0000000..d25c8c1
--- /dev/null
@@ -0,0 +1,119 @@
+package log
+
+import (
+       "fmt"
+       "io"
+       "os"
+       "strings"
+
+       "golang.org/x/crypto/ssh/terminal"
+
+       "go.fuhry.dev/runtime/utils/ansi"
+)
+
+type Level uint
+
+const (
+       noLevel Level = iota
+       DEBUG
+       INFO
+       NOTICE
+       WARNING
+       ERROR
+       CRITICAL
+       ALERT
+       FATAL
+)
+
+type levelFormat struct {
+       tag   string
+       color []ansi.Color
+}
+
+var format = map[Level]levelFormat{
+       DEBUG:    {tag: "DBG", color: []ansi.Color{ansi.Bold, ansi.Purple}},
+       INFO:     {tag: "INF", color: []ansi.Color{ansi.Bold, ansi.Cyan}},
+       NOTICE:   {tag: "NTC", color: []ansi.Color{ansi.Bold, ansi.Blue}},
+       WARNING:  {tag: "WRN", color: []ansi.Color{ansi.Bold, ansi.Yellow}},
+       ERROR:    {tag: "ERR", color: []ansi.Color{ansi.Bold, ansi.Red}},
+       CRITICAL: {tag: "CRI", color: []ansi.Color{ansi.Bold, ansi.Red, ansi.BgYellow}},
+       ALERT:    {tag: "ALR", color: []ansi.Color{ansi.Bold, ansi.White, ansi.BgRed}},
+       FATAL:    {tag: "FTL", color: []ansi.Color{ansi.Bold, ansi.Yellow, ansi.BgRed}},
+}
+
+func (l Level) prefix(w io.Writer) string {
+       file, ok := w.(*os.File)
+       if !ok {
+               return l.plainPrefix()
+       }
+       if !terminal.IsTerminal(int(file.Fd())) {
+               return l.plainPrefix()
+       }
+
+       f, ok := format[l]
+       if !ok {
+               return l.plainPrefix()
+       }
+
+       return fmt.Sprintf("[%s%s%s%s] ",
+               ansi.String(ansi.Reset),
+               ansi.String(f.color...),
+               f.tag,
+               ansi.String(ansi.Reset))
+}
+
+func (l Level) plainPrefix() string {
+       f, ok := format[l]
+       if !ok {
+               return ""
+       }
+
+       return fmt.Sprintf("[%s] ", f.tag)
+}
+
+func (l Level) String() string {
+       switch l {
+       case DEBUG:
+               return "debug"
+       case INFO:
+               return "info"
+       case NOTICE:
+               return "notice"
+       case WARNING:
+               return "warning"
+       case ERROR:
+               return "error"
+       case CRITICAL:
+               return "critical"
+       case ALERT:
+               return "alert"
+       case FATAL:
+               return "fatal"
+
+       default:
+               return "none"
+       }
+}
+
+func LevelFromString(s string) Level {
+       switch strings.ToLower(s) {
+       case "debug", "dbg", "d":
+               return DEBUG
+       case "info", "i":
+               return INFO
+       case "notice", "note", "not", "n":
+               return NOTICE
+       case "warning", "warn", "w":
+               return WARNING
+       case "error", "err", "e":
+               return ERROR
+       case "critical", "crit", "c":
+               return CRITICAL
+       case "alert", "a":
+               return ALERT
+       case "fatal", "ftl", "panic", "f", "p":
+               return FATAL
+       default:
+               return noLevel
+       }
+}
diff --git a/utils/log/log.go b/utils/log/log.go
new file mode 100644 (file)
index 0000000..843118a
--- /dev/null
@@ -0,0 +1,223 @@
+package log
+
+import (
+       "flag"
+       "io"
+       "log"
+)
+
+var logVerbosity int = 0
+var logLevel string = "notice"
+
+type Logger struct {
+       *log.Logger
+
+       level  Level
+       prefix string
+}
+
+func init() {
+       flag.IntVar(&logVerbosity, "vv", 0, "verbosity level for logs")
+       flag.StringVar(&logLevel, "v", INFO.String(), "syslog log level for logs")
+}
+
+func V(level int) *Logger {
+       if logVerbosity >= level {
+               return Default()
+       }
+
+       return NullLogger()
+}
+
+func Default() *Logger {
+       return &Logger{Logger: log.Default()}
+}
+
+func WithPrefix(prefix string) *Logger {
+       return Default().WithPrefix(prefix)
+}
+
+func NullLogger() *Logger {
+       return &Logger{Logger: log.New(io.Discard, "", 0)}
+}
+
+func Print(v ...any) {
+       Default().Print(v...)
+}
+
+func Printf(fmtstr string, args ...any) {
+       Default().Printf(fmtstr, args...)
+}
+
+func Println(v ...any) {
+       Default().Println(v...)
+}
+
+func Fatal(v ...any) {
+       Default().Fatal(v...)
+}
+
+func Fatalf(fmtstr string, args ...any) {
+       Default().Fatalf(fmtstr, args...)
+}
+
+func Panic(v ...any) {
+       Default().Panic(v...)
+}
+
+func Panicf(fmtstr string, v ...any) {
+       Default().Panicf(fmtstr, v...)
+}
+
+func Panicln(v ...any) {
+       Default().Panicln(v...)
+}
+
+func (l *Logger) V(level int) *Logger {
+       if logVerbosity >= level {
+               return l
+       }
+       return NullLogger()
+}
+
+func (l *Logger) WithPrefix(prefix string) *Logger {
+       return &Logger{
+               Logger: l.Logger,
+               prefix: prefix,
+               level:  l.level,
+       }
+}
+
+func (l Logger) WithLevel(lv Level) *Logger {
+       if lv < LevelFromString(logLevel) {
+               return NullLogger()
+       }
+
+       return &Logger{
+               Logger: l.Logger,
+               prefix: l.prefix,
+               level:  lv,
+       }
+}
+
+func (l *Logger) AppendPrefix(prefix string) *Logger {
+       return &Logger{
+               Logger: l.Logger,
+               prefix: l.prefix + prefix,
+               level:  l.level,
+       }
+}
+
+func (l *Logger) prependPrefixes(v []any) []any {
+       prefix := ""
+       if l.prefix != "" {
+               prefix = "[" + l.prefix + "] "
+       }
+       args := make([]any, len(v)+2)
+       args[0] = l.level.prefix(l.Writer())
+       args[1] = prefix
+       copy(args[2:], v)
+
+       return args
+}
+
+func (l *Logger) Print(v ...any) {
+       args := l.prependPrefixes(v)
+       l.Logger.Print(args...)
+}
+
+func (l *Logger) Printf(fmtstr string, v ...any) {
+       if l.prefix != "" {
+               fmtstr = "[" + l.prefix + "] " + fmtstr
+       }
+
+       l.Logger.Printf(l.level.prefix(l.Writer())+fmtstr, v...)
+}
+
+func (l *Logger) Println(v ...any) {
+       l.Print(v...)
+}
+
+func (l *Logger) Debug(v ...any) {
+       l.WithLevel(DEBUG).Print(v...)
+}
+
+func (l *Logger) Debugf(fmtstr string, v ...any) {
+       l.WithLevel(DEBUG).Printf(fmtstr, v...)
+}
+
+func (l *Logger) Info(v ...any) {
+       l.WithLevel(INFO).Print(v...)
+}
+
+func (l *Logger) Infof(fmtstr string, v ...any) {
+       l.WithLevel(INFO).Printf(fmtstr, v...)
+}
+
+func (l *Logger) Notice(v ...any) {
+       l.WithLevel(NOTICE).Print(v...)
+}
+
+func (l *Logger) Noticef(fmtstr string, v ...any) {
+       l.WithLevel(NOTICE).Printf(fmtstr, v...)
+}
+
+func (l *Logger) Warning(v ...any) {
+       l.WithLevel(WARNING).Print(v...)
+}
+
+func (l *Logger) Warningf(fmtstr string, v ...any) {
+       l.WithLevel(WARNING).Printf(fmtstr, v...)
+}
+
+func (l *Logger) Warn(v ...any) {
+       l.WithLevel(WARNING).Print(v...)
+}
+
+func (l *Logger) Warnf(fmtstr string, v ...any) {
+       l.WithLevel(WARNING).Printf(fmtstr, v...)
+}
+
+func (l *Logger) Error(v ...any) {
+       l.WithLevel(ERROR).Print(v...)
+}
+
+func (l *Logger) Errorf(fmtstr string, v ...any) {
+       l.WithLevel(ERROR).Printf(fmtstr, v...)
+}
+
+func (l *Logger) Critical(v ...any) {
+       l.WithLevel(CRITICAL).Print(v...)
+}
+
+func (l *Logger) Criticalf(fmtstr string, v ...any) {
+       l.WithLevel(CRITICAL).Printf(fmtstr, v...)
+}
+
+func (l *Logger) Alert(v ...any) {
+       l.WithLevel(ALERT).Print(v...)
+}
+
+func (l *Logger) Alertf(fmtstr string, v ...any) {
+       l.WithLevel(ALERT).Printf(fmtstr, v...)
+}
+
+func (l *Logger) Fatal(v ...any) {
+       if len(v) > 0 {
+               if arg1, ok := v[0].(string); ok {
+                       if l.prefix != "" {
+                               v[0] = FATAL.prefix(l.Writer()) + "[" + l.prefix + "] " + arg1
+                       }
+               }
+       }
+
+       l.Logger.Fatal(v...)
+}
+
+func (l *Logger) Fatalf(fmtstr string, v ...any) {
+       if l.prefix != "" {
+               fmtstr = FATAL.prefix(l.Writer()) + "[" + l.prefix + "] " + fmtstr
+       }
+
+       l.Logger.Fatalf(fmtstr, v...)
+}
diff --git a/utils/log/util.go b/utils/log/util.go
new file mode 100644 (file)
index 0000000..614dd97
--- /dev/null
@@ -0,0 +1,13 @@
+package log
+
+func Redact(s string) string {
+       if len(s) < 2 {
+               return "*"
+       }
+
+       b := []byte(s)
+       for i := 1; i < len(b); i++ {
+               b[i] = '*'
+       }
+       return string(b)
+}
diff --git a/utils/reverse.go b/utils/reverse.go
new file mode 100644 (file)
index 0000000..d5994b6
--- /dev/null
@@ -0,0 +1,9 @@
+package utils
+
+func Reverse[T any](s []T) []T {
+       for i := 0; i < len(s)/2; i++ {
+               j := len(s) - i - 1
+               s[i], s[j] = s[j], s[i]
+       }
+       return s
+}
diff --git a/utils/stringmatch/stringmatch.go b/utils/stringmatch/stringmatch.go
new file mode 100644 (file)
index 0000000..3a7a83f
--- /dev/null
@@ -0,0 +1,77 @@
+package stringmatch
+
+import (
+       "regexp"
+       "strings"
+)
+
+type StringMatcher interface {
+       Match(input string) bool
+}
+
+type Prefix string
+type Suffix string
+type Exact string
+type Contains string
+type Regexp string
+
+func (s Prefix) Match(input string) bool {
+       return strings.HasPrefix(input, string(s))
+}
+
+func (s Suffix) Match(input string) bool {
+       return strings.HasSuffix(input, string(s))
+}
+
+func (s Exact) Match(input string) bool {
+       return input == string(s)
+}
+
+func (s Contains) Match(input string) bool {
+       return strings.Contains(input, string(s))
+}
+
+func (s Regexp) Match(input string) bool {
+       re := regexp.MustCompile(string(s))
+       return re.MatchString(input)
+}
+
+type andMatcher struct {
+       matchers []StringMatcher
+}
+
+func (mm *andMatcher) Match(input string) bool {
+       for _, m := range mm.matchers {
+               if !m.Match(input) {
+                       return false
+               }
+       }
+
+       return true
+}
+
+func And(matchers ...StringMatcher) StringMatcher {
+       return &andMatcher{
+               matchers: matchers,
+       }
+}
+
+type orMatcher struct {
+       matchers []StringMatcher
+}
+
+func (mm *orMatcher) Match(input string) bool {
+       for _, m := range mm.matchers {
+               if m.Match(input) {
+                       return true
+               }
+       }
+
+       return false
+}
+
+func Or(matchers ...StringMatcher) StringMatcher {
+       return &orMatcher{
+               matchers: matchers,
+       }
+}