diff --git a/.gitignore b/.gitignore index f71d6af..bfc3046 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ id_rsa* .idea .env tmp +certs diff --git a/README.md b/README.md index 28f8f4a..7a52b04 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ The following environment variables can be configured in the `.env` file: | `TLS_REDIRECT` | Redirect HTTP to HTTPS | `false` | No | | `CERT_LOC` | Path to TLS certificate | `certs/cert.pem` | No | | `KEY_LOC` | Path to TLS private key | `certs/privkey.pem` | No | +| `CERT_STORAGE_PATH` | Path for CertMagic certificate storage | `certs/certmagic` | No | +| `ACME_EMAIL` | Email for Let's Encrypt registration | `admin@` | No | +| `CF_API_TOKEN` | Cloudflare API token for DNS-01 challenge | - | Yes (if auto-cert) | +| `ACME_STAGING` | Use Let's Encrypt staging server | `false` | No | | `SSH_PRIVATE_KEY` | Path to SSH private key (auto-generated if missing) | `certs/id_rsa` | No | | `CORS_LIST` | Comma-separated list of allowed CORS origins | - | No | | `ALLOWED_PORTS` | Port range for TCP tunnels (e.g., 40000-41000) | `40000-41000` | No | @@ -35,6 +39,36 @@ The following environment variables can be configured in the `.env` file: **Note:** All environment variables now use UPPERCASE naming. The application includes sensible defaults for all variables, so you can run it without a `.env` file for basic functionality. +### Automatic TLS Certificate Management + +The server supports automatic TLS certificate generation and renewal using [CertMagic](https://github.com/caddyserver/certmagic) with Cloudflare DNS-01 challenge. This is required for wildcard certificate support (`*.yourdomain.com`). + +**How it works:** +1. If user-provided certificates (`CERT_LOC`, `KEY_LOC`) exist and cover both `DOMAIN` and `*.DOMAIN`, they will be used +2. If certificates are missing, expired, expiring within 30 days, or don't cover the required domains, CertMagic will automatically obtain new certificates from Let's Encrypt +3. Certificates are automatically renewed before expiration +4. User-provided certificates support hot-reload (changes detected every 30 seconds) + +**Cloudflare API Token Setup:** + +To use automatic certificate generation, you need a Cloudflare API token with the following permissions: + +1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/profile/api-tokens) +2. Click "Create Token" +3. Use "Create Custom Token" with these permissions: + - **Zone → Zone → Read** (for all zones or specific zone) + - **Zone → DNS → Edit** (for all zones or specific zone) +4. Copy the token and set it as `CF_API_TOKEN` environment variable + +**Example configuration for automatic certificates:** +```env +DOMAIN=example.com +TLS_ENABLED=true +CF_API_TOKEN=your_cloudflare_api_token_here +ACME_EMAIL=admin@example.com +# ACME_STAGING=true # Uncomment for testing to avoid rate limits +``` + ### SSH Key Auto-Generation If the SSH private key specified in `SSH_PRIVATE_KEY` doesn't exist, the application will automatically generate a new 4096-bit RSA key pair at the specified location. This makes it easier to get started without manually creating SSH keys. diff --git a/certs/cert.pem b/certs/cert.pem deleted file mode 100644 index 0e2136d..0000000 --- a/certs/cert.pem +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEWzCCA0OgAwIBAgIUDYTdEDHwVznxV/qnn0/WVlHNMZAwDQYJKoZIhvcNAQEL -BQAwgZYxCzAJBgNVBAYTAklEMQ8wDQYDVQQIDAZCYW50ZW4xGjAYBgNVBAcMEVRh -bmdlcmFuZyBTZWxhdGFuMRIwEAYDVQQKDAlGb3NzeSBMVFMxDjAMBgNVBAsMBUZv -c3N5MRQwEgYDVQQDDAtmb3NzeS5teS5pZDEgMB4GCSqGSIb3DQEJARYRYmFnYXNA -Zm9zc3kubXkuaWQwHhcNMjUwMjA3MTYyMTU1WhcNMjYwMjA3MTYyMTU1WjCBljEL -MAkGA1UEBhMCSUQxDzANBgNVBAgMBkJhbnRlbjEaMBgGA1UEBwwRVGFuZ2VyYW5n -IFNlbGF0YW4xEjAQBgNVBAoMCUZvc3N5IExUUzEOMAwGA1UECwwFRm9zc3kxFDAS -BgNVBAMMC2Zvc3N5Lm15LmlkMSAwHgYJKoZIhvcNAQkBFhFiYWdhc0Bmb3NzeS5t -eS5pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMU3pCA0eP+VR2CA -+p3p0BgKw33xCKQmQx52jnqNbvwW4NMMDc3mS6a+wb6QB6v0WLGMI1g22TtJPatx -7dcy4PCSOCV9xJ2Yfq5HlQgDHYoyE+juy4/pGlMjo45thJ0yI8zOSzaz2esosP22 -XkfFwj7oVMXXPIY6UovcAlGU+DFtwVrNa76/esUwJs+7M3tBubjkpcal0wXR+SIX -3fmw5v0YzKV8qLGMGOvX6+OyLQCx4r9gB0d+WOrT6EfrPfAzo07NKjzWG9aWl1rk -Q+h0i38hke2MTFxId7za57L+NlXRjLo3ESbBF0hjYquTQOG3jx/UvWs+I1NEfsdu -vj8beiMCAwEAAaOBnjCBmzAfBgNVHSMEGDAWgBT6nj69+I+GSdgScjfOqnVFzX7G -aTAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAs -BgNVHREEJTAjgglsb2NhbGhvc3SCCTEyNy4wLjAuMYILKi5sb2NhbGhvc3QwHQYD -VR0OBBYEFD9ci20pAUTRLVWxvVXjfE2GKwSAMA0GCSqGSIb3DQEBCwUAA4IBAQAT -rkjU+GzQy9B3/nd/79N7ozK80ygzmnRnj3ou/bbqUHOIYQQgKM1cuN6zh57ovRh/ -u45s6pZUOUVN59POFUCvqUiOgsDkY/auXGbKtzzqzoZABuvm85ySd6zurOOx8tA5 -e7ArX1ppy3LgzSb+cXANvzYC9bCwp70w2YylMFhwHBAp5TXRVqsqG6jujD8GMLoS -zDSDx8M6o8gqWmPQve7Saim9mgLJUvvCYBzvowuNjzZrJfAeGoIfLV127xCQiylm -fzUQ1Ac6udldWm9scA32nteSQMWg2d1nW3RG1nRondp13WUkgGQ490/c97D3MKLt -D1HB5dLIYkRVHVhCKHT1 ------END CERTIFICATE----- diff --git a/certs/localhost.direct.SS.crt b/certs/localhost.direct.SS.crt deleted file mode 100644 index d36fa1f..0000000 --- a/certs/localhost.direct.SS.crt +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDjDCCAnSgAwIBAgIUPpuvw4ZdpnDBbwOZBt2ALfZykuYwDQYJKoZIhvcNAQEL -BQAwPDELMAkGA1UEBhMCVUsxDzANBgNVBAgMBkxvbmRvbjEPMA0GA1UEBwwGTG9u -ZG9uMQswCQYDVQQLDAJIUTAeFw0yNDExMTkxNjQxNTNaFw0zNDExMTcxNjQxNTNa -MDwxCzAJBgNVBAYTAlVLMQ8wDQYDVQQIDAZMb25kb24xDzANBgNVBAcMBkxvbmRv -bjELMAkGA1UECwwCSFEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDD -L/aeaBzgkYxDNiQq7+nt6tKDfGnPDfBXunJlr1xbZfIVpJeDqwrNLWaZ0gtHci5E -sHptIpu5/uTBFaFyZVH604etY/YHIsff7BT2y32OobJYiKy2lvAI3/IDR4TGeDgA -HKryOwMcB5DheBVdeggxj36m8OjxhVADaiKp7BNXE2eqUqk8f2QpwqLQYe9UaU9r -WSJllNHOFk+RH17YBDiyyQ8CD1Vf5HcVSmPItWOMQytHcSgy0DHVXQCde86mky8t -8Ik74GeNrM6f+vWR4OfQ8dU2WSyTUE4c7czagkToheMX5fbbzWJkJcd2SD9wvyIk -tOot0YiZGQAoOedtGSEnAgMBAAGjgYUwgYIwCQYDVR0TBAIwADALBgNVHQ8EBAMC -BeAwSQYDVR0RBEIwQIIQbG9jYWxob3N0LmRpcmVjdIISKi5sb2NhbGhvc3QuZGly -ZWN0ghhTUy5jZXJ0LmxvY2FsaG9zdC5kaXJlY3QwHQYDVR0OBBYEFKBVeirQGZ4D -4AKVPd7LGfCF1wEZMA0GCSqGSIb3DQEBCwUAA4IBAQCRpvsc5DrQBo8yATmUS0OK -xfUXfZR28u3xYY+qMHi+ngIVT2TKJ1yoBJezV6WwQLkcGdWacULvPYt3jCFUtaP7 -+hzfs5y1FssFsXDx+r3pQxYyE6BX3BhlrJPJhLRyG1siTTgN439Qu40/TsTzNgAT -A9GbAro+W6+qA4H92mBlyfQEOBossID/Kk95uvDnQguUOp0ZBLgFNRfE6Ra9+yC+ -ufAOksYDrisPE6kZId0Ra4Ln/GmrIXKjjmLCitq2q2REC/70JSCnaJcBYeThSJLZ -AZ24AF+JteakSJ8FEgRGxvSu0wdZfnMobNoelsjai1p5l5mCTiD8GH9sQCkslnp3 ------END CERTIFICATE----- diff --git a/certs/localhost.direct.SS.key b/certs/localhost.direct.SS.key deleted file mode 100644 index 0e0aa47..0000000 --- a/certs/localhost.direct.SS.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDDL/aeaBzgkYxD -NiQq7+nt6tKDfGnPDfBXunJlr1xbZfIVpJeDqwrNLWaZ0gtHci5EsHptIpu5/uTB -FaFyZVH604etY/YHIsff7BT2y32OobJYiKy2lvAI3/IDR4TGeDgAHKryOwMcB5Dh -eBVdeggxj36m8OjxhVADaiKp7BNXE2eqUqk8f2QpwqLQYe9UaU9rWSJllNHOFk+R -H17YBDiyyQ8CD1Vf5HcVSmPItWOMQytHcSgy0DHVXQCde86mky8t8Ik74GeNrM6f -+vWR4OfQ8dU2WSyTUE4c7czagkToheMX5fbbzWJkJcd2SD9wvyIktOot0YiZGQAo -OedtGSEnAgMBAAECggEASXibb2MnQ4zl7ELL+HGYb5sNpLrHJU5M4ujmuMn6jNjh -+C2dbs2KYlMtpMcAweMD8Y0weDYnwiplNx0KSYJECpNnJehTqrn33J0EAyXz3CWX -eWXxBUXpkp2hfoSEQSTth3VDD60Q7ZMXgRdvi2EtBmLKPNLADHGu/aoM5ENdwE9/ -E80X68E+MT2czOY/sEI/w2Tf/S2ZVOHRvFOsmvTLFlbiElWG8pmguajpJwdae7uX -c5VD87b0oYicSUvaHe6xOCCyyeBVq06sWk+vh6Tzrw6K/B+q0SYvzXdrdsJxbzUD -PvhVi9rf9AC1Ncb6lFOP2ZjGfqYQvfgGXKqaVxXWQQKBgQD8frATynMSeR15oERa -+Maa7r3GlwWV3tkUblX7/FxBo9UhAsivnWZprccZR43YPowbWNeU3AQppf/YDxmx -70/RVTCMjXPGyspSS2iFtcp+K2KnIZA4BuAG/s1EBrAUW2KrtaTaXgYu9usgb+Fq -dJUEBDWrL59XXtQUwSM1laH0BwKBgQDF5Z31VY96xOS7flmolq8Ag+frSMH0G4np -3nUnTlUkgpFXE5FkmUccbYZv9QialAVriBBUNANPDBQH4PRrNnX8Z6B2HJbpAy30 -II9jPMTNKnXT3RKFcJCamNkOQhT0sBAwm4gTsx+7gbpdZxinxH0Cr/08sfU4lbee -EtMV/J1h4QKBgC4MLLBvS20jCW0U/WJZ3F6FC7cb87jRW2WOeb/q1ihiaIwMpezh -F7xOJPFHS2cUgRi7qxVKyreNvor4tgbtTfEvSBtZ8LNgaGV5uyYncTZxUxyH0nVl -S5X7AhRV4+bSg7ws9FOesiH+hgL0ZHe1qzeATQlbNgQJF0RxtKohD9ghAoGAcM0N -WIZInoYUivreSEZ7wiNt0qNKSsZXukLfLGRuC72Q8r1opprn+cBEXRSirtmorT6F -cDmlmS0dTdBgAaytXA4FXM23B2KUkw7sLHi7BOcq+nSM1hrvke+F6aapI0AoOkyt -J+12LP8pJ4xYdWh+iUWfZzVYvcQ5QZUhVOsFGoECgYA9llKmc/cFaXrCr97g7ls8 -ZWW1kQKLawAv6+90dwECJl+zpyQN/TURyEfz6oFJDbNEL0xAAcm9thDah177Tq95 -pcHbVn/pAI4h4CLzM2ExSe8Ybvy6iPZaBiCVXq3ms1PK0EDyYLH1p3FZqm+UStZh -/6fYspyivrQEivRK3jGuWA== ------END PRIVATE KEY----- diff --git a/certs/privkey.pem b/certs/privkey.pem deleted file mode 100644 index bedf787..0000000 --- a/certs/privkey.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDFN6QgNHj/lUdg -gPqd6dAYCsN98QikJkMedo56jW78FuDTDA3N5kumvsG+kAer9FixjCNYNtk7ST2r -ce3XMuDwkjglfcSdmH6uR5UIAx2KMhPo7suP6RpTI6OObYSdMiPMzks2s9nrKLD9 -tl5HxcI+6FTF1zyGOlKL3AJRlPgxbcFazWu+v3rFMCbPuzN7Qbm45KXGpdMF0fki -F935sOb9GMylfKixjBjr1+vjsi0AseK/YAdHfljq0+hH6z3wM6NOzSo81hvWlpda -5EPodIt/IZHtjExcSHe82uey/jZV0Yy6NxEmwRdIY2Krk0Dht48f1L1rPiNTRH7H -br4/G3ojAgMBAAECggEAATiJ23ZhS1++squ87jsgAfR+TYP8Kpv408u/43Ig5KgC -ZnwPUWqvP2e0TLwyhMKwXxHMt2nITxQlSnyCXU/5nk07BYxkqhiwEni4Xo86YK+H -rQXEnKFaSHdF6dNNIo+VZiark4adS9XgJp0fsn0LnON7GhBt72MvPW6auxH1HJIC -rcjnzJu/KzTVrId9QNsEDl9cNRlHXuPSfohdq2o39PBKGDeDEDTvP9wHtasEF6oe -uj1OH5fAxiXptT0Ln0Y8QeFe8R8Odul5mUXCgMOkKmHZPDxTq9ldCuPjrz15JfnS -T3MecaWdvChpIP2WybjLstJy9qXTl0fhRkpJWpaVbQKBgQDu9puq64vZn0jak7ns -bFbKPemFw7tiiwwnqyzqCTRddw1vETJN/MgpRWvc7d1Cvzs2+Z2kn0Cgpdw/30Ej -VRHiE/d1rj+u33F6vGBUBccRue1t0UdFMFF/YnlNDRRnljQgx4N3mezzNE2RikR8 -jVp+jvWTSTgf3y4Hip1ffCSNtQKBgQDTRx1huSzUOed/36YLdnxP+AlV3L+c/EKU -GItKZk2bqRlqgs6wcsNlVjveb9o9j6M5pg/lh5HaqJyhj8DYVnEdmXfcQJiUfIPh -5802asIXQVBmL9rTMR34wsr/lzzK3k0KORqFcUdf2fDI/UQNpVUJvWQQY8fl2sVo -zmp88JAPdwKBgQCLC6fsvn5ztMF5nffTX/7oUzosgYXpgyshcfMCgzSbJgkFFaaF -xo7ZpPFsbmQO0KMuC/T0s02xrJEKAWgvnPJ48FFPgoK/yHiJiE8s1OfOordK7Tlh -QwpI6w3WDcRPuhC++hi/YSuFIGv6QdA0ATQk7B5tA2/K69wmuztzMhM6+QKBgH5E -LwQbRfZj0L20bKjHHA4y32loLz/j5upZLM2/DDyuN9lW6a28OJiUi90pHdXSxSsL -2s5DUmDKiiloH0lrh9i3wlFobYe4Tp0xCoyuCucZCrK3gODcptvnlqhfu15GsuYc -MIR1qcFYH7YO3qAFIiha/rVo3Ku7LmWvjyayInaLAoGAZ/dELEAcyhImf1HvtmBT -qNg4uI6t/2fvHdoAeQkkGjEDWFTGEaMp0cJEbETPD68TwNh5dL/xUJibTwMbK+m5 -rdIA2oTZMkFtWubIvMCrD/J6E+8Pzl+eK+0C0axO/I29S4veORNGWvCtqdFICDgZ -CluJ0NZFeMH8g8tgfI9HnHc= ------END PRIVATE KEY----- diff --git a/go.mod b/go.mod index 09be3c3..785f70d 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,22 @@ require ( golang.org/x/crypto v0.45.0 ) -require golang.org/x/sys v0.38.0 // indirect +require ( + github.com/caddyserver/certmagic v0.25.0 // indirect + github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/cloudflare v0.2.2 // indirect + github.com/libdns/libdns v1.1.1 // indirect + github.com/mholt/acmez/v3 v3.1.3 // indirect + github.com/miekg/dns v1.1.68 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect +) diff --git a/go.sum b/go.sum index 27269bf..e593e12 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,37 @@ +github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic= +github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA= +github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= +github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/mholt/acmez/v3 v3.1.3 h1:gUl789rjbJSuM5hYzOFnNaGgWPV1xVfnOs59o0dZEcc= +github.com/mholt/acmez/v3 v3.1.3/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= @@ -11,3 +39,7 @@ golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= diff --git a/server/https.go b/server/https.go index f65a0ba..2964d4f 100644 --- a/server/https.go +++ b/server/https.go @@ -13,13 +13,14 @@ import ( ) func NewHTTPSServer() error { - cert, err := tls.LoadX509KeyPair(utils.Getenv("CERT_LOC", "certs/cert.pem"), utils.Getenv("KEY_LOC", "certs/privkey.pem")) + domain := utils.Getenv("DOMAIN", "localhost") + + tlsConfig, err := NewTLSConfig(domain) if err != nil { - return err + return fmt.Errorf("failed to initialize TLS config: %w", err) } - config := &tls.Config{Certificates: []tls.Certificate{cert}} - ln, err := tls.Listen("tcp", ":443", config) + ln, err := tls.Listen("tcp", ":443", tlsConfig) if err != nil { return err } diff --git a/server/tls.go b/server/tls.go new file mode 100644 index 0000000..e5b3105 --- /dev/null +++ b/server/tls.go @@ -0,0 +1,312 @@ +package server + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "os" + "sync" + "time" + "tunnel_pls/utils" + + "github.com/caddyserver/certmagic" + "github.com/libdns/cloudflare" +) + +type TLSManager struct { + domain string + certPath string + keyPath string + storagePath string + + userCert *tls.Certificate + userCertMu sync.RWMutex + + magic *certmagic.Config + + useCertMagic bool +} + +var tlsManager *TLSManager +var tlsManagerOnce sync.Once + +func NewTLSConfig(domain string) (*tls.Config, error) { + var initErr error + + tlsManagerOnce.Do(func() { + certPath := utils.Getenv("CERT_LOC", "certs/cert.pem") + keyPath := utils.Getenv("KEY_LOC", "certs/privkey.pem") + storagePath := utils.Getenv("CERT_STORAGE_PATH", "certs/certmagic") + + tm := &TLSManager{ + domain: domain, + certPath: certPath, + keyPath: keyPath, + storagePath: storagePath, + } + + if tm.userCertsExistAndValid() { + log.Printf("Using user-provided certificates from %s and %s", certPath, keyPath) + if err := tm.loadUserCerts(); err != nil { + initErr = fmt.Errorf("failed to load user certificates: %w", err) + return + } + tm.useCertMagic = false + tm.startCertWatcher() + } else { + if !isACMEConfigComplete() { + log.Printf("User certificates missing or invalid, and ACME configuration is incomplete") + log.Printf("To enable automatic certificate generation, set CF_API_TOKEN environment variable") + initErr = fmt.Errorf("no valid certificates found and ACME configuration is incomplete (CF_API_TOKEN is required)") + return + } + + log.Printf("User certificates missing or don't cover %s and *.%s, using CertMagic", domain, domain) + if err := tm.initCertMagic(); err != nil { + initErr = fmt.Errorf("failed to initialize CertMagic: %w", err) + return + } + tm.useCertMagic = true + } + + tlsManager = tm + }) + + if initErr != nil { + return nil, initErr + } + + return tlsManager.getTLSConfig(), nil +} + +func isACMEConfigComplete() bool { + cfAPIToken := utils.Getenv("CF_API_TOKEN", "") + return cfAPIToken != "" +} + +func (tm *TLSManager) userCertsExistAndValid() bool { + if _, err := os.Stat(tm.certPath); os.IsNotExist(err) { + log.Printf("Certificate file not found: %s", tm.certPath) + return false + } + if _, err := os.Stat(tm.keyPath); os.IsNotExist(err) { + log.Printf("Key file not found: %s", tm.keyPath) + return false + } + + return ValidateCertDomains(tm.certPath, tm.domain) +} + +func ValidateCertDomains(certPath, domain string) bool { + certPEM, err := os.ReadFile(certPath) + if err != nil { + log.Printf("Failed to read certificate: %v", err) + return false + } + + block, _ := pem.Decode(certPEM) + if block == nil { + log.Printf("Failed to decode PEM block from certificate") + return false + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + log.Printf("Failed to parse certificate: %v", err) + return false + } + + if time.Now().After(cert.NotAfter) { + log.Printf("Certificate has expired (NotAfter: %v)", cert.NotAfter) + return false + } + + if time.Now().Add(30 * 24 * time.Hour).After(cert.NotAfter) { + log.Printf("Certificate expiring soon (NotAfter: %v), will use CertMagic for renewal", cert.NotAfter) + return false + } + + var certDomains []string + if cert.Subject.CommonName != "" { + certDomains = append(certDomains, cert.Subject.CommonName) + } + certDomains = append(certDomains, cert.DNSNames...) + + hasBase := false + hasWildcard := false + wildcardDomain := "*." + domain + + for _, d := range certDomains { + if d == domain { + hasBase = true + } + if d == wildcardDomain { + hasWildcard = true + } + } + + if !hasBase { + log.Printf("Certificate does not cover base domain: %s", domain) + } + if !hasWildcard { + log.Printf("Certificate does not cover wildcard domain: %s", wildcardDomain) + } + + return hasBase && hasWildcard +} + +func (tm *TLSManager) loadUserCerts() error { + cert, err := tls.LoadX509KeyPair(tm.certPath, tm.keyPath) + if err != nil { + return err + } + + tm.userCertMu.Lock() + tm.userCert = &cert + tm.userCertMu.Unlock() + + log.Printf("Loaded user certificates successfully") + return nil +} + +func (tm *TLSManager) startCertWatcher() { + go func() { + var lastCertMod, lastKeyMod time.Time + + if info, err := os.Stat(tm.certPath); err == nil { + lastCertMod = info.ModTime() + } + if info, err := os.Stat(tm.keyPath); err == nil { + lastKeyMod = info.ModTime() + } + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + certInfo, certErr := os.Stat(tm.certPath) + keyInfo, keyErr := os.Stat(tm.keyPath) + + if certErr != nil || keyErr != nil { + continue + } + + if certInfo.ModTime().After(lastCertMod) || keyInfo.ModTime().After(lastKeyMod) { + log.Printf("Certificate files changed, reloading...") + + if !ValidateCertDomains(tm.certPath, tm.domain) { + log.Printf("New certificates don't cover required domains") + + if !isACMEConfigComplete() { + log.Printf("Cannot switch to CertMagic: ACME configuration is incomplete (CF_API_TOKEN is required)") + continue + } + + log.Printf("Switching to CertMagic for automatic certificate management") + if err := tm.initCertMagic(); err != nil { + log.Printf("Failed to initialize CertMagic: %v", err) + continue + } + tm.useCertMagic = true + return + } + + if err := tm.loadUserCerts(); err != nil { + log.Printf("Failed to reload certificates: %v", err) + continue + } + + lastCertMod = certInfo.ModTime() + lastKeyMod = keyInfo.ModTime() + log.Printf("Certificates reloaded successfully") + } + } + }() +} + +func (tm *TLSManager) initCertMagic() error { + if err := os.MkdirAll(tm.storagePath, 0700); err != nil { + return fmt.Errorf("failed to create cert storage directory: %w", err) + } + + acmeEmail := utils.Getenv("ACME_EMAIL", "admin@"+tm.domain) + cfAPIToken := utils.Getenv("CF_API_TOKEN", "") + acmeStaging := utils.Getenv("ACME_STAGING", "false") == "true" + + if cfAPIToken == "" { + return fmt.Errorf("CF_API_TOKEN environment variable is required for automatic certificate generation") + } + + cfProvider := &cloudflare.Provider{ + APIToken: cfAPIToken, + } + + storage := &certmagic.FileStorage{Path: tm.storagePath} + + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) { + return tm.magic, nil + }, + }) + + magic := certmagic.New(cache, certmagic.Config{ + Storage: storage, + }) + + acmeIssuer := certmagic.NewACMEIssuer(magic, certmagic.ACMEIssuer{ + Email: acmeEmail, + Agreed: true, + DNS01Solver: &certmagic.DNS01Solver{ + DNSManager: certmagic.DNSManager{ + DNSProvider: cfProvider, + }, + }, + }) + + if acmeStaging { + acmeIssuer.CA = certmagic.LetsEncryptStagingCA + log.Printf("Using Let's Encrypt staging server") + } else { + acmeIssuer.CA = certmagic.LetsEncryptProductionCA + log.Printf("Using Let's Encrypt production server") + } + + magic.Issuers = []certmagic.Issuer{acmeIssuer} + tm.magic = magic + + domains := []string{tm.domain, "*." + tm.domain} + log.Printf("Requesting certificates for: %v", domains) + + ctx := context.Background() + if err := magic.ManageSync(ctx, domains); err != nil { + return fmt.Errorf("failed to obtain certificates: %w", err) + } + + log.Printf("Certificates obtained successfully for %v", domains) + return nil +} + +func (tm *TLSManager) getTLSConfig() *tls.Config { + return &tls.Config{ + GetCertificate: tm.getCertificate, + MinVersion: tls.VersionTLS12, + } +} + +func (tm *TLSManager) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if tm.useCertMagic { + return tm.magic.GetCertificate(hello) + } + + tm.userCertMu.RLock() + defer tm.userCertMu.RUnlock() + + if tm.userCert == nil { + return nil, fmt.Errorf("no certificate available") + } + + return tm.userCert, nil +}