This commit is contained in:
yoan
2024-08-21 12:19:12 +08:00
commit 50fa238d88
89 changed files with 11835 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
package applicant
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"errors"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
"github.com/pocketbase/pocketbase/models"
)
const (
configTypeTencent = "tencent"
)
type Certificate struct {
CertUrl string `json:"certUrl"`
CertStableUrl string `json:"certStableUrl"`
PrivateKey string `json:"privateKey"`
Certificate string `json:"certificate"`
IssuerCertificate string `json:"issuerCertificate"`
Csr string `json:"csr"`
}
type ApplyOption struct {
Email string `json:"email"`
Domain string `json:"domain"`
Access string `json:"access"`
}
type MyUser struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *MyUser) GetEmail() string {
return u.Email
}
func (u MyUser) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *MyUser) GetPrivateKey() crypto.PrivateKey {
return u.key
}
type Applicant interface {
Apply() (*Certificate, error)
}
func Get(record *models.Record) (Applicant, error) {
access := record.ExpandedOne("access")
switch access.GetString("configType") {
case configTypeTencent:
return NewTencent(&ApplyOption{
Email: "536464346@qq.com",
Domain: record.GetString("domain"),
Access: access.GetString("config"),
}), nil
default:
return nil, errors.New("unknown config type")
}
}
func apply(option *ApplyOption, provider challenge.Provider) (*Certificate, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
myUser := MyUser{
Email: option.Email,
key: privateKey,
}
config := lego.NewConfig(&myUser)
// This CA URL is configured for a local dev instance of Boulder running in Docker in a VM.
config.CADirURL = "https://acme-v02.api.letsencrypt.org/directory"
config.Certificate.KeyType = certcrypto.RSA2048
// A client facilitates communication with the CA server.
client, err := lego.NewClient(config)
if err != nil {
return nil, err
}
client.Challenge.SetDNS01Provider(provider)
// New users will need to register
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, err
}
myUser.Registration = reg
request := certificate.ObtainRequest{
Domains: []string{option.Domain},
Bundle: true,
}
certificates, err := client.Certificate.Obtain(request)
if err != nil {
return nil, err
}
return &Certificate{
CertUrl: certificates.CertURL,
CertStableUrl: certificates.CertStableURL,
PrivateKey: string(certificates.PrivateKey),
Certificate: string(certificates.Certificate),
IssuerCertificate: string(certificates.IssuerCertificate),
Csr: string(certificates.CSR),
}, nil
}

View File

@@ -0,0 +1,38 @@
package applicant
import (
"encoding/json"
"os"
"github.com/go-acme/lego/v4/providers/dns/tencentcloud"
)
type tencentAccess struct {
SecretId string `json:"secretId"`
SecretKey string `json:"secretKey"`
}
type tencent struct {
option *ApplyOption
}
func NewTencent(option *ApplyOption) Applicant {
return &tencent{
option: option,
}
}
func (t *tencent) Apply() (*Certificate, error) {
access := &tencentAccess{}
json.Unmarshal([]byte(t.option.Access), access)
os.Setenv("TENCENTCLOUD_SECRET_ID", access.SecretId)
os.Setenv("TENCENTCLOUD_SECRET_KEY", access.SecretKey)
dnsProvider, err := tencentcloud.NewDNSProvider()
if err != nil {
return nil, err
}
return apply(t.option, dnsProvider)
}

191
internal/deployer/aliyun.go Normal file
View File

@@ -0,0 +1,191 @@
package deployer
import (
"certimate/internal/applicant"
"certimate/internal/utils/rand"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
cas20200407 "github.com/alibabacloud-go/cas-20200407/v2/client"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
type aliyunAccess struct {
AccessKeyId string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
}
type aliyun struct {
client *cas20200407.Client
option *DeployerOption
}
func NewAliyun(option *DeployerOption) (Deployer, error) {
access := &aliyunAccess{}
json.Unmarshal([]byte(option.Access), access)
a := &aliyun{
option: option,
}
client, err := a.createClient(access.AccessKeyId, access.AccessKeySecret)
if err != nil {
return nil, err
}
a.client = client
return a, nil
}
func (a *aliyun) Deploy(ctx context.Context) error {
// 查询有没有对应的资源
resource, err := a.resource()
if err != nil {
return err
}
// 查询有没有对应的联系人
contacts, err := a.contacts()
if err != nil {
return err
}
// 上传证书
certId, err := a.uploadCert(&a.option.Certificate)
if err != nil {
return err
}
// 部署证书
jobId, err := a.deploy(resource, certId, contacts)
if err != nil {
return err
}
// 等待部署成功
err = a.updateDeployStatus(*jobId)
if err != nil {
return err
}
// 部署成功后删除旧的证书
a.deleteCert(resource)
return nil
}
func (a *aliyun) updateDeployStatus(jobId int64) error {
// 查询部署状态
req := &cas20200407.UpdateDeploymentJobStatusRequest{
JobId: tea.Int64(jobId),
}
_, err := a.client.UpdateDeploymentJobStatus(req)
if err != nil {
return err
}
return nil
}
func (a *aliyun) deleteCert(resource *cas20200407.ListCloudResourcesResponseBodyData) error {
// 查询有没有对应的资源
if resource.CertId == nil {
return nil
}
// 删除证书
_, err := a.client.DeleteUserCertificate(&cas20200407.DeleteUserCertificateRequest{
CertId: resource.CertId,
})
if err != nil {
return err
}
return nil
}
func (a *aliyun) contacts() ([]*cas20200407.ListContactResponseBodyContactList, error) {
listContactRequest := &cas20200407.ListContactRequest{}
runtime := &util.RuntimeOptions{}
resp, err := a.client.ListContactWithOptions(listContactRequest, runtime)
if err != nil {
return nil, err
}
if resp.Body.TotalCount == nil {
return nil, errors.New("no contact found")
}
return resp.Body.ContactList, nil
}
func (a *aliyun) deploy(resource *cas20200407.ListCloudResourcesResponseBodyData, certId int64, contacts []*cas20200407.ListContactResponseBodyContactList) (*int64, error) {
contactIds := make([]string, 0, len(contacts))
for _, contact := range contacts {
contactIds = append(contactIds, fmt.Sprintf("%d", *contact.ContactId))
}
// 部署证书
createCloudResourceRequest := &cas20200407.CreateDeploymentJobRequest{
CertIds: tea.String(fmt.Sprintf("%d", certId)),
Name: tea.String(a.option.Domain + rand.RandStr(6)),
JobType: tea.String("user"),
ResourceIds: tea.String(fmt.Sprintf("%d", *resource.Id)),
ContactIds: tea.String(strings.Join(contactIds, ",")),
}
runtime := &util.RuntimeOptions{}
resp, err := a.client.CreateDeploymentJobWithOptions(createCloudResourceRequest, runtime)
if err != nil {
return nil, err
}
return resp.Body.JobId, nil
}
func (a *aliyun) uploadCert(cert *applicant.Certificate) (int64, error) {
uploadUserCertificateRequest := &cas20200407.UploadUserCertificateRequest{
Cert: &cert.Certificate,
Key: &cert.PrivateKey,
Name: tea.String(a.option.Domain + rand.RandStr(6)),
}
runtime := &util.RuntimeOptions{}
resp, err := a.client.UploadUserCertificateWithOptions(uploadUserCertificateRequest, runtime)
if err != nil {
return 0, err
}
return *resp.Body.CertId, nil
}
func (a *aliyun) createClient(accessKeyId, accessKeySecret string) (_result *cas20200407.Client, _err error) {
config := &openapi.Config{
AccessKeyId: tea.String(accessKeyId),
AccessKeySecret: tea.String(accessKeySecret),
}
config.Endpoint = tea.String("cas.aliyuncs.com")
_result = &cas20200407.Client{}
_result, _err = cas20200407.NewClient(config)
return _result, _err
}
func (a *aliyun) resource() (*cas20200407.ListCloudResourcesResponseBodyData, error) {
listCloudResourcesRequest := &cas20200407.ListCloudResourcesRequest{
CloudProduct: tea.String(a.option.Product),
Keyword: tea.String(a.option.Domain),
}
resp, err := a.client.ListCloudResources(listCloudResourcesRequest)
if err != nil {
return nil, err
}
if *resp.Body.Total == 0 {
return nil, errors.New("no resource found")
}
return resp.Body.Data[0], nil
}

View File

@@ -0,0 +1,49 @@
package deployer
import (
"certimate/internal/applicant"
"context"
"errors"
"strings"
"github.com/pocketbase/pocketbase/models"
)
const (
configTypeAliyun = "aliyun"
)
type DeployerOption struct {
Domain string `json:"domain"`
Product string `json:"product"`
Access string `json:"access"`
Certificate applicant.Certificate `json:"certificate"`
}
type Deployer interface {
Deploy(ctx context.Context) error
}
func Get(record *models.Record) (Deployer, error) {
access := record.ExpandedOne("targetAccess")
switch access.GetString("configType") {
case configTypeAliyun:
option := &DeployerOption{
Domain: record.GetString("domain"),
Product: getProduct(record),
Access: access.GetString("config"),
Certificate: applicant.Certificate{
Certificate: record.GetString("certificate"),
PrivateKey: record.GetString("privateKey"),
},
}
return NewAliyun(option)
}
return nil, errors.New("not implemented")
}
func getProduct(record *models.Record) string {
targetType := record.GetString("targetType")
rs := strings.Split(targetType, "-")
return rs[1]
}

132
internal/domains/deploy.go Normal file
View File

@@ -0,0 +1,132 @@
package domains
import (
"certimate/internal/applicant"
"certimate/internal/deployer"
"certimate/internal/utils/app"
"context"
"errors"
"fmt"
"time"
"github.com/pocketbase/pocketbase/models"
)
type Phase string
const (
checkPhase Phase = "check"
applyPhase Phase = "apply"
deployPhase Phase = "deploy"
)
func deploy(ctx context.Context, record *models.Record) error {
currRecord, err := app.GetApp().Dao().FindRecordById("domains", record.Id)
history := NewHistory(record)
defer history.commit()
// ############1.检查域名配置
history.record(checkPhase, "开始检查", nil)
if err != nil {
app.GetApp().Logger().Error("获取记录失败", "err", err)
history.record(checkPhase, "获取域名配置失败", err)
return err
}
history.record(checkPhase, "获取记录成功", nil)
if errs := app.GetApp().Dao().ExpandRecord(currRecord, []string{"access", "targetAccess"}, nil); len(errs) > 0 {
errList := make([]error, 0)
for name, err := range errs {
errList = append(errList, fmt.Errorf("展开记录失败,%s: %w", name, err))
}
err = errors.Join(errList...)
app.GetApp().Logger().Error("展开记录失败", "err", err)
history.record(checkPhase, "获取授权信息失败", err)
return err
}
history.record(checkPhase, "获取授权信息成功", nil)
cert := currRecord.GetString("certificate")
expiredAt := currRecord.GetDateTime("expiredAt").Time()
if cert != "" && time.Until(expiredAt) > time.Hour*24 && currRecord.GetBool("deployed") {
app.GetApp().Logger().Info("证书在有效期内")
history.record(checkPhase, "证书在有效期内且已部署,跳过", nil, true)
return err
}
history.record(checkPhase, "检查通过", nil, true)
// ############2.申请证书
history.record(applyPhase, "开始申请", nil)
if cert != "" && time.Until(expiredAt) > time.Hour*24 {
history.record(applyPhase, "证书在有效期内,跳过", nil)
} else {
applicant, err := applicant.Get(currRecord)
if err != nil {
history.record(applyPhase, "获取applicant失败", err)
app.GetApp().Logger().Error("获取applicant失败", "err", err)
return err
}
certificate, err := applicant.Apply()
if err != nil {
history.record(applyPhase, "申请证书失败", err)
app.GetApp().Logger().Error("申请证书失败", "err", err)
return err
}
history.record(applyPhase, "申请证书成功", nil)
if err = saveCert(ctx, record, certificate); err != nil {
history.record(applyPhase, "保存证书失败", err)
app.GetApp().Logger().Error("保存证书失败", "err", err)
return err
}
}
history.record(applyPhase, "保存证书成功", nil, true)
// ############3.部署证书
history.record(deployPhase, "开始部署", nil)
deployer, err := deployer.Get(currRecord)
if err != nil {
history.record(deployPhase, "获取deployer失败", err)
app.GetApp().Logger().Error("获取deployer失败", "err", err)
return err
}
if err = deployer.Deploy(ctx); err != nil {
setDeployed(ctx, record, false)
app.GetApp().Logger().Error("部署失败", "err", err)
history.record(deployPhase, "部署失败", err)
return err
}
setDeployed(ctx, record, true)
app.GetApp().Logger().Info("部署成功")
history.record(deployPhase, "部署成功", nil, true)
return nil
}
func setDeployed(ctx context.Context, record *models.Record, deployed bool) error {
record.Set("deployed", deployed)
if err := app.GetApp().Dao().SaveRecord(record); err != nil {
return err
}
return nil
}
func saveCert(ctx context.Context, record *models.Record, cert *applicant.Certificate) error {
record.Set("certUrl", cert.CertUrl)
record.Set("certStableUrl", cert.CertStableUrl)
record.Set("privateKey", cert.PrivateKey)
record.Set("certificate", cert.Certificate)
record.Set("issuerCertificate", cert.IssuerCertificate)
record.Set("csr", cert.Csr)
record.Set("expiredAt", time.Now().Add(time.Hour*24*90))
if err := app.GetApp().Dao().SaveRecord(record); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,82 @@
package domains
import (
"certimate/internal/utils/app"
"context"
"fmt"
"github.com/pocketbase/pocketbase/models"
)
func create(ctx context.Context, record *models.Record) error {
if !record.GetBool("enabled") {
return nil
}
if record.GetBool("rightnow") {
defer func() {
setRightnow(ctx, record, false)
}()
if err := deploy(ctx, record); err != nil {
return err
}
}
scheduler := app.GetScheduler()
err := scheduler.Add(record.Id, record.GetString("crontab"), func() {
deploy(ctx, record)
})
if err != nil {
app.GetApp().Logger().Error("add cron job failed", "err", err)
return fmt.Errorf("add cron job failed: %w", err)
}
app.GetApp().Logger().Error("add cron job failed", "domain", record.GetString("domain"))
scheduler.Start()
return nil
}
func update(ctx context.Context, record *models.Record) error {
scheduler := app.GetScheduler()
scheduler.Remove(record.Id)
if !record.GetBool("enabled") {
return nil
}
if record.GetBool("rightnow") {
defer func() {
setRightnow(ctx, record, false)
}()
if err := deploy(ctx, record); err != nil {
return err
}
}
err := scheduler.Add(record.Id, record.GetString("crontab"), func() {
deploy(ctx, record)
})
if err != nil {
app.GetApp().Logger().Error("update cron job failed", "err", err)
return fmt.Errorf("update cron job failed: %w", err)
}
app.GetApp().Logger().Error("update cron job failed", "domain", record.GetString("domain"))
scheduler.Start()
return nil
}
func delete(_ context.Context, record *models.Record) error {
scheduler := app.GetScheduler()
scheduler.Remove(record.Id)
scheduler.Start()
return nil
}
func setRightnow(ctx context.Context, record *models.Record, ok bool) error {
record.Set("rightnow", ok)
return app.GetApp().Dao().SaveRecord(record)
}

27
internal/domains/event.go Normal file
View File

@@ -0,0 +1,27 @@
package domains
import (
"certimate/internal/utils/app"
"github.com/pocketbase/pocketbase/core"
)
const tableName = "domains"
func AddEvent() error {
app := app.GetApp()
app.OnRecordAfterCreateRequest(tableName).Add(func(e *core.RecordCreateEvent) error {
return create(e.HttpContext.Request().Context(), e.Record)
})
app.OnRecordAfterUpdateRequest(tableName).Add(func(e *core.RecordUpdateEvent) error {
return update(e.HttpContext.Request().Context(), e.Record)
})
app.OnRecordAfterDeleteRequest(tableName).Add(func(e *core.RecordDeleteEvent) error {
return delete(e.HttpContext.Request().Context(), e.Record)
})
return nil
}

View File

@@ -0,0 +1,86 @@
package domains
import (
"certimate/internal/utils/app"
"certimate/internal/utils/xtime"
"time"
"github.com/pocketbase/pocketbase/models"
)
type historyItem struct {
Time string `json:"time"`
Message string `json:"message"`
Error string `json:"error"`
}
type history struct {
Domain string `json:"domain"`
Log map[Phase][]historyItem `json:"log"`
Phase Phase `json:"phase"`
PhaseSuccess bool `json:"phaseSuccess"`
DeployedAt string `json:"deployedAt"`
}
func NewHistory(record *models.Record) *history {
return &history{
Domain: record.Id,
DeployedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
Log: make(map[Phase][]historyItem),
Phase: checkPhase,
PhaseSuccess: false,
}
}
func (a *history) record(phase Phase, msg string, err error, pass ...bool) {
a.Phase = phase
if len(pass) > 0 && pass[0] {
a.PhaseSuccess = true
}
errMsg := ""
if err != nil {
errMsg = err.Error()
a.PhaseSuccess = false
}
a.Log[phase] = append(a.Log[phase], historyItem{
Message: msg,
Error: errMsg,
Time: xtime.BeijingTimeStr(),
})
}
func (a *history) commit() error {
collection, err := app.GetApp().Dao().FindCollectionByNameOrId("deployments")
if err != nil {
return err
}
record := models.NewRecord(collection)
record.Set("domain", a.Domain)
record.Set("deployedAt", a.DeployedAt)
record.Set("log", a.Log)
record.Set("phase", string(a.Phase))
record.Set("phaseSuccess", a.PhaseSuccess)
if err := app.GetApp().Dao().SaveRecord(record); err != nil {
return err
}
domainRecord, err := app.GetApp().Dao().FindRecordById("domains", a.Domain)
if err != nil {
return err
}
domainRecord.Set("lastDeployedAt", a.DeployedAt)
domainRecord.Set("lastDeployment", record.Id)
if err := app.GetApp().Dao().SaveRecord(domainRecord); err != nil {
return err
}
return nil
}

19
internal/utils/app/app.go Normal file
View File

@@ -0,0 +1,19 @@
package app
import (
"sync"
"github.com/pocketbase/pocketbase"
)
var instance *pocketbase.PocketBase
var intanceOnce sync.Once
func GetApp() *pocketbase.PocketBase {
intanceOnce.Do(func() {
instance = pocketbase.New()
})
return instance
}

View File

@@ -0,0 +1,19 @@
package app
import (
"sync"
"github.com/pocketbase/pocketbase/tools/cron"
)
var schedulerOnce sync.Once
var scheduler *cron.Cron
func GetScheduler() *cron.Cron {
schedulerOnce.Do(func() {
scheduler = cron.New()
})
return scheduler
}

View File

@@ -0,0 +1,23 @@
package rand
import (
"math/rand"
"time"
)
// RandStr 随机生成指定长度字符串
func RandStr(n int) string {
seed := time.Now().UnixNano()
source := rand.NewSource(seed)
random := rand.New(source)
letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
// 使用循环生成指定长度的字符串
result := make([]rune, n)
for i := range result {
result[i] = letters[random.Intn(len(letters))]
}
return string(result)
}

View File

@@ -0,0 +1,15 @@
package xtime
import (
"time"
)
func BeijingTimeStr() string {
location, _ := time.LoadLocation("Asia/Shanghai")
// 获取当前时间
now := time.Now().In(location)
// 格式化为字符串
return now.Format("2006-01-02 15:04:05")
}