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_HOSTNAME=server1
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**
- `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**
- `GET /health` — healthcheck
- `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_HOSTNAME: ${DEFAULT_SERVER_HOSTNAME:-server1}
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:
- "${HTTP_PORT:-8000}:8000"
volumes:
- /var/www:/var/www
- panel_ssl:/etc/ssl/panel
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
panel_ssl:
+12 -4
View File
@@ -3,20 +3,28 @@ module github.com/panelhosting/panel
go 1.23.0
require (
github.com/go-acme/lego/v4 v4.23.1
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.36.0
)
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/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
github.com/miekg/dns v1.1.64 // indirect
go.uber.org/atomic v1.9.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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
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.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.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/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
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/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/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
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/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
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.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/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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.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/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+20 -8
View File
@@ -3,16 +3,20 @@ package config
import (
"fmt"
"os"
"strings"
)
type Config struct {
DatabaseURL string
HTTPPort string
DefaultServerName string
DefaultServerHost string
DefaultServerIP string
SessionCookieName string
SessionTTLHours int
DatabaseURL string
HTTPPort string
DefaultServerName string
DefaultServerHost string
DefaultServerIP string
SessionCookieName string
SessionTTLHours int
ACMEEmail string
ACMEStaging bool
SSLStoragePath string
}
func Load() (*Config, error) {
@@ -46,6 +50,11 @@ func Load() (*Config, error) {
cookieName = "panel_session"
}
sslPath := os.Getenv("SSL_STORAGE_PATH")
if sslPath == "" {
sslPath = "/etc/ssl/panel"
}
return &Config{
DatabaseURL: url,
HTTPPort: port,
@@ -53,6 +62,9 @@ func Load() (*Config, error) {
DefaultServerHost: serverHost,
DefaultServerIP: serverIP,
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
}
+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; }
.center-card { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.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>
@@ -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="domain" required placeholder="example.com"></div>
</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>
<div id="formMsg" class="msg"></div></div>
<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>
</div>
<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 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');
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>';
(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');
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);});}
document.getElementById('logout').onclick=async()=>{await fetch('/api/v1/auth/logout',{method:'POST'});location.reload();};
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');
btn.disabled=true;msg.className='msg';msg.textContent='';
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(()=>({}));
if(!res.ok){msg.className='msg error';msg.textContent=d.error||'Ошибка';btn.disabled=false;return;}
msg.className='msg ok';msg.textContent='Сайт создан';
+25
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/panelhosting/panel/internal/middleware"
"github.com/panelhosting/panel/internal/sitesvc"
@@ -22,6 +23,7 @@ type createSiteRequest struct {
Domain string `json:"domain"`
PHPVersion string `json:"php_version"`
ServerID int64 `json:"server_id,omitempty"`
IssueSSL *bool `json:"issue_ssl"`
}
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
}
issueSSL := true
if req.IssueSSL != nil {
issueSSL = *req.IssueSSL
}
site, err := h.svc.Create(r.Context(), sitesvc.CreateInput{
Name: req.Name,
Domain: req.Domain,
@@ -59,6 +66,7 @@ func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) {
ServerID: req.ServerID,
OwnerID: user.ID,
IsAdmin: middleware.IsAdmin(user),
IssueSSL: issueSSL,
})
if err != nil {
switch {
@@ -83,3 +91,20 @@ func (h *SiteHandler) PHPVersions(w http.ResponseWriter, r *http.Request) {
}
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 {
models.Site
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)
if err != nil {
return nil, err
@@ -52,17 +56,33 @@ func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, na
const domainQ = `
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)
}
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 {
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) {
@@ -87,9 +107,16 @@ func (r *SiteRepository) ListForUser(ctx context.Context, userID int64, isAdmin
const siteListQuery = `
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,
COALESCE(d.domain::text, '')
COALESCE(d.domain::text, ''),
COALESCE(d.id, 0),
COALESCE(ssl.status::text, 'none'),
COALESCE(ssl.error_message, '')
FROM sites s
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) {
@@ -100,6 +127,7 @@ func scanSiteList(rows pgx.Rows) ([]SiteWithDomain, error) {
&item.ID, &item.UUID, &item.ServerID, &item.OwnerID, &item.Name, &item.DocumentRoot,
&item.PHPVersion, &item.Status, &item.Settings, &item.DiskQuotaMB,
&item.CreatedAt, &item.UpdatedAt, &item.PrimaryDomain,
&item.DomainID, &item.SSLStatus, &item.SSLError,
)
if err != nil {
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/setup"
"github.com/panelhosting/panel/internal/sitesvc"
"github.com/panelhosting/panel/internal/ssl"
"github.com/panelhosting/panel/internal/sslsvc"
)
type Server struct {
@@ -28,14 +30,24 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
sessions := repository.NewSessionRepository(pool)
sites := repository.NewSiteRepository(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)
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)
authHandler := handler.NewAuthHandler(authSvc, cfg)
siteHandler := handler.NewSiteHandler(siteSvc)
acmeHandler := handler.NewACMEHandler(challengeStore)
authMW := middleware.NewAuth(authSvc, cfg)
setupRequired, err := setupSvc.Status(context.Background())
@@ -52,6 +64,7 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
mux := http.NewServeMux()
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("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/sites", authMW.Require(siteHandler.List))
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)))
+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,}$`)
)
type Service struct {
sites *repository.SiteRepository
servers *repository.ServerRepository
phpVers *repository.PHPVersionRepository
type SSLIssuer interface {
IssueAsync(sslID, domainID int64, domain, webroot string)
IssueForSite(ctx context.Context, siteID int64) error
}
func NewService(sites *repository.SiteRepository, servers *repository.ServerRepository, php *repository.PHPVersionRepository) *Service {
return &Service{sites: sites, servers: servers, phpVers: php}
type Service struct {
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 {
@@ -39,6 +45,7 @@ type CreateInput struct {
ServerID int64
OwnerID int64
IsAdmin bool
IssueSSL bool
}
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)
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;