]> git.ipfire.org Git - thirdparty/apache/httpd.git/commitdiff
Merge /httpd/httpd/trunk:r1898962,1900039,1900145,1900313-1900314
authorStefan Eissing <icing@apache.org>
Wed, 27 Apr 2022 12:08:18 +0000 (12:08 +0000)
committerStefan Eissing <icing@apache.org>
Wed, 27 Apr 2022 12:08:18 +0000 (12:08 +0000)
Backport of all recent changes to experimental mod_md.

git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/branches/2.4.x@1900316 13f79535-47bb-0310-9956-ffa450edef68

28 files changed:
CMakeLists.txt
changes-entries/md_auto_status.txt [new file with mode: 0644]
changes-entries/md_tailscale.txt [new file with mode: 0644]
changes-entries/md_timeperiod_null.txt [new file with mode: 0644]
docs/manual/mod/mod_md.xml
modules/md/config2.m4
modules/md/md_acme_drive.c
modules/md/md_core.c
modules/md/md_crypt.c
modules/md/md_crypt.h
modules/md/md_curl.c
modules/md/md_http.c
modules/md/md_http.h
modules/md/md_json.c
modules/md/md_reg.c
modules/md/md_reg.h
modules/md/md_store_fs.c
modules/md/md_tailscale.c [new file with mode: 0644]
modules/md/md_tailscale.h [new file with mode: 0644]
modules/md/md_util.c
modules/md/md_util.h
modules/md/md_version.h
modules/md/mod_md.dsp
modules/md/mod_md_config.c
modules/md/mod_md_status.c
test/modules/md/md_env.py
test/modules/md/test_780_tailscale.py [new file with mode: 0644]
test/modules/md/test_920_status.py

index ef8da3f31cd3859a7b5d2d29043a368c6a45995f..29949a34d00e4fe4a32962c7decae191ca6fe8ef 100644 (file)
@@ -489,7 +489,7 @@ SET(mod_md_extra_sources
   modules/md/md_ocsp.c               modules/md/md_util.c               
   modules/md/mod_md_config.c         modules/md/mod_md_drive.c
   modules/md/mod_md_os.c             modules/md/mod_md_status.c
-  modules/md/mod_md_ocsp.c           
+  modules/md/mod_md_ocsp.c           modules/md/md_tailscale.c
 )
 SET(mod_optional_hook_export_extra_defines AP_DECLARE_EXPORT) # bogus reuse of core API prefix
 SET(mod_proxy_extra_defines          PROXY_DECLARE_EXPORT)
diff --git a/changes-entries/md_auto_status.txt b/changes-entries/md_auto_status.txt
new file mode 100644 (file)
index 0000000..34faddd
--- /dev/null
@@ -0,0 +1,5 @@
+ * Implement full auto status ("key: value" type status output).
+   Especially not only status summary counts for certificates and
+   OCSP stapling but also lists. Auto status format is similar to
+   what was used for mod_proxy_balancer.
+   [Rainer Jung]
diff --git a/changes-entries/md_tailscale.txt b/changes-entries/md_tailscale.txt
new file mode 100644 (file)
index 0000000..e30aba6
--- /dev/null
@@ -0,0 +1,5 @@
+  *) mod_md: added support for managing certificates via a
+     local tailscale demon for users of that secure networking.
+     This gives trusted certificates for tailscale  assigned
+     domain names in the *.ts.net space.
+     [Stefan Eissing]
\ No newline at end of file
diff --git a/changes-entries/md_timeperiod_null.txt b/changes-entries/md_timeperiod_null.txt
new file mode 100644 (file)
index 0000000..5beb6c8
--- /dev/null
@@ -0,0 +1,4 @@
+  *) mod_md: a possible NULL pointer deref was fixed in
+     the JSON code for persisting time periods (start+end).
+     Fixes #282 on mod_md's github.
+     Thanks to @marcstern for finding this.
index ed2dc6cd9222c2c942d0da42fb9cdfbc8daed881..800abbaa2fdf1e04bcbec4bed30654fe5403d181 100644 (file)
@@ -285,6 +285,44 @@ MDChallengeDns01 /usr/bin/acme-setup-dns
             </p>
         </note>
 
+        <note><title>tailscale</title>
+            <p>
+                Since version 2.4.14 of the module, you can use it to get certificates
+                for your <a href="https://tailscale.com">tailscale</a> domains.
+            </p>
+            <highlight language="config">
+&lt;MDomain mydomain.some-thing.ts.net>
+  MDCertificateProtocol tailscale
+  MDCertificateAuthority file://localhost/var/run/tailscale/tailscaled.sock",
+&lt;/MDomain>
+            </highlight>
+            <p>
+                Tailscale provides secure networking between your machines, where ever
+                they are, and can provide domain names in the *.ts.net space for them.
+                For those, it will then provide Let's Encrypt certificates as well, so
+                you can open these domains in your browser securely.
+            </p>
+            <p>
+                The directives listed above tell Apache to contact the local tailscale
+                demon for obtaining and renewing certificates. This will only work for
+                the domain name that tailscale assigns to your machine.
+            </p>
+            <p>
+                Otherwise, these certificates work exactly like the ones retrieved
+                via the ACME protocol from Lets Encrypt. You see them in status reporting
+                and MDMessageCmd directives are executed for them as well.
+            </p>
+            <p>
+                More details are <a href="https://github.com/icing/mod_md#tailscale">
+                available at the mod_md github documentation</a>.
+            </p>
+            <p>
+                Note that this feature only works on machines where the tailscale
+                demon provides a unix domain socket. This, so far, seems only the
+                case on *nix systems.
+            </p>
+        </note>
+
     </summary>
     
     <directivesynopsis>
index bcce501633a26f408244a82caacd3e1974c0c60d..11d4f32dbd96a4c28b65fbd9bb740cb45e998e9e 100644 (file)
@@ -264,6 +264,7 @@ md_reg.lo dnl
 md_status.lo dnl
 md_store.lo dnl
 md_store_fs.lo dnl
+md_tailscale.lo dnl
 md_time.lo dnl
 md_util.lo dnl
 mod_md.lo dnl
index 6c461dc9af337bdaccfa687d4a08ca81364ac588..bc0f17f271004b0140cdfff2b8329b322690b2cd 100644 (file)
@@ -1036,9 +1036,19 @@ static apr_status_t acme_driver_preload(md_proto_driver_t *d,
     return rv;
 }
 
+static apr_status_t acme_complete_md(md_t *md, apr_pool_t *p)
+{
+    (void)p;
+    if (!md->ca_url) {
+        md->ca_url = MD_ACME_DEF_URL;
+    }
+    return APR_SUCCESS;
+}
+
 static md_proto_t ACME_PROTO = {
     MD_PROTO_ACME, acme_driver_init, acme_driver_renew, 
-    acme_driver_preload_init, acme_driver_preload
+    acme_driver_preload_init, acme_driver_preload,
+    acme_complete_md,
 };
  
 apr_status_t md_acme_protos_add(apr_hash_t *protos, apr_pool_t *p)
index 620b809984ca640cab5d26607ac1cf44dbd17548..f82f950503a6691f5a8609b18fde800f960593aa 100644 (file)
@@ -427,7 +427,7 @@ apr_status_t md_get_ca_url_from_name(const char **purl, apr_pool_t *p, const cha
         }
     }
     *purl = name;
-    rv = md_util_abs_http_uri_check(p, name, &err);
+    rv = md_util_abs_uri_check(p, name, &err);
     if (APR_SUCCESS != rv) {
         apr_array_header_t *names;
 
index 73e89009775308d1948428bcdcb626df95f7aecb..8baab51c8b39aed37c64dd630dd26669308f9a31 100644 (file)
@@ -709,6 +709,53 @@ apr_status_t md_pkey_fsave(md_pkey_t *pkey, apr_pool_t *p,
     return rv;
 }
 
+apr_status_t md_pkey_read_http(md_pkey_t **ppkey, apr_pool_t *pool,
+                               const struct md_http_response_t *res)
+{
+    apr_status_t rv;
+    apr_off_t data_len;
+    char *pem_data;
+    apr_size_t pem_len;
+    md_pkey_t *pkey;
+    BIO *bf;
+    passwd_ctx ctx;
+
+    rv = apr_brigade_length(res->body, 1, &data_len);
+    if (APR_SUCCESS != rv) goto leave;
+    if (data_len > 1024*1024) { /* certs usually are <2k each */
+        rv = APR_EINVAL;
+        goto leave;
+    }
+    rv = apr_brigade_pflatten(res->body, &pem_data, &pem_len, res->req->pool);
+    if (APR_SUCCESS != rv) goto leave;
+
+    if (NULL == (bf = BIO_new_mem_buf(pem_data, (int)pem_len))) {
+        rv = APR_ENOMEM;
+        goto leave;
+    }
+    pkey = make_pkey(pool);
+    ctx.pass_phrase = NULL;
+    ctx.pass_len = 0;
+    ERR_clear_error();
+    pkey->pkey = PEM_read_bio_PrivateKey(bf, NULL, NULL, &ctx);
+    BIO_free(bf);
+
+    if (pkey->pkey == NULL) {
+        unsigned long err = ERR_get_error();
+        rv = APR_EINVAL;
+        md_log_perror(MD_LOG_MARK, MD_LOG_WARNING, rv, pool,
+                      "error loading pkey from http response: %s",
+                      ERR_error_string(err, NULL));
+        goto leave;
+    }
+    rv = APR_SUCCESS;
+    apr_pool_cleanup_register(pool, pkey, pkey_cleanup, apr_pool_cleanup_null);
+
+leave:
+    *ppkey = (APR_SUCCESS == rv)? pkey : NULL;
+    return rv;
+}
+
 /* Determine the message digest used for signing with the given private key. 
  */
 static const EVP_MD *pkey_get_MD(md_pkey_t *pkey)
@@ -1137,6 +1184,11 @@ const char *md_cert_get_serial_number(const md_cert_t *cert, apr_pool_t *p)
     return s;
 }
 
+int md_certs_are_equal(const md_cert_t *a, const md_cert_t *b)
+{
+    return X509_cmp(a->x509, b->x509) == 0;
+}
+
 int md_cert_is_valid_now(const md_cert_t *cert)
 {
     return ((X509_cmp_current_time(X509_get_notBefore(cert->x509)) < 0)
index 203dc40f4098068dd9207fa07431fd177dfcdd0a..a892e00f1e6b399cd31c6a528c5e3300991be7a1 100644 (file)
@@ -117,6 +117,12 @@ void *md_pkey_get_EVP_PKEY(struct md_pkey_t *pkey);
 apr_status_t md_crypt_hmac64(const char **pmac64, const struct md_data_t *hmac_key,
                              apr_pool_t *p, const char *d, size_t dlen);
 
+/**
+ * Read a private key from a http response.
+ */
+apr_status_t md_pkey_read_http(md_pkey_t **ppkey, apr_pool_t *pool,
+                               const struct md_http_response_t *res);
+
 /**************************************************************************************************/
 /* X509 certificates */
 
@@ -179,6 +185,11 @@ apr_time_t md_cert_get_not_after(const md_cert_t *cert);
 apr_time_t md_cert_get_not_before(const md_cert_t *cert);
 struct md_timeperiod_t md_cert_get_valid(const md_cert_t *cert);
 
+/**
+ * Return != 0 iff the hash values of the certificates are equal.
+ */
+int md_certs_are_equal(const md_cert_t *a, const md_cert_t *b);
+
 apr_status_t md_cert_get_issuers_uri(const char **puri, const md_cert_t *cert, apr_pool_t *p);
 apr_status_t md_cert_get_alt_names(apr_array_header_t **pnames, const md_cert_t *cert, apr_pool_t *p);
 
index e05f37b8997b41a865d3e86a8a02c7feb85e5496..e3f32d35485178ae9bdc7b6be31ede3e9387ab93 100644 (file)
@@ -301,6 +301,9 @@ static apr_status_t internals_setup(md_http_request_t *req)
     if (req->ca_file) {
         curl_easy_setopt(curl, CURLOPT_CAINFO, req->ca_file);
     }
+    if (req->unix_socket_path) {
+        curl_easy_setopt(curl, CURLOPT_UNIX_SOCKET_PATH, req->unix_socket_path);
+    }
 
     if (req->body_len >= 0) {
         /* set the Content-Length */
index 53e4f89d99ffc419cddb655ae7f5f28674ad701f..74db961301eb3503b9086f15e6bc912ed3fd35c8 100644 (file)
@@ -33,6 +33,7 @@ struct md_http_t {
     void *impl_data;         /* to be used by the implementation */
     const char *user_agent;
     const char *proxy_url;
+    const char *unix_socket_path;
     md_http_timeouts_t timeout;
     const char *ca_file;
 };
@@ -143,6 +144,11 @@ void md_http_set_ca_file(md_http_t *http, const char *ca_file)
     http->ca_file = ca_file;
 }
 
+void md_http_set_unix_socket_path(md_http_t *http, const char *path)
+{
+    http->unix_socket_path = path;
+}
+
 static apr_status_t req_set_body(md_http_request_t *req, const char *content_type,
                                  apr_bucket_brigade *body, apr_off_t body_len,
                                  int detect_len)
@@ -211,6 +217,7 @@ static apr_status_t req_create(md_http_request_t **preq, md_http_t *http,
     req->proxy_url = http->proxy_url;
     req->timeout = http->timeout;
     req->ca_file = http->ca_file;
+    req->unix_socket_path = http->unix_socket_path;
     *preq = req;
     return rv;
 }
index e24de0311399cbd058c1c538cad971ec97e2f6ea..c210aa99130f0a51da095ba7179b9d26c5d1739b 100644 (file)
@@ -65,6 +65,7 @@ struct md_http_request_t {
     const char *user_agent;
     const char *proxy_url;
     const char *ca_file;
+    const char *unix_socket_path;
     apr_table_t *headers;
     struct apr_bucket_brigade *body;
     apr_off_t body_len;
@@ -117,6 +118,12 @@ void md_http_set_stalling(md_http_request_t *req, long bytes_per_sec, apr_time_t
  */
 void md_http_set_ca_file(md_http_t *http, const char *ca_file);
 
+/**
+ * Set the path of a unix domain socket for use instead of TCP
+ * in a connection. Disable by providing NULL as path.
+ */
+void md_http_set_unix_socket_path(md_http_t *http, const char *path);
+
 /**
  * Perform the request. Then this function returns, the request and
  * all its memory has been freed and must no longer be used.
index 52187168cd962d4c2f5cfdf61c8a33403c96f343..e0f977ea564b5dbf021e9b610d98cd3cec0afc35 100644 (file)
@@ -176,7 +176,7 @@ static apr_status_t jselect_add(json_t *val, md_json_t *json, va_list ap)
     aj = json_object_get(j, key);
     if (!aj) {
         aj = json_array();
-        json_object_set(j, key, aj);
+        json_object_set_new(j, key, aj);
     }
     
     if (!json_is_array(aj)) {
@@ -202,7 +202,7 @@ static apr_status_t jselect_insert(json_t *val, size_t index, md_json_t *json, v
     aj = json_object_get(j, key);
     if (!aj) {
         aj = json_array();
-        json_object_set(j, key, aj);
+        json_object_set_new(j, key, aj);
     }
     
     if (!json_is_array(aj)) {
@@ -1264,7 +1264,7 @@ apr_status_t md_json_set_timeperiod(const md_timeperiod_t *tp, md_json_t *json,
     const char *key;
     apr_status_t rv;
     
-    if (!tp || tp->start || tp->end) {
+    if (tp && tp->start && tp->end) {
         jn = json_object();
         apr_rfc822_date(ts, tp->start);
         json_object_set_new(jn, "from", json_string(ts));
index 4fba9c7ae459350d3fbed9d24e6f50b107e15e09..0c59aeb737d71912de827c0f603b9d4cb55b3884 100644 (file)
@@ -33,6 +33,7 @@
 #include "md_reg.h"
 #include "md_store.h"
 #include "md_status.h"
+#include "md_tailscale.h"
 #include "md_util.h"
 
 #include "md_acme.h"
@@ -98,7 +99,8 @@ apr_status_t md_reg_create(md_reg_t **preg, apr_pool_t *p, struct md_store_t *st
     md_timeslice_create(&reg->renew_window, p, MD_TIME_LIFE_NORM, MD_TIME_RENEW_WINDOW_DEF); 
     md_timeslice_create(&reg->warn_window, p, MD_TIME_LIFE_NORM, MD_TIME_WARN_WINDOW_DEF); 
     
-    if (APR_SUCCESS == (rv = md_acme_protos_add(reg->protos, p))) {
+    if (APR_SUCCESS == (rv = md_acme_protos_add(reg->protos, p))
+        && APR_SUCCESS == (rv = md_tailscale_protos_add(reg->protos, p))) {
         rv = load_props(reg, p);
     }
     
@@ -901,12 +903,22 @@ apr_status_t md_reg_sync_finish(md_reg_t *reg, md_t *md, apr_pool_t *p, apr_pool
     md_t *old;
     apr_status_t rv;
     int changed = 1;
+    md_proto_t *proto;
     
-    if (!md->ca_url) {
-        md->ca_url = MD_ACME_DEF_URL;
-        md->ca_proto = MD_PROTO_ACME; 
+    if (!md->ca_proto) {
+        md->ca_proto = MD_PROTO_ACME;
+    }
+    proto = apr_hash_get(reg->protos, md->ca_proto, (apr_ssize_t)strlen(md->ca_proto));
+    if (!proto) {
+        rv = APR_ENOTIMPL;
+        md_log_perror(MD_LOG_MARK, MD_LOG_ERR, rv, ptemp,
+                      "[%s] uses unknown CA protocol '%s'",
+                      md->name, md->ca_proto);
+        goto leave;
     }
-    
+    rv = proto->complete_md(md, p);
+    if (APR_SUCCESS != rv) goto leave;
+
     rv = state_init(reg, p, md);
     if (APR_SUCCESS != rv) goto leave;
     
index 46034b0fe68d954e15504e27f992c79a99cf623e..aa626c9276843e34b43baf53d565c2b3f7736566 100644 (file)
@@ -220,6 +220,7 @@ typedef apr_status_t md_proto_renew_cb(md_proto_driver_t *driver, struct md_resu
 typedef apr_status_t md_proto_init_preload_cb(md_proto_driver_t *driver, struct md_result_t *result);
 typedef apr_status_t md_proto_preload_cb(md_proto_driver_t *driver, 
                                          md_store_group_t group, struct md_result_t *result);
+typedef apr_status_t md_proto_complete_md_cb(md_t *md, apr_pool_t *p);
 
 struct md_proto_t {
     const char *protocol;
@@ -227,6 +228,7 @@ struct md_proto_t {
     md_proto_renew_cb *renew;
     md_proto_init_preload_cb *init_preload;
     md_proto_preload_cb *preload;
+    md_proto_complete_md_cb *complete_md;
 };
 
 /**
index ab43054ce58a0e4c9e3527f6673ddc7aa2e7c351..c2a4a4e49300bdcf1f6e67bf5863a905f138db1c 100644 (file)
@@ -503,6 +503,7 @@ static apr_status_t mk_group_dir(const char **pdir, md_store_fs_t *s_fs,
     
     perms = gperms(s_fs, group);
 
+    *pdir = NULL;
     rv = fs_get_dname(pdir, &s_fs->s, group, name, p);
     if ((APR_SUCCESS != rv) || (MD_SG_NONE == group)) goto cleanup;
 
@@ -521,7 +522,8 @@ static apr_status_t mk_group_dir(const char **pdir, md_store_fs_t *s_fs,
     }
 cleanup:
     if (APR_SUCCESS != rv) {
-        md_log_perror(MD_LOG_MARK, MD_LOG_ERR, rv, p, "mk_group_dir %d %s", group, name);
+        md_log_perror(MD_LOG_MARK, MD_LOG_ERR, rv, p, "mk_group_dir %d %s",
+            group, (*pdir? *pdir : (name? name : "(null)")));
     }
     return rv;
 }
diff --git a/modules/md/md_tailscale.c b/modules/md/md_tailscale.c
new file mode 100644 (file)
index 0000000..dd3b145
--- /dev/null
@@ -0,0 +1,381 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <assert.h>
+#include <stdlib.h>
+
+#include <apr_lib.h>
+#include <apr_strings.h>
+#include <apr_hash.h>
+#include <apr_uri.h>
+
+#include "md.h"
+#include "md_crypt.h"
+#include "md_json.h"
+#include "md_http.h"
+#include "md_log.h"
+#include "md_result.h"
+#include "md_reg.h"
+#include "md_store.h"
+#include "md_util.h"
+
+#include "md_tailscale.h"
+
+typedef struct {
+    apr_pool_t *pool;
+    md_proto_driver_t *driver;
+    const char *unix_socket_path;
+    md_t *md;
+    apr_array_header_t *chain;
+    md_pkey_t *pkey;
+} ts_ctx_t;
+
+static apr_status_t ts_init(md_proto_driver_t *d, md_result_t *result)
+{
+    ts_ctx_t *ts_ctx;
+    apr_uri_t uri;
+    const char *ca_url;
+    apr_status_t rv = APR_SUCCESS;
+
+    md_result_set(result, APR_SUCCESS, NULL);
+    ts_ctx = apr_pcalloc(d->p, sizeof(*ts_ctx));
+    ts_ctx->pool = d->p;
+    ts_ctx->driver = d;
+    ts_ctx->chain = apr_array_make(d->p, 5, sizeof(md_cert_t *));
+
+    ca_url = d->md->ca_url;
+    if (!ca_url) {
+        ca_url = MD_TAILSCALE_DEF_URL;
+    }
+    rv = apr_uri_parse(d->p, ca_url, &uri);
+    if (APR_SUCCESS != rv) {
+        md_result_printf(result, rv, "error parsing CA URL `%s`", ca_url);
+        goto leave;
+    }
+    if (uri.scheme && uri.scheme[0] && strcmp("file", uri.scheme)) {
+        rv = APR_ENOTIMPL;
+        md_result_printf(result, rv, "non `file` URLs not supported, CA URL is `%s`",
+                         ca_url);
+        goto leave;
+    }
+    if (uri.hostname && uri.hostname[0] && strcmp("localhost", uri.hostname)) {
+        rv = APR_ENOTIMPL;
+        md_result_printf(result, rv, "non `localhost` URLs not supported, CA URL is `%s`",
+                         ca_url);
+        goto leave;
+    }
+    ts_ctx->unix_socket_path = uri.path;
+    d->baton = ts_ctx;
+
+leave:
+    return rv;
+}
+
+static apr_status_t ts_preload_init(md_proto_driver_t *d, md_result_t *result)
+{
+    return ts_init(d, result);
+}
+
+static apr_status_t ts_preload(md_proto_driver_t *d,
+                               md_store_group_t load_group, md_result_t *result)
+{
+    apr_status_t rv;
+    md_t *md;
+    md_credentials_t *creds;
+    md_pkey_spec_t *pkspec;
+    apr_array_header_t *all_creds;
+    const char *name;
+    int i;
+
+    name = d->md->name;
+    md_log_perror(MD_LOG_MARK, MD_LOG_DEBUG, 0, d->p, "%s: preload start", name);
+    /* Load data from MD_SG_STAGING and save it into "load_group".
+     */
+    if (APR_SUCCESS != (rv = md_load(d->store, MD_SG_STAGING, name, &md, d->p))) {
+        md_result_set(result, rv, "loading staged md.json");
+        goto leave;
+    }
+
+     /* tailscale generates one cert+key with key specification being whatever
+      * it chooses. Use the NULL spec here.
+      */
+    all_creds = apr_array_make(d->p, 5, sizeof(md_credentials_t*));
+    pkspec = NULL;
+    if (APR_SUCCESS != (rv = md_creds_load(d->store, MD_SG_STAGING, name, pkspec, &creds, d->p))) {
+        md_result_printf(result, rv, "loading staged credentials");
+        goto leave;
+    }
+    if (!creds->chain) {
+        rv = APR_ENOENT;
+        md_result_printf(result, rv, "no certificate in staged credentials");
+        goto leave;
+    }
+    if (APR_SUCCESS != (rv = md_check_cert_and_pkey(creds->chain, creds->pkey))) {
+        md_result_printf(result, rv, "certificate and private key do not match in staged credentials");
+        goto leave;
+    }
+    APR_ARRAY_PUSH(all_creds, md_credentials_t*) = creds;
+
+    md_result_activity_setn(result, "purging store tmp space");
+    rv = md_store_purge(d->store, d->p, load_group, name);
+    if (APR_SUCCESS != rv) {
+        md_result_set(result, rv, NULL);
+        goto leave;
+    }
+
+    md_result_activity_setn(result, "saving staged md/privkey/pubcert");
+    if (APR_SUCCESS != (rv = md_save(d->store, d->p, load_group, md, 1))) {
+        md_result_set(result, rv, "writing md.json");
+        goto leave;
+    }
+
+    for (i = 0; i < all_creds->nelts; ++i) {
+        creds = APR_ARRAY_IDX(all_creds, i, md_credentials_t*);
+        if (APR_SUCCESS != (rv = md_creds_save(d->store, d->p, load_group, name, creds, 1))) {
+            md_result_printf(result, rv, "writing credentials #%d", i);
+            goto leave;
+        }
+    }
+
+    md_result_set(result, APR_SUCCESS, "saved staged data successfully");
+
+leave:
+    md_result_log(result, MD_LOG_DEBUG);
+    return rv;
+}
+
+static apr_status_t rv_of_response(const md_http_response_t *res)
+{
+    switch (res->status) {
+        case 200:
+            return APR_SUCCESS;
+        case 400:
+            return APR_EINVAL;
+        case 401: /* sectigo returns this instead of 403 */
+        case 403:
+            return APR_EACCES;
+        case 404:
+            return APR_ENOENT;
+        default:
+            return APR_EGENERAL;
+    }
+    return APR_SUCCESS;
+}
+
+static apr_status_t on_get_cert(const md_http_response_t *res, void *baton)
+{
+    ts_ctx_t *ts_ctx = baton;
+    apr_status_t rv;
+
+    rv = rv_of_response(res);
+    if (APR_SUCCESS != rv) goto leave;
+    apr_array_clear(ts_ctx->chain);
+    rv = md_cert_chain_read_http(ts_ctx->chain, ts_ctx->pool, res);
+    if (APR_SUCCESS != rv) goto leave;
+
+leave:
+    return rv;
+}
+
+static apr_status_t on_get_key(const md_http_response_t *res, void *baton)
+{
+    ts_ctx_t *ts_ctx = baton;
+    apr_status_t rv;
+
+    rv = rv_of_response(res);
+    if (APR_SUCCESS != rv) goto leave;
+    rv = md_pkey_read_http(&ts_ctx->pkey, ts_ctx->pool, res);
+    if (APR_SUCCESS != rv) goto leave;
+
+leave:
+    return rv;
+}
+
+static apr_status_t ts_renew(md_proto_driver_t *d, md_result_t *result)
+{
+    const char *name, *domain, *url;
+    apr_status_t rv = APR_ENOENT;
+    ts_ctx_t *ts_ctx = d->baton;
+    md_http_t *http;
+    const md_pubcert_t *pubcert;
+    md_cert_t *old_cert, *new_cert;
+    int reset_staging = d->reset;
+
+    /* "renewing" the certificate from tailscale. Since tailscale has its
+     * own ideas on when to do this, we can only inspect the certificate
+     * it gives us and see if it is different from the current one we have.
+     * (if we have any. first time, lacking a cert, any it gives us is
+     *  considered as 'renewed'.)
+     */
+    name = d->md->name;
+    md_log_perror(MD_LOG_MARK, MD_LOG_DEBUG, 0, d->p, "%s: renewing cert", name);
+
+    /* When not explicitly told to reset, we check the existing data. If
+     * it is incomplete or old, we trigger the reset for a clean start. */
+    if (!reset_staging) {
+        md_result_activity_setn(result, "Checking staging area");
+        rv = md_load(d->store, MD_SG_STAGING, d->md->name, &ts_ctx->md, d->p);
+        if (APR_SUCCESS == rv) {
+            /* So, we have a copy in staging, but is it a recent or an old one? */
+            if (md_is_newer(d->store, MD_SG_DOMAINS, MD_SG_STAGING, d->md->name, d->p)) {
+                reset_staging = 1;
+            }
+        }
+        else if (APR_STATUS_IS_ENOENT(rv)) {
+            reset_staging = 1;
+            rv = APR_SUCCESS;
+        }
+    }
+
+    if (reset_staging) {
+        md_result_activity_setn(result, "Resetting staging area");
+        /* reset the staging area for this domain */
+        rv = md_store_purge(d->store, d->p, MD_SG_STAGING, d->md->name);
+        md_log_perror(MD_LOG_MARK, MD_LOG_TRACE1, rv, d->p,
+                      "%s: reset staging area", d->md->name);
+        if (APR_SUCCESS != rv && !APR_STATUS_IS_ENOENT(rv)) {
+            md_result_printf(result, rv, "resetting staging area");
+            goto leave;
+        }
+        rv = APR_SUCCESS;
+        ts_ctx->md = NULL;
+    }
+
+    if (!ts_ctx->md || strcmp(ts_ctx->md->ca_url, d->md->ca_url)) {
+        md_result_activity_printf(result, "Resetting staging for %s", d->md->name);
+        /* re-initialize staging */
+        md_log_perror(MD_LOG_MARK, MD_LOG_DEBUG, 0, d->p, "%s: setup staging", d->md->name);
+        md_store_purge(d->store, d->p, MD_SG_STAGING, d->md->name);
+        ts_ctx->md = md_copy(d->p, d->md);
+        rv = md_save(d->store, d->p, MD_SG_STAGING, ts_ctx->md, 0);
+        if (APR_SUCCESS != rv) {
+            md_result_printf(result, rv, "Saving MD information in staging area.");
+            md_result_log(result, MD_LOG_ERR);
+            goto leave;
+        }
+    }
+
+    if (!ts_ctx->unix_socket_path) {
+        rv = APR_ENOTIMPL;
+        md_result_set(result, rv, "only unix sockets are supported for tailscale connections");
+        goto leave;
+    }
+
+    rv = md_util_is_unix_socket(ts_ctx->unix_socket_path, d->p);
+    if (APR_SUCCESS != rv) {
+        md_result_printf(result, rv, "tailscale socket not available, may not be up: %s",
+                         ts_ctx->unix_socket_path);
+        goto leave;
+    }
+
+    rv = md_http_create(&http, d->p,
+                        apr_psprintf(d->p, "Apache mod_md/%s", MOD_MD_VERSION),
+                        NULL);
+    if (APR_SUCCESS != rv) {
+        md_result_set(result, rv, "creating http context");
+        goto leave;
+    }
+    md_http_set_unix_socket_path(http, ts_ctx->unix_socket_path);
+
+    domain = (d->md->domains->nelts > 0)?
+              APR_ARRAY_IDX(d->md->domains, 0, const char*) : NULL;
+    if (!domain) {
+        rv = APR_EINVAL;
+        md_result_set(result, rv, "no domain names available");
+    }
+
+    url = apr_psprintf(d->p, "http://localhost/localapi/v0/cert/%s?type=crt",
+                       domain);
+    rv = md_http_GET_perform(http, url, NULL, on_get_cert, ts_ctx);
+    if (APR_SUCCESS != rv) {
+        md_result_set(result, rv, "retrieving certificate from tailscale");
+        goto leave;
+    }
+    if (ts_ctx->chain->nelts <= 0) {
+        rv = APR_ENOENT;
+        md_result_set(result, rv, "tailscale returned no certificates");
+        goto leave;
+    }
+
+    /* Got the key and the chain, is it new? */
+    rv = md_reg_get_pubcert(&pubcert, d->reg,d->md, 0, d->p);
+    if (APR_SUCCESS == rv) {
+        old_cert = APR_ARRAY_IDX(pubcert->certs, 0, md_cert_t*);
+        new_cert = APR_ARRAY_IDX(ts_ctx->chain, 0, md_cert_t*);
+        if (md_certs_are_equal(old_cert, new_cert)) {
+            /* tailscale has not renewed the certificate, yet */
+            rv = APR_ENOENT;
+            md_result_set(result, rv, "tailscale has not renewed the certificate yet");
+            /* let's check this daily */
+            md_result_delay_set(result, apr_time_now() + apr_time_from_sec(MD_SECS_PER_DAY));
+            goto leave;
+        }
+    }
+
+    /* We have a new certificate (or had none before).
+     * Get the key and store both in STAGING.
+     */
+    url = apr_psprintf(d->p, "http://localhost/localapi/v0/cert/%s?type=key",
+                       domain);
+    rv = md_http_GET_perform(http, url, NULL, on_get_key, ts_ctx);
+    if (APR_SUCCESS != rv) {
+        md_result_set(result, rv, "retrieving key from tailscale");
+        goto leave;
+    }
+
+    rv = md_pkey_save(d->store, d->p, MD_SG_STAGING, name, NULL, ts_ctx->pkey, 1);
+    if (APR_SUCCESS != rv) {
+        md_result_set(result, rv, "saving private key");
+        goto leave;
+    }
+
+    rv = md_pubcert_save(d->store, d->p, MD_SG_STAGING, name,
+                         NULL, ts_ctx->chain, 1);
+    if (APR_SUCCESS != rv) {
+        md_result_printf(result, rv, "saving new certificate chain.");
+        goto leave;
+    }
+
+    md_result_set(result, APR_SUCCESS,
+        "A new tailscale certificate has been retrieved successfully and can "
+        "be used. A graceful server restart is recommended.");
+
+leave:
+    md_result_log(result, MD_LOG_DEBUG);
+    return rv;
+}
+
+static apr_status_t ts_complete_md(md_t *md, apr_pool_t *p)
+{
+    (void)p;
+    if (!md->ca_url) {
+        md->ca_url = MD_TAILSCALE_DEF_URL;
+    }
+    return APR_SUCCESS;
+}
+
+
+static md_proto_t TAILSCALE_PROTO = {
+    MD_PROTO_TAILSCALE, ts_init, ts_renew,
+    ts_preload_init, ts_preload, ts_complete_md,
+};
+
+apr_status_t md_tailscale_protos_add(apr_hash_t *protos, apr_pool_t *p)
+{
+    (void)p;
+    apr_hash_set(protos, MD_PROTO_TAILSCALE, sizeof(MD_PROTO_TAILSCALE)-1, &TAILSCALE_PROTO);
+    return APR_SUCCESS;
+}
diff --git a/modules/md/md_tailscale.h b/modules/md/md_tailscale.h
new file mode 100644 (file)
index 0000000..67a874d
--- /dev/null
@@ -0,0 +1,25 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef mod_md_md_tailscale_h
+#define mod_md_md_tailscale_h
+
+#define MD_PROTO_TAILSCALE      "tailscale"
+
+apr_status_t md_tailscale_protos_add(struct apr_hash_t *protos, apr_pool_t *p);
+
+#endif /* mod_md_md_tailscale_h */
+
index 23923c8b2eecb9b6ef7ed66f0f457e32b2a2178f..884c0bb91e8a42c3d8f25408bf8962391584d291 100644 (file)
@@ -398,6 +398,16 @@ apr_status_t md_util_is_file(const char *path, apr_pool_t *pool)
     return rv;
 }
 
+apr_status_t md_util_is_unix_socket(const char *path, apr_pool_t *pool)
+{
+    apr_finfo_t info;
+    apr_status_t rv = apr_stat(&info, path, APR_FINFO_TYPE, pool);
+    if (rv == APR_SUCCESS) {
+        rv = (info.filetype == APR_SOCK)? APR_SUCCESS : APR_EINVAL;
+    }
+    return rv;
+}
+
 int md_file_exists(const char *fname, apr_pool_t *p)
 {
     return (fname && *fname && APR_SUCCESS == md_util_is_file(fname, p));
index 71e66eb500ca84c05c4fea70e68cf929967af7e7..e430655fca51a5aeb5cb713d0dac4be49c1fa3d1 100644 (file)
@@ -189,6 +189,7 @@ apr_status_t md_util_path_merge(const char **ppath, apr_pool_t *p, ...);
 
 apr_status_t md_util_is_dir(const char *path, apr_pool_t *pool);
 apr_status_t md_util_is_file(const char *path, apr_pool_t *pool);
+apr_status_t md_util_is_unix_socket(const char *path, apr_pool_t *pool);
 int md_file_exists(const char *fname, apr_pool_t *p);
 
 typedef apr_status_t md_util_file_cb(void *baton, struct apr_file_t *f, apr_pool_t *p);
index ae723f621ff2342518f7447135f63da16e52d55e..92cad1b1cdccde0a2adc6dc64cd127c8315d31d2 100644 (file)
@@ -27,7 +27,7 @@
  * @macro
  * Version number of the md module as c string
  */
-#define MOD_MD_VERSION "2.4.10"
+#define MOD_MD_VERSION "2.4.14"
 
 /**
  * @macro
@@ -35,8 +35,9 @@
  * release. This is a 24 bit number with 8 bits for major number, 8 bits
  * for minor and 8 bits for patch. Version 1.2.3 becomes 0x010203.
  */
-#define MOD_MD_VERSION_NUM 0x02040a
+#define MOD_MD_VERSION_NUM 0x02040e
 
-#define MD_ACME_DEF_URL    "https://acme-v02.api.letsencrypt.org/directory"
+#define MD_ACME_DEF_URL         "https://acme-v02.api.letsencrypt.org/directory"
+#define MD_TAILSCALE_DEF_URL    "file://localhost/var/run/tailscale/tailscaled.sock"
 
 #endif /* mod_md_md_version_h */
index e141ffc10f79edc446789f60491530f4ebd52488..d99fb1ccc46dbf64ba0c056d58499cce211931fb 100644 (file)
@@ -205,6 +205,10 @@ SOURCE=./md_store_fs.c
 # End Source File\r
 # Begin Source File\r
 \r
+SOURCE=./md_tailscale.c\r
+# End Source File\r
+# Begin Source File\r
+\r
 SOURCE=./md_time.c\r
 # End Source File\r
 # Begin Source File\r
index 8d3260634ecb72bd6eeb95b114151902caa0d0c6..82c7191768da9ddd0f18a4ade188c5817a168a2b 100644 (file)
@@ -26,6 +26,7 @@
 #include <http_vhost.h>
 
 #include "md.h"
+#include "md_acme.h"
 #include "md_crypt.h"
 #include "md_log.h"
 #include "md_json.h"
@@ -108,7 +109,7 @@ static md_srv_conf_t defconf = {
     &def_warn_window,          /* warn window */
     NULL,                      /* ca url */
     NULL,                      /* ca contact (email) */
-    "ACME",                    /* ca protocol */
+    MD_PROTO_ACME,             /* ca protocol */
     NULL,                      /* ca agreemnent */
     NULL,                      /* ca challenges array */
     NULL,                      /* ca eab kid */
index 390290b85cd48ca3872adca02a4386e2e7410ae8..6891ef832eeb4398765b34a3c541ed4872c4847c 100644 (file)
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 #include <assert.h>
 #include <apr_optional.h>
 #include <apr_time.h>
@@ -55,6 +55,7 @@
 
 #define APACHE_PREFIX               "/.httpd/"
 #define MD_STATUS_RESOURCE          APACHE_PREFIX"certificate-status"
+#define HTML_STATUS(X)              (!((X)->flags & AP_STATUS_SHORT))
 
 int md_http_cert_status(request_rec *r)
 {
@@ -66,13 +67,13 @@ int md_http_cert_status(request_rec *r)
     const char *keyname;
     apr_bucket_brigade *bb;
     apr_status_t rv;
-    
+
     if (!r->parsed_uri.path || strcmp(MD_STATUS_RESOURCE, r->parsed_uri.path))
         return DECLINED;
-        
+
     ap_log_rerror(APLOG_MARK, APLOG_TRACE2, 0, r,
                   "requesting status for: %s", r->hostname);
-    
+
     /* We are looking for information about a staged certificate */
     sc = ap_get_module_config(r->server->module_config, &md_module);
     if (!sc || !sc->mc || !sc->mc->reg || !sc->mc->certificate_status_enabled) return DECLINED;
@@ -84,7 +85,7 @@ int md_http_cert_status(request_rec *r)
                       "md(%s): status supports only GET", md->name);
         return HTTP_NOT_IMPLEMENTED;
     }
-    
+
     ap_log_rerror(APLOG_MARK, APLOG_TRACE2, 0, r,
                   "requesting status for MD: %s", md->name);
 
@@ -94,7 +95,7 @@ int md_http_cert_status(request_rec *r)
                       "loading md status for %s", md->name);
         return HTTP_INTERNAL_SERVER_ERROR;
     }
-    
+
     ap_log_rerror(APLOG_MARK, APLOG_TRACE2, 0, r,
                   "status for MD: %s is %s", md->name, md_json_writep(mdj, r->pool, MD_JSON_FMT_INDENT));
 
@@ -124,23 +125,23 @@ int md_http_cert_status(request_rec *r)
         }
         md_json_setj(cj, resp, keyname, NULL );
     }
-    
+
     if (md_json_has_key(mdj, MD_KEY_RENEWAL, NULL)) {
            /* copy over the information we want to make public about this:
             *  - when not finished, add an empty object to indicate something is going on
             *  - when a certificate is staged, add the information from that */
            cj = md_json_getj(mdj, MD_KEY_RENEWAL, MD_KEY_CERT, NULL);
-           cj = cj? cj : md_json_create(r->pool);
+           cj = cj? cj : md_json_create(r->pool);
            md_json_setj(cj, resp, MD_KEY_RENEWAL, MD_KEY_CERT, NULL);
      }
-    
+
     ap_log_rerror(APLOG_MARK, APLOG_TRACE2, 0, r, "md[%s]: sending status", md->name);
-    apr_table_set(r->headers_out, "Content-Type", "application/json"); 
+    apr_table_set(r->headers_out, "Content-Type", "application/json");
     bb = apr_brigade_create(r->pool, r->connection->bucket_alloc);
     md_json_writeb(resp, MD_JSON_FMT_INDENT, bb);
     ap_pass_brigade(r->output_filters, bb);
     apr_brigade_cleanup(bb);
-    
+
     return DONE;
 }
 
@@ -151,10 +152,12 @@ typedef struct {
     apr_pool_t *p;
     const md_mod_conf_t *mc;
     apr_bucket_brigade *bb;
+    int flags;
+    const char *prefix;
     const char *separator;
 } status_ctx;
 
-typedef struct status_info status_info; 
+typedef struct status_info status_info;
 
 static void add_json_val(status_ctx *ctx, md_json_t *j);
 
@@ -179,13 +182,19 @@ static void si_val_status(status_ctx *ctx, md_json_t *mdj, const status_info *in
         case MD_S_EXPIRED_DEPRECATED:
         case MD_S_COMPLETE:
             until = md_json_get_time(mdj, MD_KEY_CERT, MD_KEY_VALID, MD_KEY_UNTIL, NULL);
-            s = (!until || until > apr_time_now())? "good" : "expired"; 
+            s = (!until || until > apr_time_now())? "good" : "expired";
             break;
         case MD_S_ERROR: s = "error"; break;
         case MD_S_MISSING_INFORMATION: s = "missing information"; break;
         default: break;
     }
-    apr_brigade_puts(ctx->bb, NULL, NULL, s);
+    if (HTML_STATUS(ctx)) {
+        apr_brigade_puts(ctx->bb, NULL, NULL, s);
+    }
+    else {
+        apr_brigade_printf(ctx->bb, NULL, NULL, "%s%s: %s\n",
+                           ctx->prefix, info->label, s);
+    }
 }
 
 static void si_val_url(status_ctx *ctx, md_json_t *mdj, const status_info *info)
@@ -195,19 +204,28 @@ static void si_val_url(status_ctx *ctx, md_json_t *mdj, const status_info *info)
     s = url = md_json_gets(mdj, info->key, NULL);
     if (!url) return;
     s = md_get_ca_name_from_url(ctx->p, url);
-    apr_brigade_printf(ctx->bb, NULL, NULL, "<a href='%s'>%s</a>",
-                       ap_escape_html2(ctx->p, url, 1), 
-                       ap_escape_html2(ctx->p, s, 1));
+    if (HTML_STATUS(ctx)) {
+        apr_brigade_printf(ctx->bb, NULL, NULL, "<a href='%s'>%s</a>",
+                           ap_escape_html2(ctx->p, url, 1),
+                           ap_escape_html2(ctx->p, s, 1));
+    }
+    else {
+        apr_brigade_printf(ctx->bb, NULL, NULL, "%s%sName: %s\n",
+                           ctx->prefix, info->label, s);
+        apr_brigade_printf(ctx->bb, NULL, NULL, "%s%sURL: %s\n",
+                           ctx->prefix, info->label, url);
+    }
 }
 
-static void print_date(apr_bucket_brigade *bb, apr_time_t timestamp, const char *title)
+static void print_date(status_ctx *ctx, apr_time_t timestamp, const char *title)
 {
+    apr_bucket_brigade *bb = ctx->bb;
     if (timestamp > 0) {
         char ts[128];
         char ts2[128];
         apr_time_exp_t texp;
         apr_size_t len;
-        
+
         apr_time_exp_gmt(&texp, timestamp);
         apr_strftime(ts, &len, sizeof(ts2)-1, "%Y-%m-%d", &texp);
         ts[len] = '\0';
@@ -216,14 +234,21 @@ static void print_date(apr_bucket_brigade *bb, apr_time_t timestamp, const char
             ts2[len] = '\0';
             title = ts2;
         }
-        apr_brigade_printf(bb, NULL, NULL, 
-                           "<span title='%s' style='white-space: nowrap;'>%s</span>", 
-                           ap_escape_html2(bb->p, title, 1), ts);
+        if (HTML_STATUS(ctx)) {
+            apr_brigade_printf(bb, NULL, NULL,
+                               "<span title='%s' style='white-space: nowrap;'>%s</span>",
+                               ap_escape_html2(bb->p, title, 1), ts);
+        }
+        else {
+            apr_brigade_printf(bb, NULL, NULL, "%s%s: %s\n",
+                               ctx->prefix, title, ts);
+        }
     }
 }
 
-static void print_time(apr_bucket_brigade *bb, const char *label, apr_time_t t)
+static void print_time(status_ctx *ctx, const char *label, apr_time_t t)
 {
+    apr_bucket_brigade *bb = ctx->bb;
     apr_time_t now;
     const char *pre, *post, *sep;
     char ts[APR_RFC822_DATE_LEN];
@@ -231,7 +256,7 @@ static void print_time(apr_bucket_brigade *bb, const char *label, apr_time_t t)
     apr_time_exp_t texp;
     apr_size_t len;
     apr_interval_time_t delta;
-    
+
     if (t == 0) {
         /* timestamp is 0, we use that for "not set" */
         return;
@@ -241,25 +266,32 @@ static void print_time(apr_bucket_brigade *bb, const char *label, apr_time_t t)
     pre = post = "";
     sep = (label && strlen(label))? " " : "";
     delta = 0;
-    apr_rfc822_date(ts, t);
-    if (t > now) {
-        delta = t - now;
-        pre = "in ";
-    }
-    else {
-        delta = now - t;
-        post = " ago";
-    }
-    if (delta >= (4 * apr_time_from_sec(MD_SECS_PER_DAY))) {
-        apr_strftime(ts2, &len, sizeof(ts2)-1, "%Y-%m-%d", &texp);
-        ts2[len] = '\0';
-        apr_brigade_printf(bb, NULL, NULL, "%s%s<span title='%s' "
-                           "style='white-space: nowrap;'>%s</span>", 
-                           label, sep, ts, ts2); 
+    if (HTML_STATUS(ctx)) {
+        apr_rfc822_date(ts, t);
+        if (t > now) {
+            delta = t - now;
+            pre = "in ";
+        }
+        else {
+            delta = now - t;
+            post = " ago";
+        }
+        if (delta >= (4 * apr_time_from_sec(MD_SECS_PER_DAY))) {
+            apr_strftime(ts2, &len, sizeof(ts2)-1, "%Y-%m-%d", &texp);
+            ts2[len] = '\0';
+            apr_brigade_printf(bb, NULL, NULL, "%s%s<span title='%s' "
+                               "style='white-space: nowrap;'>%s</span>",
+                               label, sep, ts, ts2);
+        }
+        else {
+            apr_brigade_printf(bb, NULL, NULL, "%s%s<span title='%s'>%s%s%s</span>",
+                               label, sep, ts, pre, md_duration_roughly(bb->p, delta), post);
+        }
     }
     else {
-        apr_brigade_printf(bb, NULL, NULL, "%s%s<span title='%s'>%s%s%s</span>", 
-                           label, sep, ts, pre, md_duration_roughly(bb->p, delta), post); 
+        delta = t - now;
+        apr_brigade_printf(bb, NULL, NULL, "%s%s: %" APR_TIME_T_FMT "\n",
+                           ctx->prefix, label, apr_time_sec(delta));
     }
 }
 
@@ -267,37 +299,51 @@ static void si_val_valid_time(status_ctx *ctx, md_json_t *mdj, const status_info
 {
     const char *sfrom, *suntil, *sep, *title;
     apr_time_t from, until;
-    
+
     sep = NULL;
     sfrom = md_json_gets(mdj, info->key, MD_KEY_FROM, NULL);
     from = sfrom? apr_date_parse_rfc(sfrom) : 0;
     suntil = md_json_gets(mdj, info->key, MD_KEY_UNTIL, NULL);
     until = suntil?apr_date_parse_rfc(suntil) : 0;
-    
-    if (from > apr_time_now()) {
-        apr_brigade_puts(ctx->bb, NULL, NULL, "from ");
-        print_date(ctx->bb, from, sfrom);
-        sep = " ";
-    }
-    if (until) {
-        if (sep) apr_brigade_puts(ctx->bb, NULL, NULL, sep);
-        apr_brigade_puts(ctx->bb, NULL, NULL, "until ");
-        title = sfrom? apr_psprintf(ctx->p, "%s - %s", sfrom, suntil) : suntil;
-        print_date(ctx->bb, until, title);
+
+    if (HTML_STATUS(ctx)) {
+        if (from > apr_time_now()) {
+            apr_brigade_puts(ctx->bb, NULL, NULL, "from ");
+            print_date(ctx, from, sfrom);
+            sep = " ";
+        }
+        if (until) {
+            if (sep) apr_brigade_puts(ctx->bb, NULL, NULL, sep);
+            apr_brigade_puts(ctx->bb, NULL, NULL, "until ");
+            title = sfrom? apr_psprintf(ctx->p, "%s - %s", sfrom, suntil) : suntil;
+            print_date(ctx, until, title);
+        }
+    }
+    else {
+        if (from > apr_time_now()) {
+            print_date(ctx, from,
+            apr_pstrcat(ctx->p, info->label, "From", NULL));
+        }
+        if (until) {
+            print_date(ctx, from,
+            apr_pstrcat(ctx->p, info->label, "Until", NULL));
+        }
     }
 }
 
 static void si_add_header(status_ctx *ctx, const status_info *info)
 {
-    const char *html = ap_escape_html2(ctx->p, info->label, 1);
-    apr_brigade_printf(ctx->bb, NULL, NULL, "<th class=\"%s\">%s</th>", html, html);
+    if (HTML_STATUS(ctx)) {
+        const char *html = ap_escape_html2(ctx->p, info->label, 1);
+        apr_brigade_printf(ctx->bb, NULL, NULL, "<th class=\"%s\">%s</th>", html, html);
+    }
 }
 
 static void si_val_cert_valid_time(status_ctx *ctx, md_json_t *mdj, const status_info *info)
 {
     md_json_t *jcert;
     status_info sub = *info;
-    
+
     sub.key = MD_KEY_VALID;
     jcert = md_json_getj(mdj, info->key, NULL);
     if (jcert) si_val_valid_time(ctx, jcert, &sub);
@@ -306,11 +352,31 @@ static void si_val_cert_valid_time(status_ctx *ctx, md_json_t *mdj, const status
 static void si_val_ca_url(status_ctx *ctx, md_json_t *mdj, const status_info *info)
 {
     md_json_t *jcert;
-    status_info sub = *info;
-    
-    sub.key = MD_KEY_URL;
+
     jcert = md_json_getj(mdj, info->key, NULL);
-    if (jcert) si_val_url(ctx, jcert, &sub);
+    if (jcert) {
+        const char *proto, *s, *url;
+
+        proto = md_json_gets(jcert, MD_KEY_PROTO, NULL);
+        s = url = md_json_gets(jcert, MD_KEY_URL, NULL);
+        if (proto && !strcmp(proto, "tailscale")) {
+            s = "tailscale";
+        }
+        else if (url) {
+            s = md_get_ca_name_from_url(ctx->p, url);
+        }
+        if (HTML_STATUS(ctx)) {
+            apr_brigade_printf(ctx->bb, NULL, NULL, "<a href='%s'>%s</a>",
+                               ap_escape_html2(ctx->p, url, 1),
+                               ap_escape_html2(ctx->p, s, 1));
+        }
+        else {
+            apr_brigade_printf(ctx->bb, NULL, NULL, "%s%sName: %s\n",
+                               ctx->prefix, info->label, s);
+            apr_brigade_printf(ctx->bb, NULL, NULL, "%s%sURL: %s\n",
+                               ctx->prefix, info->label, url);
+        }
+    }
 }
 
 static int count_certs(void *baton, const char *key, md_json_t *json)
@@ -324,83 +390,139 @@ static int count_certs(void *baton, const char *key, md_json_t *json)
     return 1;
 }
 
-static void print_job_summary(apr_bucket_brigade *bb, md_json_t *mdj, const char *key, 
+static void print_job_summary(status_ctx *ctx, md_json_t *mdj, const char *key,
                               const char *separator)
 {
+    apr_bucket_brigade *bb = ctx->bb;
     char buffer[HUGE_STRING_LEN];
     apr_status_t rv;
     int finished, errors, cert_count;
     apr_time_t t;
     const char *s, *line;
-    
+
     if (!md_json_has_key(mdj, key, NULL)) {
         return;
     }
-    
+
     finished = md_json_getb(mdj, key, MD_KEY_FINISHED, NULL);
     errors = (int)md_json_getl(mdj, key, MD_KEY_ERRORS, NULL);
     rv = (apr_status_t)md_json_getl(mdj, key, MD_KEY_LAST, MD_KEY_STATUS, NULL);
-    
+
     line = separator? separator : "";
 
     if (rv != APR_SUCCESS) {
+        char *errstr = apr_strerror(rv, buffer, sizeof(buffer));
         s = md_json_gets(mdj, key, MD_KEY_LAST, MD_KEY_PROBLEM, NULL);
-        line = apr_psprintf(bb->p, "%s Error[%s]: %s", line, 
-                           apr_strerror(rv, buffer, sizeof(buffer)), s? s : "");
+        if (HTML_STATUS(ctx)) {
+            line = apr_psprintf(bb->p, "%s Error[%s]: %s", line,
+                                errstr, s? s : "");
+        }
+        else {
+            apr_brigade_printf(bb, NULL, NULL, "%sLastStatus: %s\n", ctx->prefix, errstr);
+            apr_brigade_printf(bb, NULL, NULL, "%sLastProblem: %s\n", ctx->prefix, s);
+        }
+    }
+
+    if (!HTML_STATUS(ctx)) {
+        apr_brigade_printf(bb, NULL, NULL, "%sFinished: %s\n", ctx->prefix,
+                           finished ? "yes" : "no");
     }
-    
     if (finished) {
         cert_count = 0;
         md_json_iterkey(count_certs, &cert_count, mdj, key, MD_KEY_CERT, NULL);
-        if (cert_count > 0) {
-            line =apr_psprintf(bb->p, "%s  finished, %d new certificate%s staged.",
-                               line, cert_count, cert_count > 1? "s" : "");
+        if (HTML_STATUS(ctx)) {
+            if (cert_count > 0) {
+                line =apr_psprintf(bb->p, "%s  finished, %d new certificate%s staged.",
+                                   line, cert_count, cert_count > 1? "s" : "");
+            }
+            else {
+                line = apr_psprintf(bb->p, "%s  finished successfully.", line);
+            }
         }
         else {
-            line = apr_psprintf(bb->p, "%s  finished successfully.", line);
+            apr_brigade_printf(bb, NULL, NULL, "%sNewStaged: %d\n", ctx->prefix, cert_count);
         }
     }
     else {
         s = md_json_gets(mdj, key, MD_KEY_LAST, MD_KEY_DETAIL, NULL);
-        if (s) line = apr_psprintf(bb->p, "%s %s", line, s);
+        if (s) {
+            if (HTML_STATUS(ctx)) {
+                line = apr_psprintf(bb->p, "%s %s", line, s);
+            }
+            else {
+                apr_brigade_printf(bb, NULL, NULL, "%sLastDetail: %s\n", ctx->prefix, s);
+            }
+        }
     }
-    
+
     errors = (int)md_json_getl(mdj, MD_KEY_ERRORS, NULL);
     if (errors > 0) {
-        line = apr_psprintf(bb->p, "%s (%d retr%s) ", line, 
-            errors, (errors > 1)? "y" : "ies");
-    } 
-    
-    apr_brigade_puts(bb, NULL, NULL, line);
+        if (HTML_STATUS(ctx)) {
+            line = apr_psprintf(bb->p, "%s (%d retr%s) ", line,
+                                errors, (errors > 1)? "y" : "ies");
+        }
+        else {
+            apr_brigade_printf(bb, NULL, NULL, "%sRetries: %d\n", ctx->prefix, errors);
+        }
+    }
+
+    if (HTML_STATUS(ctx)) {
+        apr_brigade_puts(bb, NULL, NULL, line);
+    }
 
     t = md_json_get_time(mdj, key, MD_KEY_NEXT_RUN, NULL);
     if (t > apr_time_now() && !finished) {
-        print_time(bb, "\nNext run", t);
+        print_time(ctx,
+                   HTML_STATUS(ctx) ? "\nNext run" : "NextRun",
+                   t);
     }
-    else if (!strlen(line)) {
-        apr_brigade_puts(bb, NULL, NULL, "\nOngoing...");
+    else if (line[0] != '\0') {
+        if (HTML_STATUS(ctx)) {
+            apr_brigade_puts(bb, NULL, NULL, "\nOngoing...");
+        }
+        else {
+            apr_brigade_printf(bb, NULL, NULL, "%s: Ongoing\n", ctx->prefix);
+        }
     }
 }
 
 static void si_val_activity(status_ctx *ctx, md_json_t *mdj, const status_info *info)
 {
     apr_time_t t;
-    
+    const char *prefix = ctx->prefix;
+
     (void)info;
+    if (!HTML_STATUS(ctx)) {
+        ctx->prefix = apr_pstrcat(ctx->p, prefix, info->label, NULL);
+    }
+
     if (md_json_has_key(mdj, MD_KEY_RENEWAL, NULL)) {
-        print_job_summary(ctx->bb, mdj, MD_KEY_RENEWAL, NULL);
+        print_job_summary(ctx, mdj, MD_KEY_RENEWAL, NULL);
         return;
     }
-    
+
     t = md_json_get_time(mdj, MD_KEY_RENEW_AT, NULL);
     if (t > apr_time_now()) {
-        print_time(ctx->bb, "Renew", t);
+        print_time(ctx, "Renew", t);
     }
     else if (t) {
-        apr_brigade_puts(ctx->bb, NULL, NULL, "Pending");
+        if (HTML_STATUS(ctx)) {
+            apr_brigade_puts(ctx->bb, NULL, NULL, "Pending");
+        }
+        else {
+            apr_brigade_printf(ctx->bb, NULL, NULL, "%s: %s", ctx->prefix, "Pending");
+        }
     }
     else if (MD_RENEW_MANUAL == md_json_getl(mdj, MD_KEY_RENEW_MODE, NULL)) {
-        apr_brigade_puts(ctx->bb, NULL, NULL, "Manual renew");
+        if (HTML_STATUS(ctx)) {
+            apr_brigade_puts(ctx->bb, NULL, NULL, "Manual renew");
+        }
+        else {
+            apr_brigade_printf(ctx->bb, NULL, NULL, "%s: %s", ctx->prefix, "Manual renew");
+        }
+    }
+    if (!HTML_STATUS(ctx)) {
+        ctx->prefix = prefix;
     }
 }
 
@@ -408,13 +530,33 @@ static int cert_check_iter(void *baton, const char *key, md_json_t *json)
 {
     status_ctx *ctx = baton;
     const char *fingerprint;
-    
+
     fingerprint = md_json_gets(json, MD_KEY_SHA256_FINGERPRINT, NULL);
     if (fingerprint) {
-        apr_brigade_printf(ctx->bb, NULL, NULL, 
-                           "<a href=\"%s%s\">%s[%s]</a><br>", 
-                           ctx->mc->cert_check_url, fingerprint, 
-                           ctx->mc->cert_check_name, key);
+        if (HTML_STATUS(ctx)) {
+            apr_brigade_printf(ctx->bb, NULL, NULL,
+                               "<a href=\"%s%s\">%s[%s]</a><br>",
+                               ctx->mc->cert_check_url, fingerprint,
+                               ctx->mc->cert_check_name, key);
+        }
+        else {
+            apr_brigade_printf(ctx->bb, NULL, NULL,
+                               "%sType: %s\n",
+                               ctx->prefix,
+                               key);
+            apr_brigade_printf(ctx->bb, NULL, NULL,
+                               "%sName: %s\n",
+                               ctx->prefix,
+                               ctx->mc->cert_check_name);
+            apr_brigade_printf(ctx->bb, NULL, NULL,
+                               "%sURL: %s%s\n",
+                               ctx->prefix,
+                               ctx->mc->cert_check_url, fingerprint);
+            apr_brigade_printf(ctx->bb, NULL, NULL,
+                               "%sFingerprint: %s\n",
+                               ctx->prefix,
+                               fingerprint);
+        }
     }
     return 1;
 }
@@ -423,7 +565,14 @@ static void si_val_remote_check(status_ctx *ctx, md_json_t *mdj, const status_in
 {
     (void)info;
     if (ctx->mc->cert_check_name && ctx->mc->cert_check_url) {
+        const char *prefix = ctx->prefix;
+        if (!HTML_STATUS(ctx)) {
+            ctx->prefix = apr_pstrcat(ctx->p, prefix, info->label, NULL);
+        }
         md_json_iterkey(cert_check_iter, ctx, mdj, MD_KEY_CERT, NULL);
+        if (!HTML_STATUS(ctx)) {
+            ctx->prefix = prefix;
+        }
     }
 }
 
@@ -431,24 +580,43 @@ static void si_val_stapling(status_ctx *ctx, md_json_t *mdj, const status_info *
 {
     (void)info;
     if (!md_json_getb(mdj, MD_KEY_STAPLING, NULL)) return;
-    apr_brigade_puts(ctx->bb, NULL, NULL, "on");
+    if (HTML_STATUS(ctx)) {
+        apr_brigade_puts(ctx->bb, NULL, NULL, "on");
+    }
+    else {
+        apr_brigade_printf(ctx->bb, NULL, NULL, "%s: on", ctx->prefix);
+    }
 }
 
 static int json_iter_val(void *data, size_t index, md_json_t *json)
 {
     status_ctx *ctx = data;
-    if (index) apr_brigade_puts(ctx->bb, NULL, NULL, ctx->separator);
+    const char *prefix = ctx->prefix;
+    if (HTML_STATUS(ctx)) {
+        if (index) apr_brigade_puts(ctx->bb, NULL, NULL, ctx->separator);
+    }
+    else {
+        ctx->prefix = apr_pstrcat(ctx->p, prefix, apr_psprintf(ctx->p, "[%" APR_SIZE_T_FMT "]", index), NULL);
+    }
     add_json_val(ctx, json);
+    if (!HTML_STATUS(ctx)) {
+        ctx->prefix = prefix;
+    }
     return 1;
 }
 
 static void add_json_val(status_ctx *ctx, md_json_t *j)
 {
     if (!j) return;
-    else if (md_json_is(MD_JSON_TYPE_ARRAY, j, NULL)) {
+    if (md_json_is(MD_JSON_TYPE_ARRAY, j, NULL)) {
         md_json_itera(json_iter_val, ctx, j, NULL);
+        return;
     }
-    else if (md_json_is(MD_JSON_TYPE_INT, j, NULL)) {
+    if (!HTML_STATUS(ctx)) {
+        apr_brigade_puts(ctx->bb, NULL, NULL, ctx->prefix);
+        apr_brigade_puts(ctx->bb, NULL, NULL, ": ");
+    }
+    if (md_json_is(MD_JSON_TYPE_INT, j, NULL)) {
         md_json_writeb(j, MD_JSON_FMT_COMPACT, ctx->bb);
     }
     else if (md_json_is(MD_JSON_TYPE_STRING, j, NULL)) {
@@ -460,13 +628,27 @@ static void add_json_val(status_ctx *ctx, md_json_t *j)
     else if (md_json_is(MD_JSON_TYPE_BOOL, j, NULL)) {
         apr_brigade_puts(ctx->bb, NULL, NULL, md_json_getb(j, NULL)? "on" : "off");
     }
+    if (!HTML_STATUS(ctx)) {
+        apr_brigade_puts(ctx->bb, NULL, NULL, "\n");
+    }
 }
 
 static void si_val_names(status_ctx *ctx, md_json_t *mdj, const status_info *info)
 {
-    apr_brigade_puts(ctx->bb, NULL, NULL, "<div style=\"max-width:400px;\">");
+    const char *prefix = ctx->prefix;
+    if (HTML_STATUS(ctx)) {
+        apr_brigade_puts(ctx->bb, NULL, NULL, "<div style=\"max-width:400px;\">");
+    }
+    else {
+        ctx->prefix = apr_pstrcat(ctx->p, prefix, info->label, NULL);
+    }
     add_json_val(ctx, md_json_getj(mdj, info->key, NULL));
-    apr_brigade_puts(ctx->bb, NULL, NULL, "</div>");
+    if (HTML_STATUS(ctx)) {
+        apr_brigade_puts(ctx->bb, NULL, NULL, "</div>");
+    }
+    else {
+        ctx->prefix = prefix;
+    }
 }
 
 static void add_status_cell(status_ctx *ctx, md_json_t *mdj, const status_info *info)
@@ -475,7 +657,14 @@ static void add_status_cell(status_ctx *ctx, md_json_t *mdj, const status_info *
         info->fn(ctx, mdj, info);
     }
     else {
+        const char *prefix = ctx->prefix;
+        if (!HTML_STATUS(ctx)) {
+            ctx->prefix = apr_pstrcat(ctx->p, prefix, info->label, NULL);
+        }
         add_json_val(ctx, md_json_getj(mdj, info->key, NULL));
+        if (!HTML_STATUS(ctx)) {
+            ctx->prefix = prefix;
+        }
     }
 }
 
@@ -486,22 +675,31 @@ static const status_info status_infos[] = {
     { "Valid", MD_KEY_CERT, si_val_cert_valid_time },
     { "CA", MD_KEY_CA, si_val_ca_url },
     { "Stapling", MD_KEY_STAPLING, si_val_stapling },
-    { "Check@", MD_KEY_SHA256_FINGERPRINT, si_val_remote_check },
-    { "Activity",  MD_KEY_NOTIFIED, si_val_activity },
+    { "CheckAt", MD_KEY_SHA256_FINGERPRINT, si_val_remote_check },
+    { "Activity", MD_KEY_NOTIFIED, si_val_activity },
 };
 
 static int add_md_row(void *baton, apr_size_t index, md_json_t *mdj)
 {
     status_ctx *ctx = baton;
+    const char *prefix = ctx->prefix;
     int i;
-    
-    apr_brigade_printf(ctx->bb, NULL, NULL, "<tr class=\"%s\">", (index % 2)? "odd" : "even");
-    for (i = 0; i < (int)(sizeof(status_infos)/sizeof(status_infos[0])); ++i) {
-        apr_brigade_puts(ctx->bb, NULL, NULL, "<td>");
-        add_status_cell(ctx, mdj, &status_infos[i]);
-        apr_brigade_puts(ctx->bb, NULL, NULL, "</td>");
-    }
-    apr_brigade_puts(ctx->bb, NULL, NULL, "</tr>");
+
+    if (HTML_STATUS(ctx)) {
+        apr_brigade_printf(ctx->bb, NULL, NULL, "<tr class=\"%s\">", (index % 2)? "odd" : "even");
+        for (i = 0; i < (int)(sizeof(status_infos)/sizeof(status_infos[0])); ++i) {
+            apr_brigade_puts(ctx->bb, NULL, NULL, "<td>");
+            add_status_cell(ctx, mdj, &status_infos[i]);
+            apr_brigade_puts(ctx->bb, NULL, NULL, "</td>");
+        }
+        apr_brigade_puts(ctx->bb, NULL, NULL, "</tr>");
+    } else {
+        for (i = 0; i < (int)(sizeof(status_infos)/sizeof(status_infos[0])); ++i) {
+            ctx->prefix = apr_pstrcat(ctx->p, prefix, apr_psprintf(ctx->p, "[%" APR_SIZE_T_FMT "]", index), NULL);
+            add_status_cell(ctx, mdj, &status_infos[i]);
+            ctx->prefix = prefix;
+        }
+    }
     return 1;
 }
 
@@ -514,96 +712,121 @@ int md_domains_status_hook(request_rec *r, int flags)
 {
     const md_srv_conf_t *sc;
     const md_mod_conf_t *mc;
-    int i, html;
+    int i;
     status_ctx ctx;
     apr_array_header_t *mds;
     md_json_t *jstatus, *jstock;
-    
+
     ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "server-status for managed domains, start");
     sc = ap_get_module_config(r->server->module_config, &md_module);
     if (!sc) return DECLINED;
     mc = sc->mc;
     if (!mc || !mc->server_status_enabled) return DECLINED;
 
-    html = !(flags & AP_STATUS_SHORT);
     ctx.p = r->pool;
     ctx.mc = mc;
     ctx.bb = apr_brigade_create(r->pool, r->connection->bucket_alloc);
+    ctx.flags = flags;
+    ctx.prefix = "ManagedCertificates";
     ctx.separator = " ";
 
     mds = apr_array_copy(r->pool, mc->mds);
     qsort(mds->elts, (size_t)mds->nelts, sizeof(md_t *), md_name_cmp);
 
-    if (!html) {
-        ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "no-html summary");
-        apr_brigade_puts(ctx.bb, NULL, NULL, "Managed Certificates: ");
+    if (!HTML_STATUS(&ctx)) {
+        int total = 0, complete = 0, renewing = 0, errored = 0, ready = 0;
+        ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "no-html managed domain status summary");
         if (mc->mds->nelts > 0) {
             md_status_take_stock(&jstock, mds, mc->reg, r->pool);
-            ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "got JSON summary");
-            apr_brigade_printf(ctx.bb, NULL, NULL, "total=%d, ok=%d renew=%d errored=%d ready=%d",
-                                (int)md_json_getl(jstock, MD_KEY_TOTAL, NULL), 
-                                (int)md_json_getl(jstock, MD_KEY_COMPLETE, NULL), 
-                                (int)md_json_getl(jstock, MD_KEY_RENEWING, NULL), 
-                                (int)md_json_getl(jstock, MD_KEY_ERRORED, NULL), 
-                                (int)md_json_getl(jstock, MD_KEY_READY, NULL));
-        } 
-        else {
-            apr_brigade_puts(ctx.bb, NULL, NULL, "[]"); 
+            ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "got JSON managed domain status summary");
+            total = (int)md_json_getl(jstock, MD_KEY_TOTAL, NULL);
+            complete = (int)md_json_getl(jstock, MD_KEY_COMPLETE, NULL);
+            renewing = (int)md_json_getl(jstock, MD_KEY_RENEWING, NULL);
+            errored = (int)md_json_getl(jstock, MD_KEY_ERRORED, NULL);
+            ready = (int)md_json_getl(jstock, MD_KEY_READY, NULL);
         }
-        apr_brigade_puts(ctx.bb, NULL, NULL, "\n"); 
+        apr_brigade_printf(ctx.bb, NULL, NULL, "%sTotal: %d\n", ctx.prefix, total);
+        apr_brigade_printf(ctx.bb, NULL, NULL, "%sOK: %d\n", ctx.prefix, complete);
+        apr_brigade_printf(ctx.bb, NULL, NULL, "%sRenew: %d\n", ctx.prefix, renewing);
+        apr_brigade_printf(ctx.bb, NULL, NULL, "%sErrored: %d\n", ctx.prefix, errored);
+        apr_brigade_printf(ctx.bb, NULL, NULL, "%sReady: %d\n", ctx.prefix, ready);
     }
-    else if (mc->mds->nelts > 0) {
-        ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "html table");
+    if (mc->mds->nelts > 0) {
         md_status_get_json(&jstatus, mds, mc->reg, mc->ocsp, r->pool);
-        ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "got JSON status");
-        apr_brigade_puts(ctx.bb, NULL, NULL, 
-                         "<hr>\n<h3>Managed Certificates</h3>\n<table class='md_status'><thead><tr>\n");
-        for (i = 0; i < (int)(sizeof(status_infos)/sizeof(status_infos[0])); ++i) {
-            si_add_header(&ctx, &status_infos[i]);
+        ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "got JSON managed domain status");
+        if (HTML_STATUS(&ctx)) {
+            ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "html managed domain status table");
+            apr_brigade_puts(ctx.bb, NULL, NULL,
+                             "<hr>\n<h3>Managed Certificates</h3>\n<table class='md_status'><thead><tr>\n");
+            for (i = 0; i < (int)(sizeof(status_infos)/sizeof(status_infos[0])); ++i) {
+                si_add_header(&ctx, &status_infos[i]);
+            }
+            apr_brigade_puts(ctx.bb, NULL, NULL, "</tr>\n</thead><tbody>");
+        }
+        else {
+            ctx.prefix = "ManagedDomain";
         }
-        apr_brigade_puts(ctx.bb, NULL, NULL, "</tr>\n</thead><tbody>");
+        ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "iterating JSON managed domain status");
         md_json_itera(add_md_row, &ctx, jstatus, MD_KEY_MDS, NULL);
-        apr_brigade_puts(ctx.bb, NULL, NULL, "</td></tr>\n</tbody>\n</table>\n");
+        if (HTML_STATUS(&ctx)) {
+            apr_brigade_puts(ctx.bb, NULL, NULL, "</td></tr>\n</tbody>\n</table>\n");
+        }
     }
 
     ap_pass_brigade(r->output_filters, ctx.bb);
     apr_brigade_cleanup(ctx.bb);
     ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "server-status for managed domains, end");
-    
+
     return OK;
 }
 
 static void si_val_ocsp_activity(status_ctx *ctx, md_json_t *mdj, const status_info *info)
 {
     apr_time_t t;
-    
+    const char *prefix = ctx->prefix;
+
     (void)info;
-    t = md_json_get_time(mdj,  MD_KEY_RENEW_AT, NULL);
-    print_time(ctx->bb, "Refresh", t);
-    print_job_summary(ctx->bb, mdj, MD_KEY_RENEWAL, ": ");
+    if (!HTML_STATUS(ctx)) {
+        ctx->prefix = apr_pstrcat(ctx->p, prefix, info->label, NULL);
+    }
+    t = md_json_get_time(mdj, MD_KEY_RENEW_AT, NULL);
+    print_time(ctx, "Refresh", t);
+    print_job_summary(ctx, mdj, MD_KEY_RENEWAL, ": ");
+    if (!HTML_STATUS(ctx)) {
+        ctx->prefix = prefix;
+    }
 }
 
 static const status_info ocsp_status_infos[] = {
     { "Domain", MD_KEY_DOMAIN, NULL },
-    { "Certificate ID", MD_KEY_ID, NULL },
-    { "OCSP Status", MD_KEY_STATUS, NULL },
-    { "Stapling Valid", MD_KEY_VALID, si_val_valid_time },
+    { "CertificateID", MD_KEY_ID, NULL },
+    { "OCSPStatus", MD_KEY_STATUS, NULL },
+    { "StaplingValid", MD_KEY_VALID, si_val_valid_time },
     { "Responder", MD_KEY_URL, si_val_url },
-    { "Activity",  MD_KEY_NOTIFIED, si_val_ocsp_activity },
+    { "Activity", MD_KEY_NOTIFIED, si_val_ocsp_activity },
 };
 
 static int add_ocsp_row(void *baton, apr_size_t index, md_json_t *mdj)
 {
     status_ctx *ctx = baton;
+    const char *prefix = ctx->prefix;
     int i;
-    
-    apr_brigade_printf(ctx->bb, NULL, NULL, "<tr class=\"%s\">", (index % 2)? "odd" : "even");
-    for (i = 0; i < (int)(sizeof(ocsp_status_infos)/sizeof(ocsp_status_infos[0])); ++i) {
-        apr_brigade_puts(ctx->bb, NULL, NULL, "<td>");
-        add_status_cell(ctx, mdj, &ocsp_status_infos[i]);
-        apr_brigade_puts(ctx->bb, NULL, NULL, "</td>");
-    }
-    apr_brigade_puts(ctx->bb, NULL, NULL, "</tr>");
+
+    if (HTML_STATUS(ctx)) {
+        apr_brigade_printf(ctx->bb, NULL, NULL, "<tr class=\"%s\">", (index % 2)? "odd" : "even");
+        for (i = 0; i < (int)(sizeof(ocsp_status_infos)/sizeof(ocsp_status_infos[0])); ++i) {
+            apr_brigade_puts(ctx->bb, NULL, NULL, "<td>");
+            add_status_cell(ctx, mdj, &ocsp_status_infos[i]);
+            apr_brigade_puts(ctx->bb, NULL, NULL, "</td>");
+        }
+        apr_brigade_puts(ctx->bb, NULL, NULL, "</tr>");
+    } else {
+        for (i = 0; i < (int)(sizeof(ocsp_status_infos)/sizeof(ocsp_status_infos[0])); ++i) {
+            ctx->prefix = apr_pstrcat(ctx->p, prefix, apr_psprintf(ctx->p, "[%" APR_SIZE_T_FMT "]", index), NULL);
+            add_status_cell(ctx, mdj, &ocsp_status_infos[i]);
+            ctx->prefix = prefix;
+        }
+    }
     return 1;
 }
 
@@ -611,53 +834,65 @@ int md_ocsp_status_hook(request_rec *r, int flags)
 {
     const md_srv_conf_t *sc;
     const md_mod_conf_t *mc;
-    int i, html;
+    int i;
     status_ctx ctx;
     md_json_t *jstatus, *jstock;
-    
+
     ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "server-status for ocsp stapling, start");
     sc = ap_get_module_config(r->server->module_config, &md_module);
     if (!sc) return DECLINED;
     mc = sc->mc;
     if (!mc || !mc->server_status_enabled) return DECLINED;
 
-    html = !(flags & AP_STATUS_SHORT);
     ctx.p = r->pool;
     ctx.mc = mc;
     ctx.bb = apr_brigade_create(r->pool, r->connection->bucket_alloc);
+    ctx.flags = flags;
+    ctx.prefix = "ManagedStaplings";
     ctx.separator = " ";
 
-    if (!html) {
-        apr_brigade_puts(ctx.bb, NULL, NULL, "Managed Staplings: ");
+    if (!HTML_STATUS(&ctx)) {
+        int total = 0, good = 0, revoked = 0, unknown = 0;
+        ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "no-html ocsp stapling status summary");
         if (md_ocsp_count(mc->ocsp) > 0) {
             md_ocsp_get_summary(&jstock, mc->ocsp, r->pool);
-            apr_brigade_printf(ctx.bb, NULL, NULL, "total=%d, good=%d revoked=%d unknown=%d",
-                                (int)md_json_getl(jstock, MD_KEY_TOTAL, NULL), 
-                                (int)md_json_getl(jstock, MD_KEY_GOOD, NULL), 
-                                (int)md_json_getl(jstock, MD_KEY_REVOKED, NULL), 
-                                (int)md_json_getl(jstock, MD_KEY_UNKNOWN, NULL));
-        } 
-        else {
-            apr_brigade_puts(ctx.bb, NULL, NULL, "[]"); 
+            ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "got JSON ocsp stapling status summary");
+            total = (int)md_json_getl(jstock, MD_KEY_TOTAL, NULL);
+            good = (int)md_json_getl(jstock, MD_KEY_GOOD, NULL);
+            revoked = (int)md_json_getl(jstock, MD_KEY_REVOKED, NULL);
+            unknown = (int)md_json_getl(jstock, MD_KEY_UNKNOWN, NULL);
         }
-        apr_brigade_puts(ctx.bb, NULL, NULL, "\n"); 
+        apr_brigade_printf(ctx.bb, NULL, NULL, "%sTotal: %d\n", ctx.prefix, total);
+        apr_brigade_printf(ctx.bb, NULL, NULL, "%sOK: %d\n", ctx.prefix, good);
+        apr_brigade_printf(ctx.bb, NULL, NULL, "%sRenew: %d\n", ctx.prefix, revoked);
+        apr_brigade_printf(ctx.bb, NULL, NULL, "%sErrored: %d\n", ctx.prefix, unknown);
     }
-    else if (md_ocsp_count(mc->ocsp) > 0) {
+    if (md_ocsp_count(mc->ocsp) > 0) {
         md_ocsp_get_status_all(&jstatus, mc->ocsp, r->pool);
-        apr_brigade_puts(ctx.bb, NULL, NULL, 
-                         "<hr>\n<h3>Managed Staplings</h3>\n<table class='md_ocsp_status'><thead><tr>\n");
-        for (i = 0; i < (int)(sizeof(ocsp_status_infos)/sizeof(ocsp_status_infos[0])); ++i) {
-            si_add_header(&ctx, &ocsp_status_infos[i]);
+        ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "got JSON ocsp stapling status");
+        if (HTML_STATUS(&ctx)) {
+            ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "html ocsp stapling status table");
+            apr_brigade_puts(ctx.bb, NULL, NULL,
+                             "<hr>\n<h3>Managed Staplings</h3>\n<table class='md_ocsp_status'><thead><tr>\n");
+            for (i = 0; i < (int)(sizeof(ocsp_status_infos)/sizeof(ocsp_status_infos[0])); ++i) {
+                si_add_header(&ctx, &ocsp_status_infos[i]);
+            }
+            apr_brigade_puts(ctx.bb, NULL, NULL, "</tr>\n</thead><tbody>");
+        }
+        else {
+            ctx.prefix = "ManagedStapling";
         }
-        apr_brigade_puts(ctx.bb, NULL, NULL, "</tr>\n</thead><tbody>");
+        ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "iterating JSON ocsp stapling status");
         md_json_itera(add_ocsp_row, &ctx, jstatus, MD_KEY_OCSPS, NULL);
-        apr_brigade_puts(ctx.bb, NULL, NULL, "</td></tr>\n</tbody>\n</table>\n");
+        if (HTML_STATUS(&ctx)) {
+            apr_brigade_puts(ctx.bb, NULL, NULL, "</td></tr>\n</tbody>\n</table>\n");
+        }
     }
 
     ap_pass_brigade(r->output_filters, ctx.bb);
     apr_brigade_cleanup(ctx.bb);
     ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "server-status for ocsp stapling, end");
-    
+
     return OK;
 }
 
@@ -687,7 +922,7 @@ int md_status_handler(request_rec *r)
         ap_log_rerror(APLOG_MARK, APLOG_TRACE2, 0, r, "md-status supports only GET");
         return HTTP_NOT_IMPLEMENTED;
     }
-    
+
     jstatus = NULL;
     md = NULL;
     if (r->path_info && r->path_info[0] == '/' && r->path_info[1] != '\0') {
@@ -695,7 +930,7 @@ int md_status_handler(request_rec *r)
         md = md_get_by_name(mc->mds, name);
         if (!md) md = md_get_by_domain(mc->mds, name);
     }
-    
+
     if (md) {
         md_status_get_md_json(&jstatus, md, mc->reg, mc->ocsp, r->pool);
     }
@@ -706,12 +941,12 @@ int md_status_handler(request_rec *r)
     }
 
     if (jstatus) {
-        apr_table_set(r->headers_out, "Content-Type", "application/json"); 
+        apr_table_set(r->headers_out, "Content-Type", "application/json");
         bb = apr_brigade_create(r->pool, r->connection->bucket_alloc);
         md_json_writeb(jstatus, MD_JSON_FMT_INDENT, bb);
         ap_pass_brigade(r->output_filters, bb);
         apr_brigade_cleanup(bb);
-        
+
         return DONE;
     }
     return DECLINED;
index 718e5d1742f5eabcca3cb14e5727d166bd06bc14..ca07f969374cd8e284ff70c2f9d5e7274991a6a2 100755 (executable)
@@ -111,6 +111,7 @@ class MDTestEnv(HttpdTestEnv):
 
         self._a2md_bin = os.path.join(self.bin_dir, 'a2md')
         self._default_domain = f"test1.{self.http_tld}"
+        self._tailscale_domain = "test.headless-chicken.ts.net"
         self._store_dir = "./md"
         self.set_store_dir_default()
 
@@ -119,6 +120,7 @@ class MDTestEnv(HttpdTestEnv):
                             valid_from=timedelta(days=-100),
                             valid_to=timedelta(days=-10)),
             CertificateSpec(domains=["localhost"], key_type='rsa2048'),
+            CertificateSpec(domains=[self._tailscale_domain]),
         ])
 
     def setup_httpd(self, setup: HttpdTestSetup = None):
@@ -168,6 +170,10 @@ class MDTestEnv(HttpdTestEnv):
     def store_dir(self):
         return self._store_dir
 
+    @property
+    def tailscale_domain(self):
+        return self._tailscale_domain
+
     def get_request_domain(self, request):
         name = request.node.originalname if request.node.originalname else request.node.name
         return "%s-%s" % (re.sub(r'[_]', '-', name), MDTestEnv.DOMAIN_SUFFIX)
diff --git a/test/modules/md/test_780_tailscale.py b/test/modules/md/test_780_tailscale.py
new file mode 100644 (file)
index 0000000..84a266b
--- /dev/null
@@ -0,0 +1,186 @@
+import os
+import re
+import socket
+import sys
+from threading import Thread
+
+import pytest
+
+from .md_conf import MDConf
+
+
+class TailscaleFaker:
+
+    def __init__(self, env, path):
+        self.env = env
+        self._uds_path = path
+        self._done = False
+
+    def start(self):
+        def process(self):
+            self._socket.listen(1)
+            self._process()
+
+        try:
+            os.unlink(self._uds_path)
+        except OSError:
+            if os.path.exists(self._uds_path):
+                raise
+        self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        self._socket.bind(self._uds_path)
+        self._thread = Thread(target=process, daemon=True, args=[self])
+        self._thread.start()
+
+    def stop(self):
+        self._done = True
+        self._socket.close()
+
+    def send_error(self, c, status, reason):
+        c.sendall(f"""HTTP/1.1 {status} {reason}\r
+Server: TailscaleFaker\r
+Content-Length: 0\r
+Connection: close\r
+\r
+""".encode())
+
+    def send_data(self, c, ctype: str, data: bytes):
+        c.sendall(f"""HTTP/1.1 200 OK\r
+Server: TailscaleFaker\r
+Content-Type: {ctype}\r
+Content-Length: {len(data)}\r
+Connection: close\r
+\r
+""".encode() + data)
+
+    def _process(self):
+        # a http server written on a sunny afternooon
+        while self._done is False:
+            try:
+                c, client_address = self._socket.accept()
+                try:
+                    data = c.recv(1024)
+                    lines = data.decode().splitlines()
+                    m = re.match(r'^(?P<method>\w+)\s+(?P<uri>\S+)\s+HTTP/1.1', lines[0])
+                    if m is None:
+                        self.send_error(c, 400, "Bad Request")
+                        continue
+                    uri = m.group('uri')
+                    m = re.match(r'/localapi/v0/cert/(?P<domain>\S+)\?type=(?P<type>\w+)', uri)
+                    if m is None:
+                        self.send_error(c, 404, "Not Found")
+                        continue
+                    domain = m.group('domain')
+                    cred_type = m.group('type')
+                    creds = self.env.get_credentials_for_name(domain)
+                    sys.stderr.write(f"lookup domain={domain}, type={cred_type} -> {creds}\n")
+                    if creds is None or len(creds) == 0:
+                        self.send_error(c, 404, "Not Found")
+                        continue
+                    if cred_type == 'crt':
+                        self.send_data(c, "text/plain", creds[0].cert_pem)
+                        pass
+                    elif cred_type == 'key':
+                        self.send_data(c, "text/plain", creds[0].pkey_pem)
+                    else:
+                        self.send_error(c, 404, "Not Found")
+                        continue
+                finally:
+                    c.close()
+
+            except ConnectionAbortedError:
+                self._done = True
+
+
+class TestTailscale:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env, acme):
+        UDS_PATH = f"{env.gen_dir}/tailscale.sock"
+        TestTailscale.UDS_PATH = UDS_PATH
+        faker = TailscaleFaker(env=env, path=UDS_PATH)
+        faker.start()
+        env.APACHE_CONF_SRC = "data/test_auto"
+        acme.start(config='default')
+        env.clear_store()
+        MDConf(env).install()
+        assert env.apache_restart() == 0
+        yield
+        faker.stop()
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _method_scope(self, env, request):
+        env.clear_store()
+        self.test_domain = env.get_request_domain(request)
+
+    def _write_res_file(self, doc_root, name, content):
+        if not os.path.exists(doc_root):
+            os.makedirs(doc_root)
+        open(os.path.join(doc_root, name), "w").write(content)
+
+    # create a MD using `tailscale` as protocol, wrong path
+    def test_md_780_001(self, env):
+        domain = env.tailscale_domain
+        # generate config with one MD
+        domains = [domain]
+        socket_path = '/xxx'
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.start_md(domains)
+        conf.add([
+            "MDCertificateProtocol tailscale",
+            f"MDCertificateAuthority file://{socket_path}",
+        ])
+        conf.end_md()
+        conf.add_vhost(domains)
+        conf.install()
+        # restart and watch it fail due to wrong tailscale unix socket path
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['status-description'] == 'No such file or directory'
+        assert md['renewal']['last']['detail'] == \
+               f"tailscale socket not available, may not be up: {socket_path}"
+
+    # create a MD using `tailscale` as protocol, path to faker, should succeed
+    def test_md_780_002(self, env):
+        domain = env.tailscale_domain
+        # generate config with one MD
+        domains = [domain]
+        socket_path = '/xxx'
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.start_md(domains)
+        conf.add([
+            "MDCertificateProtocol tailscale",
+            f"MDCertificateAuthority file://{self.UDS_PATH}",
+        ])
+        conf.end_md()
+        conf.add_vhost(domains)
+        conf.install()
+        # restart and watch it fail due to wrong tailscale unix socket path
+        assert env.apache_restart() == 0
+        assert env.await_completion(domains)
+        assert env.apache_restart() == 0
+        env.check_md_complete(domain)
+
+    # create a MD using `tailscale` as protocol, but domain name not assigned by tailscale
+    def test_md_780_003(self, env):
+        domain = "test.not-correct.ts.net"
+        # generate config with one MD
+        domains = [domain]
+        socket_path = '/xxx'
+        conf = MDConf(env, admin="admin@" + domain)
+        conf.start_md(domains)
+        conf.add([
+            "MDCertificateProtocol tailscale",
+            f"MDCertificateAuthority file://{self.UDS_PATH}",
+        ])
+        conf.end_md()
+        conf.add_vhost(domains)
+        conf.install()
+        # restart and watch it fail due to wrong tailscale unix socket path
+        assert env.apache_restart() == 0
+        md = env.await_error(domain)
+        assert md
+        assert md['renewal']['errors'] > 0
+        assert md['renewal']['last']['status-description'] == 'No such file or directory'
+        assert md['renewal']['last']['detail'] == "retrieving certificate from tailscale"
index 36c520ac94b849af8f8d9e286ee811c6ef02ab29..c89ce6d8d7087a8febd68fd6fdbe709bc32989f2 100644 (file)
@@ -148,13 +148,17 @@ Protocols h2 http/1.1 acme-tls/1
         assert re.search(r'<h3>Managed Certificates</h3>', status, re.MULTILINE)
         # get the ascii summary
         status = env.get_server_status(query="?auto", via_domain=env.http_addr, use_https=False)
-        m = re.search(r'Managed Certificates: total=(\d+), ok=(\d+) renew=(\d+) errored=(\d+) ready=(\d+)',
-                      status, re.MULTILINE)
+        m = re.search(r'ManagedCertificatesTotal: (\d+)', status, re.MULTILINE)
+        assert m, status
+        assert int(m.group(1)) == 1
+        m = re.search(r'ManagedCertificatesOK: (\d+)', status, re.MULTILINE)
+        assert int(m.group(1)) == 0
+        m = re.search(r'ManagedCertificatesRenew: (\d+)', status, re.MULTILINE)
+        assert int(m.group(1)) == 1
+        m = re.search(r'ManagedCertificatesErrored: (\d+)', status, re.MULTILINE)
+        assert int(m.group(1)) == 0
+        m = re.search(r'ManagedCertificatesReady: (\d+)', status, re.MULTILINE)
         assert int(m.group(1)) == 1
-        assert int(m.group(2)) == 0
-        assert int(m.group(3)) == 1
-        assert int(m.group(4)) == 0
-        assert int(m.group(5)) == 1
 
     def test_md_920_011(self, env):
         # MD with static cert files in base server, see issue #161