From 75f4ad010dd2c72a4271a0e3d37164d146069db9 Mon Sep 17 00:00:00 2001 From: jakub961241 <144362244+jakub961241@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:41:10 +0100 Subject: [PATCH 1/3] feat: add DNS zone management with PowerDNS integration Integrate PowerDNS as a Docker container with full panel UI for DNS zone and record management. This completes the hosting automation chain: Website + SSL + Database + DNS. Features: - DNS zones CRUD (Native/Primary and Slave/Secondary) - DNS records management (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) - Auto-populate A record when creating a website - SOA and TTL configuration per zone - Sync records from PowerDNS - One-click PowerDNS container deployment - Full API for automation (12 endpoints under /api/v2/dns/) - English and Chinese translations Backend: model, repo, service, API handler, router (Go/Gin/GORM) Frontend: Vue 3 views under Websites > DNS menu --- agent/app/api/v2/dns.go | 267 +++++++++ agent/app/api/v2/entry.go | 2 + agent/app/dto/request/dns.go | 64 +++ agent/app/dto/response/dns.go | 20 + agent/app/model/dns.go | 29 + agent/app/repo/dns.go | 190 ++++++ agent/app/service/dns.go | 539 ++++++++++++++++++ agent/app/service/entry.go | 3 + agent/app/service/website.go | 17 + agent/i18n/lang/en.yaml | 8 + agent/i18n/lang/zh.yaml | 8 + agent/init/migration/migrations/init.go | 2 + agent/router/common.go | 1 + agent/router/ro_dns.go | 29 + agent/utils/dns/container.go | 172 ++++++ agent/utils/dns/pdns_client.go | 200 +++++++ frontend/src/api/interface/dns.ts | 95 +++ frontend/src/api/modules/dns.ts | 54 ++ frontend/src/lang/modules/en.ts | 44 ++ frontend/src/lang/modules/zh.ts | 44 ++ frontend/src/routers/modules/website.ts | 22 + frontend/src/views/website/dns/create.vue | 112 ++++ .../src/views/website/dns/detail/index.vue | 199 +++++++ .../website/dns/detail/record-create.vue | 209 +++++++ frontend/src/views/website/dns/index.vue | 203 +++++++ 25 files changed, 2533 insertions(+) create mode 100644 agent/app/api/v2/dns.go create mode 100644 agent/app/dto/request/dns.go create mode 100644 agent/app/dto/response/dns.go create mode 100644 agent/app/model/dns.go create mode 100644 agent/app/repo/dns.go create mode 100644 agent/app/service/dns.go create mode 100644 agent/router/ro_dns.go create mode 100644 agent/utils/dns/container.go create mode 100644 agent/utils/dns/pdns_client.go create mode 100644 frontend/src/api/interface/dns.ts create mode 100644 frontend/src/api/modules/dns.ts create mode 100644 frontend/src/views/website/dns/create.vue create mode 100644 frontend/src/views/website/dns/detail/index.vue create mode 100644 frontend/src/views/website/dns/detail/record-create.vue create mode 100644 frontend/src/views/website/dns/index.vue diff --git a/agent/app/api/v2/dns.go b/agent/app/api/v2/dns.go new file mode 100644 index 000000000000..e9f20c068cb1 --- /dev/null +++ b/agent/app/api/v2/dns.go @@ -0,0 +1,267 @@ +package v2 + +import ( + "strconv" + + "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/dto/request" + "github.com/gin-gonic/gin" +) + +// @Tags DNS +// @Summary Page DNS zones +// @Accept json +// @Param request body request.DnsZoneSearch true "request" +// @Success 200 {object} dto.PageResult +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/zones/search [post] +func (b *BaseApi) PageDnsZone(c *gin.Context) { + var req request.DnsZoneSearch + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + total, zones, err := dnsService.PageZone(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, dto.PageResult{ + Total: total, + Items: zones, + }) +} + +// @Tags DNS +// @Summary Create DNS zone +// @Accept json +// @Param request body request.DnsZoneCreate true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/zones [post] +// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 DNS 域 [name]","formatEN":"Create DNS zone [name]"} +func (b *BaseApi) CreateDnsZone(c *gin.Context) { + var req request.DnsZoneCreate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := dnsService.CreateZone(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags DNS +// @Summary Update DNS zone +// @Accept json +// @Param request body request.DnsZoneUpdate true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/zones/update [post] +// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"dns_zones","output_column":"name","output_value":"name"}],"formatZH":"更新 DNS 域 [name]","formatEN":"Update DNS zone [name]"} +func (b *BaseApi) UpdateDnsZone(c *gin.Context) { + var req request.DnsZoneUpdate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := dnsService.UpdateZone(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags DNS +// @Summary Get DNS zone +// @Accept json +// @Param id path integer true "zone id" +// @Success 200 {object} response.DnsZoneRes +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/zones/:id [get] +func (b *BaseApi) GetDnsZone(c *gin.Context) { + idStr, ok := c.Params.Get("id") + if !ok { + helper.BadRequest(c, nil) + return + } + id, err := strconv.Atoi(idStr) + if err != nil { + helper.BadRequest(c, err) + return + } + zone, err := dnsService.GetZone(uint(id)) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, zone) +} + +// @Tags DNS +// @Summary Delete DNS zone +// @Accept json +// @Param request body dto.OperateByID true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/zones/del [post] +// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"dns_zones","output_column":"name","output_value":"name"}],"formatZH":"删除 DNS 域 [name]","formatEN":"Delete DNS zone [name]"} +func (b *BaseApi) DeleteDnsZone(c *gin.Context) { + var req dto.OperateByID + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := dnsService.DeleteZone(req.ID); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags DNS +// @Summary Sync DNS zone from PowerDNS +// @Accept json +// @Param request body dto.OperateByID true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/zones/sync [post] +func (b *BaseApi) SyncDnsZone(c *gin.Context) { + var req dto.OperateByID + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := dnsService.SyncZone(req.ID); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags DNS +// @Summary Page DNS records +// @Accept json +// @Param request body request.DnsRecordSearch true "request" +// @Success 200 {object} dto.PageResult +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/records/search [post] +func (b *BaseApi) PageDnsRecord(c *gin.Context) { + var req request.DnsRecordSearch + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + total, records, err := dnsService.PageRecord(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, dto.PageResult{ + Total: total, + Items: records, + }) +} + +// @Tags DNS +// @Summary Create DNS record +// @Accept json +// @Param request body request.DnsRecordCreate true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/records [post] +// @x-panel-log {"bodyKeys":["name","type"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 DNS 记录 [name][type]","formatEN":"Create DNS record [name][type]"} +func (b *BaseApi) CreateDnsRecord(c *gin.Context) { + var req request.DnsRecordCreate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := dnsService.CreateRecord(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags DNS +// @Summary Update DNS record +// @Accept json +// @Param request body request.DnsRecordUpdate true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/records/update [post] +// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"dns_records","output_column":"name","output_value":"name"}],"formatZH":"更新 DNS 记录 [name]","formatEN":"Update DNS record [name]"} +func (b *BaseApi) UpdateDnsRecord(c *gin.Context) { + var req request.DnsRecordUpdate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := dnsService.UpdateRecord(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags DNS +// @Summary Delete DNS record +// @Accept json +// @Param request body dto.OperateByID true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/records/del [post] +// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"dns_records","output_column":"name","output_value":"name"}],"formatZH":"删除 DNS 记录 [name]","formatEN":"Delete DNS record [name]"} +func (b *BaseApi) DeleteDnsRecord(c *gin.Context) { + var req dto.OperateByID + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := dnsService.DeleteRecord(req.ID); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags DNS +// @Summary Setup DNS (enable/disable PowerDNS) +// @Accept json +// @Param request body request.DnsSetup true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/setup [post] +// @x-panel-log {"bodyKeys":["enable"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"DNS 设置 [enable]","formatEN":"DNS setup [enable]"} +func (b *BaseApi) SetupDns(c *gin.Context) { + var req request.DnsSetup + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := dnsService.Setup(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags DNS +// @Summary Get DNS status +// @Success 200 {object} response.DnsStatus +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /dns/status [get] +func (b *BaseApi) GetDnsStatus(c *gin.Context) { + status, err := dnsService.GetStatus() + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, status) +} diff --git a/agent/app/api/v2/entry.go b/agent/app/api/v2/entry.go index a919fe6f70e6..9e8c726941b2 100644 --- a/agent/app/api/v2/entry.go +++ b/agent/app/api/v2/entry.go @@ -78,4 +78,6 @@ var ( alertService = service.NewIAlertService() diskService = service.NewIDiskService() + + dnsService = service.NewIDnsService() ) diff --git a/agent/app/dto/request/dns.go b/agent/app/dto/request/dns.go new file mode 100644 index 000000000000..a91c465b8fbb --- /dev/null +++ b/agent/app/dto/request/dns.go @@ -0,0 +1,64 @@ +package request + +import "github.com/1Panel-dev/1Panel/agent/app/dto" + +type DnsZoneSearch struct { + dto.PageInfo + Info string `json:"info"` + OrderBy string `json:"orderBy" validate:"oneof=name created_at createdAt status"` + Order string `json:"order" validate:"oneof=null ascending descending"` +} + +type DnsZoneCreate struct { + Name string `json:"name" validate:"required"` + Type string `json:"type" validate:"required,oneof=Native Master Slave"` + MasterIP string `json:"masterIP"` + SoaPrimary string `json:"soaPrimary"` + SoaEmail string `json:"soaEmail" validate:"required"` + DefaultTTL int `json:"defaultTTL"` +} + +type DnsZoneUpdate struct { + ID uint `json:"id" validate:"required"` + SoaPrimary string `json:"soaPrimary"` + SoaEmail string `json:"soaEmail"` + SoaRefresh int `json:"soaRefresh"` + SoaRetry int `json:"soaRetry"` + SoaExpire int `json:"soaExpire"` + SoaTTL int `json:"soaTTL"` + DefaultTTL int `json:"defaultTTL"` +} + +type DnsRecordSearch struct { + dto.PageInfo + ZoneID uint `json:"zoneID" validate:"required"` + Type string `json:"type"` + Info string `json:"info"` +} + +type DnsRecordCreate struct { + ZoneID uint `json:"zoneID" validate:"required"` + Name string `json:"name" validate:"required"` + Type string `json:"type" validate:"required,oneof=A AAAA CNAME MX TXT NS SRV CAA"` + Content string `json:"content" validate:"required"` + TTL int `json:"ttl"` + Priority int `json:"priority"` +} + +type DnsRecordUpdate struct { + ID uint `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + Type string `json:"type" validate:"required,oneof=A AAAA CNAME MX TXT NS SRV CAA"` + Content string `json:"content" validate:"required"` + TTL int `json:"ttl"` + Priority int `json:"priority"` + Disabled bool `json:"disabled"` +} + +type DnsSetup struct { + Enable bool `json:"enable"` +} + +type DnsSync struct { + ID uint `json:"id" validate:"required"` +} diff --git a/agent/app/dto/response/dns.go b/agent/app/dto/response/dns.go new file mode 100644 index 000000000000..5ec86a97cde8 --- /dev/null +++ b/agent/app/dto/response/dns.go @@ -0,0 +1,20 @@ +package response + +import "github.com/1Panel-dev/1Panel/agent/app/model" + +type DnsZoneRes struct { + model.DnsZone + RecordCount int `json:"recordCount"` +} + +type DnsRecordRes struct { + model.DnsRecord + ZoneName string `json:"zoneName"` +} + +type DnsStatus struct { + Enabled bool `json:"enabled"` + ContainerUp bool `json:"containerUp"` + ContainerName string `json:"containerName"` + Version string `json:"version"` +} diff --git a/agent/app/model/dns.go b/agent/app/model/dns.go new file mode 100644 index 000000000000..34ede2eab709 --- /dev/null +++ b/agent/app/model/dns.go @@ -0,0 +1,29 @@ +package model + +type DnsZone struct { + BaseModel + Name string `gorm:"not null;uniqueIndex" json:"name"` + Type string `gorm:"not null;default:'Native'" json:"type"` + MasterIP string `json:"masterIP"` + SoaPrimary string `json:"soaPrimary"` + SoaEmail string `json:"soaEmail"` + SoaRefresh int `gorm:"default:10800" json:"soaRefresh"` + SoaRetry int `gorm:"default:3600" json:"soaRetry"` + SoaExpire int `gorm:"default:604800" json:"soaExpire"` + SoaTTL int `gorm:"default:3600" json:"soaTTL"` + DefaultTTL int `gorm:"default:3600" json:"defaultTTL"` + Status string `gorm:"not null;default:'active'" json:"status"` + PdnsID string `json:"pdnsID"` +} + +type DnsRecord struct { + BaseModel + ZoneID uint `gorm:"not null;index" json:"zoneID"` + Name string `gorm:"not null" json:"name"` + Type string `gorm:"not null" json:"type"` + Content string `gorm:"not null" json:"content"` + TTL int `gorm:"default:3600" json:"ttl"` + Priority int `json:"priority"` + Disabled bool `gorm:"default:false" json:"disabled"` + AutoGen bool `gorm:"default:false" json:"autoGen"` +} diff --git a/agent/app/repo/dns.go b/agent/app/repo/dns.go new file mode 100644 index 000000000000..5544ab7b6059 --- /dev/null +++ b/agent/app/repo/dns.go @@ -0,0 +1,190 @@ +package repo + +import ( + "context" + + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/global" + "gorm.io/gorm" +) + +type DnsZoneRepo struct{} + +type IDnsZoneRepo interface { + Page(page, size int, opts ...DBOption) (int64, []model.DnsZone, error) + List(opts ...DBOption) ([]model.DnsZone, error) + GetFirst(opts ...DBOption) (model.DnsZone, error) + Create(ctx context.Context, zone *model.DnsZone) error + Save(ctx context.Context, zone *model.DnsZone) error + Update(id uint, vars map[string]interface{}) error + DeleteBy(ctx context.Context, opts ...DBOption) error + WithName(name string) DBOption + WithLikeName(name string) DBOption +} + +func NewIDnsZoneRepo() IDnsZoneRepo { + return &DnsZoneRepo{} +} + +func (d *DnsZoneRepo) Page(page, size int, opts ...DBOption) (int64, []model.DnsZone, error) { + var zones []model.DnsZone + db := global.DB.Model(&model.DnsZone{}) + for _, opt := range opts { + db = opt(db) + } + count := int64(0) + db = db.Count(&count) + if err := db.Limit(size).Offset(size * (page - 1)).Find(&zones).Error; err != nil { + return count, zones, err + } + return count, zones, nil +} + +func (d *DnsZoneRepo) List(opts ...DBOption) ([]model.DnsZone, error) { + var zones []model.DnsZone + db := global.DB.Model(&model.DnsZone{}) + for _, opt := range opts { + db = opt(db) + } + if err := db.Find(&zones).Error; err != nil { + return zones, err + } + return zones, nil +} + +func (d *DnsZoneRepo) GetFirst(opts ...DBOption) (model.DnsZone, error) { + var zone model.DnsZone + db := global.DB + for _, opt := range opts { + db = opt(db) + } + if err := db.First(&zone).Error; err != nil { + return zone, err + } + return zone, nil +} + +func (d *DnsZoneRepo) Create(ctx context.Context, zone *model.DnsZone) error { + return getTx(ctx).Create(zone).Error +} + +func (d *DnsZoneRepo) Save(ctx context.Context, zone *model.DnsZone) error { + return getTx(ctx).Save(zone).Error +} + +func (d *DnsZoneRepo) Update(id uint, vars map[string]interface{}) error { + return global.DB.Model(&model.DnsZone{}).Where("id = ?", id).Updates(vars).Error +} + +func (d *DnsZoneRepo) DeleteBy(ctx context.Context, opts ...DBOption) error { + return getTx(ctx, opts...).Delete(&model.DnsZone{}).Error +} + +func (d *DnsZoneRepo) WithName(name string) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("name = ?", name) + } +} + +func (d *DnsZoneRepo) WithLikeName(name string) DBOption { + return func(g *gorm.DB) *gorm.DB { + if len(name) == 0 { + return g + } + return g.Where("name like ?", "%"+name+"%") + } +} + +// DnsRecord repo + +type DnsRecordRepo struct{} + +type IDnsRecordRepo interface { + Page(page, size int, opts ...DBOption) (int64, []model.DnsRecord, error) + List(opts ...DBOption) ([]model.DnsRecord, error) + GetFirst(opts ...DBOption) (model.DnsRecord, error) + Create(ctx context.Context, record *model.DnsRecord) error + Save(ctx context.Context, record *model.DnsRecord) error + DeleteBy(ctx context.Context, opts ...DBOption) error + WithZoneID(zoneID uint) DBOption + WithType(recordType string) DBOption + WithLikeName(name string) DBOption +} + +func NewIDnsRecordRepo() IDnsRecordRepo { + return &DnsRecordRepo{} +} + +func (d *DnsRecordRepo) Page(page, size int, opts ...DBOption) (int64, []model.DnsRecord, error) { + var records []model.DnsRecord + db := global.DB.Model(&model.DnsRecord{}) + for _, opt := range opts { + db = opt(db) + } + count := int64(0) + db = db.Count(&count) + if err := db.Limit(size).Offset(size * (page - 1)).Find(&records).Error; err != nil { + return count, records, err + } + return count, records, nil +} + +func (d *DnsRecordRepo) List(opts ...DBOption) ([]model.DnsRecord, error) { + var records []model.DnsRecord + db := global.DB.Model(&model.DnsRecord{}) + for _, opt := range opts { + db = opt(db) + } + if err := db.Find(&records).Error; err != nil { + return records, err + } + return records, nil +} + +func (d *DnsRecordRepo) GetFirst(opts ...DBOption) (model.DnsRecord, error) { + var record model.DnsRecord + db := global.DB + for _, opt := range opts { + db = opt(db) + } + if err := db.First(&record).Error; err != nil { + return record, err + } + return record, nil +} + +func (d *DnsRecordRepo) Create(ctx context.Context, record *model.DnsRecord) error { + return getTx(ctx).Create(record).Error +} + +func (d *DnsRecordRepo) Save(ctx context.Context, record *model.DnsRecord) error { + return getTx(ctx).Save(record).Error +} + +func (d *DnsRecordRepo) DeleteBy(ctx context.Context, opts ...DBOption) error { + return getTx(ctx, opts...).Delete(&model.DnsRecord{}).Error +} + +func (d *DnsRecordRepo) WithZoneID(zoneID uint) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("zone_id = ?", zoneID) + } +} + +func (d *DnsRecordRepo) WithType(recordType string) DBOption { + return func(g *gorm.DB) *gorm.DB { + if len(recordType) == 0 { + return g + } + return g.Where("`type` = ?", recordType) + } +} + +func (d *DnsRecordRepo) WithLikeName(name string) DBOption { + return func(g *gorm.DB) *gorm.DB { + if len(name) == 0 { + return g + } + return g.Where("name like ?", "%"+name+"%") + } +} diff --git a/agent/app/service/dns.go b/agent/app/service/dns.go new file mode 100644 index 000000000000..53d3c4f7fa15 --- /dev/null +++ b/agent/app/service/dns.go @@ -0,0 +1,539 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/dto/request" + "github.com/1Panel-dev/1Panel/agent/app/dto/response" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/app/repo" + "github.com/1Panel-dev/1Panel/agent/buserr" + "github.com/1Panel-dev/1Panel/agent/global" + dnsutil "github.com/1Panel-dev/1Panel/agent/utils/dns" + "github.com/google/uuid" +) + +type DnsService struct{} + +type IDnsService interface { + PageZone(req request.DnsZoneSearch) (int64, []response.DnsZoneRes, error) + CreateZone(req request.DnsZoneCreate) error + UpdateZone(req request.DnsZoneUpdate) error + DeleteZone(id uint) error + GetZone(id uint) (response.DnsZoneRes, error) + + PageRecord(req request.DnsRecordSearch) (int64, []response.DnsRecordRes, error) + CreateRecord(req request.DnsRecordCreate) error + UpdateRecord(req request.DnsRecordUpdate) error + DeleteRecord(id uint) error + + Setup(req request.DnsSetup) error + GetStatus() (response.DnsStatus, error) + SyncZone(id uint) error + + AutoCreateARecord(domain, ip string) error + AutoCreateMXRecords(domain, mailServer string) error + FindZoneForDomain(domain string) (*model.DnsZone, error) +} + +func NewIDnsService() IDnsService { + return &DnsService{} +} + +func (d *DnsService) getPdnsClient() (*dnsutil.PdnsClient, error) { + enabled, _ := settingRepo.Get(settingRepo.WithByKey("DnsEnabled")) + if enabled.Value != "true" { + return nil, buserr.New("ErrDnsNotEnabled") + } + apiURL, _ := settingRepo.Get(settingRepo.WithByKey("DnsApiUrl")) + apiKey, _ := settingRepo.Get(settingRepo.WithByKey("DnsApiKey")) + if apiURL.Value == "" { + apiURL.Value = "http://127.0.0.1:8081" + } + return dnsutil.NewPdnsClient(apiURL.Value, apiKey.Value), nil +} + +// Zone operations + +func (d *DnsService) PageZone(req request.DnsZoneSearch) (int64, []response.DnsZoneRes, error) { + var opts []repo.DBOption + if req.Info != "" { + opts = append(opts, dnsZoneRepo.WithLikeName(req.Info)) + } + opts = append(opts, repo.WithOrderRuleBy(req.OrderBy, req.Order)) + + total, zones, err := dnsZoneRepo.Page(req.Page, req.PageSize, opts...) + if err != nil { + return 0, nil, err + } + + var results []response.DnsZoneRes + for _, zone := range zones { + count := int64(0) + global.DB.Model(&model.DnsRecord{}).Where("zone_id = ?", zone.ID).Count(&count) + results = append(results, response.DnsZoneRes{ + DnsZone: zone, + RecordCount: int(count), + }) + } + return total, results, nil +} + +func (d *DnsService) GetZone(id uint) (response.DnsZoneRes, error) { + zone, err := dnsZoneRepo.GetFirst(repo.WithByID(id)) + if err != nil { + return response.DnsZoneRes{}, buserr.New("ErrDnsZoneNotFound") + } + count := int64(0) + global.DB.Model(&model.DnsRecord{}).Where("zone_id = ?", zone.ID).Count(&count) + return response.DnsZoneRes{ + DnsZone: zone, + RecordCount: int(count), + }, nil +} + +func (d *DnsService) CreateZone(req request.DnsZoneCreate) error { + existing, _ := dnsZoneRepo.GetFirst(dnsZoneRepo.WithName(req.Name)) + if existing.ID != 0 { + return buserr.WithMap("ErrDnsZoneExist", map[string]interface{}{"name": req.Name}, nil) + } + + pdnsClient, err := d.getPdnsClient() + if err != nil { + return err + } + + canonicalName := dnsutil.CanonicalName(req.Name) + soaPrimary := req.SoaPrimary + if soaPrimary == "" { + soaPrimary = "ns1." + req.Name + } + defaultTTL := req.DefaultTTL + if defaultTTL == 0 { + defaultTTL = 3600 + } + + pdnsReq := dnsutil.PdnsZoneCreate{ + Name: canonicalName, + Kind: req.Type, + Nameservers: []string{dnsutil.CanonicalName(soaPrimary)}, + SOAEdit: "DEFAULT", + SOAEditAPI: "DEFAULT", + } + if req.Type == "Slave" && req.MasterIP != "" { + pdnsReq.Masters = []string{req.MasterIP} + } + + if err := pdnsClient.CreateZone(pdnsReq); err != nil { + return buserr.WithDetail("ErrDnsPdnsApi", err.Error(), err) + } + + zone := &model.DnsZone{ + Name: req.Name, + Type: req.Type, + MasterIP: req.MasterIP, + SoaPrimary: soaPrimary, + SoaEmail: req.SoaEmail, + SoaRefresh: 10800, + SoaRetry: 3600, + SoaExpire: 604800, + SoaTTL: 3600, + DefaultTTL: defaultTTL, + Status: "active", + PdnsID: canonicalName, + } + if err := dnsZoneRepo.Create(context.Background(), zone); err != nil { + return err + } + + // Sync records from PowerDNS (SOA and NS were auto-created) + _ = d.syncRecordsFromPdns(zone) + + return nil +} + +func (d *DnsService) UpdateZone(req request.DnsZoneUpdate) error { + zone, err := dnsZoneRepo.GetFirst(repo.WithByID(req.ID)) + if err != nil { + return buserr.New("ErrDnsZoneNotFound") + } + + upMap := make(map[string]interface{}) + if req.SoaPrimary != "" { + upMap["soa_primary"] = req.SoaPrimary + } + if req.SoaEmail != "" { + upMap["soa_email"] = req.SoaEmail + } + if req.SoaRefresh > 0 { + upMap["soa_refresh"] = req.SoaRefresh + } + if req.SoaRetry > 0 { + upMap["soa_retry"] = req.SoaRetry + } + if req.SoaExpire > 0 { + upMap["soa_expire"] = req.SoaExpire + } + if req.SoaTTL > 0 { + upMap["soa_ttl"] = req.SoaTTL + } + if req.DefaultTTL > 0 { + upMap["default_ttl"] = req.DefaultTTL + } + + if len(upMap) == 0 { + return nil + } + + _ = zone // used for future SOA update via PowerDNS if needed + return dnsZoneRepo.Update(req.ID, upMap) +} + +func (d *DnsService) DeleteZone(id uint) error { + zone, err := dnsZoneRepo.GetFirst(repo.WithByID(id)) + if err != nil { + return buserr.New("ErrDnsZoneNotFound") + } + + pdnsClient, err := d.getPdnsClient() + if err != nil { + return err + } + + if err := pdnsClient.DeleteZone(zone.PdnsID); err != nil { + global.LOG.Errorf("failed to delete zone %s from PowerDNS: %v", zone.Name, err) + } + + if err := dnsRecordRepo.DeleteBy(context.Background(), dnsRecordRepo.WithZoneID(id)); err != nil { + return err + } + return dnsZoneRepo.DeleteBy(context.Background(), repo.WithByID(id)) +} + +// Record operations + +func (d *DnsService) PageRecord(req request.DnsRecordSearch) (int64, []response.DnsRecordRes, error) { + var opts []repo.DBOption + opts = append(opts, dnsRecordRepo.WithZoneID(req.ZoneID)) + if req.Type != "" { + opts = append(opts, dnsRecordRepo.WithType(req.Type)) + } + if req.Info != "" { + opts = append(opts, dnsRecordRepo.WithLikeName(req.Info)) + } + opts = append(opts, repo.WithOrderDesc("created_at")) + + total, records, err := dnsRecordRepo.Page(req.Page, req.PageSize, opts...) + if err != nil { + return 0, nil, err + } + + zone, _ := dnsZoneRepo.GetFirst(repo.WithByID(req.ZoneID)) + var results []response.DnsRecordRes + for _, record := range records { + results = append(results, response.DnsRecordRes{ + DnsRecord: record, + ZoneName: zone.Name, + }) + } + return total, results, nil +} + +func (d *DnsService) CreateRecord(req request.DnsRecordCreate) error { + zone, err := dnsZoneRepo.GetFirst(repo.WithByID(req.ZoneID)) + if err != nil { + return buserr.New("ErrDnsZoneNotFound") + } + + pdnsClient, err := d.getPdnsClient() + if err != nil { + return err + } + + fqdn := d.buildFQDN(req.Name, zone.Name) + ttl := req.TTL + if ttl == 0 { + ttl = zone.DefaultTTL + } + + content := req.Content + if req.Type == "MX" || req.Type == "SRV" { + content = fmt.Sprintf("%d %s", req.Priority, req.Content) + } + + rrset := dnsutil.RRSet{ + Name: dnsutil.CanonicalName(fqdn), + Type: req.Type, + TTL: ttl, + ChangeType: "REPLACE", + Records: []dnsutil.Record{ + {Content: content, Disabled: false}, + }, + } + + // Check for existing records with same name+type and merge + existingRecords, _ := dnsRecordRepo.List( + dnsRecordRepo.WithZoneID(req.ZoneID), + dnsRecordRepo.WithType(req.Type), + ) + for _, existing := range existingRecords { + if existing.Name == fqdn { + rrset.Records = append(rrset.Records, dnsutil.Record{ + Content: existing.Content, + Disabled: existing.Disabled, + }) + } + } + + if err := pdnsClient.PatchRecords(zone.PdnsID, []dnsutil.RRSet{rrset}); err != nil { + return buserr.WithDetail("ErrDnsPdnsApi", err.Error(), err) + } + + record := &model.DnsRecord{ + ZoneID: req.ZoneID, + Name: fqdn, + Type: req.Type, + Content: req.Content, + TTL: ttl, + Priority: req.Priority, + } + return dnsRecordRepo.Create(context.Background(), record) +} + +func (d *DnsService) UpdateRecord(req request.DnsRecordUpdate) error { + record, err := dnsRecordRepo.GetFirst(repo.WithByID(req.ID)) + if err != nil { + return buserr.New("ErrDnsRecordNotFound") + } + + zone, err := dnsZoneRepo.GetFirst(repo.WithByID(record.ZoneID)) + if err != nil { + return buserr.New("ErrDnsZoneNotFound") + } + + pdnsClient, err := d.getPdnsClient() + if err != nil { + return err + } + + fqdn := d.buildFQDN(req.Name, zone.Name) + ttl := req.TTL + if ttl == 0 { + ttl = zone.DefaultTTL + } + + content := req.Content + if req.Type == "MX" || req.Type == "SRV" { + content = fmt.Sprintf("%d %s", req.Priority, req.Content) + } + + // If name or type changed, delete old record first + if record.Name != fqdn || record.Type != req.Type { + oldRRSet := dnsutil.RRSet{ + Name: dnsutil.CanonicalName(record.Name), + Type: record.Type, + ChangeType: "DELETE", + } + _ = pdnsClient.PatchRecords(zone.PdnsID, []dnsutil.RRSet{oldRRSet}) + } + + rrset := dnsutil.RRSet{ + Name: dnsutil.CanonicalName(fqdn), + Type: req.Type, + TTL: ttl, + ChangeType: "REPLACE", + Records: []dnsutil.Record{ + {Content: content, Disabled: req.Disabled}, + }, + } + + if err := pdnsClient.PatchRecords(zone.PdnsID, []dnsutil.RRSet{rrset}); err != nil { + return buserr.WithDetail("ErrDnsPdnsApi", err.Error(), err) + } + + record.Name = fqdn + record.Type = req.Type + record.Content = req.Content + record.TTL = ttl + record.Priority = req.Priority + record.Disabled = req.Disabled + return dnsRecordRepo.Save(context.Background(), &record) +} + +func (d *DnsService) DeleteRecord(id uint) error { + record, err := dnsRecordRepo.GetFirst(repo.WithByID(id)) + if err != nil { + return buserr.New("ErrDnsRecordNotFound") + } + + zone, err := dnsZoneRepo.GetFirst(repo.WithByID(record.ZoneID)) + if err != nil { + return buserr.New("ErrDnsZoneNotFound") + } + + pdnsClient, err := d.getPdnsClient() + if err != nil { + return err + } + + rrset := dnsutil.RRSet{ + Name: dnsutil.CanonicalName(record.Name), + Type: record.Type, + ChangeType: "DELETE", + } + + if err := pdnsClient.PatchRecords(zone.PdnsID, []dnsutil.RRSet{rrset}); err != nil { + global.LOG.Errorf("failed to delete record from PowerDNS: %v", err) + } + + return dnsRecordRepo.DeleteBy(context.Background(), repo.WithByID(id)) +} + +// Setup / status + +func (d *DnsService) Setup(req request.DnsSetup) error { + if req.Enable { + apiKey := uuid.New().String() + if err := dnsutil.EnsurePdnsContainer(apiKey); err != nil { + return buserr.WithDetail("ErrDnsContainerFailed", err.Error(), err) + } + d.saveSetting("DnsEnabled", "true") + d.saveSetting("DnsApiKey", apiKey) + d.saveSetting("DnsApiUrl", "http://127.0.0.1:8081") + d.saveSetting("DnsContainerName", dnsutil.PdnsContainerName) + } else { + _ = dnsutil.StopPdnsContainer() + d.saveSetting("DnsEnabled", "false") + } + return nil +} + +func (d *DnsService) GetStatus() (response.DnsStatus, error) { + enabled, _ := settingRepo.Get(settingRepo.WithByKey("DnsEnabled")) + containerName, _ := settingRepo.Get(settingRepo.WithByKey("DnsContainerName")) + + status := dnsutil.GetContainerStatus() + + result := response.DnsStatus{ + Enabled: enabled.Value == "true", + ContainerUp: status.Running, + ContainerName: containerName.Value, + Version: status.Version, + } + return result, nil +} + +func (d *DnsService) SyncZone(id uint) error { + zone, err := dnsZoneRepo.GetFirst(repo.WithByID(id)) + if err != nil { + return buserr.New("ErrDnsZoneNotFound") + } + return d.syncRecordsFromPdns(&zone) +} + +// Integration hooks + +func (d *DnsService) AutoCreateARecord(domain, ip string) error { + zone, err := d.FindZoneForDomain(domain) + if err != nil || zone == nil { + return nil + } + return d.CreateRecord(request.DnsRecordCreate{ + ZoneID: zone.ID, + Name: domain, + Type: "A", + Content: ip, + }) +} + +func (d *DnsService) AutoCreateMXRecords(domain, mailServer string) error { + zone, err := d.FindZoneForDomain(domain) + if err != nil || zone == nil { + return nil + } + return d.CreateRecord(request.DnsRecordCreate{ + ZoneID: zone.ID, + Name: domain, + Type: "MX", + Content: mailServer, + Priority: 10, + }) +} + +func (d *DnsService) FindZoneForDomain(domain string) (*model.DnsZone, error) { + parts := strings.Split(domain, ".") + for i := 0; i < len(parts)-1; i++ { + candidate := strings.Join(parts[i:], ".") + zone, err := dnsZoneRepo.GetFirst(dnsZoneRepo.WithName(candidate)) + if err == nil && zone.ID != 0 { + return &zone, nil + } + } + return nil, nil +} + +// Internal helpers + +func (d *DnsService) buildFQDN(name, zoneName string) string { + if name == "@" || name == "" { + return zoneName + } + if strings.HasSuffix(name, "."+zoneName) { + return name + } + if strings.HasSuffix(name, ".") { + return name[:len(name)-1] + } + return name + "." + zoneName +} + +func (d *DnsService) syncRecordsFromPdns(zone *model.DnsZone) error { + pdnsClient, err := d.getPdnsClient() + if err != nil { + return err + } + + pdnsZone, err := pdnsClient.GetZone(zone.PdnsID) + if err != nil { + return err + } + + // Delete existing cached records + _ = dnsRecordRepo.DeleteBy(context.Background(), dnsRecordRepo.WithZoneID(zone.ID)) + + // Re-create from PowerDNS data + for _, rrset := range pdnsZone.RRSets { + for _, record := range rrset.Records { + name := strings.TrimSuffix(rrset.Name, ".") + priority := 0 + content := record.Content + + // Parse priority from MX/SRV content + if rrset.Type == "MX" || rrset.Type == "SRV" { + parts := strings.SplitN(content, " ", 2) + if len(parts) == 2 { + fmt.Sscanf(parts[0], "%d", &priority) + content = parts[1] + } + } + + _ = dnsRecordRepo.Create(context.Background(), &model.DnsRecord{ + ZoneID: zone.ID, + Name: name, + Type: rrset.Type, + Content: content, + TTL: rrset.TTL, + Priority: priority, + Disabled: record.Disabled, + }) + } + } + return nil +} + +func (d *DnsService) saveSetting(key, value string) { + _ = settingRepo.UpdateOrCreate(key, value) +} diff --git a/agent/app/service/entry.go b/agent/app/service/entry.go index a27e6fa409f5..0e19b3a4c482 100644 --- a/agent/app/service/entry.go +++ b/agent/app/service/entry.go @@ -55,4 +55,7 @@ var ( groupRepo = repo.NewIGroupRepo() alertRepo = repo.NewIAlertRepo() + + dnsZoneRepo = repo.NewIDnsZoneRepo() + dnsRecordRepo = repo.NewIDnsRecordRepo() ) diff --git a/agent/app/service/website.go b/agent/app/service/website.go index 2c006878eed1..b84fd4fa4bd0 100644 --- a/agent/app/service/website.go +++ b/agent/app/service/website.go @@ -495,6 +495,23 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error) createTask.AddSubTask(i18n.GetMsgByKey("ConfigOpenresty"), configNginx, deleteWebsite) + // Auto-create DNS A record if DNS is enabled + dnsEnabled, _ := settingRepo.GetValueByKey("DnsEnabled") + if dnsEnabled == "true" { + autoDnsRecord := func(t *task.Task) error { + serverIP, _ := settingRepo.GetValueByKey("SystemIP") + if serverIP == "" { + return nil + } + dnsSvc := NewIDnsService() + for _, d := range create.Domains { + _ = dnsSvc.AutoCreateARecord(d.Domain, serverIP) + } + return nil + } + createTask.AddSubTask("Auto DNS Record", autoDnsRecord, nil) + } + if create.EnableSSL { enableSSL := func(t *task.Task) error { websiteModel, err := websiteSSLRepo.GetFirst(repo.WithByID(create.WebsiteSSLID)) diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index 519802aa396c..17391e90dd8d 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -565,3 +565,11 @@ PartitionDiskErr: 'Partition failed: {{ .err }}' FormatDiskErr: 'Format failed: {{ .err }}' MountDiskErr: 'Mount failed: {{ .err }}' XfsNotFound: 'xfs not found; install xfsprogs first' + +#dns +ErrDnsZoneExist: 'DNS zone already exists: {{ .name }}' +ErrDnsZoneNotFound: 'DNS zone not found' +ErrDnsRecordNotFound: 'DNS record not found' +ErrDnsNotEnabled: 'DNS management is not enabled' +ErrDnsContainerFailed: 'Failed to start PowerDNS container: {{ .detail }}' +ErrDnsPdnsApi: 'PowerDNS API error: {{ .detail }}' diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index 8b816b6e71fb..04eb5fcfa7fe 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -565,3 +565,11 @@ PartitionDiskErr: "分区失败,{{ .err }}" FormatDiskErr: "格式化磁盘失败,{{ .err }}" MountDiskErr: "挂载磁盘失败,{{ .err }}" XfsNotFound: "未检测到 xfs 文件系统,请先安装 xfsprogs" + +#dns +ErrDnsZoneExist: "DNS 域已存在: {{ .name }}" +ErrDnsZoneNotFound: "DNS 域未找到" +ErrDnsRecordNotFound: "DNS 记录未找到" +ErrDnsNotEnabled: "DNS 管理未启用" +ErrDnsContainerFailed: "PowerDNS 容器启动失败: {{ .detail }}" +ErrDnsPdnsApi: "PowerDNS API 错误: {{ .detail }}" diff --git a/agent/init/migration/migrations/init.go b/agent/init/migration/migrations/init.go index 3fbddca436b2..aa473c3490c7 100644 --- a/agent/init/migration/migrations/init.go +++ b/agent/init/migration/migrations/init.go @@ -76,6 +76,8 @@ var AddTable = &gormigrate.Migration{ &model.McpServer{}, &model.RootCert{}, &model.ClamRecord{}, + &model.DnsZone{}, + &model.DnsRecord{}, ) }, } diff --git a/agent/router/common.go b/agent/router/common.go index 5ebcd5e3d4c1..85aafb79909e 100644 --- a/agent/router/common.go +++ b/agent/router/common.go @@ -24,5 +24,6 @@ func commonGroups() []CommonRouter { &AIToolsRouter{}, &GroupRouter{}, &AlertRouter{}, + &DnsRouter{}, } } diff --git a/agent/router/ro_dns.go b/agent/router/ro_dns.go new file mode 100644 index 000000000000..9ec1ed9eacf3 --- /dev/null +++ b/agent/router/ro_dns.go @@ -0,0 +1,29 @@ +package router + +import ( + v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2" + "github.com/gin-gonic/gin" +) + +type DnsRouter struct{} + +func (a *DnsRouter) InitRouter(Router *gin.RouterGroup) { + dnsRouter := Router.Group("dns") + baseApi := v2.ApiGroupApp.BaseApi + { + dnsRouter.POST("/zones/search", baseApi.PageDnsZone) + dnsRouter.POST("/zones", baseApi.CreateDnsZone) + dnsRouter.POST("/zones/update", baseApi.UpdateDnsZone) + dnsRouter.GET("/zones/:id", baseApi.GetDnsZone) + dnsRouter.POST("/zones/del", baseApi.DeleteDnsZone) + dnsRouter.POST("/zones/sync", baseApi.SyncDnsZone) + + dnsRouter.POST("/records/search", baseApi.PageDnsRecord) + dnsRouter.POST("/records", baseApi.CreateDnsRecord) + dnsRouter.POST("/records/update", baseApi.UpdateDnsRecord) + dnsRouter.POST("/records/del", baseApi.DeleteDnsRecord) + + dnsRouter.POST("/setup", baseApi.SetupDns) + dnsRouter.GET("/status", baseApi.GetDnsStatus) + } +} diff --git a/agent/utils/dns/container.go b/agent/utils/dns/container.go new file mode 100644 index 000000000000..273b08bf7e06 --- /dev/null +++ b/agent/utils/dns/container.go @@ -0,0 +1,172 @@ +package dns + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/1Panel-dev/1Panel/agent/utils/docker" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/network" + "github.com/docker/go-connections/nat" +) + +const ( + PdnsImage = "powerdns/pdns-auth:latest" + PdnsContainerName = "1panel-pdns" + PdnsVolumeName = "1panel-pdns-data" + PdnsAPIPort = "8081" +) + +type ContainerStatus struct { + Running bool + Version string +} + +func EnsurePdnsContainer(apiKey string) error { + ctx := context.Background() + cli, err := docker.NewDockerClient() + if err != nil { + return fmt.Errorf("failed to create docker client: %w", err) + } + defer cli.Close() + + // Check if container already exists + f := filters.NewArgs() + f.Add("name", PdnsContainerName) + containers, err := cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f}) + if err != nil { + return fmt.Errorf("failed to list containers: %w", err) + } + + if len(containers) > 0 { + c := containers[0] + if c.State != "running" { + if err := cli.ContainerStart(ctx, c.ID, container.StartOptions{}); err != nil { + return fmt.Errorf("failed to start existing PowerDNS container: %w", err) + } + } + return nil + } + + // Pull image + reader, err := cli.ImagePull(ctx, PdnsImage, image.PullOptions{}) + if err != nil { + return fmt.Errorf("failed to pull PowerDNS image: %w", err) + } + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + + // Create container + containerConfig := &container.Config{ + Image: PdnsImage, + Env: []string{ + "PDNS_AUTH_API_KEY=" + apiKey, + }, + Cmd: []string{ + "--api=yes", + "--api-key=" + apiKey, + "--webserver=yes", + "--webserver-address=0.0.0.0", + "--webserver-port=8081", + "--webserver-allow-from=0.0.0.0/0", + "--launch=gsqlite3", + "--gsqlite3-database=/var/lib/powerdns/pdns.sqlite3", + "--default-soa-edit=DEFAULT", + "--default-soa-edit-signed=DEFAULT", + }, + ExposedPorts: nat.PortSet{ + "53/tcp": {}, + "53/udp": {}, + "8081/tcp": {}, + }, + } + + hostConfig := &container.HostConfig{ + PortBindings: nat.PortMap{ + "53/tcp": []nat.PortBinding{ + {HostIP: "0.0.0.0", HostPort: "53"}, + }, + "53/udp": []nat.PortBinding{ + {HostIP: "0.0.0.0", HostPort: "53"}, + }, + "8081/tcp": []nat.PortBinding{ + {HostIP: "127.0.0.1", HostPort: PdnsAPIPort}, + }, + }, + Mounts: []mount.Mount{ + { + Type: mount.TypeVolume, + Source: PdnsVolumeName, + Target: "/var/lib/powerdns", + }, + }, + RestartPolicy: container.RestartPolicy{ + Name: container.RestartPolicyMode("always"), + }, + } + + resp, err := cli.ContainerCreate(ctx, containerConfig, hostConfig, &network.NetworkingConfig{}, nil, PdnsContainerName) + if err != nil { + return fmt.Errorf("failed to create PowerDNS container: %w", err) + } + + if err := cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { + return fmt.Errorf("failed to start PowerDNS container: %w", err) + } + + // Wait briefly for the container to be ready + time.Sleep(3 * time.Second) + + return nil +} + +func StopPdnsContainer() error { + ctx := context.Background() + cli, err := docker.NewDockerClient() + if err != nil { + return err + } + defer cli.Close() + + timeout := 10 + return cli.ContainerStop(ctx, PdnsContainerName, container.StopOptions{Timeout: &timeout}) +} + +func RemovePdnsContainer() error { + ctx := context.Background() + cli, err := docker.NewDockerClient() + if err != nil { + return err + } + defer cli.Close() + + timeout := 10 + _ = cli.ContainerStop(ctx, PdnsContainerName, container.StopOptions{Timeout: &timeout}) + return cli.ContainerRemove(ctx, PdnsContainerName, container.RemoveOptions{Force: true}) +} + +func GetContainerStatus() ContainerStatus { + ctx := context.Background() + cli, err := docker.NewDockerClient() + if err != nil { + return ContainerStatus{} + } + defer cli.Close() + + f := filters.NewArgs() + f.Add("name", PdnsContainerName) + containers, err := cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f}) + if err != nil || len(containers) == 0 { + return ContainerStatus{} + } + + return ContainerStatus{ + Running: containers[0].State == "running", + Version: containers[0].Image, + } +} diff --git a/agent/utils/dns/pdns_client.go b/agent/utils/dns/pdns_client.go new file mode 100644 index 000000000000..1aeed4de12fe --- /dev/null +++ b/agent/utils/dns/pdns_client.go @@ -0,0 +1,200 @@ +package dns + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type PdnsClient struct { + apiURL string + apiKey string + client *http.Client +} + +type PdnsZone struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + DNSSec bool `json:"dnssec"` + Serial int64 `json:"serial"` + NotifiedSerial int64 `json:"notified_serial"` + Masters []string `json:"masters"` + RRSets []RRSet `json:"rrsets"` +} + +type RRSet struct { + Name string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl"` + ChangeType string `json:"changetype,omitempty"` + Records []Record `json:"records"` + Comments []Comment `json:"comments,omitempty"` +} + +type Record struct { + Content string `json:"content"` + Disabled bool `json:"disabled"` +} + +type Comment struct { + Content string `json:"content"` + Account string `json:"account"` + ModifiedAt int64 `json:"modified_at"` +} + +type PdnsZoneCreate struct { + Name string `json:"name"` + Kind string `json:"kind"` + Masters []string `json:"masters,omitempty"` + Nameservers []string `json:"nameservers"` + SOAEdit string `json:"soa_edit"` + SOAEditAPI string `json:"soa_edit_api"` + RRSets []RRSet `json:"rrsets,omitempty"` +} + +type PdnsError struct { + Error string `json:"error"` +} + +func NewPdnsClient(apiURL, apiKey string) *PdnsClient { + return &PdnsClient{ + apiURL: strings.TrimRight(apiURL, "/"), + apiKey: apiKey, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *PdnsClient) baseURL() string { + return fmt.Sprintf("%s/api/v1/servers/localhost", c.apiURL) +} + +func (c *PdnsClient) doRequest(method, path string, body interface{}) ([]byte, int, error) { + var reqBody io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, c.baseURL()+path, reqBody) + if err != nil { + return nil, 0, err + } + req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + + return respBody, resp.StatusCode, nil +} + +func (c *PdnsClient) ListZones() ([]PdnsZone, error) { + body, status, err := c.doRequest("GET", "/zones", nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("PowerDNS API error (status %d): %s", status, string(body)) + } + var zones []PdnsZone + if err := json.Unmarshal(body, &zones); err != nil { + return nil, err + } + return zones, nil +} + +func (c *PdnsClient) GetZone(zoneID string) (*PdnsZone, error) { + body, status, err := c.doRequest("GET", fmt.Sprintf("/zones/%s", zoneID), nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("PowerDNS API error (status %d): %s", status, string(body)) + } + var zone PdnsZone + if err := json.Unmarshal(body, &zone); err != nil { + return nil, err + } + return &zone, nil +} + +func (c *PdnsClient) CreateZone(req PdnsZoneCreate) error { + body, status, err := c.doRequest("POST", "/zones", req) + if err != nil { + return err + } + if status != http.StatusCreated { + var pdnsErr PdnsError + _ = json.Unmarshal(body, &pdnsErr) + return fmt.Errorf("PowerDNS create zone failed (status %d): %s", status, pdnsErr.Error) + } + return nil +} + +func (c *PdnsClient) DeleteZone(zoneID string) error { + _, status, err := c.doRequest("DELETE", fmt.Sprintf("/zones/%s", zoneID), nil) + if err != nil { + return err + } + if status != http.StatusNoContent { + return fmt.Errorf("PowerDNS delete zone failed (status %d)", status) + } + return nil +} + +func (c *PdnsClient) PatchRecords(zoneID string, rrsets []RRSet) error { + payload := map[string]interface{}{ + "rrsets": rrsets, + } + body, status, err := c.doRequest("PATCH", fmt.Sprintf("/zones/%s", zoneID), payload) + if err != nil { + return err + } + if status != http.StatusNoContent { + var pdnsErr PdnsError + _ = json.Unmarshal(body, &pdnsErr) + return fmt.Errorf("PowerDNS patch records failed (status %d): %s", status, pdnsErr.Error) + } + return nil +} + +func (c *PdnsClient) GetVersion() (string, error) { + body, status, err := c.doRequest("GET", "", nil) + if err != nil { + return "", err + } + if status != http.StatusOK { + return "", fmt.Errorf("PowerDNS API error (status %d)", status) + } + var server struct { + Version string `json:"version"` + } + if err := json.Unmarshal(body, &server); err != nil { + return "", err + } + return server.Version, nil +} + +// canonicalName ensures a domain name ends with a dot (FQDN) +func CanonicalName(name string) string { + if !strings.HasSuffix(name, ".") { + return name + "." + } + return name +} diff --git a/frontend/src/api/interface/dns.ts b/frontend/src/api/interface/dns.ts new file mode 100644 index 000000000000..dbcb9913061e --- /dev/null +++ b/frontend/src/api/interface/dns.ts @@ -0,0 +1,95 @@ +import { CommonModel, ReqPage } from '.'; + +export namespace Dns { + export interface Zone extends CommonModel { + name: string; + type: string; + masterIP: string; + soaPrimary: string; + soaEmail: string; + soaRefresh: number; + soaRetry: number; + soaExpire: number; + soaTTL: number; + defaultTTL: number; + status: string; + pdnsID: string; + } + + export interface ZoneRes extends Zone { + recordCount: number; + } + + export interface ZoneSearch extends ReqPage { + info: string; + orderBy: string; + order: string; + } + + export interface ZoneCreate { + name: string; + type: string; + masterIP?: string; + soaPrimary?: string; + soaEmail: string; + defaultTTL?: number; + } + + export interface ZoneUpdate { + id: number; + soaPrimary?: string; + soaEmail?: string; + soaRefresh?: number; + soaRetry?: number; + soaExpire?: number; + soaTTL?: number; + defaultTTL?: number; + } + + export interface Record extends CommonModel { + zoneID: number; + name: string; + type: string; + content: string; + ttl: number; + priority: number; + disabled: boolean; + autoGen: boolean; + } + + export interface RecordRes extends Record { + zoneName: string; + } + + export interface RecordSearch extends ReqPage { + zoneID: number; + type?: string; + info?: string; + } + + export interface RecordCreate { + zoneID: number; + name: string; + type: string; + content: string; + ttl?: number; + priority?: number; + } + + export interface RecordUpdate { + id: number; + name: string; + type: string; + content: string; + ttl?: number; + priority?: number; + disabled?: boolean; + } + + export interface Status { + enabled: boolean; + containerUp: boolean; + containerName: string; + version: string; + } +} diff --git a/frontend/src/api/modules/dns.ts b/frontend/src/api/modules/dns.ts new file mode 100644 index 000000000000..a61a9b7bb4ad --- /dev/null +++ b/frontend/src/api/modules/dns.ts @@ -0,0 +1,54 @@ +import http from '@/api'; +import { ResPage } from '../interface'; +import { Dns } from '../interface/dns'; + +// Zones +export const searchDnsZones = (req: Dns.ZoneSearch) => { + return http.post>('/dns/zones/search', req); +}; + +export const createDnsZone = (req: Dns.ZoneCreate) => { + return http.post('/dns/zones', req); +}; + +export const updateDnsZone = (req: Dns.ZoneUpdate) => { + return http.post('/dns/zones/update', req); +}; + +export const getDnsZone = (id: number) => { + return http.get(`/dns/zones/${id}`); +}; + +export const deleteDnsZone = (params: { id: number }) => { + return http.post('/dns/zones/del', params); +}; + +export const syncDnsZone = (params: { id: number }) => { + return http.post('/dns/zones/sync', params); +}; + +// Records +export const searchDnsRecords = (req: Dns.RecordSearch) => { + return http.post>('/dns/records/search', req); +}; + +export const createDnsRecord = (req: Dns.RecordCreate) => { + return http.post('/dns/records', req); +}; + +export const updateDnsRecord = (req: Dns.RecordUpdate) => { + return http.post('/dns/records/update', req); +}; + +export const deleteDnsRecord = (params: { id: number }) => { + return http.post('/dns/records/del', params); +}; + +// Setup & Status +export const setupDns = (params: { enable: boolean }) => { + return http.post('/dns/setup', params); +}; + +export const getDnsStatus = () => { + return http.get('/dns/status'); +}; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 1b235bb83d83..b96fc27dbfde 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -402,6 +402,7 @@ const message = { settings: 'Settings', toolbox: 'Toolbox', logs: 'Log | Logs', + dns: 'DNS', runtime: 'Runtime | Runtimes', processManage: 'Process | Processes', process: 'Process | Processes', @@ -4268,6 +4269,49 @@ const message = { masterHostError: 'The master node IP cannot be 127.0.0.1', }, }, + dns: { + zone: 'Zone | Zones', + record: 'Record | Records', + createZone: 'Create Zone', + editZone: 'Edit Zone', + deleteZone: 'Delete Zone', + createRecord: 'Add Record', + editRecord: 'Edit Record', + deleteRecord: 'Delete Record', + zoneName: 'Domain', + zoneType: 'Zone Type', + recordType: 'Record Type', + content: 'Value', + ttl: 'TTL', + priority: 'Priority', + soaEmail: 'SOA Email', + soaPrimary: 'Primary NS', + defaultTTL: 'Default TTL', + masterIP: 'Master IP', + native: 'Native (Primary)', + slave: 'Slave (Secondary)', + status: 'Status', + enabled: 'Enabled', + disabled: 'Disabled', + setup: 'Enable DNS Management', + setupHelper: 'This will deploy a PowerDNS container for DNS zone management.', + syncZone: 'Sync from PowerDNS', + syncHelper: 'Re-fetch all records from PowerDNS to update the local cache.', + autoGenerated: 'Auto', + autoGenHelper: 'This record was auto-generated by 1Panel.', + deleteZoneHelper: 'This will permanently delete the DNS zone and all its records. Continue?', + deleteRecordHelper: 'This will delete the DNS record. Continue?', + recordCount: 'Records', + soaConfig: 'SOA Configuration', + soaRefresh: 'Refresh', + soaRetry: 'Retry', + soaExpire: 'Expire', + soaMinTTL: 'Minimum TTL', + pdnsNotRunning: 'PowerDNS container is not running.', + pdnsRunning: 'PowerDNS is running.', + name: 'Name', + viewRecords: 'View Records', + }, }; export default { diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 1125086779d7..f0d0c7712098 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -370,6 +370,7 @@ const message = { settings: '面板设置', toolbox: '工具箱', logs: '日志审计', + dns: 'DNS', runtime: '运行环境', processManage: '进程管理', process: '进程', @@ -3946,6 +3947,49 @@ const message = { masterHostError: '主节点 IP 不能为 127.0.0.1', }, }, + dns: { + zone: '域', + record: '记录', + createZone: '创建域', + editZone: '编辑域', + deleteZone: '删除域', + createRecord: '添加记录', + editRecord: '编辑记录', + deleteRecord: '删除记录', + zoneName: '域名', + zoneType: '域类型', + recordType: '记录类型', + content: '值', + ttl: 'TTL', + priority: '优先级', + soaEmail: 'SOA 邮箱', + soaPrimary: '主 NS', + defaultTTL: '默认 TTL', + masterIP: '主服务器 IP', + native: '主域 (Native)', + slave: '从域 (Slave)', + status: '状态', + enabled: '已启用', + disabled: '已禁用', + setup: '启用 DNS 管理', + setupHelper: '将部署 PowerDNS 容器用于 DNS 域管理。', + syncZone: '从 PowerDNS 同步', + syncHelper: '从 PowerDNS 重新获取所有记录以更新本地缓存。', + autoGenerated: '自动', + autoGenHelper: '此记录由 1Panel 自动生成。', + deleteZoneHelper: '将永久删除 DNS 域及其所有记录。继续?', + deleteRecordHelper: '将删除该 DNS 记录。继续?', + recordCount: '记录数', + soaConfig: 'SOA 配置', + soaRefresh: '刷新', + soaRetry: '重试', + soaExpire: '过期', + soaMinTTL: '最小 TTL', + pdnsNotRunning: 'PowerDNS 容器未运行。', + pdnsRunning: 'PowerDNS 正在运行。', + name: '名称', + viewRecords: '查看记录', + }, }; export default { ...fit2cloudZhLocale, diff --git a/frontend/src/routers/modules/website.ts b/frontend/src/routers/modules/website.ts index 5b50dcd2c693..9e82d74e50f6 100644 --- a/frontend/src/routers/modules/website.ts +++ b/frontend/src/routers/modules/website.ts @@ -43,6 +43,28 @@ const webSiteRouter = { requiresAuth: false, }, }, + { + path: '/websites/dns', + name: 'DNS', + component: () => import('@/views/website/dns/index.vue'), + meta: { + icon: 'p-website', + title: 'menu.dns', + requiresAuth: false, + }, + }, + { + path: '/websites/dns/:id', + name: 'DnsZoneDetail', + component: () => import('@/views/website/dns/detail/index.vue'), + hidden: true, + props: true, + meta: { + activeMenu: '/websites/dns', + requiresAuth: false, + ignoreTab: true, + }, + }, { path: '/websites/runtimes/php', name: 'PHP', diff --git a/frontend/src/views/website/dns/create.vue b/frontend/src/views/website/dns/create.vue new file mode 100644 index 000000000000..479939d0e2b9 --- /dev/null +++ b/frontend/src/views/website/dns/create.vue @@ -0,0 +1,112 @@ + + + diff --git a/frontend/src/views/website/dns/detail/index.vue b/frontend/src/views/website/dns/detail/index.vue new file mode 100644 index 000000000000..a224b6dc04e1 --- /dev/null +++ b/frontend/src/views/website/dns/detail/index.vue @@ -0,0 +1,199 @@ + + + diff --git a/frontend/src/views/website/dns/detail/record-create.vue b/frontend/src/views/website/dns/detail/record-create.vue new file mode 100644 index 000000000000..1d715919a117 --- /dev/null +++ b/frontend/src/views/website/dns/detail/record-create.vue @@ -0,0 +1,209 @@ + + + diff --git a/frontend/src/views/website/dns/index.vue b/frontend/src/views/website/dns/index.vue new file mode 100644 index 000000000000..2ca63cd807fa --- /dev/null +++ b/frontend/src/views/website/dns/index.vue @@ -0,0 +1,203 @@ + + + From 63f0e084a8a3a187492eda05261ab1f61ef57187 Mon Sep 17 00:00:00 2001 From: jakub961241 <144362244+jakub961241@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:57:42 +0100 Subject: [PATCH 2/3] feat: add mail server management with docker-mailserver integration Integrate docker-mailserver (Postfix+Dovecot+Rspamd) and Roundcube webmail as Docker containers with full panel UI for email hosting. Features: - Mail domains CRUD with auto DNS record creation (MX/SPF/DKIM/DMARC) - Mail accounts management (create, delete, change password, set quota) - Mail aliases and forwarding rules - Roundcube webmail integration - Spam filtering via Rspamd - One-click mail stack deployment - Full API for automation (12 endpoints under /api/v2/mail/) - English and Chinese translations Backend: model, repo, service, API handler, router (Go/Gin/GORM) Frontend: Vue 3 views under Websites > Mail Server menu Docker: mailserver/docker-mailserver + roundcube/roundcubemail --- agent/app/api/v2/entry.go | 3 +- agent/app/api/v2/mail.go | 248 ++++++++++++ agent/app/dto/request/mail.go | 48 +++ agent/app/dto/response/mail.go | 28 ++ agent/app/model/mail.go | 25 ++ agent/app/repo/mail.go | 256 ++++++++++++ agent/app/service/entry.go | 4 + agent/app/service/mail.go | 374 ++++++++++++++++++ agent/i18n/lang/en.yaml | 11 + agent/i18n/lang/zh.yaml | 11 + agent/init/migration/migrate.go | 1 + agent/init/migration/migrations/init.go | 14 + agent/router/common.go | 1 + agent/router/ro_mail.go | 30 ++ agent/utils/mail/container.go | 197 +++++++++ agent/utils/mail/exec.go | 134 +++++++ frontend/src/api/interface/mail.ts | 85 ++++ frontend/src/api/modules/mail.ts | 55 +++ frontend/src/lang/modules/en.ts | 46 +++ frontend/src/lang/modules/zh.ts | 46 +++ frontend/src/routers/modules/website.ts | 22 ++ frontend/src/views/website/mail/create.vue | 59 +++ .../views/website/mail/detail/accounts.vue | 148 +++++++ .../src/views/website/mail/detail/aliases.vue | 115 ++++++ .../views/website/mail/detail/dns-config.vue | 73 ++++ .../src/views/website/mail/detail/index.vue | 79 ++++ frontend/src/views/website/mail/index.vue | 150 +++++++ 27 files changed, 2262 insertions(+), 1 deletion(-) create mode 100644 agent/app/api/v2/mail.go create mode 100644 agent/app/dto/request/mail.go create mode 100644 agent/app/dto/response/mail.go create mode 100644 agent/app/model/mail.go create mode 100644 agent/app/repo/mail.go create mode 100644 agent/app/service/mail.go create mode 100644 agent/router/ro_mail.go create mode 100644 agent/utils/mail/container.go create mode 100644 agent/utils/mail/exec.go create mode 100644 frontend/src/api/interface/mail.ts create mode 100644 frontend/src/api/modules/mail.ts create mode 100644 frontend/src/views/website/mail/create.vue create mode 100644 frontend/src/views/website/mail/detail/accounts.vue create mode 100644 frontend/src/views/website/mail/detail/aliases.vue create mode 100644 frontend/src/views/website/mail/detail/dns-config.vue create mode 100644 frontend/src/views/website/mail/detail/index.vue create mode 100644 frontend/src/views/website/mail/index.vue diff --git a/agent/app/api/v2/entry.go b/agent/app/api/v2/entry.go index 9e8c726941b2..f3760d1dba9e 100644 --- a/agent/app/api/v2/entry.go +++ b/agent/app/api/v2/entry.go @@ -79,5 +79,6 @@ var ( diskService = service.NewIDiskService() - dnsService = service.NewIDnsService() + dnsService = service.NewIDnsService() + mailService = service.NewIMailService() ) diff --git a/agent/app/api/v2/mail.go b/agent/app/api/v2/mail.go new file mode 100644 index 000000000000..603a7e6aab73 --- /dev/null +++ b/agent/app/api/v2/mail.go @@ -0,0 +1,248 @@ +package v2 + +import ( + "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/dto/request" + "github.com/gin-gonic/gin" +) + +// @Tags Mail +// @Summary List mail domains +// @Success 200 {array} response.MailDomainRes +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/domains [get] +func (b *BaseApi) ListMailDomains(c *gin.Context) { + domains, err := mailService.ListDomains() + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, domains) +} + +// @Tags Mail +// @Summary Create mail domain +// @Accept json +// @Param request body request.MailDomainCreate true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/domains [post] +// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建邮件域 [name]","formatEN":"Create mail domain [name]"} +func (b *BaseApi) CreateMailDomain(c *gin.Context) { + var req request.MailDomainCreate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := mailService.CreateDomain(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags Mail +// @Summary Delete mail domain +// @Accept json +// @Param request body request.MailDomainDelete true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/domains/del [post] +// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"mail_domains","output_column":"name","output_value":"name"}],"formatZH":"删除邮件域 [name]","formatEN":"Delete mail domain [name]"} +func (b *BaseApi) DeleteMailDomain(c *gin.Context) { + var req request.MailDomainDelete + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := mailService.DeleteDomain(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags Mail +// @Summary Search mail accounts +// @Accept json +// @Param request body request.MailAccountSearch true "request" +// @Success 200 {object} dto.PageResult +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/accounts/search [post] +func (b *BaseApi) SearchMailAccounts(c *gin.Context) { + var req request.MailAccountSearch + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + total, accounts, err := mailService.SearchAccounts(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, dto.PageResult{Total: total, Items: accounts}) +} + +// @Tags Mail +// @Summary Create mail account +// @Accept json +// @Param request body request.MailAccountCreate true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/accounts [post] +// @x-panel-log {"bodyKeys":["username"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建邮箱账号 [username]","formatEN":"Create mail account [username]"} +func (b *BaseApi) CreateMailAccount(c *gin.Context) { + var req request.MailAccountCreate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := mailService.CreateAccount(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags Mail +// @Summary Update mail account +// @Accept json +// @Param request body request.MailAccountUpdate true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/accounts/update [post] +// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"mail_accounts","output_column":"email","output_value":"email"}],"formatZH":"更新邮箱账号 [email]","formatEN":"Update mail account [email]"} +func (b *BaseApi) UpdateMailAccount(c *gin.Context) { + var req request.MailAccountUpdate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := mailService.UpdateAccount(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags Mail +// @Summary Delete mail account +// @Accept json +// @Param request body dto.OperateByID true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/accounts/del [post] +// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"mail_accounts","output_column":"email","output_value":"email"}],"formatZH":"删除邮箱账号 [email]","formatEN":"Delete mail account [email]"} +func (b *BaseApi) DeleteMailAccount(c *gin.Context) { + var req dto.OperateByID + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := mailService.DeleteAccount(req.ID); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags Mail +// @Summary Search mail aliases +// @Accept json +// @Param request body request.MailAliasSearch true "request" +// @Success 200 {object} dto.PageResult +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/aliases/search [post] +func (b *BaseApi) SearchMailAliases(c *gin.Context) { + var req request.MailAliasSearch + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + total, aliases, err := mailService.SearchAliases(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, dto.PageResult{Total: total, Items: aliases}) +} + +// @Tags Mail +// @Summary Create mail alias +// @Accept json +// @Param request body request.MailAliasCreate true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/aliases [post] +// @x-panel-log {"bodyKeys":["source","target"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建邮箱别名 [source] → [target]","formatEN":"Create mail alias [source] → [target]"} +func (b *BaseApi) CreateMailAlias(c *gin.Context) { + var req request.MailAliasCreate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := mailService.CreateAlias(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags Mail +// @Summary Delete mail alias +// @Accept json +// @Param request body dto.OperateByID true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/aliases/del [post] +// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"mail_aliases","output_column":"source","output_value":"source"}],"formatZH":"删除邮箱别名 [source]","formatEN":"Delete mail alias [source]"} +func (b *BaseApi) DeleteMailAlias(c *gin.Context) { + var req dto.OperateByID + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := mailService.DeleteAlias(req.ID); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags Mail +// @Summary Setup mail (enable/disable) +// @Accept json +// @Param request body request.MailSetup true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/setup [post] +// @x-panel-log {"bodyKeys":["enable"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"邮件服务设置 [enable]","formatEN":"Mail setup [enable]"} +func (b *BaseApi) SetupMail(c *gin.Context) { + var req request.MailSetup + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := mailService.Setup(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags Mail +// @Summary Get mail status +// @Success 200 {object} response.MailStatus +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /mail/status [get] +func (b *BaseApi) GetMailStatus(c *gin.Context) { + status, err := mailService.GetStatus() + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, status) +} diff --git a/agent/app/dto/request/mail.go b/agent/app/dto/request/mail.go new file mode 100644 index 000000000000..3c3822b2839b --- /dev/null +++ b/agent/app/dto/request/mail.go @@ -0,0 +1,48 @@ +package request + +import "github.com/1Panel-dev/1Panel/agent/app/dto" + +type MailDomainCreate struct { + Name string `json:"name" validate:"required"` + DnsAutoGen bool `json:"dnsAutoGen"` +} + +type MailDomainDelete struct { + ID uint `json:"id" validate:"required"` + CleanupDns bool `json:"cleanupDns"` +} + +type MailAccountSearch struct { + dto.PageInfo + DomainID uint `json:"domainID"` + Info string `json:"info"` +} + +type MailAccountCreate struct { + DomainID uint `json:"domainID" validate:"required"` + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required,min=8"` + Quota int `json:"quota"` +} + +type MailAccountUpdate struct { + ID uint `json:"id" validate:"required"` + Password string `json:"password"` + Quota int `json:"quota"` +} + +type MailAliasSearch struct { + dto.PageInfo + DomainID uint `json:"domainID"` + Info string `json:"info"` +} + +type MailAliasCreate struct { + DomainID uint `json:"domainID" validate:"required"` + Source string `json:"source" validate:"required"` + Target string `json:"target" validate:"required"` +} + +type MailSetup struct { + Enable bool `json:"enable"` +} diff --git a/agent/app/dto/response/mail.go b/agent/app/dto/response/mail.go new file mode 100644 index 000000000000..95ef3ed323e2 --- /dev/null +++ b/agent/app/dto/response/mail.go @@ -0,0 +1,28 @@ +package response + +import "github.com/1Panel-dev/1Panel/agent/app/model" + +type MailDomainRes struct { + model.MailDomain + AccountCount int `json:"accountCount"` + AliasCount int `json:"aliasCount"` +} + +type MailAccountRes struct { + model.MailAccount + DomainName string `json:"domainName"` +} + +type MailAliasRes struct { + model.MailAlias + DomainName string `json:"domainName"` +} + +type MailStatus struct { + Enabled bool `json:"enabled"` + MailContainerUp bool `json:"mailContainerUp"` + MailContainerName string `json:"mailContainerName"` + RoundcubeUp bool `json:"roundcubeUp"` + RoundcubeURL string `json:"roundcubeURL"` + Version string `json:"version"` +} diff --git a/agent/app/model/mail.go b/agent/app/model/mail.go new file mode 100644 index 000000000000..45132c46830d --- /dev/null +++ b/agent/app/model/mail.go @@ -0,0 +1,25 @@ +package model + +type MailDomain struct { + BaseModel + Name string `gorm:"not null;uniqueIndex" json:"name"` + Status string `gorm:"not null;default:'active'" json:"status"` + DkimKey string `gorm:"type:text" json:"dkimKey"` + DnsAutoGen bool `gorm:"default:true" json:"dnsAutoGen"` +} + +type MailAccount struct { + BaseModel + DomainID uint `gorm:"not null;index" json:"domainID"` + Username string `gorm:"not null" json:"username"` + Email string `gorm:"not null;uniqueIndex" json:"email"` + Quota int `gorm:"default:0" json:"quota"` + Status string `gorm:"not null;default:'active'" json:"status"` +} + +type MailAlias struct { + BaseModel + DomainID uint `gorm:"not null;index" json:"domainID"` + Source string `gorm:"not null" json:"source"` + Target string `gorm:"not null" json:"target"` +} diff --git a/agent/app/repo/mail.go b/agent/app/repo/mail.go new file mode 100644 index 000000000000..fd16de9d8c86 --- /dev/null +++ b/agent/app/repo/mail.go @@ -0,0 +1,256 @@ +package repo + +import ( + "context" + + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/global" + "gorm.io/gorm" +) + +// MailDomain repo + +type MailDomainRepo struct{} + +type IMailDomainRepo interface { + List(opts ...DBOption) ([]model.MailDomain, error) + GetFirst(opts ...DBOption) (model.MailDomain, error) + Create(ctx context.Context, domain *model.MailDomain) error + Save(ctx context.Context, domain *model.MailDomain) error + Update(id uint, vars map[string]interface{}) error + DeleteBy(ctx context.Context, opts ...DBOption) error + WithName(name string) DBOption +} + +func NewIMailDomainRepo() IMailDomainRepo { + return &MailDomainRepo{} +} + +func (d *MailDomainRepo) List(opts ...DBOption) ([]model.MailDomain, error) { + var domains []model.MailDomain + db := global.DB.Model(&model.MailDomain{}) + for _, opt := range opts { + db = opt(db) + } + if err := db.Find(&domains).Error; err != nil { + return domains, err + } + return domains, nil +} + +func (d *MailDomainRepo) GetFirst(opts ...DBOption) (model.MailDomain, error) { + var domain model.MailDomain + db := global.DB + for _, opt := range opts { + db = opt(db) + } + if err := db.First(&domain).Error; err != nil { + return domain, err + } + return domain, nil +} + +func (d *MailDomainRepo) Create(ctx context.Context, domain *model.MailDomain) error { + return getTx(ctx).Create(domain).Error +} + +func (d *MailDomainRepo) Save(ctx context.Context, domain *model.MailDomain) error { + return getTx(ctx).Save(domain).Error +} + +func (d *MailDomainRepo) Update(id uint, vars map[string]interface{}) error { + return global.DB.Model(&model.MailDomain{}).Where("id = ?", id).Updates(vars).Error +} + +func (d *MailDomainRepo) DeleteBy(ctx context.Context, opts ...DBOption) error { + return getTx(ctx, opts...).Delete(&model.MailDomain{}).Error +} + +func (d *MailDomainRepo) WithName(name string) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("name = ?", name) + } +} + +// MailAccount repo + +type MailAccountRepo struct{} + +type IMailAccountRepo interface { + Page(page, size int, opts ...DBOption) (int64, []model.MailAccount, error) + List(opts ...DBOption) ([]model.MailAccount, error) + GetFirst(opts ...DBOption) (model.MailAccount, error) + Create(ctx context.Context, account *model.MailAccount) error + Save(ctx context.Context, account *model.MailAccount) error + DeleteBy(ctx context.Context, opts ...DBOption) error + WithDomainID(domainID uint) DBOption + WithEmail(email string) DBOption + WithLikeInfo(info string) DBOption +} + +func NewIMailAccountRepo() IMailAccountRepo { + return &MailAccountRepo{} +} + +func (d *MailAccountRepo) Page(page, size int, opts ...DBOption) (int64, []model.MailAccount, error) { + var accounts []model.MailAccount + db := global.DB.Model(&model.MailAccount{}) + for _, opt := range opts { + db = opt(db) + } + count := int64(0) + db = db.Count(&count) + if err := db.Limit(size).Offset(size * (page - 1)).Find(&accounts).Error; err != nil { + return count, accounts, err + } + return count, accounts, nil +} + +func (d *MailAccountRepo) List(opts ...DBOption) ([]model.MailAccount, error) { + var accounts []model.MailAccount + db := global.DB.Model(&model.MailAccount{}) + for _, opt := range opts { + db = opt(db) + } + if err := db.Find(&accounts).Error; err != nil { + return accounts, err + } + return accounts, nil +} + +func (d *MailAccountRepo) GetFirst(opts ...DBOption) (model.MailAccount, error) { + var account model.MailAccount + db := global.DB + for _, opt := range opts { + db = opt(db) + } + if err := db.First(&account).Error; err != nil { + return account, err + } + return account, nil +} + +func (d *MailAccountRepo) Create(ctx context.Context, account *model.MailAccount) error { + return getTx(ctx).Create(account).Error +} + +func (d *MailAccountRepo) Save(ctx context.Context, account *model.MailAccount) error { + return getTx(ctx).Save(account).Error +} + +func (d *MailAccountRepo) DeleteBy(ctx context.Context, opts ...DBOption) error { + return getTx(ctx, opts...).Delete(&model.MailAccount{}).Error +} + +func (d *MailAccountRepo) WithDomainID(domainID uint) DBOption { + return func(g *gorm.DB) *gorm.DB { + if domainID == 0 { + return g + } + return g.Where("domain_id = ?", domainID) + } +} + +func (d *MailAccountRepo) WithEmail(email string) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("email = ?", email) + } +} + +func (d *MailAccountRepo) WithLikeInfo(info string) DBOption { + return func(g *gorm.DB) *gorm.DB { + if len(info) == 0 { + return g + } + return g.Where("email like ? OR username like ?", "%"+info+"%", "%"+info+"%") + } +} + +// MailAlias repo + +type MailAliasRepo struct{} + +type IMailAliasRepo interface { + Page(page, size int, opts ...DBOption) (int64, []model.MailAlias, error) + List(opts ...DBOption) ([]model.MailAlias, error) + GetFirst(opts ...DBOption) (model.MailAlias, error) + Create(ctx context.Context, alias *model.MailAlias) error + DeleteBy(ctx context.Context, opts ...DBOption) error + WithDomainID(domainID uint) DBOption + WithSource(source string) DBOption + WithLikeInfo(info string) DBOption +} + +func NewIMailAliasRepo() IMailAliasRepo { + return &MailAliasRepo{} +} + +func (d *MailAliasRepo) Page(page, size int, opts ...DBOption) (int64, []model.MailAlias, error) { + var aliases []model.MailAlias + db := global.DB.Model(&model.MailAlias{}) + for _, opt := range opts { + db = opt(db) + } + count := int64(0) + db = db.Count(&count) + if err := db.Limit(size).Offset(size * (page - 1)).Find(&aliases).Error; err != nil { + return count, aliases, err + } + return count, aliases, nil +} + +func (d *MailAliasRepo) List(opts ...DBOption) ([]model.MailAlias, error) { + var aliases []model.MailAlias + db := global.DB.Model(&model.MailAlias{}) + for _, opt := range opts { + db = opt(db) + } + if err := db.Find(&aliases).Error; err != nil { + return aliases, err + } + return aliases, nil +} + +func (d *MailAliasRepo) GetFirst(opts ...DBOption) (model.MailAlias, error) { + var alias model.MailAlias + db := global.DB + for _, opt := range opts { + db = opt(db) + } + if err := db.First(&alias).Error; err != nil { + return alias, err + } + return alias, nil +} + +func (d *MailAliasRepo) Create(ctx context.Context, alias *model.MailAlias) error { + return getTx(ctx).Create(alias).Error +} + +func (d *MailAliasRepo) DeleteBy(ctx context.Context, opts ...DBOption) error { + return getTx(ctx, opts...).Delete(&model.MailAlias{}).Error +} + +func (d *MailAliasRepo) WithDomainID(domainID uint) DBOption { + return func(g *gorm.DB) *gorm.DB { + if domainID == 0 { + return g + } + return g.Where("domain_id = ?", domainID) + } +} + +func (d *MailAliasRepo) WithSource(source string) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("source = ?", source) + } +} + +func (d *MailAliasRepo) WithLikeInfo(info string) DBOption { + return func(g *gorm.DB) *gorm.DB { + if len(info) == 0 { + return g + } + return g.Where("source like ? OR target like ?", "%"+info+"%", "%"+info+"%") + } +} diff --git a/agent/app/service/entry.go b/agent/app/service/entry.go index 0e19b3a4c482..77e01688f7b6 100644 --- a/agent/app/service/entry.go +++ b/agent/app/service/entry.go @@ -58,4 +58,8 @@ var ( dnsZoneRepo = repo.NewIDnsZoneRepo() dnsRecordRepo = repo.NewIDnsRecordRepo() + + mailDomainRepo = repo.NewIMailDomainRepo() + mailAccountRepo = repo.NewIMailAccountRepo() + mailAliasRepo = repo.NewIMailAliasRepo() ) diff --git a/agent/app/service/mail.go b/agent/app/service/mail.go new file mode 100644 index 000000000000..36b750546c60 --- /dev/null +++ b/agent/app/service/mail.go @@ -0,0 +1,374 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "github.com/1Panel-dev/1Panel/agent/app/dto/request" + "github.com/1Panel-dev/1Panel/agent/app/dto/response" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/app/repo" + "github.com/1Panel-dev/1Panel/agent/buserr" + "github.com/1Panel-dev/1Panel/agent/global" + mailutil "github.com/1Panel-dev/1Panel/agent/utils/mail" +) + +type MailService struct{} + +type IMailService interface { + ListDomains() ([]response.MailDomainRes, error) + CreateDomain(req request.MailDomainCreate) error + DeleteDomain(req request.MailDomainDelete) error + + SearchAccounts(req request.MailAccountSearch) (int64, []response.MailAccountRes, error) + CreateAccount(req request.MailAccountCreate) error + UpdateAccount(req request.MailAccountUpdate) error + DeleteAccount(id uint) error + + SearchAliases(req request.MailAliasSearch) (int64, []response.MailAliasRes, error) + CreateAlias(req request.MailAliasCreate) error + DeleteAlias(id uint) error + + Setup(req request.MailSetup) error + GetStatus() (response.MailStatus, error) +} + +func NewIMailService() IMailService { + return &MailService{} +} + +// Domain operations + +func (m *MailService) ListDomains() ([]response.MailDomainRes, error) { + domains, err := mailDomainRepo.List(repo.WithOrderDesc("created_at")) + if err != nil { + return nil, err + } + var results []response.MailDomainRes + for _, domain := range domains { + accountCount := int64(0) + aliasCount := int64(0) + global.DB.Model(&model.MailAccount{}).Where("domain_id = ?", domain.ID).Count(&accountCount) + global.DB.Model(&model.MailAlias{}).Where("domain_id = ?", domain.ID).Count(&aliasCount) + results = append(results, response.MailDomainRes{ + MailDomain: domain, + AccountCount: int(accountCount), + AliasCount: int(aliasCount), + }) + } + return results, nil +} + +func (m *MailService) CreateDomain(req request.MailDomainCreate) error { + existing, _ := mailDomainRepo.GetFirst(mailDomainRepo.WithName(req.Name)) + if existing.ID != 0 { + return buserr.WithMap("ErrMailDomainExist", map[string]interface{}{"name": req.Name}, nil) + } + + // Generate DKIM key via docker exec + if err := mailutil.GenerateDKIM(req.Name); err != nil { + global.LOG.Errorf("failed to generate DKIM for %s: %v", req.Name, err) + } + + dkimKey, _ := mailutil.GetDKIMPublicKey(req.Name) + + domain := &model.MailDomain{ + Name: req.Name, + Status: "active", + DkimKey: dkimKey, + DnsAutoGen: req.DnsAutoGen, + } + if err := mailDomainRepo.Create(context.Background(), domain); err != nil { + return err + } + + // Auto-create DNS records if DNS is enabled + if req.DnsAutoGen { + m.autoCreateDnsRecords(domain) + } + + return nil +} + +func (m *MailService) DeleteDomain(req request.MailDomainDelete) error { + domain, err := mailDomainRepo.GetFirst(repo.WithByID(req.ID)) + if err != nil { + return buserr.New("ErrMailDomainNotFound") + } + + // Delete all accounts from mailserver + accounts, _ := mailAccountRepo.List(mailAccountRepo.WithDomainID(domain.ID)) + for _, account := range accounts { + _ = mailutil.DeleteAccount(account.Email) + } + + // Delete all aliases from mailserver + aliases, _ := mailAliasRepo.List(mailAliasRepo.WithDomainID(domain.ID)) + for _, alias := range aliases { + _ = mailutil.DeleteAlias(alias.Source, alias.Target) + } + + // Cleanup DB + _ = mailAccountRepo.DeleteBy(context.Background(), mailAccountRepo.WithDomainID(domain.ID)) + _ = mailAliasRepo.DeleteBy(context.Background(), mailAliasRepo.WithDomainID(domain.ID)) + return mailDomainRepo.DeleteBy(context.Background(), repo.WithByID(req.ID)) +} + +// Account operations + +func (m *MailService) SearchAccounts(req request.MailAccountSearch) (int64, []response.MailAccountRes, error) { + var opts []repo.DBOption + if req.DomainID > 0 { + opts = append(opts, mailAccountRepo.WithDomainID(req.DomainID)) + } + if req.Info != "" { + opts = append(opts, mailAccountRepo.WithLikeInfo(req.Info)) + } + opts = append(opts, repo.WithOrderDesc("created_at")) + + total, accounts, err := mailAccountRepo.Page(req.Page, req.PageSize, opts...) + if err != nil { + return 0, nil, err + } + + var results []response.MailAccountRes + for _, account := range accounts { + domainName := "" + domain, _ := mailDomainRepo.GetFirst(repo.WithByID(account.DomainID)) + if domain.ID != 0 { + domainName = domain.Name + } + results = append(results, response.MailAccountRes{ + MailAccount: account, + DomainName: domainName, + }) + } + return total, results, nil +} + +func (m *MailService) CreateAccount(req request.MailAccountCreate) error { + domain, err := mailDomainRepo.GetFirst(repo.WithByID(req.DomainID)) + if err != nil { + return buserr.New("ErrMailDomainNotFound") + } + + email := fmt.Sprintf("%s@%s", req.Username, domain.Name) + existing, _ := mailAccountRepo.GetFirst(mailAccountRepo.WithEmail(email)) + if existing.ID != 0 { + return buserr.WithMap("ErrMailAccountExist", map[string]interface{}{"email": email}, nil) + } + + if err := mailutil.AddAccount(email, req.Password); err != nil { + return buserr.WithDetail("ErrMailExecFailed", err.Error(), err) + } + + if req.Quota > 0 { + if err := mailutil.SetQuota(email, req.Quota); err != nil { + global.LOG.Errorf("failed to set quota for %s: %v", email, err) + } + } + + account := &model.MailAccount{ + DomainID: req.DomainID, + Username: req.Username, + Email: email, + Quota: req.Quota, + Status: "active", + } + return mailAccountRepo.Create(context.Background(), account) +} + +func (m *MailService) UpdateAccount(req request.MailAccountUpdate) error { + account, err := mailAccountRepo.GetFirst(repo.WithByID(req.ID)) + if err != nil { + return buserr.New("ErrMailAccountNotFound") + } + + if req.Password != "" { + if err := mailutil.UpdatePassword(account.Email, req.Password); err != nil { + return buserr.WithDetail("ErrMailExecFailed", err.Error(), err) + } + } + + if req.Quota != account.Quota { + if err := mailutil.SetQuota(account.Email, req.Quota); err != nil { + global.LOG.Errorf("failed to set quota for %s: %v", account.Email, err) + } + account.Quota = req.Quota + } + + return mailAccountRepo.Save(context.Background(), &account) +} + +func (m *MailService) DeleteAccount(id uint) error { + account, err := mailAccountRepo.GetFirst(repo.WithByID(id)) + if err != nil { + return buserr.New("ErrMailAccountNotFound") + } + + if err := mailutil.DeleteAccount(account.Email); err != nil { + global.LOG.Errorf("failed to delete account %s from mailserver: %v", account.Email, err) + } + + return mailAccountRepo.DeleteBy(context.Background(), repo.WithByID(id)) +} + +// Alias operations + +func (m *MailService) SearchAliases(req request.MailAliasSearch) (int64, []response.MailAliasRes, error) { + var opts []repo.DBOption + if req.DomainID > 0 { + opts = append(opts, mailAliasRepo.WithDomainID(req.DomainID)) + } + if req.Info != "" { + opts = append(opts, mailAliasRepo.WithLikeInfo(req.Info)) + } + opts = append(opts, repo.WithOrderDesc("created_at")) + + total, aliases, err := mailAliasRepo.Page(req.Page, req.PageSize, opts...) + if err != nil { + return 0, nil, err + } + + var results []response.MailAliasRes + for _, alias := range aliases { + domainName := "" + domain, _ := mailDomainRepo.GetFirst(repo.WithByID(alias.DomainID)) + if domain.ID != 0 { + domainName = domain.Name + } + results = append(results, response.MailAliasRes{ + MailAlias: alias, + DomainName: domainName, + }) + } + return total, results, nil +} + +func (m *MailService) CreateAlias(req request.MailAliasCreate) error { + domain, err := mailDomainRepo.GetFirst(repo.WithByID(req.DomainID)) + if err != nil { + return buserr.New("ErrMailDomainNotFound") + } + + source := req.Source + if !strings.Contains(source, "@") { + source = source + "@" + domain.Name + } + + existing, _ := mailAliasRepo.GetFirst(mailAliasRepo.WithSource(source)) + if existing.ID != 0 { + return buserr.New("ErrMailAliasExist") + } + + if err := mailutil.AddAlias(source, req.Target); err != nil { + return buserr.WithDetail("ErrMailExecFailed", err.Error(), err) + } + + alias := &model.MailAlias{ + DomainID: req.DomainID, + Source: source, + Target: req.Target, + } + return mailAliasRepo.Create(context.Background(), alias) +} + +func (m *MailService) DeleteAlias(id uint) error { + alias, err := mailAliasRepo.GetFirst(repo.WithByID(id)) + if err != nil { + return buserr.New("ErrMailAliasNotFound") + } + + if err := mailutil.DeleteAlias(alias.Source, alias.Target); err != nil { + global.LOG.Errorf("failed to delete alias %s from mailserver: %v", alias.Source, err) + } + + return mailAliasRepo.DeleteBy(context.Background(), repo.WithByID(id)) +} + +// Setup + +func (m *MailService) Setup(req request.MailSetup) error { + if req.Enable { + if err := mailutil.EnsureMailContainers(); err != nil { + return buserr.WithDetail("ErrMailContainerFailed", err.Error(), err) + } + m.saveSetting("MailEnabled", "true") + m.saveSetting("MailContainerName", mailutil.MailContainerName) + m.saveSetting("RoundcubeURL", fmt.Sprintf("http://localhost:%s", mailutil.RoundcubePort)) + } else { + _ = mailutil.StopMailContainers() + m.saveSetting("MailEnabled", "false") + } + return nil +} + +func (m *MailService) GetStatus() (response.MailStatus, error) { + enabled, _ := settingRepo.GetValueByKey("MailEnabled") + containerName, _ := settingRepo.GetValueByKey("MailContainerName") + roundcubeURL, _ := settingRepo.GetValueByKey("RoundcubeURL") + + status := mailutil.GetContainerStatus() + return response.MailStatus{ + Enabled: enabled == "true", + MailContainerUp: status.MailRunning, + MailContainerName: containerName, + RoundcubeUp: status.RoundcubeRunning, + RoundcubeURL: roundcubeURL, + Version: status.MailVersion, + }, nil +} + +// Helpers + +func (m *MailService) saveSetting(key, value string) { + _ = settingRepo.UpdateOrCreate(key, value) +} + +func (m *MailService) autoCreateDnsRecords(domain *model.MailDomain) { + dnsEnabled, _ := settingRepo.GetValueByKey("DnsEnabled") + if dnsEnabled != "true" { + return + } + serverIP, _ := settingRepo.GetValueByKey("SystemIP") + dnsSvc := NewIDnsService() + + // MX record + _ = dnsSvc.AutoCreateMXRecords(domain.Name, "mail."+domain.Name) + + // A record for mail subdomain + if serverIP != "" { + _ = dnsSvc.AutoCreateARecord("mail."+domain.Name, serverIP) + } + + // SPF TXT record + zone, _ := dnsSvc.FindZoneForDomain(domain.Name) + if zone != nil { + _ = dnsSvc.CreateRecord(request.DnsRecordCreate{ + ZoneID: zone.ID, + Name: domain.Name, + Type: "TXT", + Content: "\"v=spf1 mx a ~all\"", + }) + + // DKIM TXT record + if domain.DkimKey != "" { + _ = dnsSvc.CreateRecord(request.DnsRecordCreate{ + ZoneID: zone.ID, + Name: "mail._domainkey." + domain.Name, + Type: "TXT", + Content: domain.DkimKey, + }) + } + + // DMARC TXT record + _ = dnsSvc.CreateRecord(request.DnsRecordCreate{ + ZoneID: zone.ID, + Name: "_dmarc." + domain.Name, + Type: "TXT", + Content: "\"v=DMARC1; p=quarantine; rua=mailto:postmaster@" + domain.Name + "\"", + }) + } +} + diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index 17391e90dd8d..d4e308ce0f18 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -573,3 +573,14 @@ ErrDnsRecordNotFound: 'DNS record not found' ErrDnsNotEnabled: 'DNS management is not enabled' ErrDnsContainerFailed: 'Failed to start PowerDNS container: {{ .detail }}' ErrDnsPdnsApi: 'PowerDNS API error: {{ .detail }}' + +#mail +ErrMailNotEnabled: 'Mail management is not enabled' +ErrMailContainerFailed: 'Failed to start mail containers: {{ .detail }}' +ErrMailDomainExist: 'Mail domain already exists: {{ .name }}' +ErrMailDomainNotFound: 'Mail domain not found' +ErrMailAccountExist: 'Mail account already exists: {{ .email }}' +ErrMailAccountNotFound: 'Mail account not found' +ErrMailAliasExist: 'Mail alias already exists' +ErrMailAliasNotFound: 'Mail alias not found' +ErrMailExecFailed: 'Mail server command failed: {{ .detail }}' diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index 04eb5fcfa7fe..e9bf2487bc79 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -573,3 +573,14 @@ ErrDnsRecordNotFound: "DNS 记录未找到" ErrDnsNotEnabled: "DNS 管理未启用" ErrDnsContainerFailed: "PowerDNS 容器启动失败: {{ .detail }}" ErrDnsPdnsApi: "PowerDNS API 错误: {{ .detail }}" + +#mail +ErrMailNotEnabled: "邮件管理未启用" +ErrMailContainerFailed: "邮件容器启动失败: {{ .detail }}" +ErrMailDomainExist: "邮件域已存在: {{ .name }}" +ErrMailDomainNotFound: "邮件域未找到" +ErrMailAccountExist: "邮箱账号已存在: {{ .email }}" +ErrMailAccountNotFound: "邮箱账号未找到" +ErrMailAliasExist: "邮箱别名已存在" +ErrMailAliasNotFound: "邮箱别名未找到" +ErrMailExecFailed: "邮件服务器命令失败: {{ .detail }}" diff --git a/agent/init/migration/migrate.go b/agent/init/migration/migrate.go index 8616c6330ca2..4007e7924bf2 100644 --- a/agent/init/migration/migrate.go +++ b/agent/init/migration/migrate.go @@ -76,6 +76,7 @@ func InitAgentDB() { migrations.InitAgentAccountModelPool, migrations.AddHostTable, migrations.AddAITerminalSettings, + migrations.AddMailTables, }) if err := m.Migrate(); err != nil { global.LOG.Error(err) diff --git a/agent/init/migration/migrations/init.go b/agent/init/migration/migrations/init.go index aa473c3490c7..00676df6cca4 100644 --- a/agent/init/migration/migrations/init.go +++ b/agent/init/migration/migrations/init.go @@ -78,6 +78,9 @@ var AddTable = &gormigrate.Migration{ &model.ClamRecord{}, &model.DnsZone{}, &model.DnsRecord{}, + &model.MailDomain{}, + &model.MailAccount{}, + &model.MailAlias{}, ) }, } @@ -1135,3 +1138,14 @@ var AddAITerminalSettings = &gormigrate.Migration{ }).Error }, } + +var AddMailTables = &gormigrate.Migration{ + ID: "20260321-add-mail-tables", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate( + &model.MailDomain{}, + &model.MailAccount{}, + &model.MailAlias{}, + ) + }, +} diff --git a/agent/router/common.go b/agent/router/common.go index 85aafb79909e..a19438a52ab6 100644 --- a/agent/router/common.go +++ b/agent/router/common.go @@ -25,5 +25,6 @@ func commonGroups() []CommonRouter { &GroupRouter{}, &AlertRouter{}, &DnsRouter{}, + &MailRouter{}, } } diff --git a/agent/router/ro_mail.go b/agent/router/ro_mail.go new file mode 100644 index 000000000000..b6d158914c26 --- /dev/null +++ b/agent/router/ro_mail.go @@ -0,0 +1,30 @@ +package router + +import ( + v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2" + "github.com/gin-gonic/gin" +) + +type MailRouter struct{} + +func (a *MailRouter) InitRouter(Router *gin.RouterGroup) { + mailRouter := Router.Group("mail") + baseApi := v2.ApiGroupApp.BaseApi + { + mailRouter.GET("/domains", baseApi.ListMailDomains) + mailRouter.POST("/domains", baseApi.CreateMailDomain) + mailRouter.POST("/domains/del", baseApi.DeleteMailDomain) + + mailRouter.POST("/accounts/search", baseApi.SearchMailAccounts) + mailRouter.POST("/accounts", baseApi.CreateMailAccount) + mailRouter.POST("/accounts/update", baseApi.UpdateMailAccount) + mailRouter.POST("/accounts/del", baseApi.DeleteMailAccount) + + mailRouter.POST("/aliases/search", baseApi.SearchMailAliases) + mailRouter.POST("/aliases", baseApi.CreateMailAlias) + mailRouter.POST("/aliases/del", baseApi.DeleteMailAlias) + + mailRouter.POST("/setup", baseApi.SetupMail) + mailRouter.GET("/status", baseApi.GetMailStatus) + } +} diff --git a/agent/utils/mail/container.go b/agent/utils/mail/container.go new file mode 100644 index 000000000000..65feb7a63658 --- /dev/null +++ b/agent/utils/mail/container.go @@ -0,0 +1,197 @@ +package mail + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/1Panel-dev/1Panel/agent/utils/docker" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" +) + +const ( + MailServerImage = "mailserver/docker-mailserver:latest" + MailContainerName = "1panel-mailserver" + MailVolumeName = "1panel-mailserver-data" + MailConfigVolume = "1panel-mailserver-config" + + RoundcubeImage = "roundcube/roundcubemail:latest" + RoundcubeContainer = "1panel-roundcube" + RoundcubePort = "8443" + + MailNetworkName = "1panel-mail-network" +) + +type ContainerStatus struct { + MailRunning bool + MailVersion string + RoundcubeRunning bool +} + +func EnsureMailContainers() error { + ctx := context.Background() + cli, err := docker.NewDockerClient() + if err != nil { + return fmt.Errorf("failed to create docker client: %w", err) + } + defer cli.Close() + + // Ensure network + networks, _ := cli.NetworkList(ctx, network.ListOptions{ + Filters: filters.NewArgs(filters.Arg("name", MailNetworkName)), + }) + if len(networks) == 0 { + _, err := cli.NetworkCreate(ctx, MailNetworkName, network.CreateOptions{Driver: "bridge"}) + if err != nil { + return fmt.Errorf("failed to create mail network: %w", err) + } + } + + // Pull images + for _, img := range []string{MailServerImage, RoundcubeImage} { + reader, err := cli.ImagePull(ctx, img, image.PullOptions{}) + if err != nil { + return fmt.Errorf("failed to pull %s: %w", img, err) + } + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + } + + // Create/start mailserver + if err := ensureOneContainer(ctx, cli, MailContainerName, &container.Config{ + Image: MailServerImage, + Hostname: MailContainerName, + Env: []string{ + "ENABLE_RSPAMD=1", + "ENABLE_CLAMAV=0", + "ENABLE_FAIL2BAN=1", + "ONE_DIR=1", + "SSL_TYPE=", + "PERMIT_DOCKER=network", + "POSTMASTER_ADDRESS=postmaster@localhost", + }, + ExposedPorts: nat.PortSet{ + "25/tcp": {}, "465/tcp": {}, "587/tcp": {}, "993/tcp": {}, + }, + }, &container.HostConfig{ + PortBindings: nat.PortMap{ + "25/tcp": {{HostIP: "0.0.0.0", HostPort: "25"}}, + "465/tcp": {{HostIP: "0.0.0.0", HostPort: "465"}}, + "587/tcp": {{HostIP: "0.0.0.0", HostPort: "587"}}, + "993/tcp": {{HostIP: "0.0.0.0", HostPort: "993"}}, + }, + Mounts: []mount.Mount{ + {Type: mount.TypeVolume, Source: MailVolumeName, Target: "/var/mail-state"}, + {Type: mount.TypeVolume, Source: MailConfigVolume, Target: "/tmp/docker-mailserver"}, + }, + RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode("always")}, + }, MailNetworkName); err != nil { + return fmt.Errorf("failed to start mailserver: %w", err) + } + + // Create/start roundcube + if err := ensureOneContainer(ctx, cli, RoundcubeContainer, &container.Config{ + Image: RoundcubeImage, + Env: []string{ + "ROUNDCUBEMAIL_DEFAULT_HOST=" + MailContainerName, + "ROUNDCUBEMAIL_SMTP_SERVER=" + MailContainerName, + "ROUNDCUBEMAIL_DEFAULT_PORT=993", + "ROUNDCUBEMAIL_SMTP_PORT=587", + }, + ExposedPorts: nat.PortSet{"80/tcp": {}}, + }, &container.HostConfig{ + PortBindings: nat.PortMap{ + "80/tcp": {{HostIP: "0.0.0.0", HostPort: RoundcubePort}}, + }, + RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode("always")}, + }, MailNetworkName); err != nil { + return fmt.Errorf("failed to start roundcube: %w", err) + } + + time.Sleep(5 * time.Second) + return nil +} + +func ensureOneContainer(ctx context.Context, cli *client.Client, name string, config *container.Config, hostConfig *container.HostConfig, networkName string) error { + f := filters.NewArgs(filters.Arg("name", name)) + containers, err := cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f}) + if err != nil { + return err + } + if len(containers) > 0 { + if containers[0].State != "running" { + return cli.ContainerStart(ctx, containers[0].ID, container.StartOptions{}) + } + return nil + } + + networkConfig := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + networkName: {}, + }, + } + resp, err := cli.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, name) + if err != nil { + return err + } + return cli.ContainerStart(ctx, resp.ID, container.StartOptions{}) +} + +func StopMailContainers() error { + ctx := context.Background() + cli, err := docker.NewDockerClient() + if err != nil { + return err + } + defer cli.Close() + timeout := 10 + _ = cli.ContainerStop(ctx, MailContainerName, container.StopOptions{Timeout: &timeout}) + _ = cli.ContainerStop(ctx, RoundcubeContainer, container.StopOptions{Timeout: &timeout}) + return nil +} + +func RemoveMailContainers() error { + ctx := context.Background() + cli, err := docker.NewDockerClient() + if err != nil { + return err + } + defer cli.Close() + timeout := 10 + _ = cli.ContainerStop(ctx, MailContainerName, container.StopOptions{Timeout: &timeout}) + _ = cli.ContainerStop(ctx, RoundcubeContainer, container.StopOptions{Timeout: &timeout}) + _ = cli.ContainerRemove(ctx, MailContainerName, container.RemoveOptions{Force: true}) + _ = cli.ContainerRemove(ctx, RoundcubeContainer, container.RemoveOptions{Force: true}) + return nil +} + +func GetContainerStatus() ContainerStatus { + ctx := context.Background() + cli, err := docker.NewDockerClient() + if err != nil { + return ContainerStatus{} + } + defer cli.Close() + + status := ContainerStatus{} + f := filters.NewArgs(filters.Arg("name", MailContainerName)) + containers, _ := cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f}) + if len(containers) > 0 { + status.MailRunning = containers[0].State == "running" + status.MailVersion = containers[0].Image + } + + f2 := filters.NewArgs(filters.Arg("name", RoundcubeContainer)) + containers2, _ := cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f2}) + if len(containers2) > 0 { + status.RoundcubeRunning = containers2[0].State == "running" + } + return status +} diff --git a/agent/utils/mail/exec.go b/agent/utils/mail/exec.go new file mode 100644 index 000000000000..ba45c61d0a5a --- /dev/null +++ b/agent/utils/mail/exec.go @@ -0,0 +1,134 @@ +package mail + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + + "github.com/1Panel-dev/1Panel/agent/utils/docker" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/pkg/stdcopy" +) + +func execSetup(args ...string) (string, error) { + ctx := context.Background() + cli, err := docker.NewDockerClient() + if err != nil { + return "", fmt.Errorf("failed to create docker client: %w", err) + } + defer cli.Close() + + cmd := append([]string{"setup"}, args...) + resp, err := cli.ContainerExecCreate(ctx, MailContainerName, container.ExecOptions{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return "", fmt.Errorf("exec create failed: %w", err) + } + + hijack, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{}) + if err != nil { + return "", fmt.Errorf("exec attach failed: %w", err) + } + defer hijack.Close() + + raw, err := io.ReadAll(hijack.Reader) + if err != nil { + return "", err + } + var stdout, stderr bytes.Buffer + _, _ = stdcopy.StdCopy(&stdout, &stderr, bytes.NewReader(raw)) + + info, err := cli.ContainerExecInspect(ctx, resp.ID) + if err != nil { + return stdout.String(), err + } + if info.ExitCode != 0 { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg == "" { + errMsg = strings.TrimSpace(stdout.String()) + } + if errMsg == "" { + errMsg = fmt.Sprintf("command failed with exit code %d", info.ExitCode) + } + return "", fmt.Errorf("%s", errMsg) + } + return strings.TrimSpace(stdout.String()), nil +} + +func AddAccount(email, password string) error { + _, err := execSetup("email", "add", email, password) + return err +} + +func DeleteAccount(email string) error { + _, err := execSetup("email", "del", "-y", email) + return err +} + +func UpdatePassword(email, password string) error { + _, err := execSetup("email", "update", email, password) + return err +} + +func SetQuota(email string, quotaMB int) error { + quota := fmt.Sprintf("%dM", quotaMB) + if quotaMB == 0 { + // Remove quota + _, err := execSetup("quota", "del", email) + return err + } + _, err := execSetup("quota", "set", email, quota) + return err +} + +func AddAlias(source, target string) error { + _, err := execSetup("alias", "add", source, target) + return err +} + +func DeleteAlias(source, target string) error { + _, err := execSetup("alias", "del", source, target) + return err +} + +func GenerateDKIM(domain string) error { + _, err := execSetup("config", "dkim", "domain", domain) + return err +} + +func GetDKIMPublicKey(domain string) (string, error) { + ctx := context.Background() + cli, err := docker.NewDockerClient() + if err != nil { + return "", err + } + defer cli.Close() + + keyPath := fmt.Sprintf("/tmp/docker-mailserver/opendkim/keys/%s/mail.txt", domain) + cmd := []string{"cat", keyPath} + resp, err := cli.ContainerExecCreate(ctx, MailContainerName, container.ExecOptions{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return "", err + } + + hijack, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{}) + if err != nil { + return "", err + } + defer hijack.Close() + + raw, _ := io.ReadAll(hijack.Reader) + var stdout, stderr bytes.Buffer + _, _ = stdcopy.StdCopy(&stdout, &stderr, bytes.NewReader(raw)) + + return strings.TrimSpace(stdout.String()), nil +} diff --git a/frontend/src/api/interface/mail.ts b/frontend/src/api/interface/mail.ts new file mode 100644 index 000000000000..0f040d68d463 --- /dev/null +++ b/frontend/src/api/interface/mail.ts @@ -0,0 +1,85 @@ +import { CommonModel, ReqPage } from '.'; + +export namespace Mail { + export interface Domain extends CommonModel { + name: string; + status: string; + dkimKey: string; + dnsAutoGen: boolean; + } + + export interface DomainRes extends Domain { + accountCount: number; + aliasCount: number; + } + + export interface DomainCreate { + name: string; + dnsAutoGen: boolean; + } + + export interface DomainDelete { + id: number; + cleanupDns: boolean; + } + + export interface Account extends CommonModel { + domainID: number; + username: string; + email: string; + quota: number; + status: string; + } + + export interface AccountRes extends Account { + domainName: string; + } + + export interface AccountSearch extends ReqPage { + domainID?: number; + info?: string; + } + + export interface AccountCreate { + domainID: number; + username: string; + password: string; + quota?: number; + } + + export interface AccountUpdate { + id: number; + password?: string; + quota?: number; + } + + export interface Alias extends CommonModel { + domainID: number; + source: string; + target: string; + } + + export interface AliasRes extends Alias { + domainName: string; + } + + export interface AliasSearch extends ReqPage { + domainID?: number; + info?: string; + } + + export interface AliasCreate { + domainID: number; + source: string; + target: string; + } + + export interface Status { + enabled: boolean; + mailContainerUp: boolean; + mailContainerName: string; + roundcubeUp: boolean; + roundcubeURL: string; + version: string; + } +} diff --git a/frontend/src/api/modules/mail.ts b/frontend/src/api/modules/mail.ts new file mode 100644 index 000000000000..f134a53ace19 --- /dev/null +++ b/frontend/src/api/modules/mail.ts @@ -0,0 +1,55 @@ +import http from '@/api'; +import { ResPage } from '../interface'; +import { Mail } from '../interface/mail'; + +// Domains +export const listMailDomains = () => { + return http.get('/mail/domains'); +}; + +export const createMailDomain = (req: Mail.DomainCreate) => { + return http.post('/mail/domains', req); +}; + +export const deleteMailDomain = (req: Mail.DomainDelete) => { + return http.post('/mail/domains/del', req); +}; + +// Accounts +export const searchMailAccounts = (req: Mail.AccountSearch) => { + return http.post>('/mail/accounts/search', req); +}; + +export const createMailAccount = (req: Mail.AccountCreate) => { + return http.post('/mail/accounts', req); +}; + +export const updateMailAccount = (req: Mail.AccountUpdate) => { + return http.post('/mail/accounts/update', req); +}; + +export const deleteMailAccount = (params: { id: number }) => { + return http.post('/mail/accounts/del', params); +}; + +// Aliases +export const searchMailAliases = (req: Mail.AliasSearch) => { + return http.post>('/mail/aliases/search', req); +}; + +export const createMailAlias = (req: Mail.AliasCreate) => { + return http.post('/mail/aliases', req); +}; + +export const deleteMailAlias = (params: { id: number }) => { + return http.post('/mail/aliases/del', params); +}; + +// Setup & Status +export const setupMail = (params: { enable: boolean }) => { + return http.post('/mail/setup', params); +}; + +export const getMailStatus = () => { + return http.get('/mail/status'); +}; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index b96fc27dbfde..1dcc2b417bd8 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -403,6 +403,7 @@ const message = { toolbox: 'Toolbox', logs: 'Log | Logs', dns: 'DNS', + mail: 'Mail Server', runtime: 'Runtime | Runtimes', processManage: 'Process | Processes', process: 'Process | Processes', @@ -4312,6 +4313,51 @@ const message = { name: 'Name', viewRecords: 'View Records', }, + mail: { + setup: 'Enable Mail Server', + setupHelper: 'Deploy Postfix + Dovecot + Rspamd + Roundcube for email hosting.', + domain: 'Mail Domain', + domains: 'Mail Domains', + createDomain: 'Add Domain', + deleteDomain: 'Delete Domain', + domainName: 'Domain Name', + accounts: 'Accounts', + accountCount: 'Accounts', + aliasCount: 'Aliases', + createAccount: 'Create Account', + editAccount: 'Edit Account', + deleteAccount: 'Delete Account', + username: 'Username', + password: 'Password', + newPassword: 'New Password', + quota: 'Quota (MB)', + quotaHelper: '0 means unlimited', + email: 'Email Address', + aliases: 'Aliases', + createAlias: 'Create Alias', + deleteAlias: 'Delete Alias', + source: 'Alias Address', + target: 'Forward To', + dnsConfig: 'DNS Configuration', + dkimKey: 'DKIM Public Key', + spfRecord: 'SPF Record', + dmarcRecord: 'DMARC Record', + dnsAutoGen: 'Auto-configure DNS records', + dnsAutoGenHelper: 'Automatically create MX, SPF, DKIM, and DMARC records in your DNS zones.', + openWebmail: 'Open Webmail', + mailRunning: 'Mail server is running.', + mailNotRunning: 'Mail server is not running.', + roundcubeRunning: 'Webmail is available.', + roundcubeNotRunning: 'Webmail is not available.', + cleanupDns: 'Also remove auto-generated DNS records', + status: 'Status', + enabled: 'Active', + disabled: 'Disabled', + deleteDomainHelper: 'This will permanently delete the mail domain, all accounts, and all aliases. Continue?', + deleteAccountHelper: 'This will delete the mail account. Continue?', + deleteAliasHelper: 'This will delete the mail alias. Continue?', + viewDomain: 'Manage', + }, }; export default { diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index f0d0c7712098..5c2b5ed02f0d 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -371,6 +371,7 @@ const message = { toolbox: '工具箱', logs: '日志审计', dns: 'DNS', + mail: '邮件服务', runtime: '运行环境', processManage: '进程管理', process: '进程', @@ -3990,6 +3991,51 @@ const message = { name: '名称', viewRecords: '查看记录', }, + mail: { + setup: '启用邮件服务', + setupHelper: '部署 Postfix + Dovecot + Rspamd + Roundcube 邮件托管。', + domain: '邮件域', + domains: '邮件域', + createDomain: '添加域', + deleteDomain: '删除域', + domainName: '域名', + accounts: '账号', + accountCount: '账号数', + aliasCount: '别名数', + createAccount: '创建账号', + editAccount: '编辑账号', + deleteAccount: '删除账号', + username: '用户名', + password: '密码', + newPassword: '新密码', + quota: '配额 (MB)', + quotaHelper: '0 表示无限制', + email: '邮箱地址', + aliases: '别名', + createAlias: '创建别名', + deleteAlias: '删除别名', + source: '别名地址', + target: '转发到', + dnsConfig: 'DNS 配置', + dkimKey: 'DKIM 公钥', + spfRecord: 'SPF 记录', + dmarcRecord: 'DMARC 记录', + dnsAutoGen: '自动配置 DNS 记录', + dnsAutoGenHelper: '自动在 DNS 区域中创建 MX、SPF、DKIM 和 DMARC 记录。', + openWebmail: '打开网页邮箱', + mailRunning: '邮件服务器正在运行。', + mailNotRunning: '邮件服务器未运行。', + roundcubeRunning: '网页邮箱可用。', + roundcubeNotRunning: '网页邮箱不可用。', + cleanupDns: '同时删除自动生成的 DNS 记录', + status: '状态', + enabled: '启用', + disabled: '禁用', + deleteDomainHelper: '将永久删除邮件域及其所有账号和别名。继续?', + deleteAccountHelper: '将删除该邮箱账号。继续?', + deleteAliasHelper: '将删除该邮箱别名。继续?', + viewDomain: '管理', + }, }; export default { ...fit2cloudZhLocale, diff --git a/frontend/src/routers/modules/website.ts b/frontend/src/routers/modules/website.ts index 9e82d74e50f6..5c45f66e48aa 100644 --- a/frontend/src/routers/modules/website.ts +++ b/frontend/src/routers/modules/website.ts @@ -65,6 +65,28 @@ const webSiteRouter = { ignoreTab: true, }, }, + { + path: '/websites/mail', + name: 'Mail', + component: () => import('@/views/website/mail/index.vue'), + meta: { + icon: 'p-website', + title: 'menu.mail', + requiresAuth: false, + }, + }, + { + path: '/websites/mail/:id', + name: 'MailDomainDetail', + component: () => import('@/views/website/mail/detail/index.vue'), + hidden: true, + props: true, + meta: { + activeMenu: '/websites/mail', + requiresAuth: false, + ignoreTab: true, + }, + }, { path: '/websites/runtimes/php', name: 'PHP', diff --git a/frontend/src/views/website/mail/create.vue b/frontend/src/views/website/mail/create.vue new file mode 100644 index 000000000000..6af5d4831cf2 --- /dev/null +++ b/frontend/src/views/website/mail/create.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/views/website/mail/detail/accounts.vue b/frontend/src/views/website/mail/detail/accounts.vue new file mode 100644 index 000000000000..b77491c3fab9 --- /dev/null +++ b/frontend/src/views/website/mail/detail/accounts.vue @@ -0,0 +1,148 @@ + + + diff --git a/frontend/src/views/website/mail/detail/aliases.vue b/frontend/src/views/website/mail/detail/aliases.vue new file mode 100644 index 000000000000..69576f91994e --- /dev/null +++ b/frontend/src/views/website/mail/detail/aliases.vue @@ -0,0 +1,115 @@ + + + diff --git a/frontend/src/views/website/mail/detail/dns-config.vue b/frontend/src/views/website/mail/detail/dns-config.vue new file mode 100644 index 000000000000..80d15af5bcd1 --- /dev/null +++ b/frontend/src/views/website/mail/detail/dns-config.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/views/website/mail/detail/index.vue b/frontend/src/views/website/mail/detail/index.vue new file mode 100644 index 000000000000..9d2aea568e75 --- /dev/null +++ b/frontend/src/views/website/mail/detail/index.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/src/views/website/mail/index.vue b/frontend/src/views/website/mail/index.vue new file mode 100644 index 000000000000..9cd774ebbbf3 --- /dev/null +++ b/frontend/src/views/website/mail/index.vue @@ -0,0 +1,150 @@ + + + From 2843be3c375722e10b11564f7c56caad2524d147 Mon Sep 17 00:00:00 2001 From: jakub961241 <144362244+jakub961241@users.noreply.github.com> Date: Sun, 22 Mar 2026 00:10:44 +0100 Subject: [PATCH 3/3] feat: add compose backup scheduled task type Add "compose" as a new cronjob type that supports automatic backup of Docker Compose deployments on a schedule. Users can select specific compose projects or "all" for backup, with the same backup account, retention, and retry options available for other backup types. Changes: - Add handleCompose() in cronjob_backup.go using existing ComposeBackup - Add "compose" case to loadTask() dispatch in cronjob_helper.go - Update hasBackup() to include "compose" - Add compose type to frontend cronjob type list and create form - Add compose selection dropdown populated from existing composes - Add English and Chinese translations for the new type --- agent/app/service/cronjob_backup.go | 63 +++++++++++++++++++ agent/app/service/cronjob_helper.go | 4 +- frontend/src/lang/modules/en.ts | 1 + frontend/src/lang/modules/zh.ts | 1 + frontend/src/views/cronjob/cronjob/helper.ts | 3 + .../views/cronjob/cronjob/import/index.vue | 1 + .../views/cronjob/cronjob/operate/index.vue | 35 ++++++++++- 7 files changed, 105 insertions(+), 3 deletions(-) diff --git a/agent/app/service/cronjob_backup.go b/agent/app/service/cronjob_backup.go index ef3c6f62ef25..c1ef9fa7bffb 100644 --- a/agent/app/service/cronjob_backup.go +++ b/agent/app/service/cronjob_backup.go @@ -357,6 +357,69 @@ func (u *CronjobService) handleSnapshot(cronjob model.Cronjob, jobRecord model.J return nil } +func (u *CronjobService) handleCompose(cronjob model.Cronjob, startTime time.Time, taskItem *task.Task) error { + composeNames := loadComposesForJob(cronjob) + if len(composeNames) == 0 { + addSkipTask("Compose", taskItem) + return nil + } + accountMap := NewBackupClientMap(strings.Split(cronjob.SourceAccountIDs, ",")) + if !accountMap[fmt.Sprintf("%d", cronjob.DownloadAccountID)].isOk { + return buserr.New(i18n.GetMsgWithDetail("LoadBackupFailed", accountMap[fmt.Sprintf("%d", cronjob.DownloadAccountID)].message)) + } + for _, composeName := range composeNames { + retry := 0 + taskItem.AddSubTaskWithOps(task.GetTaskName(composeName, task.TaskBackup, task.TaskScopeCronjob), func(t *task.Task) error { + var record model.BackupRecord + record.Status = constant.StatusSuccess + record.From = "cronjob" + record.Type = "compose" + record.CronjobID = cronjob.ID + record.Name = composeName + record.DetailName = composeName + record.DownloadAccountID, record.SourceAccountIDs = cronjob.DownloadAccountID, cronjob.SourceAccountIDs + backupDir := path.Join(global.Dir.LocalBackupDir, fmt.Sprintf("tmp/compose/%s", composeName)) + record.FileName = simplifiedFileName(fmt.Sprintf("compose_%s_%s.tar.gz", composeName, startTime.Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5))) + + req := dto.CommonBackup{ + Type: "compose", + Name: composeName, + } + if err := handleComposeBackup(req, t, 0, backupDir, record.FileName); err != nil { + if retry < int(cronjob.RetryTimes) || !cronjob.IgnoreErr { + retry++ + return err + } else { + t.Log(i18n.GetMsgWithDetail("IgnoreBackupErr", err.Error())) + cleanAccountMap(accountMap) + return nil + } + } + record.FileDir = fmt.Sprintf("compose/%s", composeName) + if err := backupRepo.CreateRecord(&record); err != nil { + global.LOG.Errorf("save compose backup record failed, err: %v", err) + } + return uploadCronjobBackFile(cronjob, accountMap, path.Join(backupDir, record.FileName), record) + }, nil, cronjob.RetryTimes, 0) + } + u.removeExpiredBackup(cronjob, accountMap, model.BackupRecord{}) + return nil +} + +func loadComposesForJob(cronjob model.Cronjob) []string { + if cronjob.AppID == "all" { + records, _ := composeRepo.ListRecord() + var names []string + for _, record := range records { + if record.Name != "" { + names = append(names, record.Name) + } + } + return names + } + return strings.Split(cronjob.AppID, ",") +} + func loadAppsForJob(cronjob model.Cronjob) []model.AppInstall { var apps []model.AppInstall if cronjob.AppID == "all" { diff --git a/agent/app/service/cronjob_helper.go b/agent/app/service/cronjob_helper.go index 9c4ebcfb0e46..2e98491fd9fe 100644 --- a/agent/app/service/cronjob_helper.go +++ b/agent/app/service/cronjob_helper.go @@ -131,6 +131,8 @@ func (u *CronjobService) loadTask(cronjob *model.Cronjob, record *model.JobRecor err = u.handleDirectory(*cronjob, record.StartTime, taskItem) case "log": err = u.handleSystemLog(*cronjob, record.StartTime, taskItem) + case "compose": + err = u.handleCompose(*cronjob, record.StartTime, taskItem) case "syncIpGroup": u.handleSyncIpGroup(*cronjob, taskItem) case "cleanLog": @@ -479,7 +481,7 @@ func (u *CronjobService) removeExpiredLog(cronjob model.Cronjob) { } func hasBackup(cronjobType string) bool { - return cronjobType == "app" || cronjobType == "database" || cronjobType == "website" || cronjobType == "directory" || cronjobType == "snapshot" || cronjobType == "log" || cronjobType == "cutWebsiteLog" + return cronjobType == "app" || cronjobType == "database" || cronjobType == "website" || cronjobType == "directory" || cronjobType == "snapshot" || cronjobType == "log" || cronjobType == "cutWebsiteLog" || cronjobType == "compose" } func handleCronJobAlert(cronjob *model.Cronjob) { diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 1dcc2b417bd8..8c54a73bef0f 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -1279,6 +1279,7 @@ const message = { cutWebsiteLog: 'Website log rotation', cutWebsiteLogHelper: 'The rotated log files will be backed up to the backup directory of 1Panel.', syncIpGroup: 'Sync WAF IP groups', + compose: 'Backup compose', requestExpirationTime: 'Upload request expiration time(Hours)', unitHours: 'Unit: Hours', alertTitle: 'Planned Task - {0} 「{1}」 Task Failure Alert', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 5c2b5ed02f0d..a32405afa5e0 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1222,6 +1222,7 @@ const message = { cutWebsiteLog: '切割网站日志', cutWebsiteLogHelper: '切割的日志文件会备份到 1Panel 的 backup 目录下', syncIpGroup: '同步 WAF IP 组', + compose: '备份容器编排', requestExpirationTime: '上传请求过期时间(小时)', unitHours: '单位:小时', diff --git a/frontend/src/views/cronjob/cronjob/helper.ts b/frontend/src/views/cronjob/cronjob/helper.ts index 94e3849c5ace..8366303574d0 100644 --- a/frontend/src/views/cronjob/cronjob/helper.ts +++ b/frontend/src/views/cronjob/cronjob/helper.ts @@ -56,6 +56,7 @@ export function loadDefaultSpec(type: string) { break; case 'app': case 'database': + case 'compose': item.specType = 'perDay'; item.hour = 2; item.minute = 30; @@ -84,6 +85,7 @@ export function loadDefaultSpecCustom(type: string) { return '30 1 * * 1'; case 'app': case 'database': + case 'compose': return '30 2 * * *'; case 'directory': case 'cutWebsiteLog': @@ -224,4 +226,5 @@ export const cronjobTypes = [ { value: 'ntp', label: i18n.global.t('cronjob.ntp') }, { value: 'syncIpGroup', label: i18n.global.t('cronjob.syncIpGroup') }, { value: 'cleanLog', label: i18n.global.t('cronjob.cleanLog') }, + { value: 'compose', label: i18n.global.t('cronjob.compose') }, ]; diff --git a/frontend/src/views/cronjob/cronjob/import/index.vue b/frontend/src/views/cronjob/cronjob/import/index.vue index 78131c4e5e2f..d91db0606452 100644 --- a/frontend/src/views/cronjob/cronjob/import/index.vue +++ b/frontend/src/views/cronjob/cronjob/import/index.vue @@ -173,6 +173,7 @@ const checkDataFormat = (item: any) => { 'clean', 'snapshot', 'ntp', + 'compose', ]; if (!item.type || cronjobTypes.indexOf(item.type) === -1) { return false; diff --git a/frontend/src/views/cronjob/cronjob/operate/index.vue b/frontend/src/views/cronjob/cronjob/operate/index.vue index 9957823fcd6e..a6946468a93a 100644 --- a/frontend/src/views/cronjob/cronjob/operate/index.vue +++ b/frontend/src/views/cronjob/cronjob/operate/index.vue @@ -300,6 +300,25 @@ + + + + +
+ +
+
+
+
@@ -837,7 +856,7 @@ import { listDbItems } from '@/api/modules/database'; import { getWebsiteOptions } from '@/api/modules/website'; import { MsgError, MsgSuccess } from '@/utils/message'; import { useRouter } from 'vue-router'; -import { listContainer } from '@/api/modules/container'; +import { listContainer, searchCompose } from '@/api/modules/container'; import { Database } from '@/api/interface/database'; import { listAppInstalled } from '@/api/modules/app'; import { @@ -1044,6 +1063,7 @@ const search = async () => { } loadBackups(); loadAppInstalls(); + loadComposeOptions(); loadUserOptions(true); loadWebsites(); loadContainers(); @@ -1064,6 +1084,7 @@ const websiteOptions = ref([]); const backupOptions = ref([]); const accountOptions = ref([]); const appOptions = ref([]); +const composeOptions = ref<{ name: string }[]>([]); const userOptions = ref([]); const scriptOptions = ref([]); const groupOptions = ref([]); @@ -1470,6 +1491,15 @@ const loadAppInstalls = async () => { appOptions.value = res.data || []; }; +const loadComposeOptions = async () => { + try { + const res = await searchCompose({ page: 1, pageSize: 1000, info: '' }); + composeOptions.value = (res.data.items || []).map((item: any) => ({ name: item.name })); + } catch (e) { + composeOptions.value = []; + } +}; + const loadWebsites = async () => { const res = await getWebsiteOptions({}); websiteOptions.value = res.data || []; @@ -1487,7 +1517,8 @@ function isBackup() { form.type === 'database' || form.type === 'directory' || form.type === 'snapshot' || - form.type === 'log' + form.type === 'log' || + form.type === 'compose' ); }