mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
feat: add DELETE endpoint for DDNS configuration and implement deletion logic
This commit is contained in:
@@ -241,6 +241,24 @@ func UpdateDDNSConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, toDDNSResponse(cfg))
|
||||
}
|
||||
|
||||
// DeleteDDNSConfig removes DDNS settings for a domain and stops its schedule.
|
||||
func DeleteDDNSConfig(c *gin.Context) {
|
||||
domainID := cast.ToUint64(c.Param("id"))
|
||||
|
||||
svc := dnsService.NewService()
|
||||
if err := svc.DeleteDDNSConfig(c.Request.Context(), domainID); err != nil {
|
||||
cosy.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := cron.RemoveDDNSJob(domainID); err != nil {
|
||||
cosy.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func buildPagination(page, perPage int, total int64) model.Pagination {
|
||||
page = lo.If(page < 1, 1).Else(page)
|
||||
perPage = lo.If(perPage <= 0, 50).Else(perPage)
|
||||
|
||||
@@ -24,6 +24,7 @@ func InitRouter(r *gin.RouterGroup) {
|
||||
|
||||
group.GET("/domains/:id/ddns", GetDDNSConfig)
|
||||
group.PUT("/domains/:id/ddns", UpdateDDNSConfig)
|
||||
group.DELETE("/domains/:id/ddns", DeleteDDNSConfig)
|
||||
|
||||
group.GET("/ddns", ListDDNSConfig)
|
||||
}
|
||||
|
||||
+15
-13
@@ -9,6 +9,17 @@ import (
|
||||
"github.com/uozi-tech/cosy"
|
||||
)
|
||||
|
||||
func buildNamespaceTestConfigResponse(namespaceID uint64, result nginx.TestConfigResult) gin.H {
|
||||
return gin.H{
|
||||
"message": result.Message,
|
||||
"level": result.Level,
|
||||
"namespace_id": namespaceID,
|
||||
"test_scope": result.TestScope,
|
||||
"sandbox_status": result.SandboxStatus,
|
||||
"error_category": result.ErrorCategory,
|
||||
}
|
||||
}
|
||||
|
||||
// Reload reloads the nginx
|
||||
func Reload(c *gin.Context) {
|
||||
nginx.Control(nginx.Reload).Resp(c)
|
||||
@@ -17,10 +28,8 @@ func Reload(c *gin.Context) {
|
||||
// TestConfig tests the nginx config
|
||||
func TestConfig(c *gin.Context) {
|
||||
lastResult := nginx.Control(nginx.TestConfig)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": lastResult.GetOutput(),
|
||||
"level": lastResult.GetLevel(),
|
||||
})
|
||||
result := nginx.NewTestConfigResult(lastResult.GetStdOut(), lastResult.GetStdErr(), nginx.TestScopeGlobal, "")
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// TestConfigWithNamespace tests nginx config in isolated sandbox for a specific namespace
|
||||
@@ -73,15 +82,8 @@ func TestConfigWithNamespace(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Use sandbox test with namespace-specific paths
|
||||
result := nginx.Control(func() (string, error) {
|
||||
return nginx.SandboxTestConfigWithPaths(namespaceInfo, sitePaths, streamPaths)
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": result.GetOutput(),
|
||||
"level": result.GetLevel(),
|
||||
"namespace_id": req.NamespaceID,
|
||||
})
|
||||
result := nginx.SandboxTestConfigWithPaths(namespaceInfo, sitePaths, streamPaths)
|
||||
c.JSON(http.StatusOK, buildNamespaceTestConfigResponse(req.NamespaceID, result))
|
||||
}
|
||||
|
||||
// Restart restarts the nginx
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
internalnginx "github.com/0xJacky/Nginx-UI/internal/nginx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildNamespaceTestConfigResponseIncludesSandboxFields(t *testing.T) {
|
||||
response := buildNamespaceTestConfigResponse(9, internalnginx.TestConfigResult{
|
||||
Message: "sandbox failed",
|
||||
Level: internalnginx.Error,
|
||||
TestScope: internalnginx.TestScopeNamespaceSandbox,
|
||||
SandboxStatus: internalnginx.SandboxStatusFailed,
|
||||
ErrorCategory: internalnginx.ErrorCategoryMissingInclude,
|
||||
})
|
||||
|
||||
assert.Equal(t, uint64(9), response["namespace_id"])
|
||||
assert.Equal(t, "sandbox failed", response["message"])
|
||||
assert.Equal(t, internalnginx.TestScopeNamespaceSandbox, response["test_scope"])
|
||||
assert.Equal(t, internalnginx.SandboxStatusFailed, response["sandbox_status"])
|
||||
assert.Equal(t, internalnginx.ErrorCategoryMissingInclude, response["error_category"])
|
||||
}
|
||||
@@ -108,6 +108,9 @@ export const dnsApi = {
|
||||
listDDNS() {
|
||||
return http.get<{ data: DDNSDomainItem[] }>(`/dns/ddns`)
|
||||
},
|
||||
deleteDDNSConfig(domainId: number) {
|
||||
return http.delete(`${baseDomainUrl}/${domainId}/ddns`)
|
||||
},
|
||||
}
|
||||
|
||||
export type { DnsCredential }
|
||||
|
||||
+11
-2
@@ -111,6 +111,15 @@ export interface NgxModule {
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
export interface NgxTestResult {
|
||||
message: string
|
||||
level: number
|
||||
namespace_id?: number
|
||||
test_scope?: 'global' | 'namespace_sandbox'
|
||||
sandbox_status?: 'ok' | 'skipped' | 'failed'
|
||||
error_category?: 'missing_include' | 'sandbox_build_error' | 'syntax_error' | 'nginx_runtime_error'
|
||||
}
|
||||
|
||||
const ngx = {
|
||||
build_config(ngxConfig: NgxConfig) {
|
||||
return http.post('/ngx/build_config', ngxConfig)
|
||||
@@ -144,11 +153,11 @@ const ngx = {
|
||||
return http.post('/nginx/restart')
|
||||
},
|
||||
|
||||
test() {
|
||||
test(): Promise<NgxTestResult> {
|
||||
return http.post('/nginx/test')
|
||||
},
|
||||
|
||||
test_namespace(namespace_id?: number): Promise<{ message: string, level: number, namespace_id?: number }> {
|
||||
test_namespace(namespace_id?: number): Promise<NgxTestResult> {
|
||||
return http.post('/nginx/test_namespace', { namespace_id })
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { NgxTestResult } from '@/api/ngx'
|
||||
import type { CosyError } from '@/lib/http/types'
|
||||
import ngx from '@/api/ngx'
|
||||
import { logLevel } from '@/constants/config'
|
||||
@@ -9,41 +10,92 @@ const props = defineProps<{
|
||||
namespaceId?: number | string
|
||||
}>()
|
||||
|
||||
interface TestResult extends CosyError {
|
||||
message: string
|
||||
level: number
|
||||
namespace_id?: number
|
||||
interface TestResult extends NgxTestResult {
|
||||
code?: string
|
||||
scope?: string
|
||||
params?: string[]
|
||||
}
|
||||
|
||||
const data = ref<TestResult>()
|
||||
const translatedError = ref<string>('')
|
||||
const testLoading = ref(false)
|
||||
|
||||
const statusMessage = computed(() => {
|
||||
switch (data.value?.sandbox_status) {
|
||||
case 'skipped':
|
||||
return $gettext('Sandbox validation skipped')
|
||||
case 'failed':
|
||||
return $gettext('Sandbox validation failed')
|
||||
default:
|
||||
return $gettext('Error')
|
||||
}
|
||||
})
|
||||
|
||||
const categoryMessage = computed(() => {
|
||||
switch (data.value?.error_category) {
|
||||
case 'missing_include':
|
||||
return $gettext('A required include file is missing from the sandbox or source configuration.')
|
||||
case 'sandbox_build_error':
|
||||
return $gettext('Sandbox setup failed before Nginx could validate the configuration.')
|
||||
case 'syntax_error':
|
||||
return $gettext('Nginx reported a configuration syntax error.')
|
||||
case 'nginx_runtime_error':
|
||||
return $gettext('Nginx failed to validate the configuration.')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const translatedDetails = computed(() => {
|
||||
if (!translatedError.value || translatedError.value === data.value?.message) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return translatedError.value
|
||||
})
|
||||
|
||||
// Watch for namespace changes and auto-test
|
||||
watch(() => props.namespaceId, () => {
|
||||
test()
|
||||
}, { immediate: true })
|
||||
|
||||
function test() {
|
||||
async function test() {
|
||||
testLoading.value = true
|
||||
translatedError.value = ''
|
||||
const namespaceIdNum = props.namespaceId ? Number(props.namespaceId) : 0
|
||||
const testPromise = namespaceIdNum > 0
|
||||
? ngx.test_namespace(namespaceIdNum)
|
||||
: ngx.test()
|
||||
|
||||
testPromise.then(r => {
|
||||
data.value = r
|
||||
if (r && r.level > logLevel.Warn) {
|
||||
const cosyError: CosyError = {
|
||||
...r,
|
||||
}
|
||||
translateError(cosyError).then(translated => {
|
||||
translatedError.value = translated
|
||||
})
|
||||
try {
|
||||
const result = namespaceIdNum > 0
|
||||
? await ngx.test_namespace(namespaceIdNum)
|
||||
: await ngx.test()
|
||||
|
||||
data.value = result
|
||||
|
||||
const testResult = result as TestResult
|
||||
if (testResult.level > logLevel.Warn && testResult.code && testResult.scope) {
|
||||
translatedError.value = await translateError(testResult as CosyError)
|
||||
}
|
||||
}).finally(() => {
|
||||
}
|
||||
catch (error) {
|
||||
const cosyError = error as Partial<CosyError>
|
||||
const message = cosyError?.message ?? $gettext('Server error')
|
||||
|
||||
data.value = {
|
||||
...cosyError,
|
||||
message,
|
||||
level: logLevel.Error,
|
||||
sandbox_status: namespaceIdNum > 0 ? 'failed' : undefined,
|
||||
error_category: 'nginx_runtime_error',
|
||||
test_scope: namespaceIdNum > 0 ? 'namespace_sandbox' : 'global',
|
||||
}
|
||||
|
||||
if (cosyError?.code && cosyError?.scope) {
|
||||
translatedError.value = await translateError(cosyError as CosyError)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
testLoading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -53,9 +105,45 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<div class="inspect-container">
|
||||
<!-- Test Results -->
|
||||
<AAlert
|
||||
v-if="data && data.level <= logLevel.Info"
|
||||
v-if="testLoading"
|
||||
:banner
|
||||
:message="$gettext('Testing Nginx configuration...')"
|
||||
type="info"
|
||||
show-icon
|
||||
/>
|
||||
<AAlert
|
||||
v-else-if="data?.sandbox_status === 'skipped'"
|
||||
:banner
|
||||
:message="$gettext('Sandbox validation skipped')"
|
||||
type="info"
|
||||
show-icon
|
||||
>
|
||||
<template #description>
|
||||
{{ data?.message }}
|
||||
</template>
|
||||
</AAlert>
|
||||
<AAlert
|
||||
v-else-if="data?.sandbox_status === 'failed'"
|
||||
:banner
|
||||
:message="$gettext('Sandbox validation failed')"
|
||||
type="error"
|
||||
show-icon
|
||||
>
|
||||
<template #description>
|
||||
<div v-if="categoryMessage">
|
||||
{{ categoryMessage }}
|
||||
</div>
|
||||
<div v-if="translatedDetails">
|
||||
{{ translatedDetails }}
|
||||
</div>
|
||||
<div v-if="data?.message">
|
||||
{{ data?.message }}
|
||||
</div>
|
||||
</template>
|
||||
</AAlert>
|
||||
<AAlert
|
||||
v-else-if="data && data.level <= logLevel.Info"
|
||||
:banner
|
||||
:message="namespaceId
|
||||
? $gettext('Configuration file is test successful in isolated sandbox')
|
||||
@@ -77,13 +165,21 @@ defineExpose({
|
||||
|
||||
<AAlert
|
||||
v-else-if="data && data.level > logLevel.Warn"
|
||||
:message="$gettext('Error')"
|
||||
:message="statusMessage"
|
||||
:banner
|
||||
type="error"
|
||||
show-icon
|
||||
>
|
||||
<template #description>
|
||||
{{ translatedError }}
|
||||
<div v-if="categoryMessage">
|
||||
{{ categoryMessage }}
|
||||
</div>
|
||||
<div v-if="translatedDetails">
|
||||
{{ translatedDetails }}
|
||||
</div>
|
||||
<div v-if="data?.message">
|
||||
{{ data?.message }}
|
||||
</div>
|
||||
</template>
|
||||
</AAlert>
|
||||
</div>
|
||||
|
||||
@@ -8359,3 +8359,15 @@ msgstr "零分配管道"
|
||||
|
||||
#~ msgid "Used: %{used} / Total: %{total}"
|
||||
#~ msgstr "已使用: %{used} / 总共: %{total}"
|
||||
|
||||
#: src/views/dns/DDNSManager.vue:195
|
||||
msgid "DDNS config deleted"
|
||||
msgstr "DDNS 配置已删除"
|
||||
|
||||
#: src/views/dns/DDNSManager.vue:220
|
||||
msgid "Search domain, provider or target"
|
||||
msgstr "搜索域名、提供商或目标记录"
|
||||
|
||||
#: src/views/dns/DDNSManager.vue:276
|
||||
msgid "Are you sure to delete this DDNS config?"
|
||||
msgstr "确定要删除这个 DDNS 配置吗?"
|
||||
|
||||
@@ -95,6 +95,16 @@ export const useDnsStore = defineStore('dns-store', {
|
||||
this.ddnsLoading = false
|
||||
}
|
||||
},
|
||||
async deleteDDNSConfig(domainId: number) {
|
||||
this.ddnsLoading = true
|
||||
try {
|
||||
await dnsApi.deleteDDNSConfig(domainId)
|
||||
this.ddnsConfig = undefined
|
||||
}
|
||||
finally {
|
||||
this.ddnsLoading = false
|
||||
}
|
||||
},
|
||||
resetRecords() {
|
||||
this.records = []
|
||||
this.recordsPagination = undefined
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { DDNSDomainItem, DNSRecord, UpdateDDNSPayload } from '@/api/dns'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { DeleteOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
@@ -11,9 +11,11 @@ const store = useDnsStore()
|
||||
|
||||
const loading = computed(() => store.ddnsListLoading)
|
||||
const items = computed(() => store.ddnsList)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const deletingDomainId = ref<number | null>(null)
|
||||
const currentDomain = ref<DDNSDomainItem | null>(null)
|
||||
const ddnsForm = ref<UpdateDDNSPayload>({
|
||||
enabled: false,
|
||||
@@ -24,6 +26,14 @@ const ddnsForm = ref<UpdateDDNSPayload>({
|
||||
const records = ref<DNSRecord[]>([])
|
||||
const recordsLoading = ref(false)
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
if (!keyword)
|
||||
return items.value
|
||||
|
||||
return items.value.filter(item => matchKeyword(item, keyword))
|
||||
})
|
||||
|
||||
const recordOptions = computed(() => {
|
||||
const opts = new Map<string, { value: string, label: string }>()
|
||||
records.value
|
||||
@@ -55,6 +65,34 @@ function filterRecordOption(input: string, option?: { label: string, value: stri
|
||||
return option.label.toLowerCase().includes(keyword)
|
||||
}
|
||||
|
||||
function normalizeText(value?: string | null) {
|
||||
return value?.toLowerCase().trim() ?? ''
|
||||
}
|
||||
|
||||
function hasDDNSConfig(item: DDNSDomainItem) {
|
||||
const config = item.config
|
||||
return config.enabled
|
||||
|| Boolean(config.targets?.length)
|
||||
|| Boolean(config.last_run_at)
|
||||
|| Boolean(config.last_error)
|
||||
|| Boolean(config.last_ipv4)
|
||||
|| Boolean(config.last_ipv6)
|
||||
}
|
||||
|
||||
function matchKeyword(item: DDNSDomainItem, keyword: string) {
|
||||
const targetText = item.config.targets
|
||||
?.map(target => `${target.name} ${target.type}`)
|
||||
.join(' ')
|
||||
|
||||
return [
|
||||
item.domain,
|
||||
item.credential_name,
|
||||
item.credential_provider,
|
||||
targetText,
|
||||
item.config.enabled ? 'enabled' : 'disabled',
|
||||
].some(value => normalizeText(value).includes(keyword))
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: $gettext('Domain'),
|
||||
@@ -105,6 +143,7 @@ function formatTime(value?: string) {
|
||||
|
||||
async function openDrawer(record: DDNSDomainItem) {
|
||||
currentDomain.value = record
|
||||
records.value = []
|
||||
ddnsForm.value = {
|
||||
enabled: record.config.enabled,
|
||||
interval_seconds: record.config.interval_seconds,
|
||||
@@ -125,6 +164,12 @@ async function loadRecords(domainId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false
|
||||
currentDomain.value = null
|
||||
records.value = []
|
||||
}
|
||||
|
||||
async function saveDDNS() {
|
||||
if (!currentDomain.value)
|
||||
return
|
||||
@@ -133,13 +178,27 @@ async function saveDDNS() {
|
||||
await store.updateDDNSConfig(currentDomain.value.id, ddnsForm.value)
|
||||
await store.refreshDDNSItem(currentDomain.value.id)
|
||||
message.success($gettext('DDNS saved'))
|
||||
drawerOpen.value = false
|
||||
closeDrawer()
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDDNS(record: DDNSDomainItem) {
|
||||
deletingDomainId.value = record.id
|
||||
try {
|
||||
await store.deleteDDNSConfig(record.id)
|
||||
await store.refreshDDNSItem(record.id)
|
||||
if (currentDomain.value?.id === record.id)
|
||||
closeDrawer()
|
||||
message.success($gettext('DDNS config deleted'))
|
||||
}
|
||||
finally {
|
||||
deletingDomainId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
@@ -147,27 +206,40 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="ddns-page">
|
||||
<ACard>
|
||||
<ACard class="ddns-card">
|
||||
<template #title>
|
||||
<ASpace align="center">
|
||||
{{ $gettext('DDNS Overview') }}
|
||||
</ASpace>
|
||||
</template>
|
||||
<template #extra>
|
||||
<AButton type="link" size="small" :loading="loading" @click="init">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
{{ $gettext('Refresh') }}
|
||||
</AButton>
|
||||
<div class="toolbar">
|
||||
<AInput
|
||||
v-model:value="searchKeyword"
|
||||
allow-clear
|
||||
:placeholder="$gettext('Search domain, provider or target')"
|
||||
class="toolbar-search"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</AInput>
|
||||
<AButton size="small" :loading="loading" @click="init">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
{{ $gettext('Refresh') }}
|
||||
</AButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ATable
|
||||
:loading="loading"
|
||||
:data-source="items"
|
||||
:data-source="filteredItems"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 960 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
@@ -196,10 +268,28 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<ASpace>
|
||||
<ASpace size="small" wrap>
|
||||
<AButton size="small" type="link" @click="openDrawer(record as DDNSDomainItem)">
|
||||
{{ $gettext('Configure') }}
|
||||
</AButton>
|
||||
<APopconfirm
|
||||
:title="$gettext('Are you sure to delete this DDNS config?')"
|
||||
:disabled="!hasDDNSConfig(record as DDNSDomainItem)"
|
||||
@confirm="deleteDDNS(record as DDNSDomainItem)"
|
||||
>
|
||||
<AButton
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
:disabled="!hasDDNSConfig(record as DDNSDomainItem)"
|
||||
:loading="deletingDomainId === (record as DDNSDomainItem).id"
|
||||
>
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
{{ $gettext('Delete') }}
|
||||
</AButton>
|
||||
</APopconfirm>
|
||||
</ASpace>
|
||||
</template>
|
||||
</template>
|
||||
@@ -210,7 +300,7 @@ onMounted(() => {
|
||||
:open="drawerOpen"
|
||||
:title="currentDomain ? `${$gettext('Configure DDNS')} - ${currentDomain.domain}` : ''"
|
||||
width="520"
|
||||
@close="drawerOpen = false"
|
||||
@close="closeDrawer"
|
||||
>
|
||||
<ASkeleton v-if="recordsLoading" active />
|
||||
<template v-else>
|
||||
@@ -240,7 +330,7 @@ onMounted(() => {
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<AButton @click="drawerOpen = false">
|
||||
<AButton @click="closeDrawer">
|
||||
{{ $gettext('Cancel') }}
|
||||
</AButton>
|
||||
<AButton type="primary" :loading="saving" @click="saveDDNS">
|
||||
@@ -256,4 +346,22 @@ onMounted(() => {
|
||||
.ddns-page {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.ddns-card :deep(.ant-card-head) {
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.ddns-card :deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: min(320px, 55vw);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -209,6 +209,18 @@ func (s *Service) UpdateDDNSConfig(ctx context.Context, domainID uint64, input D
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// DeleteDDNSConfig removes DDNS configuration for the given domain.
|
||||
func (s *Service) DeleteDDNSConfig(ctx context.Context, domainID uint64) error {
|
||||
if _, err := loadDomain(ctx, domainID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return model.UseDB().WithContext(ctx).
|
||||
Model(&model.DnsDomain{}).
|
||||
Where("id = ?", domainID).
|
||||
Update("ddns_config", nil).Error
|
||||
}
|
||||
|
||||
// ListEnabledDDNSSchedules returns schedules for enabled DDNS domains.
|
||||
func ListEnabledDDNSSchedules(ctx context.Context) ([]DDNSSchedule, error) {
|
||||
domains, err := query.DnsDomain.WithContext(ctx).Find()
|
||||
|
||||
@@ -56,10 +56,18 @@ func (t *ControlResult) GetOutput() string {
|
||||
return strings.Join([]string{t.stdOut, t.stdErr.Error()}, " ")
|
||||
}
|
||||
|
||||
func (t *ControlResult) GetStdOut() string {
|
||||
return t.stdOut
|
||||
}
|
||||
|
||||
func (t *ControlResult) GetError() error {
|
||||
return cosy.WrapErrorWithParams(ErrNginx, t.GetOutput())
|
||||
}
|
||||
|
||||
func (t *ControlResult) GetStdErr() error {
|
||||
return t.stdErr
|
||||
}
|
||||
|
||||
func (t *ControlResult) GetLevel() int {
|
||||
return GetLogLevel(t.stdOut)
|
||||
}
|
||||
|
||||
+232
-70
@@ -20,30 +20,42 @@ type NamespaceInfo struct {
|
||||
DeployMode string
|
||||
}
|
||||
|
||||
// SandboxTestConfigWithPaths tests nginx config in an isolated sandbox with provided paths
|
||||
func SandboxTestConfigWithPaths(namespace *NamespaceInfo, sitePaths, streamPaths []string) (stdOut string, stdErr error) {
|
||||
// SandboxTestConfigWithPaths tests nginx config in an isolated sandbox with provided paths.
|
||||
func SandboxTestConfigWithPaths(namespace *NamespaceInfo, sitePaths, streamPaths []string) TestConfigResult {
|
||||
// If custom test command is set, use it (no sandbox support)
|
||||
if settings.NginxSettings.TestConfigCmd != "" {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
return execShell(settings.NginxSettings.TestConfigCmd)
|
||||
stdOut, stdErr := execShell(settings.NginxSettings.TestConfigCmd)
|
||||
result := NewTestConfigResult(stdOut, stdErr, TestScopeNamespaceSandbox, SandboxStatusSkipped)
|
||||
result.Message = strings.TrimSpace(strings.Join([]string{
|
||||
"Sandbox validation skipped because a custom test command is configured.",
|
||||
result.Message,
|
||||
}, "\n"))
|
||||
return result
|
||||
}
|
||||
|
||||
// Skip local test for remote-only namespaces
|
||||
if namespace != nil && namespace.DeployMode == "remote" {
|
||||
return "Config validation skipped for remote-only namespace", nil
|
||||
return TestConfigResult{
|
||||
Message: "Config validation skipped for remote-only namespace",
|
||||
Level: Notice,
|
||||
TestScope: TestScopeNamespaceSandbox,
|
||||
SandboxStatus: SandboxStatusSkipped,
|
||||
}
|
||||
}
|
||||
|
||||
// If namespace is nil, directly test in real directory (no sandbox)
|
||||
if namespace == nil {
|
||||
return TestConfig()
|
||||
stdOut, stdErr := TestConfig()
|
||||
return NewTestConfigResult(stdOut, stdErr, TestScopeGlobal, "")
|
||||
}
|
||||
|
||||
// Create sandbox and test
|
||||
sandbox, err := createSandbox(namespace, sitePaths, streamPaths)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create sandbox: %v", err)
|
||||
return TestConfig() // Fallback to normal test
|
||||
return NewSandboxBuildFailureResult(err)
|
||||
}
|
||||
defer sandbox.Cleanup()
|
||||
|
||||
@@ -56,7 +68,8 @@ func SandboxTestConfigWithPaths(namespace *NamespaceInfo, sitePaths, streamPaths
|
||||
sbin = "nginx"
|
||||
}
|
||||
|
||||
return execCommand(sbin, "-t", "-c", sandbox.ConfigPath)
|
||||
stdOut, stdErr := execCommand(sbin, "-t", "-c", sandbox.ConfigPath)
|
||||
return NewTestConfigResult(stdOut, stdErr, TestScopeNamespaceSandbox, SandboxStatusOK)
|
||||
}
|
||||
|
||||
// Sandbox represents an isolated nginx test environment
|
||||
@@ -78,6 +91,7 @@ func createSandbox(namespace *NamespaceInfo, sitePaths, streamPaths []string) (*
|
||||
Dir: tempDir,
|
||||
Namespace: namespace,
|
||||
}
|
||||
builder := newSandboxBuilder(tempDir)
|
||||
|
||||
// Copy full nginx conf directory to sandbox, excluding sites-* and streams-*
|
||||
if err := copyConfigBaseExceptSitesStreams(tempDir); err != nil {
|
||||
@@ -96,14 +110,14 @@ func createSandbox(namespace *NamespaceInfo, sitePaths, streamPaths []string) (*
|
||||
}
|
||||
|
||||
// Collect and copy only enabled sites/streams for the given namespace
|
||||
siteFiles, streamFiles, err := collectAndCopyNamespaceEnabled(namespace, sitePaths, streamPaths, tempDir)
|
||||
siteFiles, streamFiles, err := collectAndCopyNamespaceEnabled(namespace, sitePaths, streamPaths, builder)
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to collect/copy namespace configs: %w", err)
|
||||
}
|
||||
|
||||
// Generate sandbox nginx.conf
|
||||
configContent, err := generateSandboxConfig(namespace, siteFiles, streamFiles, tempDir)
|
||||
configContent, err := generateSandboxConfig(namespace, siteFiles, streamFiles, builder)
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to generate sandbox config: %w", err)
|
||||
@@ -131,8 +145,8 @@ func (s *Sandbox) Cleanup() {
|
||||
}
|
||||
}
|
||||
|
||||
// generateSandboxConfig generates a minimal nginx.conf that only includes configs from specified paths
|
||||
func generateSandboxConfig(namespace *NamespaceInfo, siteFiles, streamFiles []string, sandboxDir string) (string, error) {
|
||||
// generateSandboxConfig generates a minimal nginx.conf that only includes configs from specified paths.
|
||||
func generateSandboxConfig(namespace *NamespaceInfo, siteFiles, streamFiles []string, builder *sandboxBuilder) (string, error) {
|
||||
// Read the main nginx.conf to get basic structure
|
||||
mainConfPath := GetConfEntryPath()
|
||||
mainConf, err := os.ReadFile(mainConfPath)
|
||||
@@ -145,22 +159,25 @@ func generateSandboxConfig(namespace *NamespaceInfo, siteFiles, streamFiles []st
|
||||
// Generate include patterns based on provided paths
|
||||
siteIncludeLines := make([]string, 0, len(siteFiles))
|
||||
for _, f := range siteFiles {
|
||||
siteIncludeLines = append(siteIncludeLines, fmt.Sprintf(" include %s;", filepath.Join(sandboxDir, "sites-enabled", f)))
|
||||
siteIncludeLines = append(siteIncludeLines, fmt.Sprintf(" include %s;", filepath.Join(builder.sandboxDir, "sites-enabled", f)))
|
||||
}
|
||||
streamIncludeLines := make([]string, 0, len(streamFiles))
|
||||
for _, f := range streamFiles {
|
||||
streamIncludeLines = append(streamIncludeLines, fmt.Sprintf(" include %s;", filepath.Join(sandboxDir, "streams-enabled", f)))
|
||||
streamIncludeLines = append(streamIncludeLines, fmt.Sprintf(" include %s;", filepath.Join(builder.sandboxDir, "streams-enabled", f)))
|
||||
}
|
||||
|
||||
// Replace include directives with sandbox-specific ones
|
||||
sandboxConf := replaceIncludeDirectives(mainConfStr, sandboxDir, siteIncludeLines, streamIncludeLines)
|
||||
sandboxConf, err := replaceIncludeDirectives(mainConfStr, mainConfPath, builder, siteIncludeLines, streamIncludeLines)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return sandboxConf, nil
|
||||
}
|
||||
|
||||
// replaceIncludeDirectives replaces only sites-enabled and streams-enabled includes
|
||||
// replaceIncludeDirectives replaces only sites-enabled and streams-enabled includes.
|
||||
// Rewrites other includes to point to copied files under sandboxDir, preserving isolation.
|
||||
func replaceIncludeDirectives(mainConf string, sandboxDir string, siteIncludeLines, streamIncludeLines []string) string {
|
||||
func replaceIncludeDirectives(mainConf string, sourcePath string, builder *sandboxBuilder, siteIncludeLines, streamIncludeLines []string) (string, error) {
|
||||
lines := strings.Split(mainConf, "\n")
|
||||
var result []string
|
||||
httpDepth := 0
|
||||
@@ -218,7 +235,10 @@ func replaceIncludeDirectives(mainConf string, sandboxDir string, siteIncludeLin
|
||||
}
|
||||
|
||||
// Rewrite other includes to sandbox paths
|
||||
normalized := rewriteIncludeLineToSandbox(line, sandboxDir)
|
||||
normalized, err := builder.rewriteIncludeLine(line, filepath.Dir(sourcePath))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if normalized != "" {
|
||||
result = append(result, normalized)
|
||||
}
|
||||
@@ -253,44 +273,13 @@ func replaceIncludeDirectives(mainConf string, sandboxDir string, siteIncludeLin
|
||||
result = append(result, line)
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// rewriteIncludeLineToSandbox rewrites include lines to point to files/directories inside sandboxDir.
|
||||
// If an include path is relative, it will be rewritten relative to the nginx conf dir inside sandbox.
|
||||
func rewriteIncludeLineToSandbox(line string, sandboxDir string) string {
|
||||
includeRegex := regexp.MustCompile(`(?i)include\s+([^;#]+);`)
|
||||
matches := includeRegex.FindStringSubmatch(line)
|
||||
if len(matches) < 2 {
|
||||
return line
|
||||
}
|
||||
path := strings.TrimSpace(matches[1])
|
||||
|
||||
confBase := GetConfPath()
|
||||
var rewritten string
|
||||
if filepath.IsAbs(path) {
|
||||
// If absolute under confBase, map to sandbox
|
||||
if helper.IsUnderDirectory(path, confBase) {
|
||||
rel, err := filepath.Rel(confBase, path)
|
||||
if err == nil {
|
||||
rewritten = filepath.Join(sandboxDir, rel)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Relative includes should point inside sandbox conf root
|
||||
rewritten = filepath.Join(sandboxDir, path)
|
||||
}
|
||||
if rewritten == "" {
|
||||
rewritten = path
|
||||
}
|
||||
trimmed := includeRegex.ReplaceAllString(line, "include "+rewritten+";")
|
||||
return trimmed
|
||||
return strings.Join(result, "\n"), nil
|
||||
}
|
||||
|
||||
// collectAndCopyNamespaceEnabled collects and copies enabled site/stream configs based on provided paths.
|
||||
// It rewrites relative includes to absolute, and writes them into sandboxDir/{sites-enabled,streams-enabled}.
|
||||
// Returns the written file names.
|
||||
func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []string, sandboxDir string) (siteFiles, streamFiles []string, err error) {
|
||||
func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []string, builder *sandboxBuilder) (siteFiles, streamFiles []string, err error) {
|
||||
// Helper to process and write a single config by kind and name
|
||||
readSourceAndWrite := func(kind, name string) (writtenName string, wErr error) {
|
||||
var enabledCandidates []string
|
||||
@@ -340,10 +329,10 @@ func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []s
|
||||
}
|
||||
|
||||
// Rewrite include lines to sandbox paths (resolve relative to source dir first)
|
||||
absRewriter := regexp.MustCompile(`(?m)^[ \t]*include\s+([^;#]+);`)
|
||||
rewritten := absRewriter.ReplaceAllStringFunc(string(content), func(m string) string {
|
||||
return normalizeIncludeLineRelativeTo(m, filepath.Dir(srcPath), sandboxDir)
|
||||
})
|
||||
rewritten, rErr := builder.rewriteConfigContent(string(content), srcPath)
|
||||
if rErr != nil {
|
||||
return "", fmt.Errorf("rewrite sandbox %s: %w", kind, rErr)
|
||||
}
|
||||
|
||||
// Compute destination file name respecting platform symlink naming
|
||||
var destName string
|
||||
@@ -354,7 +343,7 @@ func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []s
|
||||
destName = filepath.Base(GetConfSymlinkPath(GetConfPath("streams-enabled", name)))
|
||||
}
|
||||
|
||||
destDir := filepath.Join(sandboxDir, kind+"s-enabled")
|
||||
destDir := filepath.Join(builder.sandboxDir, kind+"s-enabled")
|
||||
if err := os.WriteFile(filepath.Join(destDir, destName), []byte(rewritten), 0644); err != nil {
|
||||
return "", fmt.Errorf("write sandbox %s: %w", kind, err)
|
||||
}
|
||||
@@ -384,29 +373,202 @@ func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []s
|
||||
return siteFiles, streamFiles, nil
|
||||
}
|
||||
|
||||
// normalizeIncludeLineRelativeTo rewrites a single include line:
|
||||
// - resolves relative paths against baseDir
|
||||
// - if the resolved path is under confBase, map to sandboxDir mirror; else keep as is
|
||||
func normalizeIncludeLineRelativeTo(line, baseDir, sandboxDir string) string {
|
||||
type sandboxBuilder struct {
|
||||
sandboxDir string
|
||||
confBase string
|
||||
mirrored map[string]bool
|
||||
}
|
||||
|
||||
func newSandboxBuilder(sandboxDir string) *sandboxBuilder {
|
||||
return &sandboxBuilder{
|
||||
sandboxDir: sandboxDir,
|
||||
confBase: GetConfPath(),
|
||||
mirrored: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *sandboxBuilder) rewriteConfigContent(content string, sourcePath string) (string, error) {
|
||||
includeRegex := regexp.MustCompile(`(?m)^[ \t]*include\s+([^;#]+);`)
|
||||
var rewriteErr error
|
||||
|
||||
rewritten := includeRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||
if rewriteErr != nil {
|
||||
return match
|
||||
}
|
||||
|
||||
line, err := b.rewriteIncludeLine(match, filepath.Dir(sourcePath))
|
||||
if err != nil {
|
||||
rewriteErr = err
|
||||
return match
|
||||
}
|
||||
|
||||
return line
|
||||
})
|
||||
|
||||
if rewriteErr != nil {
|
||||
return "", rewriteErr
|
||||
}
|
||||
|
||||
return rewritten, nil
|
||||
}
|
||||
|
||||
func (b *sandboxBuilder) rewriteIncludeLine(line string, baseDir string) (string, error) {
|
||||
includeRegex := regexp.MustCompile(`(?i)include\s+([^;#]+);`)
|
||||
matches := includeRegex.FindStringSubmatch(line)
|
||||
if len(matches) < 2 {
|
||||
return line
|
||||
return line, nil
|
||||
}
|
||||
path := strings.TrimSpace(matches[1])
|
||||
|
||||
// If relative, make absolute to source file dir
|
||||
resolved := path
|
||||
if !filepath.IsAbs(resolved) {
|
||||
resolved = filepath.Clean(filepath.Join(baseDir, resolved))
|
||||
includePath := strings.TrimSpace(matches[1])
|
||||
resolvedPath, matchedFiles, err := b.resolveIncludePath(includePath, baseDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
confBase := GetConfPath()
|
||||
if helper.IsUnderDirectory(resolved, confBase) {
|
||||
if rel, err := filepath.Rel(confBase, resolved); err == nil {
|
||||
resolved = filepath.Join(sandboxDir, rel)
|
||||
|
||||
if helper.IsUnderDirectory(resolvedPath, b.confBase) {
|
||||
for _, matchedFile := range matchedFiles {
|
||||
if err := b.mirrorDependency(matchedFile); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(b.confBase, resolvedPath)
|
||||
if err == nil {
|
||||
resolvedPath = filepath.Join(b.sandboxDir, rel)
|
||||
}
|
||||
}
|
||||
return includeRegex.ReplaceAllString(line, "include "+resolved+";")
|
||||
|
||||
return includeRegex.ReplaceAllString(line, "include "+resolvedPath+";"), nil
|
||||
}
|
||||
|
||||
func (b *sandboxBuilder) resolveIncludePath(includePath string, baseDir string) (string, []string, error) {
|
||||
if filepath.IsAbs(includePath) {
|
||||
matches, err := matchIncludePattern(includePath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(matches) == 0 && helper.IsUnderDirectory(includePath, b.confBase) {
|
||||
return "", nil, newSandboxIncludeError(baseDir, includePath)
|
||||
}
|
||||
return includePath, matches, nil
|
||||
}
|
||||
|
||||
candidates := uniqueSandboxCandidates(
|
||||
filepath.Clean(filepath.Join(baseDir, includePath)),
|
||||
filepath.Clean(filepath.Join(b.confBase, includePath)),
|
||||
)
|
||||
|
||||
for _, candidate := range candidates {
|
||||
matches, err := matchIncludePattern(candidate)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(matches) > 0 {
|
||||
return candidate, matches, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return includePath, nil, nil
|
||||
}
|
||||
|
||||
return "", nil, newSandboxIncludeError(baseDir, includePath)
|
||||
}
|
||||
|
||||
func (b *sandboxBuilder) mirrorDependency(sourcePath string) error {
|
||||
sourcePath = filepath.Clean(sourcePath)
|
||||
if !helper.IsUnderDirectory(sourcePath, b.confBase) {
|
||||
return nil
|
||||
}
|
||||
if b.mirrored[sourcePath] {
|
||||
return nil
|
||||
}
|
||||
b.mirrored[sourcePath] = true
|
||||
|
||||
data, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
return &SandboxBuildError{
|
||||
Category: ErrorCategorySandboxBuildError,
|
||||
Message: fmt.Sprintf("failed to read sandbox dependency %s: %v", sourcePath, err),
|
||||
}
|
||||
}
|
||||
|
||||
rewritten, err := b.rewriteConfigContent(string(data), sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(b.confBase, sourcePath)
|
||||
if err != nil {
|
||||
return &SandboxBuildError{
|
||||
Category: ErrorCategorySandboxBuildError,
|
||||
Message: fmt.Sprintf("failed to resolve sandbox dependency path %s: %v", sourcePath, err),
|
||||
}
|
||||
}
|
||||
|
||||
destPath := filepath.Join(b.sandboxDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return &SandboxBuildError{
|
||||
Category: ErrorCategorySandboxBuildError,
|
||||
Message: fmt.Sprintf("failed to create sandbox dependency dir for %s: %v", sourcePath, err),
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(destPath, []byte(rewritten), 0644); err != nil {
|
||||
return &SandboxBuildError{
|
||||
Category: ErrorCategorySandboxBuildError,
|
||||
Message: fmt.Sprintf("failed to write sandbox dependency %s: %v", sourcePath, err),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func matchIncludePattern(pattern string) ([]string, error) {
|
||||
if strings.ContainsAny(pattern, "*?[") {
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, &SandboxBuildError{
|
||||
Category: ErrorCategorySandboxBuildError,
|
||||
Message: fmt.Sprintf("invalid include pattern %s: %v", pattern, err),
|
||||
}
|
||||
}
|
||||
|
||||
var existing []string
|
||||
for _, match := range matches {
|
||||
info, statErr := os.Stat(match)
|
||||
if statErr == nil && !info.IsDir() {
|
||||
existing = append(existing, match)
|
||||
}
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(pattern)
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []string{pattern}, nil
|
||||
}
|
||||
|
||||
func uniqueSandboxCandidates(paths ...string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]string, 0, len(paths))
|
||||
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
result = append(result, path)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// copyConfigBaseExceptSitesStreams copies the entire nginx conf directory into sandboxDir,
|
||||
|
||||
+139
-127
@@ -1,82 +1,110 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/0xJacky/Nginx-UI/settings"
|
||||
)
|
||||
|
||||
func TestNormalizeIncludeLineRelativeTo(t *testing.T) {
|
||||
baseDir := "/etc/nginx/sites-available"
|
||||
if runtime.GOOS == "windows" {
|
||||
// keep test portable; filepath.Join will use OS-specific separator
|
||||
baseDir = `C:\nginx\conf\sites-available`
|
||||
}
|
||||
sandboxDir := "/tmp/sbx"
|
||||
func withSandboxPaths(t *testing.T, files map[string]string, fn func(confDir string, sandboxDir string)) {
|
||||
t.Helper()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
wantPrefix string
|
||||
}{
|
||||
{
|
||||
name: "relative simple file",
|
||||
in: " include mime.types;",
|
||||
wantPrefix: " include ",
|
||||
},
|
||||
{
|
||||
name: "relative path with subdir",
|
||||
in: "include ../common/snippets/*.conf;",
|
||||
wantPrefix: "include ",
|
||||
},
|
||||
originalConfigDir := settings.NginxSettings.ConfigDir
|
||||
originalConfigPath := settings.NginxSettings.ConfigPath
|
||||
|
||||
t.Cleanup(func() {
|
||||
settings.NginxSettings.ConfigDir = originalConfigDir
|
||||
settings.NginxSettings.ConfigPath = originalConfigPath
|
||||
})
|
||||
|
||||
confDir := t.TempDir()
|
||||
settings.NginxSettings.ConfigDir = confDir
|
||||
settings.NginxSettings.ConfigPath = filepath.Join(confDir, "nginx.conf")
|
||||
|
||||
if _, ok := files["nginx.conf"]; !ok {
|
||||
files["nginx.conf"] = "events {}\nhttp {\n include sites-enabled/*;\n}\n"
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := normalizeIncludeLineRelativeTo(tt.in, baseDir, sandboxDir)
|
||||
if out == "" {
|
||||
t.Fatalf("expected non-empty include, got empty")
|
||||
}
|
||||
if !strings.HasPrefix(out, tt.wantPrefix) {
|
||||
t.Fatalf("unexpected prefix: %q, got %q", tt.wantPrefix, out)
|
||||
}
|
||||
// if relative input (first two cases), ensure absolute joined path appears
|
||||
if tt.name == "relative simple file" || tt.name == "relative path with subdir" {
|
||||
parts := strings.Split(out, "include ")
|
||||
if len(parts) < 2 {
|
||||
t.Fatalf("malformed include line: %q", out)
|
||||
}
|
||||
pathWithSemi := parts[1]
|
||||
path := strings.TrimSuffix(pathWithSemi, ";")
|
||||
if !filepath.IsAbs(path) {
|
||||
t.Fatalf("expected absolute path, got %q", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
for relPath, content := range files {
|
||||
path := filepath.Join(confDir, relPath)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
fn(confDir, t.TempDir())
|
||||
}
|
||||
|
||||
func TestReplaceIncludeDirectives(t *testing.T) {
|
||||
mainConf := `
|
||||
func TestSandboxBuilderRewriteIncludeLineFallsBackToConfBase(t *testing.T) {
|
||||
withSandboxPaths(t, map[string]string{
|
||||
"fastcgi.conf": "fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\n",
|
||||
}, func(confDir string, sandboxDir string) {
|
||||
builder := newSandboxBuilder(sandboxDir)
|
||||
|
||||
line, err := builder.rewriteIncludeLine(" include fastcgi.conf;", filepath.Join(confDir, "sites-available"))
|
||||
if err != nil {
|
||||
t.Fatalf("rewriteIncludeLine() error = %v", err)
|
||||
}
|
||||
|
||||
expected := " include " + filepath.Join(sandboxDir, "fastcgi.conf") + ";"
|
||||
if line != expected {
|
||||
t.Fatalf("rewriteIncludeLine() = %q, want %q", line, expected)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(sandboxDir, "fastcgi.conf")); err != nil {
|
||||
t.Fatalf("expected mirrored fastcgi.conf: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSandboxBuilderMirrorsNestedIncludeDependencies(t *testing.T) {
|
||||
withSandboxPaths(t, map[string]string{
|
||||
"fastcgi.conf": "fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\n",
|
||||
"sites-available/fragments/php.conf": "include fastcgi.conf;\n",
|
||||
}, func(confDir string, sandboxDir string) {
|
||||
builder := newSandboxBuilder(sandboxDir)
|
||||
sourcePath := filepath.Join(confDir, "sites-available", "example.conf")
|
||||
content := "include fragments/php.conf;\n"
|
||||
|
||||
rewritten, err := builder.rewriteConfigContent(content, sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("rewriteConfigContent() error = %v", err)
|
||||
}
|
||||
|
||||
expectedInclude := filepath.Join(sandboxDir, "sites-available", "fragments", "php.conf")
|
||||
if !strings.Contains(rewritten, expectedInclude) {
|
||||
t.Fatalf("rewriteConfigContent() = %q, want include %q", rewritten, expectedInclude)
|
||||
}
|
||||
|
||||
nestedPath := filepath.Join(sandboxDir, "sites-available", "fragments", "php.conf")
|
||||
nestedContent, err := os.ReadFile(nestedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read mirrored nested dependency: %v", err)
|
||||
}
|
||||
|
||||
expectedNestedInclude := filepath.Join(sandboxDir, "fastcgi.conf")
|
||||
if !strings.Contains(string(nestedContent), expectedNestedInclude) {
|
||||
t.Fatalf("nested dependency = %q, want include %q", string(nestedContent), expectedNestedInclude)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReplaceIncludeDirectivesInjectsSandboxIncludes(t *testing.T) {
|
||||
withSandboxPaths(t, map[string]string{
|
||||
"mime.types": "types { text/html html; }\n",
|
||||
}, func(confDir string, sandboxDir string) {
|
||||
mainConf := `
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
events {}
|
||||
|
||||
http {
|
||||
server {
|
||||
location / {
|
||||
return 200;
|
||||
}
|
||||
}
|
||||
include mime.types;
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
include /etc/nginx/sites-enabled/*;
|
||||
}
|
||||
|
||||
@@ -84,77 +112,61 @@ stream {
|
||||
include /etc/nginx/streams-enabled/*;
|
||||
}
|
||||
`
|
||||
siteLines := []string{" include /tmp/sbx/sites-enabled/a.conf;"}
|
||||
streamLines := []string{" include /tmp/sbx/streams-enabled/s1.conf;"}
|
||||
|
||||
out := replaceIncludeDirectives(mainConf, "/tmp/sbx", siteLines, streamLines)
|
||||
|
||||
// ensure site includes inserted before closing http brace (inside block)
|
||||
lines := strings.Split(out, "\n")
|
||||
httpStart := -1
|
||||
httpClose := -1
|
||||
inHttp := false
|
||||
depth := 0
|
||||
for i, l := range lines {
|
||||
if strings.Contains(l, "http {") && httpStart == -1 {
|
||||
httpStart = i
|
||||
inHttp = true
|
||||
depth = 1
|
||||
continue
|
||||
builder := newSandboxBuilder(sandboxDir)
|
||||
out, err := replaceIncludeDirectives(
|
||||
mainConf,
|
||||
filepath.Join(confDir, "nginx.conf"),
|
||||
builder,
|
||||
[]string{" include " + filepath.Join(sandboxDir, "sites-enabled", "a.conf") + ";"},
|
||||
[]string{" include " + filepath.Join(sandboxDir, "streams-enabled", "s1.conf") + ";"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("replaceIncludeDirectives() error = %v", err)
|
||||
}
|
||||
if inHttp {
|
||||
depth += strings.Count(l, "{")
|
||||
depth -= strings.Count(l, "}")
|
||||
if depth == 0 {
|
||||
httpClose = i
|
||||
inHttp = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if httpStart == -1 || httpClose == -1 {
|
||||
t.Fatal("failed to locate http block bounds")
|
||||
}
|
||||
incIdx := -1
|
||||
for i := httpStart; i <= httpClose; i++ {
|
||||
if strings.Contains(lines[i], "/tmp/sbx/sites-enabled/a.conf;") {
|
||||
incIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if incIdx == -1 || incIdx >= httpClose {
|
||||
t.Fatalf("sandbox site include should be inside http block before closing brace, got index=%d close=%d", incIdx, httpClose)
|
||||
}
|
||||
|
||||
if strings.Contains(out, "/etc/nginx/sites-enabled/*") {
|
||||
t.Fatal("sites-enabled wildcard should be replaced by sandbox files")
|
||||
}
|
||||
if !strings.Contains(out, "/tmp/sbx/sites-enabled/a.conf;") {
|
||||
t.Fatal("sandbox site include missing")
|
||||
}
|
||||
if strings.Contains(out, "/etc/nginx/streams-enabled/*") {
|
||||
t.Fatal("streams-enabled wildcard should be replaced by sandbox files")
|
||||
}
|
||||
if !strings.Contains(out, "/tmp/sbx/streams-enabled/s1.conf;") {
|
||||
t.Fatal("sandbox stream include missing")
|
||||
}
|
||||
// mime.types should be kept (possibly normalized)
|
||||
if !strings.Contains(strings.ToLower(out), "include") {
|
||||
t.Fatal("expected include directives to remain")
|
||||
}
|
||||
if strings.Contains(out, "/etc/nginx/sites-enabled/*") {
|
||||
t.Fatal("sites-enabled wildcard should be replaced by sandbox files")
|
||||
}
|
||||
if strings.Contains(out, "/etc/nginx/streams-enabled/*") {
|
||||
t.Fatal("streams-enabled wildcard should be replaced by sandbox files")
|
||||
}
|
||||
if !strings.Contains(out, filepath.Join(sandboxDir, "sites-enabled", "a.conf")) {
|
||||
t.Fatal("sandbox site include missing")
|
||||
}
|
||||
if !strings.Contains(out, filepath.Join(sandboxDir, "streams-enabled", "s1.conf")) {
|
||||
t.Fatal("sandbox stream include missing")
|
||||
}
|
||||
if !strings.Contains(out, filepath.Join(sandboxDir, "mime.types")) {
|
||||
t.Fatal("main nginx include should be rewritten into sandbox")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSandboxTestConfigWithPathsNilNamespaceReturns(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
func TestSandboxTestConfigWithPathsReturnsSandboxFailureWithoutFallback(t *testing.T) {
|
||||
withSandboxPaths(t, map[string]string{
|
||||
"sites-enabled/example.conf": "include missing-fastcgi.conf;\n",
|
||||
}, func(_ string, _ string) {
|
||||
result := SandboxTestConfigWithPaths(&NamespaceInfo{Name: "demo"}, []string{"/tmp/example.conf"}, nil)
|
||||
|
||||
go func() {
|
||||
_, _ = SandboxTestConfigWithPaths(nil, nil, nil)
|
||||
close(done)
|
||||
}()
|
||||
if result.SandboxStatus != SandboxStatusFailed {
|
||||
t.Fatalf("SandboxStatus = %q, want %q", result.SandboxStatus, SandboxStatusFailed)
|
||||
}
|
||||
if result.ErrorCategory != ErrorCategoryMissingInclude {
|
||||
t.Fatalf("ErrorCategory = %q, want %q", result.ErrorCategory, ErrorCategoryMissingInclude)
|
||||
}
|
||||
if !strings.Contains(result.Message, "Sandbox test setup failed") {
|
||||
t.Fatalf("Message = %q, want sandbox setup failure", result.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SandboxTestConfigWithPaths should fall back to TestConfig without blocking when namespace is nil")
|
||||
func TestSandboxTestConfigWithPathsSkipsRemoteNamespaces(t *testing.T) {
|
||||
result := SandboxTestConfigWithPaths(&NamespaceInfo{DeployMode: "remote"}, nil, nil)
|
||||
|
||||
if result.SandboxStatus != SandboxStatusSkipped {
|
||||
t.Fatalf("SandboxStatus = %q, want %q", result.SandboxStatus, SandboxStatusSkipped)
|
||||
}
|
||||
if result.TestScope != TestScopeNamespaceSandbox {
|
||||
t.Fatalf("TestScope = %q, want %q", result.TestScope, TestScopeNamespaceSandbox)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TestScope string
|
||||
|
||||
const (
|
||||
TestScopeGlobal TestScope = "global"
|
||||
TestScopeNamespaceSandbox TestScope = "namespace_sandbox"
|
||||
)
|
||||
|
||||
type SandboxStatus string
|
||||
|
||||
const (
|
||||
SandboxStatusOK SandboxStatus = "ok"
|
||||
SandboxStatusSkipped SandboxStatus = "skipped"
|
||||
SandboxStatusFailed SandboxStatus = "failed"
|
||||
)
|
||||
|
||||
type ErrorCategory string
|
||||
|
||||
const (
|
||||
ErrorCategoryNone ErrorCategory = ""
|
||||
ErrorCategoryMissingInclude ErrorCategory = "missing_include"
|
||||
ErrorCategorySandboxBuildError ErrorCategory = "sandbox_build_error"
|
||||
ErrorCategorySyntaxError ErrorCategory = "syntax_error"
|
||||
ErrorCategoryNginxRuntimeError ErrorCategory = "nginx_runtime_error"
|
||||
)
|
||||
|
||||
type TestConfigResult struct {
|
||||
Message string `json:"message"`
|
||||
Level int `json:"level"`
|
||||
TestScope TestScope `json:"test_scope"`
|
||||
SandboxStatus SandboxStatus `json:"sandbox_status,omitempty"`
|
||||
ErrorCategory ErrorCategory `json:"error_category,omitempty"`
|
||||
}
|
||||
|
||||
type SandboxBuildError struct {
|
||||
Category ErrorCategory
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *SandboxBuildError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func newSandboxIncludeError(baseDir string, includePath string) error {
|
||||
return &SandboxBuildError{
|
||||
Category: ErrorCategoryMissingInclude,
|
||||
Message: fmt.Sprintf("sandbox include not found: %s (resolved from %s)", includePath, baseDir),
|
||||
}
|
||||
}
|
||||
|
||||
func NewSandboxBuildFailureResult(err error) TestConfigResult {
|
||||
category := ErrorCategorySandboxBuildError
|
||||
var sandboxErr *SandboxBuildError
|
||||
if errors.As(err, &sandboxErr) && sandboxErr.Category != ErrorCategoryNone {
|
||||
category = sandboxErr.Category
|
||||
}
|
||||
|
||||
return TestConfigResult{
|
||||
Message: fmt.Sprintf("Sandbox test setup failed: %v", err),
|
||||
Level: Error,
|
||||
TestScope: TestScopeNamespaceSandbox,
|
||||
SandboxStatus: SandboxStatusFailed,
|
||||
ErrorCategory: category,
|
||||
}
|
||||
}
|
||||
|
||||
func NewTestConfigResult(stdOut string, stdErr error, scope TestScope, sandboxStatus SandboxStatus) TestConfigResult {
|
||||
message := stdOut
|
||||
if stdErr != nil {
|
||||
message = strings.TrimSpace(strings.Join([]string{stdOut, stdErr.Error()}, " "))
|
||||
}
|
||||
|
||||
level := GetLogLevel(message)
|
||||
if level == Unknown && stdErr != nil {
|
||||
level = Error
|
||||
}
|
||||
|
||||
result := TestConfigResult{
|
||||
Message: message,
|
||||
Level: level,
|
||||
TestScope: scope,
|
||||
SandboxStatus: sandboxStatus,
|
||||
ErrorCategory: DetectErrorCategory(message, stdErr),
|
||||
}
|
||||
|
||||
if sandboxStatus == "" && scope == TestScopeNamespaceSandbox {
|
||||
result.SandboxStatus = SandboxStatusOK
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func DetectErrorCategory(message string, stdErr error) ErrorCategory {
|
||||
if stdErr == nil {
|
||||
return ErrorCategoryNone
|
||||
}
|
||||
|
||||
lowerMessage := strings.ToLower(message)
|
||||
|
||||
switch {
|
||||
case strings.Contains(lowerMessage, "failed (2: no such file or directory)") ||
|
||||
strings.Contains(lowerMessage, "open()") && strings.Contains(lowerMessage, "no such file or directory"):
|
||||
return ErrorCategoryMissingInclude
|
||||
case strings.Contains(lowerMessage, "unknown directive") ||
|
||||
strings.Contains(lowerMessage, "invalid number of arguments") ||
|
||||
strings.Contains(lowerMessage, "directive is not terminated by") ||
|
||||
strings.Contains(lowerMessage, "unexpected end of file"):
|
||||
return ErrorCategorySyntaxError
|
||||
default:
|
||||
return ErrorCategoryNginxRuntimeError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectErrorCategory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
want ErrorCategory
|
||||
}{
|
||||
{
|
||||
name: "missing include",
|
||||
message: `open() "/tmp/nginx-ui-sandbox/sites-available/fastcgi.conf" failed (2: No such file or directory)`,
|
||||
want: ErrorCategoryMissingInclude,
|
||||
},
|
||||
{
|
||||
name: "syntax error",
|
||||
message: `nginx: [emerg] unknown directive "servername" in /etc/nginx/nginx.conf:5`,
|
||||
want: ErrorCategorySyntaxError,
|
||||
},
|
||||
{
|
||||
name: "runtime error",
|
||||
message: `nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)`,
|
||||
want: ErrorCategoryNginxRuntimeError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DetectErrorCategory(tt.message, errors.New("exit status 1"))
|
||||
if got != tt.want {
|
||||
t.Fatalf("DetectErrorCategory() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSandboxBuildFailureResultPreservesCategory(t *testing.T) {
|
||||
result := NewSandboxBuildFailureResult(&SandboxBuildError{
|
||||
Category: ErrorCategoryMissingInclude,
|
||||
Message: "sandbox include not found: fastcgi.conf",
|
||||
})
|
||||
|
||||
if result.SandboxStatus != SandboxStatusFailed {
|
||||
t.Fatalf("SandboxStatus = %q, want %q", result.SandboxStatus, SandboxStatusFailed)
|
||||
}
|
||||
if result.ErrorCategory != ErrorCategoryMissingInclude {
|
||||
t.Fatalf("ErrorCategory = %q, want %q", result.ErrorCategory, ErrorCategoryMissingInclude)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user