feat: Let's Encrypt SSL on site creation

This commit is contained in:
orohi
2026-06-17 05:57:01 +03:00
parent 9b7eb99d20
commit 00d6789897
18 changed files with 703 additions and 51 deletions
+5
View File
@@ -16,3 +16,8 @@ HTTP_PORT=8000
DEFAULT_SERVER_NAME=local DEFAULT_SERVER_NAME=local
DEFAULT_SERVER_HOSTNAME=server1 DEFAULT_SERVER_HOSTNAME=server1
DEFAULT_SERVER_IP=127.0.0.1 DEFAULT_SERVER_IP=127.0.0.1
# Let's Encrypt
ACME_EMAIL=admin@example.com
ACME_STAGING=false
SSL_STORAGE_PATH=/etc/ssl/panel
+12
View File
@@ -91,6 +91,18 @@ go run ./cmd/panel
**PHP** **PHP**
- `GET /api/v1/php-versions` — доступные версии PHP - `GET /api/v1/php-versions` — доступные версии PHP
**SSL (Let's Encrypt)**
- При создании сайта с `issue_ssl: true` автоматически выпускается сертификат
- `POST /api/v1/sites/{id}/ssl/issue` — повторный выпуск
- `GET /.well-known/acme-challenge/{token}` — HTTP-01 challenge
Требования для SSL:
- `ACME_EMAIL` в `.env`
- DNS домена указывает на IP сервера
- Порт 80 доступен для HTTP-01 (nginx → webroot или панель)
Сертификаты сохраняются в БД и в `SSL_STORAGE_PATH/live/{domain}/`
**Setup** **Setup**
- `GET /health` — healthcheck - `GET /health` — healthcheck
- `GET /api/v1/setup/status``{"setup_required": true|false}` - `GET /api/v1/setup/status``{"setup_required": true|false}`
+7
View File
@@ -46,11 +46,18 @@ services:
DEFAULT_SERVER_NAME: ${DEFAULT_SERVER_NAME:-local} DEFAULT_SERVER_NAME: ${DEFAULT_SERVER_NAME:-local}
DEFAULT_SERVER_HOSTNAME: ${DEFAULT_SERVER_HOSTNAME:-server1} DEFAULT_SERVER_HOSTNAME: ${DEFAULT_SERVER_HOSTNAME:-server1}
DEFAULT_SERVER_IP: ${DEFAULT_SERVER_IP:-127.0.0.1} DEFAULT_SERVER_IP: ${DEFAULT_SERVER_IP:-127.0.0.1}
ACME_EMAIL: ${ACME_EMAIL:-}
ACME_STAGING: ${ACME_STAGING:-false}
SSL_STORAGE_PATH: /etc/ssl/panel
ports: ports:
- "${HTTP_PORT:-8000}:8000" - "${HTTP_PORT:-8000}:8000"
volumes:
- /var/www:/var/www
- panel_ssl:/etc/ssl/panel
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
volumes: volumes:
postgres_data: postgres_data:
panel_ssl:
+12 -4
View File
@@ -3,20 +3,28 @@ module github.com/panelhosting/panel
go 1.23.0 go 1.23.0
require ( require (
github.com/go-acme/lego/v4 v4.23.1
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2 github.com/jackc/pgx/v5 v5.7.2
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.36.0
) )
require ( require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9 // indirect
go.uber.org/atomic v1.7.0 // indirect github.com/miekg/dns v1.1.64 // indirect
golang.org/x/sync v0.10.0 // indirect go.uber.org/atomic v1.9.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/mod v0.23.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.30.0 // indirect
) )
+42 -22
View File
@@ -2,9 +2,12 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
@@ -17,6 +20,10 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-acme/lego/v4 v4.23.1 h1:lZ5fGtGESA2L9FB8dNTvrQUq3/X4QOb8ExkKyY7LSV4=
github.com/go-acme/lego/v4 v4.23.1/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -25,6 +32,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -42,6 +51,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ=
github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -54,31 +65,40 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+20 -8
View File
@@ -3,16 +3,20 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
) )
type Config struct { type Config struct {
DatabaseURL string DatabaseURL string
HTTPPort string HTTPPort string
DefaultServerName string DefaultServerName string
DefaultServerHost string DefaultServerHost string
DefaultServerIP string DefaultServerIP string
SessionCookieName string SessionCookieName string
SessionTTLHours int SessionTTLHours int
ACMEEmail string
ACMEStaging bool
SSLStoragePath string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -46,6 +50,11 @@ func Load() (*Config, error) {
cookieName = "panel_session" cookieName = "panel_session"
} }
sslPath := os.Getenv("SSL_STORAGE_PATH")
if sslPath == "" {
sslPath = "/etc/ssl/panel"
}
return &Config{ return &Config{
DatabaseURL: url, DatabaseURL: url,
HTTPPort: port, HTTPPort: port,
@@ -53,6 +62,9 @@ func Load() (*Config, error) {
DefaultServerHost: serverHost, DefaultServerHost: serverHost,
DefaultServerIP: serverIP, DefaultServerIP: serverIP,
SessionCookieName: cookieName, SessionCookieName: cookieName,
SessionTTLHours: 168, // 7 days SessionTTLHours: 168,
ACMEEmail: strings.TrimSpace(os.Getenv("ACME_EMAIL")),
ACMEStaging: os.Getenv("ACME_STAGING") == "true" || os.Getenv("ACME_STAGING") == "1",
SSLStoragePath: sslPath,
}, nil }, nil
} }
+32
View File
@@ -0,0 +1,32 @@
package handler
import (
"net/http"
"github.com/panelhosting/panel/internal/ssl"
)
type ACMEHandler struct {
store *ssl.ChallengeStore
}
func NewACMEHandler(store *ssl.ChallengeStore) *ACMEHandler {
return &ACMEHandler{store: store}
}
func (h *ACMEHandler) Challenge(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if token == "" {
http.NotFound(w, r)
return
}
keyAuth, ok := h.store.Get(token)
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(keyAuth))
}
+12 -4
View File
@@ -68,6 +68,11 @@ const baseCSS = `
.badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; background: #14532d; color: #bbf7d0; } .badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; background: #14532d; color: #bbf7d0; }
.center-card { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; } .center-card { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.center-card .card { width: 100%; max-width: 420px; } .center-card .card { width: 100%; max-width: 420px; }
label.check { display: flex; align-items: center; gap: .5rem; margin-bottom: 1rem; font-size: .9rem; color: #cbd5e1; }
label.check input { width: auto; margin: 0; }
.badge.ssl-pending { background: #713f12; color: #fde68a; }
.badge.ssl-active { background: #14532d; color: #bbf7d0; }
.badge.ssl-error { background: #7f1d1d; color: #fecaca; }
` `
const setupPageHTML = `<!DOCTYPE html> const setupPageHTML = `<!DOCTYPE html>
@@ -119,17 +124,20 @@ func dashboardPageHTML(username string) string {
<div><label>Имя сайта</label><input id="name" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="mysite"></div> <div><label>Имя сайта</label><input id="name" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="mysite"></div>
<div><label>Домен</label><input id="domain" required placeholder="example.com"></div> <div><label>Домен</label><input id="domain" required placeholder="example.com"></div>
</div><label>PHP версия</label><select id="php_version" required></select> </div><label>PHP версия</label><select id="php_version" required></select>
<label class="check"><input type="checkbox" id="issue_ssl" checked> Выпустить SSL (Let's Encrypt)</label>
<button type="submit" id="createBtn">Создать сайт</button></form> <button type="submit" id="createBtn">Создать сайт</button></form>
<div id="formMsg" class="msg"></div></div> <div id="formMsg" class="msg"></div></div>
<div class="card"><h2>Сайты</h2> <div class="card"><h2>Сайты</h2>
<table><thead><tr><th>Имя</th><th>Домен</th><th>PHP</th><th>Статус</th><th>Путь</th></tr></thead> <table><thead><tr><th>Имя</th><th>Домен</th><th>PHP</th><th>SSL</th><th>Статус</th><th>Путь</th><th></th></tr></thead>
<tbody id="sites"></tbody></table></div> <tbody id="sites"></tbody></table></div>
</div> </div>
<script> <script>
async function loadPHP(){const fallback=['8.1','8.2','8.3','8.4'];let versions=fallback;try{const r=await fetch('/api/v1/php-versions');const d=await r.json();if(r.ok&&d.versions&&d.versions.length)versions=d.versions;}catch(e){}const s=document.getElementById('php_version');s.innerHTML='';versions.forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent='PHP '+v;s.appendChild(o);});if(versions.length)s.value=versions[versions.length-1];} async function loadPHP(){const fallback=['8.1','8.2','8.3','8.4'];let versions=fallback;try{const r=await fetch('/api/v1/php-versions');const d=await r.json();if(r.ok&&d.versions&&d.versions.length)versions=d.versions;}catch(e){}const s=document.getElementById('php_version');s.innerHTML='';versions.forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent='PHP '+v;s.appendChild(o);});if(versions.length)s.value=versions[versions.length-1];}
async function loadSites(){const r=await fetch('/api/v1/sites');const d=await r.json();const tb=document.getElementById('sites');tb.innerHTML=''; async function loadSites(){const r=await fetch('/api/v1/sites');const d=await r.json();const tb=document.getElementById('sites');tb.innerHTML='';
(d.sites||[]).forEach(site=>{const tr=document.createElement('tr'); (d.sites||[]).forEach(site=>{const tr=document.createElement('tr');const ssl=site.ssl_status||'none';const sslCls=ssl==='active'?'ssl-active':(ssl==='error'?'ssl-error':'ssl-pending');
tr.innerHTML='<td>'+site.name+'</td><td>'+site.primary_domain+'</td><td>'+(site.php_version||'-')+'</td><td><span class="badge">'+site.status+'</span></td><td><code>'+site.document_root+'</code></td>'; let actions='';if(ssl==='error'||ssl==='pending'){actions='<button class="secondary" data-id="'+site.id+'">SSL</button>';}
tr.innerHTML='<td>'+site.name+'</td><td>'+site.primary_domain+'</td><td>'+(site.php_version||'-')+'</td><td><span class="badge '+sslCls+'">'+ssl+'</span></td><td><span class="badge">'+site.status+'</span></td><td><code>'+site.document_root+'</code></td><td>'+actions+'</td>';
if(actions){tr.querySelector('button').onclick=async()=>{await fetch('/api/v1/sites/'+site.id+'/ssl/issue',{method:'POST'});setTimeout(loadSites,2000);};}
tb.appendChild(tr);});} tb.appendChild(tr);});}
document.getElementById('logout').onclick=async()=>{await fetch('/api/v1/auth/logout',{method:'POST'});location.reload();}; document.getElementById('logout').onclick=async()=>{await fetch('/api/v1/auth/logout',{method:'POST'});location.reload();};
document.getElementById('siteForm').onsubmit=async e=>{ document.getElementById('siteForm').onsubmit=async e=>{
@@ -138,7 +146,7 @@ const btn=document.getElementById('createBtn'),msg=document.getElementById('form
const siteName=document.getElementById('name'),siteDomain=document.getElementById('domain'),phpSelect=document.getElementById('php_version'); const siteName=document.getElementById('name'),siteDomain=document.getElementById('domain'),phpSelect=document.getElementById('php_version');
btn.disabled=true;msg.className='msg';msg.textContent=''; btn.disabled=true;msg.className='msg';msg.textContent='';
try{ try{
const res=await fetch('/api/v1/sites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:siteName.value.trim().toLowerCase(),domain:siteDomain.value.trim().toLowerCase(),php_version:phpSelect.value})}); const res=await fetch('/api/v1/sites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:siteName.value.trim().toLowerCase(),domain:siteDomain.value.trim().toLowerCase(),php_version:phpSelect.value,issue_ssl:document.getElementById('issue_ssl').checked})});
const d=await res.json().catch(()=>({})); const d=await res.json().catch(()=>({}));
if(!res.ok){msg.className='msg error';msg.textContent=d.error||'Ошибка';btn.disabled=false;return;} if(!res.ok){msg.className='msg error';msg.textContent=d.error||'Ошибка';btn.disabled=false;return;}
msg.className='msg ok';msg.textContent='Сайт создан'; msg.className='msg ok';msg.textContent='Сайт создан';
+25
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv"
"github.com/panelhosting/panel/internal/middleware" "github.com/panelhosting/panel/internal/middleware"
"github.com/panelhosting/panel/internal/sitesvc" "github.com/panelhosting/panel/internal/sitesvc"
@@ -22,6 +23,7 @@ type createSiteRequest struct {
Domain string `json:"domain"` Domain string `json:"domain"`
PHPVersion string `json:"php_version"` PHPVersion string `json:"php_version"`
ServerID int64 `json:"server_id,omitempty"` ServerID int64 `json:"server_id,omitempty"`
IssueSSL *bool `json:"issue_ssl"`
} }
func (h *SiteHandler) List(w http.ResponseWriter, r *http.Request) { func (h *SiteHandler) List(w http.ResponseWriter, r *http.Request) {
@@ -52,6 +54,11 @@ func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
issueSSL := true
if req.IssueSSL != nil {
issueSSL = *req.IssueSSL
}
site, err := h.svc.Create(r.Context(), sitesvc.CreateInput{ site, err := h.svc.Create(r.Context(), sitesvc.CreateInput{
Name: req.Name, Name: req.Name,
Domain: req.Domain, Domain: req.Domain,
@@ -59,6 +66,7 @@ func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) {
ServerID: req.ServerID, ServerID: req.ServerID,
OwnerID: user.ID, OwnerID: user.ID,
IsAdmin: middleware.IsAdmin(user), IsAdmin: middleware.IsAdmin(user),
IssueSSL: issueSSL,
}) })
if err != nil { if err != nil {
switch { switch {
@@ -83,3 +91,20 @@ func (h *SiteHandler) PHPVersions(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, map[string]any{"versions": versions}) writeJSON(w, http.StatusOK, map[string]any{"versions": versions})
} }
func (h *SiteHandler) ReissueSSL(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
siteID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid site id")
return
}
h.svc.ReissueSSLAsync(siteID)
writeJSON(w, http.StatusOK, map[string]string{"message": "ssl issuance started"})
}
+33 -5
View File
@@ -22,9 +22,13 @@ func NewSiteRepository(pool *pgxpool.Pool) *SiteRepository {
type SiteWithDomain struct { type SiteWithDomain struct {
models.Site models.Site
PrimaryDomain string `json:"primary_domain"` PrimaryDomain string `json:"primary_domain"`
DomainID int64 `json:"domain_id,omitempty"`
SSLID int64 `json:"ssl_id,omitempty"`
SSLStatus string `json:"ssl_status,omitempty"`
SSLError string `json:"ssl_error,omitempty"`
} }
func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, name, documentRoot, phpVersion, domain string) (*SiteWithDomain, error) { func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, name, documentRoot, phpVersion, domain string, issueSSL bool) (*SiteWithDomain, error) {
tx, err := r.pool.Begin(ctx) tx, err := r.pool.Begin(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -52,17 +56,33 @@ func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, na
const domainQ = ` const domainQ = `
INSERT INTO domains (site_id, domain, is_primary, ssl_enabled) INSERT INTO domains (site_id, domain, is_primary, ssl_enabled)
VALUES ($1, $2, true, true) VALUES ($1, $2, true, $3)
RETURNING id
` `
if _, err = tx.Exec(ctx, domainQ, s.ID, domain); err != nil { var domainID int64
if err = tx.QueryRow(ctx, domainQ, s.ID, domain, issueSSL).Scan(&domainID); err != nil {
return nil, fmt.Errorf("create domain: %w", err) return nil, fmt.Errorf("create domain: %w", err)
} }
result := &SiteWithDomain{Site: s, PrimaryDomain: domain, DomainID: domainID}
if issueSSL {
const sslQ = `
INSERT INTO ssl_certificates (domain_id, type, status, auto_renew)
VALUES ($1, 'letsencrypt', 'pending', true)
RETURNING id
`
if err = tx.QueryRow(ctx, sslQ, domainID).Scan(&result.SSLID); err != nil {
return nil, fmt.Errorf("create ssl cert: %w", err)
}
result.SSLStatus = "pending"
}
if err = tx.Commit(ctx); err != nil { if err = tx.Commit(ctx); err != nil {
return nil, err return nil, err
} }
return &SiteWithDomain{Site: s, PrimaryDomain: domain}, nil return result, nil
} }
func (r *SiteRepository) ListForUser(ctx context.Context, userID int64, isAdmin bool) ([]SiteWithDomain, error) { func (r *SiteRepository) ListForUser(ctx context.Context, userID int64, isAdmin bool) ([]SiteWithDomain, error) {
@@ -87,9 +107,16 @@ func (r *SiteRepository) ListForUser(ctx context.Context, userID int64, isAdmin
const siteListQuery = ` const siteListQuery = `
SELECT s.id, s.uuid, s.server_id, s.owner_id, s.name, s.document_root, s.php_version, s.status, SELECT s.id, s.uuid, s.server_id, s.owner_id, s.name, s.document_root, s.php_version, s.status,
s.settings, s.disk_quota_mb, s.created_at, s.updated_at, s.settings, s.disk_quota_mb, s.created_at, s.updated_at,
COALESCE(d.domain::text, '') COALESCE(d.domain::text, ''),
COALESCE(d.id, 0),
COALESCE(ssl.status::text, 'none'),
COALESCE(ssl.error_message, '')
FROM sites s FROM sites s
LEFT JOIN domains d ON d.site_id = s.id AND d.is_primary = true LEFT JOIN domains d ON d.site_id = s.id AND d.is_primary = true
LEFT JOIN LATERAL (
SELECT status, error_message FROM ssl_certificates
WHERE domain_id = d.id ORDER BY id DESC LIMIT 1
) ssl ON true
` `
func scanSiteList(rows pgx.Rows) ([]SiteWithDomain, error) { func scanSiteList(rows pgx.Rows) ([]SiteWithDomain, error) {
@@ -100,6 +127,7 @@ func scanSiteList(rows pgx.Rows) ([]SiteWithDomain, error) {
&item.ID, &item.UUID, &item.ServerID, &item.OwnerID, &item.Name, &item.DocumentRoot, &item.ID, &item.UUID, &item.ServerID, &item.OwnerID, &item.Name, &item.DocumentRoot,
&item.PHPVersion, &item.Status, &item.Settings, &item.DiskQuotaMB, &item.PHPVersion, &item.Status, &item.Settings, &item.DiskQuotaMB,
&item.CreatedAt, &item.UpdatedAt, &item.PrimaryDomain, &item.CreatedAt, &item.UpdatedAt, &item.PrimaryDomain,
&item.DomainID, &item.SSLStatus, &item.SSLError,
) )
if err != nil { if err != nil {
return nil, err return nil, err
+110
View File
@@ -0,0 +1,110 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type SSLCertificate struct {
ID int64 `json:"id"`
DomainID int64 `json:"domain_id"`
Type string `json:"type"`
Status string `json:"status"`
Issuer *string `json:"issuer,omitempty"`
ErrorMessage *string `json:"error_message,omitempty"`
IssuedAt *time.Time `json:"issued_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
AutoRenew bool `json:"auto_renew"`
}
type SSLRepository struct {
pool *pgxpool.Pool
}
func NewSSLRepository(pool *pgxpool.Pool) *SSLRepository {
return &SSLRepository{pool: pool}
}
func (r *SSLRepository) CreatePending(ctx context.Context, domainID int64) (int64, error) {
const q = `
INSERT INTO ssl_certificates (domain_id, type, status, auto_renew)
VALUES ($1, 'letsencrypt', 'pending', true)
RETURNING id
`
var id int64
err := r.pool.QueryRow(ctx, q, domainID).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create ssl cert: %w", err)
}
return id, nil
}
func (r *SSLRepository) MarkActive(ctx context.Context, id int64, issuer, certPEM, keyPEM, chainPEM string, issuedAt, expiresAt time.Time) error {
const q = `
UPDATE ssl_certificates
SET status = 'active', issuer = $2, cert_pem = $3, key_pem = $4, chain_pem = $5,
issued_at = $6, expires_at = $7, error_message = NULL, updated_at = now()
WHERE id = $1
`
_, err := r.pool.Exec(ctx, q, id, issuer, certPEM, keyPEM, chainPEM, issuedAt, expiresAt)
return err
}
func (r *SSLRepository) MarkError(ctx context.Context, id int64, msg string) error {
const q = `
UPDATE ssl_certificates
SET status = 'error', error_message = $2, updated_at = now()
WHERE id = $1
`
_, err := r.pool.Exec(ctx, q, id, msg)
return err
}
func (r *SSLRepository) GetByDomainID(ctx context.Context, domainID int64) (*SSLCertificate, error) {
const q = `
SELECT id, domain_id, type::text, status::text, issuer, error_message, issued_at, expires_at, auto_renew
FROM ssl_certificates WHERE domain_id = $1 ORDER BY id DESC LIMIT 1
`
var c SSLCertificate
err := r.pool.QueryRow(ctx, q, domainID).Scan(
&c.ID, &c.DomainID, &c.Type, &c.Status, &c.Issuer, &c.ErrorMessage,
&c.IssuedAt, &c.ExpiresAt, &c.AutoRenew,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
func (r *SSLRepository) GetDomainInfo(ctx context.Context, domainID int64) (domain string, webroot string, err error) {
const q = `
SELECT d.domain::text, s.document_root
FROM domains d
JOIN sites s ON s.id = d.site_id
WHERE d.id = $1
`
err = r.pool.QueryRow(ctx, q, domainID).Scan(&domain, &webroot)
return
}
func (r *SSLRepository) GetSiteOwner(ctx context.Context, siteID int64) (int64, error) {
var ownerID int64
err := r.pool.QueryRow(ctx, `SELECT owner_id FROM sites WHERE id = $1`, siteID).Scan(&ownerID)
return ownerID, err
}
func (r *SSLRepository) GetDomainIDBySite(ctx context.Context, siteID int64) (int64, error) {
var id int64
err := r.pool.QueryRow(ctx, `
SELECT id FROM domains WHERE site_id = $1 AND is_primary = true LIMIT 1
`, siteID).Scan(&id)
return id, err
}
+15 -1
View File
@@ -16,6 +16,8 @@ import (
"github.com/panelhosting/panel/internal/repository" "github.com/panelhosting/panel/internal/repository"
"github.com/panelhosting/panel/internal/setup" "github.com/panelhosting/panel/internal/setup"
"github.com/panelhosting/panel/internal/sitesvc" "github.com/panelhosting/panel/internal/sitesvc"
"github.com/panelhosting/panel/internal/ssl"
"github.com/panelhosting/panel/internal/sslsvc"
) )
type Server struct { type Server struct {
@@ -28,14 +30,24 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
sessions := repository.NewSessionRepository(pool) sessions := repository.NewSessionRepository(pool)
sites := repository.NewSiteRepository(pool) sites := repository.NewSiteRepository(pool)
phpVers := repository.NewPHPVersionRepository(pool) phpVers := repository.NewPHPVersionRepository(pool)
sslRepo := repository.NewSSLRepository(pool)
challengeStore := ssl.NewChallengeStore()
issuer := ssl.NewIssuer(cfg.ACMEEmail, cfg.ACMEStaging, cfg.SSLStoragePath, challengeStore)
sslService := sslsvc.NewService(sslRepo, issuer, cfg)
setupSvc := setup.NewService(users, servers, cfg) setupSvc := setup.NewService(users, servers, cfg)
authSvc := authsvc.NewService(users, sessions, cfg.SessionTTLHours) authSvc := authsvc.NewService(users, sessions, cfg.SessionTTLHours)
siteSvc := sitesvc.NewService(sites, servers, phpVers) siteSvc := sitesvc.NewService(sites, servers, phpVers, sslService)
if cfg.ACMEEmail == "" {
log.Println("ssl: ACME_EMAIL not set, let's encrypt issuance disabled")
}
setupHandler := handler.NewSetupHandler(setupSvc) setupHandler := handler.NewSetupHandler(setupSvc)
authHandler := handler.NewAuthHandler(authSvc, cfg) authHandler := handler.NewAuthHandler(authSvc, cfg)
siteHandler := handler.NewSiteHandler(siteSvc) siteHandler := handler.NewSiteHandler(siteSvc)
acmeHandler := handler.NewACMEHandler(challengeStore)
authMW := middleware.NewAuth(authSvc, cfg) authMW := middleware.NewAuth(authSvc, cfg)
setupRequired, err := setupSvc.Status(context.Background()) setupRequired, err := setupSvc.Status(context.Background())
@@ -52,6 +64,7 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", handler.Health) mux.HandleFunc("GET /health", handler.Health)
mux.HandleFunc("GET /.well-known/acme-challenge/{token}", acmeHandler.Challenge)
mux.HandleFunc("GET /api/v1/setup/status", setupHandler.Status) mux.HandleFunc("GET /api/v1/setup/status", setupHandler.Status)
mux.HandleFunc("POST /api/v1/setup/register", setupHandler.Register) mux.HandleFunc("POST /api/v1/setup/register", setupHandler.Register)
@@ -63,6 +76,7 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
mux.HandleFunc("GET /api/v1/php-versions", siteHandler.PHPVersions) mux.HandleFunc("GET /api/v1/php-versions", siteHandler.PHPVersions)
mux.HandleFunc("GET /api/v1/sites", authMW.Require(siteHandler.List)) mux.HandleFunc("GET /api/v1/sites", authMW.Require(siteHandler.List))
mux.HandleFunc("POST /api/v1/sites", authMW.Require(siteHandler.Create)) mux.HandleFunc("POST /api/v1/sites", authMW.Require(siteHandler.Create))
mux.HandleFunc("POST /api/v1/sites/{id}/ssl/issue", authMW.Require(siteHandler.ReissueSSL))
mux.HandleFunc("GET /{$}", authMW.Optional(handler.IndexPage(setupSvc))) mux.HandleFunc("GET /{$}", authMW.Optional(handler.IndexPage(setupSvc)))
+32 -7
View File
@@ -22,14 +22,20 @@ var (
domainRegex = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`) domainRegex = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
) )
type Service struct { type SSLIssuer interface {
sites *repository.SiteRepository IssueAsync(sslID, domainID int64, domain, webroot string)
servers *repository.ServerRepository IssueForSite(ctx context.Context, siteID int64) error
phpVers *repository.PHPVersionRepository
} }
func NewService(sites *repository.SiteRepository, servers *repository.ServerRepository, php *repository.PHPVersionRepository) *Service { type Service struct {
return &Service{sites: sites, servers: servers, phpVers: php} sites *repository.SiteRepository
servers *repository.ServerRepository
phpVers *repository.PHPVersionRepository
ssl SSLIssuer
}
func NewService(sites *repository.SiteRepository, servers *repository.ServerRepository, php *repository.PHPVersionRepository, ssl SSLIssuer) *Service {
return &Service{sites: sites, servers: servers, phpVers: php, ssl: ssl}
} }
type CreateInput struct { type CreateInput struct {
@@ -39,6 +45,7 @@ type CreateInput struct {
ServerID int64 ServerID int64
OwnerID int64 OwnerID int64
IsAdmin bool IsAdmin bool
IssueSSL bool
} }
func (s *Service) List(ctx context.Context, user *models.User, isAdmin bool) ([]repository.SiteWithDomain, error) { func (s *Service) List(ctx context.Context, user *models.User, isAdmin bool) ([]repository.SiteWithDomain, error) {
@@ -86,5 +93,23 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (*repository.SiteW
} }
documentRoot := fmt.Sprintf("/var/www/%s/public", name) documentRoot := fmt.Sprintf("/var/www/%s/public", name)
return s.sites.Create(ctx, serverID, in.OwnerID, name, documentRoot, phpVersion, domain) site, err := s.sites.Create(ctx, serverID, in.OwnerID, name, documentRoot, phpVersion, domain, in.IssueSSL)
if err != nil {
return nil, err
}
if in.IssueSSL && s.ssl != nil && site.SSLID > 0 {
s.ssl.IssueAsync(site.SSLID, site.DomainID, domain, documentRoot)
}
return site, nil
}
func (s *Service) ReissueSSLAsync(siteID int64) {
if s.ssl == nil {
return
}
go func() {
_ = s.ssl.IssueForSite(context.Background(), siteID)
}()
} }
+33
View File
@@ -0,0 +1,33 @@
package ssl
import (
"sync"
)
type ChallengeStore struct {
mu sync.RWMutex
tokens map[string]string
}
func NewChallengeStore() *ChallengeStore {
return &ChallengeStore{tokens: make(map[string]string)}
}
func (s *ChallengeStore) Set(token, keyAuth string) {
s.mu.Lock()
defer s.mu.Unlock()
s.tokens[token] = keyAuth
}
func (s *ChallengeStore) Get(token string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.tokens[token]
return v, ok
}
func (s *ChallengeStore) Delete(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.tokens, token)
}
+223
View File
@@ -0,0 +1,223 @@
package ssl
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
)
type ACMEUser struct {
Email string
Registration *registration.Resource
key *ecdsa.PrivateKey
}
func (u *ACMEUser) GetEmail() string { return u.Email }
func (u *ACMEUser) GetRegistration() *registration.Resource { return u.Registration }
func (u *ACMEUser) GetPrivateKey() crypto.PrivateKey { return u.key }
type webrootProvider struct {
webroot string
store *ChallengeStore
}
func (p *webrootProvider) Present(domain, token, keyAuth string) error {
p.store.Set(token, keyAuth)
dir := filepath.Join(p.webroot, ".well-known", "acme-challenge")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, token), []byte(keyAuth), 0o644)
}
func (p *webrootProvider) CleanUp(domain, token, keyAuth string) error {
p.store.Delete(token)
_ = os.Remove(filepath.Join(p.webroot, ".well-known", "acme-challenge", token))
return nil
}
type Issuer struct {
email string
staging bool
storageDir string
store *ChallengeStore
mu sync.Mutex
client *lego.Client
}
func NewIssuer(email string, staging bool, storageDir string, store *ChallengeStore) *Issuer {
return &Issuer{
email: email,
staging: staging,
storageDir: storageDir,
store: store,
}
}
func (i *Issuer) Obtain(ctx context.Context, domain, webroot string) (*certificate.Resource, error) {
if i.email == "" {
return nil, errors.New("ACME_EMAIL is not configured")
}
client, err := i.getClient()
if err != nil {
return nil, err
}
provider := &webrootProvider{webroot: webroot, store: i.store}
if err := client.Challenge.SetHTTP01Provider(provider); err != nil {
return nil, err
}
request := certificate.ObtainRequest{
Domains: []string{domain},
Bundle: true,
}
res, err := client.Certificate.Obtain(request)
if err != nil {
return nil, fmt.Errorf("acme obtain: %w", err)
}
if err := i.saveToDisk(domain, res); err != nil {
log.Printf("ssl: save to disk: %v", err)
}
return res, nil
}
func (i *Issuer) getClient() (*lego.Client, error) {
i.mu.Lock()
defer i.mu.Unlock()
if i.client != nil {
return i.client, nil
}
user, err := i.loadOrCreateUser()
if err != nil {
return nil, err
}
config := lego.NewConfig(user)
config.Certificate.KeyType = certcrypto.RSA2048
if i.staging {
config.CADirURL = lego.LEDirectoryStaging
} else {
config.CADirURL = lego.LEDirectoryProduction
}
client, err := lego.NewClient(config)
if err != nil {
return nil, err
}
if user.Registration == nil {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, fmt.Errorf("acme register: %w", err)
}
user.Registration = reg
if err := i.saveAccount(user); err != nil {
log.Printf("ssl: save acme account: %v", err)
}
}
i.client = client
return client, nil
}
func (i *Issuer) accountPath() string {
return filepath.Join(i.storageDir, "acme", "account.pem")
}
func (i *Issuer) loadOrCreateUser() (*ACMEUser, error) {
user := &ACMEUser{Email: i.email}
if data, err := os.ReadFile(i.accountPath()); err == nil {
block, _ := pem.Decode(data)
if block != nil {
key, err := x509.ParseECPrivateKey(block.Bytes)
if err == nil {
user.key = key
return user, nil
}
}
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
user.key = key
if err := os.MkdirAll(filepath.Dir(i.accountPath()), 0o700); err != nil {
return nil, err
}
if err := i.saveAccount(user); err != nil {
return nil, err
}
return user, nil
}
func (i *Issuer) saveAccount(user *ACMEUser) error {
der, err := x509.MarshalECPrivateKey(user.key)
if err != nil {
return err
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
return os.WriteFile(i.accountPath(), pemBytes, 0o600)
}
func (i *Issuer) saveToDisk(domain string, res *certificate.Resource) error {
dir := filepath.Join(i.storageDir, "live", sanitizeDomain(domain))
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "fullchain.pem"), res.Certificate, 0o644); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "privkey.pem"), res.PrivateKey, 0o600)
}
func (i *Issuer) Store() *ChallengeStore {
return i.store
}
func sanitizeDomain(domain string) string {
return strings.ReplaceAll(domain, "..", "")
}
func CertPaths(storageDir, domain string) (certPath, keyPath string) {
dir := filepath.Join(storageDir, "live", sanitizeDomain(domain))
return filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem")
}
func ParseCertExpiry(certPEM []byte) (time.Time, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return time.Time{}, errors.New("invalid certificate pem")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return time.Time{}, err
}
return cert.NotAfter, nil
}
+88
View File
@@ -0,0 +1,88 @@
package sslsvc
import (
"context"
"log"
"time"
"github.com/panelhosting/panel/internal/config"
"github.com/panelhosting/panel/internal/repository"
"github.com/panelhosting/panel/internal/ssl"
)
type Service struct {
repo *repository.SSLRepository
issuer *ssl.Issuer
cfg *config.Config
}
func NewService(repo *repository.SSLRepository, issuer *ssl.Issuer, cfg *config.Config) *Service {
return &Service{repo: repo, issuer: issuer, cfg: cfg}
}
func (s *Service) IssueAsync(sslID, domainID int64, domain, webroot string) {
go func() {
ctx := context.Background()
if err := s.Issue(ctx, sslID, domainID, domain, webroot); err != nil {
log.Printf("ssl issue %s: %v", domain, err)
}
}()
}
func (s *Service) Issue(ctx context.Context, sslID, domainID int64, domain, webroot string) error {
if s.cfg.ACMEEmail == "" {
return s.repo.MarkError(ctx, sslID, "ACME_EMAIL not configured")
}
res, err := s.issuer.Obtain(ctx, domain, webroot)
if err != nil {
_ = s.repo.MarkError(ctx, sslID, err.Error())
return err
}
expiresAt, err := ssl.ParseCertExpiry(res.Certificate)
if err != nil {
expiresAt = time.Now().Add(90 * 24 * time.Hour)
}
issuerName := "Let's Encrypt"
if s.cfg.ACMEStaging {
issuerName = "Let's Encrypt (staging)"
}
return s.repo.MarkActive(ctx, sslID, issuerName, string(res.Certificate), string(res.PrivateKey), string(res.IssuerCertificate), time.Now(), expiresAt)
}
func (s *Service) IssueForSite(ctx context.Context, siteID int64) error {
domainID, err := s.repo.GetDomainIDBySite(ctx, siteID)
if err != nil {
return err
}
domain, webroot, err := s.repo.GetDomainInfo(ctx, domainID)
if err != nil {
return err
}
cert, err := s.repo.GetByDomainID(ctx, domainID)
if err != nil {
return err
}
var sslID int64
if cert == nil {
sslID, err = s.repo.CreatePending(ctx, domainID)
} else {
sslID = cert.ID
}
if err != nil {
return err
}
go func() {
if err := s.Issue(context.Background(), sslID, domainID, domain, webroot); err != nil {
log.Printf("ssl reissue %s: %v", domain, err)
}
}()
return nil
}
+1
View File
@@ -0,0 +1 @@
ALTER TABLE ssl_certificates DROP COLUMN IF EXISTS error_message;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE ssl_certificates ADD COLUMN IF NOT EXISTS error_message TEXT;