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=
+13 -1
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
) )
type Config struct { type Config struct {
@@ -13,6 +14,9 @@ type Config struct {
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)))
+28 -3
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 SSLIssuer interface {
IssueAsync(sslID, domainID int64, domain, webroot string)
IssueForSite(ctx context.Context, siteID int64) error
}
type Service struct { type Service struct {
sites *repository.SiteRepository sites *repository.SiteRepository
servers *repository.ServerRepository servers *repository.ServerRepository
phpVers *repository.PHPVersionRepository phpVers *repository.PHPVersionRepository
ssl SSLIssuer
} }
func NewService(sites *repository.SiteRepository, servers *repository.ServerRepository, php *repository.PHPVersionRepository) *Service { func NewService(sites *repository.SiteRepository, servers *repository.ServerRepository, php *repository.PHPVersionRepository, ssl SSLIssuer) *Service {
return &Service{sites: sites, servers: servers, phpVers: php} 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;