Showing 5 changed files with 321 additions and 0 deletions
+35 -0
.doc/host-manager.md
@@ -28,9 +28,12 @@ Healthcheck-ul `/healthz` este disponibil doar pe backend-ul local (`127.0.0.1:8
28 28
 Endpoint-uri cu OTP:
29 29
 
30 30
 - `/api/hosts`
31
+- `/api/ca/status`
32
+- `/api/ca/certificates`
31 33
 - `/download/hosts.yaml`
32 34
 - `/download/local-hosts.tsv`
33 35
 - `/download/monitoring.json`
36
+- `/download/ca.crt`
34 37
 - `POST /api/hosts/upsert`
35 38
 - `POST /api/hosts/delete`
36 39
 - `POST /api/render/local-hosts-tsv`
@@ -117,6 +120,38 @@ servicii consumatoare
117 120
 
118 121
 Pentru etapa MVP, aplicația nu face commit/push automat. După o modificare, schimbarea rămâne vizibilă în working tree și se comite explicit după review. Automatizarea commit/push poate fi adăugată ulterior, dar numai cu cheie separată și reguli clare de semnare/audit.
119 122
 
123
+## Autoritate locală de certificate
124
+
125
+Host Manager include un helper OpenSSL pentru o autoritate locală de certificate pentru hosturi. Cheia privată CA nu este în git și nu este servită prin aplicație.
126
+
127
+Locația implicită:
128
+
129
+```text
130
+/usr/local/xdev-host-manager/var/ca
131
+```
132
+
133
+Inițializare CA, pe jumper:
134
+
135
+```bash
136
+cd /usr/local/xdev-host-manager
137
+sudo scripts/ca_manager.sh init
138
+```
139
+
140
+Semnare CSR pentru un host:
141
+
142
+```bash
143
+sudo scripts/ca_manager.sh sign-csr host-id /path/to/host.csr host.madagascar.xdev.ro host
144
+```
145
+
146
+Aplicația web, după OTP, poate afișa statusul CA, lista certificatelor emise și poate descărca certificatul public CA prin `/download/ca.crt`.
147
+
148
+Reguli:
149
+
150
+- Se semnează preferabil CSR-uri generate pe hosturi; cheia privată a hostului nu trebuie copiată în Host Manager.
151
+- CA private key rămâne local pe jumper și în afara git-ului.
152
+- Endpoint-urile CA sunt în spatele OTP-ului.
153
+- Automatizarea pentru emitere direct din UI se adaugă ulterior doar cu helper privilegiat și audit separat.
154
+
120 155
 ## Limitări MVP
121 156
 
122 157
 - Parserul YAML acceptă schema strictă generată de aplicație, nu YAML arbitrar.
+1 -0
.gitignore
@@ -1,4 +1,5 @@
1 1
 /backups/
2
+/var/
2 3
 *.log
3 4
 *.tmp
4 5
 *.temp
+7 -0
README.md
@@ -8,6 +8,7 @@ This project lives on jumper and is the local source for:
8 8
 - `config/local-hosts.tsv` - DNS manifest exported for local resolvers
9 9
 - `scripts/host_manager.pl` - Perl-only web app
10 10
 - `scripts/sync_local_hosts.sh` - local DNS sync to is-vpn-gw and as01
11
+- `scripts/ca_manager.sh` - local OpenSSL CA helper for host certificates
11 12
 
12 13
 The public `xdev.ro` zone is maintained in the separate DNS public-zone repository.
13 14
 
@@ -20,3 +21,9 @@ Runtime path:
20 21
 Secrets live outside git in `/etc/xdev/host-manager.env`.
21 22
 
22 23
 The web UI is OTP-protected for all registry data, downloads, exports, and writes. Automation should consume this repository through git with dedicated read-only keys, not through unauthenticated HTTP.
24
+
25
+The local host CA stores private material outside git under `var/ca`. Initialize it on jumper with:
26
+
27
+```bash
28
+sudo scripts/ca_manager.sh init
29
+```
+211 -0
scripts/ca_manager.sh
@@ -0,0 +1,211 @@
1
+#!/usr/bin/env bash
2
+#
3
+# ca_manager.sh - Local host certificate authority helper.
4
+#
5
+
6
+set -euo pipefail
7
+
8
+CA_DIR="${HOST_MANAGER_CA_DIR:-/usr/local/xdev-host-manager/var/ca}"
9
+OPENSSL="${OPENSSL:-openssl}"
10
+DEFAULT_SUBJECT="/O=Xdev/OU=Madagascar/CN=Xdev Madagascar Local Host CA"
11
+DEFAULT_DAYS=3650
12
+HOST_CERT_DAYS=825
13
+
14
+usage() {
15
+    cat <<EOF
16
+Usage:
17
+  $0 init [subject]
18
+  $0 status-json
19
+  $0 list-json
20
+  $0 export-ca
21
+  $0 sign-csr name csr-file dns-name [dns-name...]
22
+
23
+Notes:
24
+  - Run init/sign-csr as root.
25
+  - CA private key is stored outside git in \$HOST_MANAGER_CA_DIR/private.
26
+  - The web app only reads status, issued cert metadata, and the public CA cert.
27
+EOF
28
+}
29
+
30
+die() {
31
+    printf '[ERROR] %s\n' "$*" >&2
32
+    exit 1
33
+}
34
+
35
+need_openssl() {
36
+    command -v "$OPENSSL" >/dev/null 2>&1 || die "openssl not found"
37
+}
38
+
39
+json_escape() {
40
+    perl -Mstrict -Mwarnings -e '
41
+        my $s = join("", <>);
42
+        $s =~ s/\\/\\\\/g;
43
+        $s =~ s/"/\\"/g;
44
+        $s =~ s/\n/\\n/g;
45
+        $s =~ s/\r/\\r/g;
46
+        $s =~ s/\t/\\t/g;
47
+        print qq{"$s"};
48
+    ' <<< "${1:-}"
49
+}
50
+
51
+safe_name() {
52
+    local name="${1:-}"
53
+    [[ "$name" =~ ^[A-Za-z0-9_.-]+$ ]] || die "unsafe name: $name"
54
+    printf '%s' "$name"
55
+}
56
+
57
+ca_cert="$CA_DIR/certs/ca.cert.pem"
58
+ca_key="$CA_DIR/private/ca.key.pem"
59
+serial_file="$CA_DIR/serial.srl"
60
+
61
+init_ca() {
62
+    need_openssl
63
+    local subject="${1:-$DEFAULT_SUBJECT}"
64
+    [[ ! -e "$ca_key" ]] || die "CA key already exists: $ca_key"
65
+
66
+    install -d -m 0755 "$CA_DIR" "$CA_DIR/certs" "$CA_DIR/csr" "$CA_DIR/issued" "$CA_DIR/requests"
67
+    install -d -m 0700 "$CA_DIR/private"
68
+
69
+    "$OPENSSL" genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$ca_key"
70
+    chmod 0600 "$ca_key"
71
+
72
+    "$OPENSSL" req -new -x509 -days "$DEFAULT_DAYS" -sha256 \
73
+        -key "$ca_key" \
74
+        -out "$ca_cert" \
75
+        -subj "$subject" \
76
+        -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
77
+        -addext "keyUsage=critical,keyCertSign,cRLSign" \
78
+        -addext "subjectKeyIdentifier=hash"
79
+
80
+    chmod 0644 "$ca_cert"
81
+    chown -R root:host-manager "$CA_DIR" 2>/dev/null || true
82
+    chown root:root "$ca_key" 2>/dev/null || true
83
+    chmod 0600 "$ca_key"
84
+}
85
+
86
+cert_field() {
87
+    local cert="$1"
88
+    local field="$2"
89
+    case "$field" in
90
+        subject) "$OPENSSL" x509 -in "$cert" -noout -subject | sed 's/^subject=//' ;;
91
+        issuer) "$OPENSSL" x509 -in "$cert" -noout -issuer | sed 's/^issuer=//' ;;
92
+        not_before) "$OPENSSL" x509 -in "$cert" -noout -startdate | sed 's/^notBefore=//' ;;
93
+        not_after) "$OPENSSL" x509 -in "$cert" -noout -enddate | sed 's/^notAfter=//' ;;
94
+        fingerprint) "$OPENSSL" x509 -in "$cert" -noout -fingerprint -sha256 | sed 's/^sha256 Fingerprint=//' ;;
95
+        serial) "$OPENSSL" x509 -in "$cert" -noout -serial | sed 's/^serial=//' ;;
96
+        *) return 1 ;;
97
+    esac
98
+}
99
+
100
+status_json() {
101
+    need_openssl
102
+    if [[ ! -f "$ca_cert" ]]; then
103
+        printf '{"initialized":false,"ca_dir":'
104
+        json_escape "$CA_DIR"
105
+        printf ',"certificates":0}\n'
106
+        return
107
+    fi
108
+
109
+    local count=0
110
+    shopt -s nullglob
111
+    local cert
112
+    for cert in "$CA_DIR"/issued/*.cert.pem; do
113
+        count=$((count + 1))
114
+    done
115
+    shopt -u nullglob
116
+
117
+    printf '{"initialized":true'
118
+    printf ',"ca_dir":'; json_escape "$CA_DIR"
119
+    printf ',"subject":'; json_escape "$(cert_field "$ca_cert" subject)"
120
+    printf ',"not_before":'; json_escape "$(cert_field "$ca_cert" not_before)"
121
+    printf ',"not_after":'; json_escape "$(cert_field "$ca_cert" not_after)"
122
+    printf ',"fingerprint_sha256":'; json_escape "$(cert_field "$ca_cert" fingerprint)"
123
+    printf ',"certificates":%s' "$count"
124
+    printf '}\n'
125
+}
126
+
127
+list_json() {
128
+    need_openssl
129
+    printf '['
130
+    local first=1 cert name
131
+    shopt -s nullglob
132
+    for cert in "$CA_DIR"/issued/*.cert.pem; do
133
+        name="$(basename "$cert" .cert.pem)"
134
+        if [[ "$first" -eq 0 ]]; then
135
+            printf ','
136
+        fi
137
+        first=0
138
+        printf '{"name":'; json_escape "$name"
139
+        printf ',"subject":'; json_escape "$(cert_field "$cert" subject)"
140
+        printf ',"issuer":'; json_escape "$(cert_field "$cert" issuer)"
141
+        printf ',"serial":'; json_escape "$(cert_field "$cert" serial)"
142
+        printf ',"not_before":'; json_escape "$(cert_field "$cert" not_before)"
143
+        printf ',"not_after":'; json_escape "$(cert_field "$cert" not_after)"
144
+        printf ',"fingerprint_sha256":'; json_escape "$(cert_field "$cert" fingerprint)"
145
+        printf '}'
146
+    done
147
+    shopt -u nullglob
148
+    printf ']\n'
149
+}
150
+
151
+export_ca() {
152
+    [[ -f "$ca_cert" ]] || die "CA is not initialized"
153
+    cat "$ca_cert"
154
+}
155
+
156
+sign_csr() {
157
+    need_openssl
158
+    [[ -f "$ca_key" && -f "$ca_cert" ]] || die "CA is not initialized"
159
+    local name csr days ext cert
160
+    name="$(safe_name "${1:-}")"
161
+    csr="${2:-}"
162
+    shift 2 || true
163
+    [[ -f "$csr" ]] || die "missing CSR file: $csr"
164
+    [[ "$#" -ge 1 ]] || die "at least one DNS SAN is required"
165
+
166
+    cert="$CA_DIR/issued/$name.cert.pem"
167
+    [[ ! -e "$cert" ]] || die "certificate already exists: $cert"
168
+    ext="$(mktemp)"
169
+    {
170
+        printf 'basicConstraints=critical,CA:FALSE\n'
171
+        printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
172
+        printf 'extendedKeyUsage=serverAuth,clientAuth\n'
173
+        printf 'subjectAltName='
174
+        local first=1 dns
175
+        for dns in "$@"; do
176
+            [[ "$dns" =~ ^[A-Za-z0-9_.-]+$ ]] || die "unsafe DNS SAN: $dns"
177
+            if [[ "$first" -eq 0 ]]; then
178
+                printf ','
179
+            fi
180
+            first=0
181
+            printf 'DNS:%s' "$dns"
182
+        done
183
+        printf '\n'
184
+    } > "$ext"
185
+
186
+    cp "$csr" "$CA_DIR/csr/$name.csr.pem"
187
+    "$OPENSSL" x509 -req -sha256 \
188
+        -in "$csr" \
189
+        -CA "$ca_cert" \
190
+        -CAkey "$ca_key" \
191
+        -CAserial "$serial_file" \
192
+        -CAcreateserial \
193
+        -days "$HOST_CERT_DAYS" \
194
+        -extfile "$ext" \
195
+        -out "$cert"
196
+    rm -f "$ext"
197
+    chmod 0644 "$cert" "$CA_DIR/csr/$name.csr.pem"
198
+    chown root:host-manager "$cert" "$CA_DIR/csr/$name.csr.pem" 2>/dev/null || true
199
+    printf '%s\n' "$cert"
200
+}
201
+
202
+cmd="${1:-}"
203
+case "$cmd" in
204
+    init) shift; init_ca "$@" ;;
205
+    status-json) status_json ;;
206
+    list-json) list_json ;;
207
+    export-ca) export_ca ;;
208
+    sign-csr) shift; sign_csr "$@" ;;
209
+    -h|--help|help|'') usage ;;
210
+    *) die "unknown command: $cmd" ;;
211
+esac
+67 -0
scripts/host_manager.pl
@@ -147,6 +147,15 @@ sub handle_client {
147 147
         my $registry = load_registry();
148 148
         return send_download($client, 200, json_encode(render_monitoring($registry)), 'application/json; charset=utf-8', 'monitoring-hosts.json');
149 149
     }
150
+    if ($method eq 'GET' && $path eq '/api/ca/status') {
151
+        return send_json_raw($client, 200, ca_manager_json('status-json'));
152
+    }
153
+    if ($method eq 'GET' && $path eq '/api/ca/certificates') {
154
+        return send_json_raw($client, 200, ca_manager_json('list-json'));
155
+    }
156
+    if ($method eq 'GET' && $path eq '/download/ca.crt') {
157
+        return send_file($client, ca_cert_path(), 'application/x-pem-file; charset=utf-8', 'xdev-madagascar-host-ca.crt');
158
+    }
150 159
 
151 160
     if ($method eq 'POST' && $path =~ m{^/api/}) {
152 161
         if ($path eq '/api/hosts/upsert') {
@@ -318,6 +327,30 @@ sub render_monitoring {
318 327
     };
319 328
 }
320 329
 
330
+sub ca_script_path {
331
+    return "$project_dir/scripts/ca_manager.sh";
332
+}
333
+
334
+sub ca_dir {
335
+    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
336
+}
337
+
338
+sub ca_cert_path {
339
+    return ca_dir() . "/certs/ca.cert.pem";
340
+}
341
+
342
+sub ca_manager_json {
343
+    my ($command) = @_;
344
+    my $script = ca_script_path();
345
+    die "CA manager script is missing\n" unless -x $script;
346
+    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
347
+    open my $fh, '-|', $script, $command or die "Cannot run CA manager\n";
348
+    local $/;
349
+    my $out = <$fh>;
350
+    close $fh or die "CA manager failed\n";
351
+    return $out || '{}';
352
+}
353
+
321 354
 sub parse_hosts_yaml {
322 355
     my ($text) = @_;
323 356
     my %registry = (
@@ -714,6 +747,11 @@ sub send_json {
714 747
     return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
715 748
 }
716 749
 
750
+sub send_json_raw {
751
+    my ($client, $status, $json_body, $extra_headers) = @_;
752
+    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
753
+}
754
+
717 755
 sub send_html {
718 756
     my ($client, $status, $html) = @_;
719 757
     return send_response($client, $status, $html, 'text/html; charset=utf-8');
@@ -972,6 +1010,14 @@ sub app_html {
972 1010
         <div class="problems" id="problems"></div>
973 1011
       </section>
974 1012
 
1013
+      <section class="panel">
1014
+        <div class="panel-head">
1015
+          <h2>Certificate Authority</h2>
1016
+          <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
1017
+        </div>
1018
+        <div class="problems" id="ca-status"></div>
1019
+      </section>
1020
+
975 1021
       <section class="panel">
976 1022
         <div class="panel-head">
977 1023
           <h2>Hosts</h2>
@@ -1054,6 +1100,7 @@ sub app_html {
1054 1100
       state.hosts = data.hosts || [];
1055 1101
       state.problems = data.problems || [];
1056 1102
       render(data);
1103
+      await renderCa();
1057 1104
     }
1058 1105
 
1059 1106
     function render(data) {
@@ -1071,6 +1118,26 @@ sub app_html {
1071 1118
       renderHosts();
1072 1119
     }
1073 1120
 
1121
+    async function renderCa() {
1122
+      try {
1123
+        const status = await api('/api/ca/status');
1124
+        if (!status.initialized) {
1125
+          $('ca-status').innerHTML = '<div class="problem"><strong>not initialized</strong> Run <code>sudo scripts/ca_manager.sh init</code> on jumper.</div>';
1126
+          return;
1127
+        }
1128
+        const certs = await api('/api/ca/certificates');
1129
+        $('ca-status').innerHTML = `
1130
+          <div class="muted" style="display:grid;gap:6px">
1131
+            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
1132
+            <div>SHA256 ${escapeHtml(status.fingerprint_sha256 || '')}</div>
1133
+            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
1134
+            <div>${certs.length} issued certificate(s)</div>
1135
+          </div>`;
1136
+      } catch (e) {
1137
+        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
1138
+      }
1139
+    }
1140
+
1074 1141
     function renderHosts() {
1075 1142
       const filter = $('filter').value.toLowerCase();
1076 1143
       $('hosts').innerHTML = state.hosts