# Self-Signed HTTPS for Local Docker Services

## When to use this

Your local web service (Node.js Express, Python, etc.) is behind a Docker container and needs HTTPS because:
- **Android 9+** blocks cleartext HTTP to non-localhost IPs (API 28+)
- Chrome flags HTTP on local network services
- Mobile/tablet clients refuse cleartext

## Architecture: CA + Server Cert (not raw self-signed)

A **CA + server cert** approach is better than a plain self-signed cert because:
- Install the CA once on each device → every future server cert you sign is trusted
- Server certs can include specific SANs (IPs, DNS names) per service
- The CA lives in the Docker volume and persists across container restarts

## Key constraints

### 1. OpenSSL SAN: NO wildcard IPs

OpenSSL **does not accept** wildcard patterns in IP SAN entries. These fail:

```
IP:192.168.*    ✗  "bad ip address"
IP:10.*         ✗  "bad ip address"
IP:100.*        ✗  "bad ip address"
```

Only explicit IPs work:
```
IP:192.168.1.50  ✓
IP:127.0.0.1     ✓
```

### 2. Alpine/BusyBox: limited shell commands

Alpine (used in `node:20-alpine`) ships BusyBox, which lacks:

| Unavailable | Alternative |
|---|---|
| `hostname -I` | `ip -4 addr show \| grep -oE 'inet [0-9.]+' \| cut -d' ' -f2'` |
| `grep -P` | Use `grep -E` (ERE) instead of PCRE |
| `grep -oP` | Use `grep -oE` or `awk`/`sed` |

The `ip` command IS available on Alpine via BusyBox.

### 3. Docker container: visible IP ≠ host LAN IP

Docker containers see their own bridge IP (e.g. `172.17.0.2`), NOT the host's LAN IP (e.g. `192.168.1.50`). The cert needs the **host's LAN IP** in its SAN because that's what clients connect to.

**Solution:** pass `SCRAPER_PUBLIC_IP` as an environment variable in docker-compose.yml.

## Implementation pattern (Node.js/Express)

### 1. docker-compose.yml

```yaml
environment:
  - SCRAPER_PUBLIC_IP=192.168.1.50   # host's LAN IP
```

### 2. tls.js — cert auto-generation

The function that generates the CA + server cert:

```javascript
function resolveLocalIPs(count = 8) {
  const addresses = new Set();
  addresses.add('127.0.0.1');
  addresses.add('::1');

  const publicIp = process.env.SCRAPER_PUBLIC_IP;
  if (publicIp && !addresses.has(publicIp)) addresses.add(publicIp);

  try {
    // Alpine-compatible IP detection
    const output = execSync(
      `ip -4 addr show | grep -oE 'inet [0-9.]+' | grep -v '127\\.' | cut -d' ' -f2`,
      { encoding: 'utf8', timeout: 5000 }
    );
    const ips = output.trim().split('\n').filter(Boolean);
    for (const ip of ips) {
      const clean = ip.replace(/\/\d+$/, '').trim();
      if (clean && clean !== '127.0.0.1') addresses.add(clean);
    }
  } catch {}
  return [...addresses].slice(0, count);
}
```

The SAN config file uses explicit IPs:

```
[req]
distinguished_name=req
[req_distinguished_name]
[v3_ext]
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:localhost,DNS:myservice,IP:127.0.0.1,IP:192.168.1.50
```

### 3. index.js — dual HTTP + HTTPS startup

```javascript
import https from 'https';
import fs from 'fs';
import { ensureSelfSignedCert } from './tls.js';

// HTTP (optional — for Docker health checks)
app.listen(3091, '0.0.0.0', () => { ... });

// HTTPS
try {
  const { cert: tlsCert, key: tlsKey } = ensureSelfSignedCert(certPath, keyPath);
  https.createServer({
    cert: fs.readFileSync(tlsCert),
    key: fs.readFileSync(tlsKey),
  }, app).listen(3443, '0.0.0.0', () => { ... });
} catch (err) {
  console.error(`[TLS] Failed: ${err.message}`);
  // fall back to HTTP-only
}
```

## Android network_security_config.xml

Add `<certificates src="user" />` to the LAN domain-config so user-installed CA certs are trusted:

```xml
<domain-config cleartextTrafficPermitted="true">
    <domain includeSubdomains="true">10.0.0.0</domain>
    <domain includeSubdomains="true">172.16.0.0</domain>
    <domain includeSubdomains="true">192.168</domain>
    <domain includeSubdomains="true">100</domain>  <!-- Tailscale CGNAT range -->
    <trust-anchors>
        <certificates src="system" />
        <certificates src="user" />
    </trust-anchors>
</domain-config>
```

The `100` prefix covers Tailscale's CGNAT range (`100.64.0.0/10`).

## Verification

```bash
# Check SANs
echo | openssl s_client -connect 192.168.1.50:3443 \
  -servername 192.168.1.50 2>/dev/null \
  | openssl x509 -noout -text | grep -A1 "Subject Alternative Name"

# Test HTTPS
curl -sk https://192.168.1.50:3443/manifest.json | head

# Extract CA cert from container for device installation
docker cp <container>:/app/src/data/tls/ca-cert.pem ~/
```

## Client setup

1. Copy `ca-cert.pem` to the Android device
2. Install: Settings → Security → Encryption & Credentials → Install from device storage
3. Restart the app

Without installing the CA: the HTTPS connection still works (the cleartext error is gone), but Android logs TLS warnings. Adding `<certificates src="user" />` makes the app accept the CA once installed.
