Skip to content

Commit 9906ad9

Browse files
committed
feat(dns): implement DDNS management #1194, #1140
1 parent 2135f3b commit 9906ad9

File tree

32 files changed

+2488
-584
lines changed

32 files changed

+2488
-584
lines changed

api/dns/dto.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dns
22

33
import (
44
"github.com/0xJacky/Nginx-UI/internal/dns"
5+
"github.com/0xJacky/Nginx-UI/model"
56
)
67

78
type domainListQuery struct {
@@ -47,3 +48,68 @@ func toRecordInput(req recordRequest) dns.RecordInput {
4748
}
4849

4950
const timeFormat = "2006-01-02T15:04:05Z07:00"
51+
52+
type ddnsConfigRequest struct {
53+
Enabled bool `json:"enabled"`
54+
IntervalSeconds int `json:"interval_seconds" binding:"required,min=60"`
55+
RecordIDs []string `json:"record_ids"`
56+
}
57+
58+
type ddnsRecordTarget struct {
59+
ID string `json:"id"`
60+
Name string `json:"name"`
61+
Type string `json:"type"`
62+
}
63+
64+
type ddnsConfigResponse struct {
65+
Enabled bool `json:"enabled"`
66+
IntervalSeconds int `json:"interval_seconds"`
67+
Targets []ddnsRecordTarget `json:"targets"`
68+
LastIPv4 string `json:"last_ipv4,omitempty"`
69+
LastIPv6 string `json:"last_ipv6,omitempty"`
70+
LastRunAt string `json:"last_run_at,omitempty"`
71+
LastError string `json:"last_error,omitempty"`
72+
}
73+
74+
func toDDNSResponse(cfg *model.DDNSConfig) ddnsConfigResponse {
75+
resp := ddnsConfigResponse{
76+
Enabled: cfg != nil && cfg.Enabled,
77+
IntervalSeconds: dns.DefaultDDNSInterval(),
78+
Targets: []ddnsRecordTarget{},
79+
}
80+
81+
if cfg == nil {
82+
return resp
83+
}
84+
85+
interval := cfg.IntervalSeconds
86+
if interval <= 0 {
87+
interval = dns.DefaultDDNSInterval()
88+
}
89+
resp.IntervalSeconds = interval
90+
resp.LastIPv4 = cfg.LastIPv4
91+
resp.LastIPv6 = cfg.LastIPv6
92+
resp.LastError = cfg.LastError
93+
94+
if cfg.LastRunAt != nil {
95+
resp.LastRunAt = cfg.LastRunAt.Format(timeFormat)
96+
}
97+
98+
for _, target := range cfg.Targets {
99+
resp.Targets = append(resp.Targets, ddnsRecordTarget{
100+
ID: target.ID,
101+
Name: target.Name,
102+
Type: target.Type,
103+
})
104+
}
105+
106+
return resp
107+
}
108+
109+
type ddnsDomainItem struct {
110+
ID uint64 `json:"id"`
111+
Domain string `json:"domain"`
112+
CredentialName string `json:"credential_name,omitempty"`
113+
CredentialProvider string `json:"credential_provider,omitempty"`
114+
Config ddnsConfigResponse `json:"config"`
115+
}

api/dns/handler.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/spf13/cast"
1010
"github.com/uozi-tech/cosy"
1111

12+
"github.com/0xJacky/Nginx-UI/internal/cron"
1213
dnsService "github.com/0xJacky/Nginx-UI/internal/dns"
1314
"github.com/0xJacky/Nginx-UI/model"
1415
)
@@ -142,6 +143,104 @@ func DeleteRecord(c *gin.Context) {
142143
c.Status(http.StatusNoContent)
143144
}
144145

146+
// GetDDNSConfig returns the DDNS configuration for a domain.
147+
func GetDDNSConfig(c *gin.Context) {
148+
domainID := cast.ToUint64(c.Param("id"))
149+
svc := dnsService.NewService()
150+
151+
cfg, err := svc.GetDDNSConfig(c.Request.Context(), domainID)
152+
if err != nil {
153+
cosy.ErrHandler(c, err)
154+
return
155+
}
156+
157+
c.JSON(http.StatusOK, toDDNSResponse(cfg))
158+
}
159+
160+
// ListDDNSConfig returns DDNS overview for all domains.
161+
func ListDDNSConfig(c *gin.Context) {
162+
ctx := c.Request.Context()
163+
domains, err := dnsService.ListDDNSDomains(ctx)
164+
if err != nil {
165+
cosy.ErrHandler(c, err)
166+
return
167+
}
168+
169+
items := make([]ddnsDomainItem, 0, len(domains))
170+
for _, domain := range domains {
171+
cfg := domain.DDNSConfig
172+
if cfg == nil {
173+
cfg = &model.DDNSConfig{
174+
Enabled: false,
175+
IntervalSeconds: dnsService.DefaultDDNSInterval(),
176+
Targets: []model.DDNSRecordTarget{},
177+
}
178+
} else if cfg.IntervalSeconds <= 0 {
179+
cfg.IntervalSeconds = dnsService.DefaultDDNSInterval()
180+
}
181+
182+
credName := ""
183+
credProvider := ""
184+
if domain.DnsCredential != nil {
185+
credName = domain.DnsCredential.Name
186+
credProvider = domain.DnsCredential.Provider
187+
}
188+
189+
item := ddnsDomainItem{
190+
ID: domain.ID,
191+
Domain: domain.Domain,
192+
CredentialName: credName,
193+
CredentialProvider: credProvider,
194+
Config: toDDNSResponse(cfg),
195+
}
196+
items = append(items, item)
197+
}
198+
199+
c.JSON(http.StatusOK, gin.H{
200+
"data": items,
201+
})
202+
}
203+
204+
// UpdateDDNSConfig updates DDNS settings for a domain and restarts its schedule.
205+
func UpdateDDNSConfig(c *gin.Context) {
206+
domainID := cast.ToUint64(c.Param("id"))
207+
208+
var payload ddnsConfigRequest
209+
if !cosy.BindAndValid(c, &payload) {
210+
return
211+
}
212+
213+
if payload.Enabled && len(payload.RecordIDs) == 0 {
214+
cosy.ErrHandler(c, dnsService.ErrDDNSTargetRequired)
215+
return
216+
}
217+
218+
svc := dnsService.NewService()
219+
cfg, err := svc.UpdateDDNSConfig(c.Request.Context(), domainID, dnsService.DDNSUpdateInput{
220+
Enabled: payload.Enabled,
221+
IntervalSeconds: payload.IntervalSeconds,
222+
RecordIDs: payload.RecordIDs,
223+
})
224+
if err != nil {
225+
cosy.ErrHandler(c, err)
226+
return
227+
}
228+
229+
if cfg.Enabled {
230+
if err := cron.AddOrUpdateDDNSJob(domainID, cfg.IntervalSeconds); err != nil {
231+
cosy.ErrHandler(c, err)
232+
return
233+
}
234+
} else {
235+
if err := cron.RemoveDDNSJob(domainID); err != nil {
236+
cosy.ErrHandler(c, err)
237+
return
238+
}
239+
}
240+
241+
c.JSON(http.StatusOK, toDDNSResponse(cfg))
242+
}
243+
145244
func buildPagination(page, perPage int, total int64) model.Pagination {
146245
page = lo.If(page < 1, 1).Else(page)
147246
perPage = lo.If(perPage <= 0, 50).Else(perPage)

api/dns/router.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ func InitRouter(r *gin.RouterGroup) {
2121
group.POST("/domains/:id/records", CreateRecord)
2222
group.PUT("/domains/:id/records/:record_id", UpdateRecord)
2323
group.DELETE("/domains/:id/records/:record_id", DeleteRecord)
24+
25+
group.GET("/domains/:id/ddns", GetDDNSConfig)
26+
group.PUT("/domains/:id/ddns", UpdateDDNSConfig)
27+
28+
group.GET("/ddns", ListDDNSConfig)
2429
}
2530
}
26-

app/components.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ declare module 'vue' {
2828
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
2929
AComment: typeof import('ant-design-vue/es')['Comment']
3030
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
31+
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
32+
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
3133
ADivider: typeof import('ant-design-vue/es')['Divider']
3234
ADrawer: typeof import('ant-design-vue/es')['Drawer']
3335
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
@@ -65,6 +67,7 @@ declare module 'vue' {
6567
ASelect: typeof import('ant-design-vue/es')['Select']
6668
ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup']
6769
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
70+
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
6871
ASpace: typeof import('ant-design-vue/es')['Space']
6972
ASpin: typeof import('ant-design-vue/es')['Spin']
7073
AStatistic: typeof import('ant-design-vue/es')['Statistic']
@@ -165,6 +168,8 @@ declare global {
165168
const ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
166169
const AComment: typeof import('ant-design-vue/es')['Comment']
167170
const AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
171+
const ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
172+
const ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
168173
const ADivider: typeof import('ant-design-vue/es')['Divider']
169174
const ADrawer: typeof import('ant-design-vue/es')['Drawer']
170175
const ADropdown: typeof import('ant-design-vue/es')['Dropdown']
@@ -202,6 +207,7 @@ declare global {
202207
const ASelect: typeof import('ant-design-vue/es')['Select']
203208
const ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup']
204209
const ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
210+
const ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
205211
const ASpace: typeof import('ant-design-vue/es')['Space']
206212
const ASpin: typeof import('ant-design-vue/es')['Spin']
207213
const AStatistic: typeof import('ant-design-vue/es')['Statistic']

app/src/api/dns.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,36 @@ export interface DNSRecord {
2424
proxied?: boolean
2525
}
2626

27+
export interface DDNSRecordTarget {
28+
id: string
29+
name: string
30+
type: string
31+
}
32+
33+
export interface DDNSConfig {
34+
enabled: boolean
35+
interval_seconds: number
36+
targets: DDNSRecordTarget[]
37+
last_ipv4?: string
38+
last_ipv6?: string
39+
last_run_at?: string
40+
last_error?: string
41+
}
42+
43+
export interface DDNSDomainItem {
44+
id: number
45+
domain: string
46+
credential_name?: string
47+
credential_provider?: string
48+
config: DDNSConfig
49+
}
50+
51+
export interface UpdateDDNSPayload {
52+
enabled: boolean
53+
interval_seconds: number
54+
record_ids: string[]
55+
}
56+
2757
export interface DomainListParams {
2858
keyword?: string
2959
credential_id?: number
@@ -48,23 +78,32 @@ export interface RecordPayload {
4878
proxied?: boolean
4979
}
5080

51-
const baseUrl = '/dns/domains'
81+
const baseDomainUrl = '/dns/domains'
5282

53-
const domainApi = useCurdApi<DNSDomain>(baseUrl)
83+
const domainApi = useCurdApi<DNSDomain>(baseDomainUrl)
5484

5585
export const dnsApi = {
5686
...domainApi,
5787
listRecords(domainId: number, params?: RecordListParams) {
58-
return http.get<{ data: DNSRecord[], pagination: Pagination }>(`${baseUrl}/${domainId}/records`, { params })
88+
return http.get<{ data: DNSRecord[], pagination: Pagination }>(`${baseDomainUrl}/${domainId}/records`, { params })
5989
},
6090
createRecord(domainId: number, payload: RecordPayload) {
61-
return http.post<DNSRecord>(`${baseUrl}/${domainId}/records`, payload)
91+
return http.post<DNSRecord>(`${baseDomainUrl}/${domainId}/records`, payload)
6292
},
6393
updateRecord(domainId: number, recordId: string, payload: RecordPayload) {
64-
return http.put<DNSRecord>(`${baseUrl}/${domainId}/records/${recordId}`, payload)
94+
return http.put<DNSRecord>(`${baseDomainUrl}/${domainId}/records/${recordId}`, payload)
6595
},
6696
deleteRecord(domainId: number, recordId: string) {
67-
return http.delete(`${baseUrl}/${domainId}/records/${recordId}`)
97+
return http.delete(`${baseDomainUrl}/${domainId}/records/${recordId}`)
98+
},
99+
getDDNSConfig(domainId: number) {
100+
return http.get<DDNSConfig>(`${baseDomainUrl}/${domainId}/ddns`)
101+
},
102+
updateDDNSConfig(domainId: number, payload: UpdateDDNSPayload) {
103+
return http.put<DDNSConfig>(`${baseDomainUrl}/${domainId}/ddns`, payload)
104+
},
105+
listDDNS() {
106+
return http.get<{ data: DDNSDomainItem[] }>(`/dns/ddns`)
68107
},
69108
}
70109

app/src/components/AutoCertForm/DNSChallenge.vue

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const code = computed(() => {
2121
return data.value.code
2222
})
2323
24-
const providerIdx = ref<number>()
24+
const providerIdx = ref<number | undefined>(undefined)
2525
function init() {
2626
providerIdx.value = undefined
2727
providers.value?.forEach((v: DNSProvider, k: number) => {
@@ -31,7 +31,12 @@ function init() {
3131
}
3232
3333
const current = computed(() => {
34-
return providers.value?.[providerIdx.value ?? -1]
34+
const idx = providerIdx.value
35+
if (idx === undefined || idx === null)
36+
return undefined
37+
if (idx < 0 || idx >= providers.value.length)
38+
return undefined
39+
return providers.value?.[idx]
3540
})
3641
3742
const mounted = ref(false)
@@ -50,7 +55,8 @@ watch(current, () => {
5055
}
5156
credentials.value = []
5257
data.value.code = current.value.code
53-
data.value.provider = current.value.name
58+
// Keep provider consistent with credential records (prefer provider/code over display name).
59+
data.value.provider = current.value.provider || current.value.code || current.value.name
5460
if (mounted.value)
5561
data.value.dns_credential_id = undefined
5662
@@ -122,7 +128,7 @@ function filterOption(input: string, option?: DefaultOptionType) {
122128
/>
123129
</AFormItem>
124130
<AFormItem
125-
v-if="((providerIdx ?? -1) > -1)"
131+
v-if="(providerIdx !== undefined && providerIdx !== null && providerIdx > -1)"
126132
:label="$gettext('Credential')"
127133
:rules="[{ required: true }]"
128134
>

0 commit comments

Comments
 (0)