merge next-core: items 14/18/19/20/21/22 (L-UI V1.0.0/A1.0.0)
This commit is contained in:
@@ -20,7 +20,7 @@ export function usePageTitle() {
|
||||
|
||||
useEffect(() => {
|
||||
const key = TITLE_KEYS[pathname];
|
||||
const title = key ? t(key) : '3X-UI';
|
||||
const title = key ? t(key) : 'L-UI';
|
||||
const host = window.location.hostname;
|
||||
document.title = host ? `${host} - ${title}` : title;
|
||||
}, [pathname, t]);
|
||||
|
||||
@@ -356,7 +356,7 @@ export default function AppSidebar() {
|
||||
>
|
||||
<div className="sider-brand">
|
||||
<div className="brand-block">
|
||||
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
|
||||
<span className="brand-text">{railCollapsed ? 'L' : 'L-UI'}</span>
|
||||
</div>
|
||||
{!railCollapsed && (
|
||||
<div className="brand-actions">
|
||||
@@ -420,7 +420,7 @@ export default function AppSidebar() {
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<div className="brand-block">
|
||||
<span className="drawer-brand">3X-UI</span>
|
||||
<span className="drawer-brand">L-UI</span>
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||
|
||||
@@ -22,6 +22,9 @@ function parseVersionParts(version: string): [number, number, number] | null {
|
||||
export function formatPanelVersion(version: string | undefined | null): string {
|
||||
const v = (version || '').trim();
|
||||
if (!v) return '';
|
||||
// This fork's display versions (master "V1.0.0", agent "A1.0.0") are
|
||||
// shown exactly as written — never re-prefixed or re-cased.
|
||||
if (/^[VA]\d+\.\d+\.\d+$/.test(v)) return v;
|
||||
const normalized = v.replace(/^v/i, '');
|
||||
return /^\d/.test(normalized) ? `v${normalized}` : v;
|
||||
}
|
||||
|
||||
@@ -172,8 +172,17 @@ export default function IndexPage() {
|
||||
<OverviewActionBar
|
||||
isMobile={isMobile}
|
||||
panelVersion={displayVersion}
|
||||
latestVersion={panelUpdateInfo.latestVersion}
|
||||
updateAvailable={panelUpdateInfo.updateAvailable}
|
||||
latestVersion={
|
||||
panelUpdateInfo.channel === 'dev'
|
||||
? panelUpdateInfo.latestVersion
|
||||
: panelUpdateInfo.displayLatestVersion || panelUpdateInfo.latestVersion
|
||||
}
|
||||
updateAvailable={
|
||||
panelUpdateInfo.channel === 'dev'
|
||||
? panelUpdateInfo.updateAvailable
|
||||
: (panelUpdateInfo.displayUpdateAvailable ??
|
||||
panelUpdateInfo.updateAvailable)
|
||||
}
|
||||
onOpenConfig={openConfig}
|
||||
onOpenBackup={() => setBackupOpen(true)}
|
||||
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
||||
|
||||
@@ -17,6 +17,11 @@ export interface PanelUpdateInfo {
|
||||
currentCommit?: string;
|
||||
latestCommit?: string;
|
||||
updateAvailable: boolean;
|
||||
// V-series display versions (items 18/20); the stable channel's update
|
||||
// decision and labels use these when present.
|
||||
displayCurrentVersion?: string;
|
||||
displayLatestVersion?: string;
|
||||
displayUpdateAvailable?: boolean;
|
||||
}
|
||||
|
||||
interface BusyEvent {
|
||||
@@ -47,6 +52,13 @@ export default function PanelUpdateModal({
|
||||
|
||||
const isDev = info.channel === 'dev';
|
||||
|
||||
// On the stable channel the V-series display comparison decides (item
|
||||
// 20); the dev channel keeps its commit-based decision.
|
||||
const masterUpdateAvailable = isDev
|
||||
? info.updateAvailable
|
||||
: (info.displayUpdateAvailable ?? info.updateAvailable);
|
||||
const displayLatest = info.displayLatestVersion || info.latestVersion;
|
||||
|
||||
async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
|
||||
await PromiseUtil.sleep(5000);
|
||||
const deadline = Date.now() + 90_000;
|
||||
@@ -85,13 +97,14 @@ export default function PanelUpdateModal({
|
||||
title: t('pages.index.panelUpdateDialog'),
|
||||
content: t('pages.index.panelUpdateDialogDesc').replace(
|
||||
'#version#',
|
||||
info.latestVersion || '',
|
||||
isDev ? info.latestVersion || '' : displayLatest || '',
|
||||
),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const baseTip = t('pages.index.dontRefresh');
|
||||
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
|
||||
const tipVersion = isDev ? info.latestVersion : displayLatest;
|
||||
const tip = tipVersion ? `${baseTip} (${tipVersion})` : baseTip;
|
||||
onClose();
|
||||
onBusy({ busy: true, tip });
|
||||
const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
|
||||
@@ -128,7 +141,7 @@ export default function PanelUpdateModal({
|
||||
<>
|
||||
{contextHolder}
|
||||
<Modal open={open} title={t('pages.index.updatePanel')} footer={null} onCancel={onClose}>
|
||||
{info.updateAvailable && (
|
||||
{masterUpdateAvailable && (
|
||||
<Alert
|
||||
type="warning"
|
||||
className="mb-12"
|
||||
@@ -166,12 +179,14 @@ export default function PanelUpdateModal({
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{info.updateAvailable ? (
|
||||
{masterUpdateAvailable ? (
|
||||
<div className="version-list-item">
|
||||
<span>
|
||||
{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}
|
||||
</span>
|
||||
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
|
||||
<Tag color="purple">
|
||||
{(isDev ? info.latestCommit : formatPanelVersion(displayLatest)) || '-'}
|
||||
</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<div className="version-list-item">
|
||||
@@ -184,7 +199,7 @@ export default function PanelUpdateModal({
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!info.updateAvailable}
|
||||
disabled={!masterUpdateAvailable}
|
||||
onClick={updatePanel}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
>
|
||||
|
||||
@@ -29,18 +29,11 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
|
||||
<ClockCircleOutlined />
|
||||
{t('pages.index.uptime')}
|
||||
</div>
|
||||
<div className="ov-strip-split">
|
||||
<div>
|
||||
<div className="ov-strip-sub">Xray</div>
|
||||
<div className="ov-strip-value">
|
||||
{TimeFormatter.formatSecond(status.appStats.uptime)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="ov-strip-split-sep" />
|
||||
<div>
|
||||
<div className="ov-strip-sub">OS</div>
|
||||
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
|
||||
</div>
|
||||
{/* The lite master runs no local xray core, so the uptime card
|
||||
shows the OS uptime only (the Xray half always read 0s). */}
|
||||
<div>
|
||||
<div className="ov-strip-sub">OS</div>
|
||||
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function LoginPage() {
|
||||
) : (
|
||||
<div className="login-card">
|
||||
<div className="brand">
|
||||
<span className="brand-name">3X-UI</span>
|
||||
<span className="brand-name">L-UI</span>
|
||||
<span className="brand-accent" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="welcome">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
||||
import type { Msg } from '@/utils';
|
||||
import { HttpUtil, type Msg } from '@/utils';
|
||||
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||
@@ -81,6 +81,17 @@ export default function NodeFormModal({
|
||||
const [agentToken, setAgentToken] = useState<string | null>(null);
|
||||
const [agentNodeName, setAgentNodeName] = useState('');
|
||||
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
||||
// Edit mode (item 19): the stored install material for an existing agent
|
||||
// node, fetched from the master; null until loaded / for non-agent nodes.
|
||||
// Keyed by node id so a stale result from a previously edited node is
|
||||
// never shown (visibility is derived during render below).
|
||||
const [editInstall, setEditInstall] = useState<{
|
||||
nodeId: number;
|
||||
name: string;
|
||||
token: string;
|
||||
hasToken: boolean;
|
||||
} | null>(null);
|
||||
const [editMasterUrl, setEditMasterUrl] = useState('');
|
||||
const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
|
||||
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
|
||||
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
||||
@@ -136,6 +147,39 @@ export default function NodeFormModal({
|
||||
setTestResult(null);
|
||||
}
|
||||
|
||||
// Load the stored install material when editing an agent node so the
|
||||
// full install command can be re-displayed (item 19). Nodes created
|
||||
// before tokens were stored answer hasToken=false until the agent's
|
||||
// next authenticated request backfills the token.
|
||||
const editNodeId = mode === 'edit' && isAgent ? (node?.id ?? 0) : 0;
|
||||
useEffect(() => {
|
||||
if (!open || !editNodeId) return;
|
||||
let cancelled = false;
|
||||
HttpUtil.get<{ name: string; token: string; hasToken: boolean }>(
|
||||
`/panel/api/agent/install/${editNodeId}`,
|
||||
).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) {
|
||||
setEditInstall({ nodeId: editNodeId, ...msg.obj });
|
||||
setEditMasterUrl(currentPanelMasterUrl());
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, editNodeId]);
|
||||
|
||||
const shownInstall = editInstall && editInstall.nodeId === editNodeId ? editInstall : null;
|
||||
|
||||
const editInstallCmd = useMemo(() => {
|
||||
if (!shownInstall || !editMasterUrl) return '';
|
||||
const base =
|
||||
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
|
||||
` --master ${editMasterUrl}`;
|
||||
return shownInstall.hasToken
|
||||
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
|
||||
: `${base} --name ${shownInstall.name}`;
|
||||
}, [shownInstall, editMasterUrl]);
|
||||
|
||||
const title = useMemo(
|
||||
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
|
||||
[mode, t],
|
||||
@@ -391,6 +435,43 @@ export default function NodeFormModal({
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && isAgent && shownInstall && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>{t('pages.nodes.agentInstallCommand')}</div>
|
||||
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
|
||||
<Input
|
||||
value={editMasterUrl}
|
||||
onChange={(e) => setEditMasterUrl(e.target.value.trim())}
|
||||
style={{ marginBottom: 12, fontFamily: 'monospace' }}
|
||||
/>
|
||||
{!shownInstall.hasToken && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
title={t('pages.nodes.agentTokenNotStored')}
|
||||
/>
|
||||
)}
|
||||
<Input.TextArea
|
||||
value={editInstallCmd}
|
||||
readOnly
|
||||
rows={4}
|
||||
style={{ fontFamily: 'monospace', marginBottom: 12 }}
|
||||
/>
|
||||
<Button
|
||||
disabled={!editInstallCmd}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(editInstallCmd).then(
|
||||
() => messageApi.success(t('pages.nodes.agentTokenCopied')),
|
||||
() => messageApi.error(t('pages.nodes.agentTokenCopyFailed')),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('pages.nodes.copyToken')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.allowPrivateAddress')}
|
||||
|
||||
@@ -239,7 +239,7 @@ export default function NodeList({
|
||||
{
|
||||
title: t('pages.nodes.actions'),
|
||||
align: 'center',
|
||||
width: 190,
|
||||
width: 150,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<Tooltip
|
||||
@@ -264,13 +264,23 @@ export default function NodeList({
|
||||
/>
|
||||
</Tooltip>
|
||||
{isUpdateEligible(record) && (
|
||||
<Tooltip title={t('pages.nodes.updatePanel')}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
aria-label={t('pages.nodes.updatePanel')}
|
||||
aria-label={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -303,7 +313,7 @@ export default function NodeList({
|
||||
title: t('pages.nodes.enable'),
|
||||
dataIndex: 'enable',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
width: 64,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<span style={{ opacity: 0.4 }}>—</span>
|
||||
@@ -362,17 +372,20 @@ export default function NodeList({
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'url',
|
||||
width: 330,
|
||||
ellipsis: true,
|
||||
render: (_value, record) => {
|
||||
if (record.nodeType === 'agent') {
|
||||
// Agent nodes have no panel to link to: show the host IPs the
|
||||
// agent reports with its heartbeat as plain text (P13).
|
||||
// agent reports with its heartbeat as plain text on one line
|
||||
// (P13, layout per item 21).
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return (
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{ips.length > 0
|
||||
? ips.map((ip) => (
|
||||
<span key={ip} style={{ display: 'block' }}>
|
||||
? ips.map((ip, i) => (
|
||||
<span key={ip}>
|
||||
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
||||
{ip}
|
||||
</span>
|
||||
))
|
||||
@@ -436,10 +449,11 @@ export default function NodeList({
|
||||
dataIndex: 'panelVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) => {
|
||||
// Agent rows carry an "agent <version>" label, not an upstream
|
||||
// panel version: the update badge must never light up for them
|
||||
// (the version comparator falls back to string inequality for
|
||||
// unparseable versions, which would always differ) (P13).
|
||||
// Agent rows carry the A-series display version ("A1.0.0"), not
|
||||
// an upstream panel version: the update badge must never light
|
||||
// up for them (the version comparator falls back to string
|
||||
// inequality for unparseable versions, which would always
|
||||
// differ) (P13, item 18).
|
||||
const canUpdate =
|
||||
record.nodeType !== 'agent' &&
|
||||
isUpdateEligible(record) &&
|
||||
@@ -474,7 +488,7 @@ export default function NodeList({
|
||||
{
|
||||
title: t('clients'),
|
||||
align: 'center',
|
||||
width: 180,
|
||||
width: 150,
|
||||
render: (_value, record) => (
|
||||
<Space size={2}>
|
||||
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
@@ -527,7 +541,7 @@ export default function NodeList({
|
||||
title: t('pages.nodes.latency'),
|
||||
dataIndex: 'latencyMs',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
width: 84,
|
||||
render: (_value, record) =>
|
||||
record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
|
||||
},
|
||||
@@ -535,7 +549,7 @@ export default function NodeList({
|
||||
title: t('pages.nodes.lastHeartbeat'),
|
||||
dataIndex: 'lastHeartbeat',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
width: 96,
|
||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||
},
|
||||
],
|
||||
@@ -652,7 +666,12 @@ export default function NodeList({
|
||||
key: 'update',
|
||||
label: (
|
||||
<>
|
||||
<CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}
|
||||
<CloudDownloadOutlined />{' '}
|
||||
{t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
</>
|
||||
),
|
||||
onClick: () => onUpdateNode(record),
|
||||
|
||||
@@ -232,6 +232,18 @@ export default function NodesPage() {
|
||||
|
||||
const onUpdateNode = useCallback(
|
||||
(node: NodeRecord) => {
|
||||
// Agent nodes update through the master-recorded intent (item 20):
|
||||
// there is no channel to pick, so the confirm is a plain message.
|
||||
if (node.nodeType === 'agent') {
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate([node.id], false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
|
||||
@@ -51,6 +51,10 @@ export const NodeRecordSchema = z
|
||||
// Rendered as plain text in the address column for agent rows.
|
||||
ipv4: z.string().optional(),
|
||||
ipv6: z.string().optional(),
|
||||
// Item 20: last one-click agent update outcome, agent rows only.
|
||||
agentUpdateState: z.string().optional(),
|
||||
agentUpdateError: z.string().optional(),
|
||||
agentUpdateTarget: z.string().optional(),
|
||||
})
|
||||
.loose();
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ describe('formatPanelVersion', () => {
|
||||
|
||||
it('does not double up the v on already-prefixed tags', () => {
|
||||
expect(formatPanelVersion('v3.4.0')).toBe('v3.4.0');
|
||||
expect(formatPanelVersion('V3.4.0')).toBe('v3.4.0');
|
||||
// An uppercase-V three-part version is this fork's master display
|
||||
// form and stays verbatim (see the V/A case below).
|
||||
expect(formatPanelVersion('V3.4.0')).toBe('V3.4.0');
|
||||
});
|
||||
|
||||
it('shows dev builds verbatim without a v prefix', () => {
|
||||
@@ -53,4 +55,10 @@ describe('formatPanelVersion', () => {
|
||||
expect(formatPanelVersion(undefined)).toBe('');
|
||||
expect(formatPanelVersion('?')).toBe('?');
|
||||
});
|
||||
|
||||
it('shows this fork’s V/A display versions verbatim', () => {
|
||||
expect(formatPanelVersion('V1.0.0')).toBe('V1.0.0');
|
||||
expect(formatPanelVersion('A1.0.0')).toBe('A1.0.0');
|
||||
expect(formatPanelVersion('A1.0.9')).toBe('A1.0.9');
|
||||
});
|
||||
});
|
||||
|
||||
+19
-2
@@ -75,6 +75,15 @@ type Agent struct {
|
||||
xrayVer string
|
||||
xrayVerFor string // binary path the cached version was read from
|
||||
xrayVerMod time.Time
|
||||
|
||||
// One-click update bookkeeping (item 20, see update.go).
|
||||
updateMu sync.Mutex
|
||||
updateRunning bool
|
||||
updateHandled int64
|
||||
updateFailedReq int64
|
||||
updateFailedAt time.Time
|
||||
updateReport *agentUpdateStatus
|
||||
exePathOverride string
|
||||
}
|
||||
|
||||
// New creates a new agent instance
|
||||
@@ -272,6 +281,9 @@ func (a *Agent) sendHeartbeat() error {
|
||||
"timestamp": time.Now().Unix(),
|
||||
"telemetry": a.collectTelemetry(),
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep != nil {
|
||||
payload["updateStatus"] = rep
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -312,13 +324,18 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ConfigHash string `json:"config_hash"`
|
||||
XrayConfig json.RawMessage `json:"xray_config"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
XrayConfig json.RawMessage `json:"xray_config"`
|
||||
AgentUpdate *updateIntent `json:"agent_update"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// One-click update intents ride this poll (item 20); handle them even
|
||||
// when the xray config itself is unchanged.
|
||||
a.maybeHandleUpdate(result.AgentUpdate)
|
||||
|
||||
// Skip if config hasn't changed
|
||||
if result.ConfigHash == a.lastConfigHash && result.ConfigHash != "" {
|
||||
return nil
|
||||
|
||||
@@ -35,7 +35,9 @@ type telemetryPayload struct {
|
||||
// collectTelemetry samples every source independently; a failing source
|
||||
// only blanks its own field, never the whole report.
|
||||
func (a *Agent) collectTelemetry() *telemetryPayload {
|
||||
t := &telemetryPayload{AgentVersion: config.GetBaseVersion()}
|
||||
// Agents report the A-series display version; the master shows it
|
||||
// verbatim on agent node rows.
|
||||
t := &telemetryPayload{AgentVersion: config.GetAgentDisplayVersion()}
|
||||
if v, ok := a.cachedXrayVersion(); ok {
|
||||
t.XrayVersion = v
|
||||
}
|
||||
|
||||
@@ -58,13 +58,14 @@ func TestChooseHostIPs(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCollectTelemetryShape pins the always-on fields on any Linux host:
|
||||
// the agent's own build version and a positive host uptime. Source-specific
|
||||
// fields (xray version, IPs) are environment-dependent and not asserted.
|
||||
// the agent's own A-series display version (item 18) and a positive host
|
||||
// uptime. Source-specific fields (xray version, IPs) are
|
||||
// environment-dependent and not asserted.
|
||||
func TestCollectTelemetryShape(t *testing.T) {
|
||||
a := New(&Config{MasterURL: "http://127.0.0.1:1", Token: "t", NodeName: "n"})
|
||||
tel := a.collectTelemetry()
|
||||
if tel.AgentVersion != config.GetBaseVersion() || tel.AgentVersion == "" {
|
||||
t.Errorf("AgentVersion = %q, want config.GetBaseVersion() %q", tel.AgentVersion, config.GetBaseVersion())
|
||||
if tel.AgentVersion != config.GetAgentDisplayVersion() || tel.AgentVersion == "" {
|
||||
t.Errorf("AgentVersion = %q, want config.GetAgentDisplayVersion() %q", tel.AgentVersion, config.GetAgentDisplayVersion())
|
||||
}
|
||||
if tel.UptimeSeconds == nil || *tel.UptimeSeconds == 0 {
|
||||
t.Errorf("UptimeSeconds = %v, want non-nil positive", tel.UptimeSeconds)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
||||
)
|
||||
|
||||
// One-click agent update (item 20). The master records an intent on the
|
||||
// agent row; the intent rides the existing config poll (no new channel).
|
||||
// The agent downloads the release binary for its architecture from the
|
||||
// master's release base, verifies it against the published .sha256
|
||||
// sidecar, swaps it into place keeping the old binary as .bak, and
|
||||
// re-execs itself. Any failure before the swap leaves the old binary
|
||||
// running untouched; the outcome is reported on heartbeats, and the new
|
||||
// A-series version appearing at the master is the success signal.
|
||||
|
||||
// updateIntent is the master's update request as delivered on the config
|
||||
// response.
|
||||
type updateIntent struct {
|
||||
RequestedAt int64 `json:"requested_at"`
|
||||
Target string `json:"target"` // A-form display target, e.g. "A1.0.1"
|
||||
Tag string `json:"tag"` // release tag, e.g. "V1.0.1"
|
||||
BaseURL string `json:"base_url"` // release download base (trailing slash)
|
||||
}
|
||||
|
||||
// agentUpdateStatus mirrors the master's heartbeat updateStatus object.
|
||||
type agentUpdateStatus struct {
|
||||
RequestedAt int64 `json:"requestedAt"`
|
||||
State string `json:"state"` // downloading | swapped | failed | current
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// updateRetryBackoff suppresses immediate re-downloads after a failure for
|
||||
// the same intent (the master clears failed intents on report, so this
|
||||
// only guards a master that keeps re-offering the same requested_at).
|
||||
const updateRetryBackoff = 10 * time.Minute
|
||||
|
||||
// execSelf replaces the current process image with the binary at path.
|
||||
// A package variable so tests can stub the restart.
|
||||
var execSelf = func(path string) error {
|
||||
return syscall.Exec(path, append([]string{path}, os.Args[1:]...), os.Environ())
|
||||
}
|
||||
|
||||
func (a *Agent) setUpdateReport(st agentUpdateStatus) {
|
||||
a.updateMu.Lock()
|
||||
cp := st
|
||||
a.updateReport = &cp
|
||||
a.updateMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Agent) currentUpdateReport() *agentUpdateStatus {
|
||||
a.updateMu.Lock()
|
||||
defer a.updateMu.Unlock()
|
||||
if a.updateReport == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *a.updateReport
|
||||
return &cp
|
||||
}
|
||||
|
||||
// maybeHandleUpdate is called on every config poll with the intent the
|
||||
// master attached (nil when none). Terminal reports are dropped once the
|
||||
// master withdraws the intent, so heartbeats stop repeating them.
|
||||
func (a *Agent) maybeHandleUpdate(intent *updateIntent) {
|
||||
if intent == nil {
|
||||
a.updateMu.Lock()
|
||||
if a.updateReport != nil &&
|
||||
(a.updateReport.State == "failed" || a.updateReport.State == "current") {
|
||||
a.updateReport = nil
|
||||
}
|
||||
a.updateMu.Unlock()
|
||||
return
|
||||
}
|
||||
a.updateMu.Lock()
|
||||
if a.updateRunning || intent.RequestedAt == a.updateHandled ||
|
||||
(intent.RequestedAt == a.updateFailedReq && time.Since(a.updateFailedAt) < updateRetryBackoff) {
|
||||
a.updateMu.Unlock()
|
||||
return
|
||||
}
|
||||
a.updateRunning = true
|
||||
a.updateMu.Unlock()
|
||||
go a.runUpdate(*intent)
|
||||
}
|
||||
|
||||
// runUpdate executes one intent end to end. It runs synchronously in its
|
||||
// own goroutine (spawned by maybeHandleUpdate) — tests call it directly.
|
||||
func (a *Agent) runUpdate(intent updateIntent) {
|
||||
defer func() {
|
||||
a.updateMu.Lock()
|
||||
a.updateRunning = false
|
||||
a.updateMu.Unlock()
|
||||
}()
|
||||
a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "downloading"})
|
||||
|
||||
fail := func(err error) {
|
||||
a.updateMu.Lock()
|
||||
a.updateHandled = intent.RequestedAt
|
||||
a.updateFailedReq = intent.RequestedAt
|
||||
a.updateFailedAt = time.Now()
|
||||
a.updateMu.Unlock()
|
||||
a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "failed", Error: err.Error()})
|
||||
logger.Warningf("agent update to %s failed: %v (old version keeps running)", intent.Target, err)
|
||||
}
|
||||
|
||||
// Already at (or beyond) the target — e.g. the intent is re-delivered
|
||||
// after the swap-restart before the master closes it out. Report
|
||||
// "current" so the master clears the intent without a download.
|
||||
if cmp, ok := config.CompareDisplayVersion(config.GetAgentDisplayVersion(), intent.Target); ok && cmp >= 0 {
|
||||
a.updateMu.Lock()
|
||||
a.updateHandled = intent.RequestedAt
|
||||
a.updateMu.Unlock()
|
||||
a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "current"})
|
||||
return
|
||||
}
|
||||
|
||||
exePath, err := a.selfExePath()
|
||||
if err != nil {
|
||||
fail(fmt.Errorf("locate own binary: %w", err))
|
||||
return
|
||||
}
|
||||
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
base := intent.BaseURL
|
||||
if base == "" {
|
||||
base = selfupdate.BaseURL()
|
||||
}
|
||||
base = strings.TrimSuffix(base, "/") + "/" + intent.Tag + "/"
|
||||
|
||||
tmp := filepath.Join(filepath.Dir(exePath), fmt.Sprintf(".x-ui-update-%d", intent.RequestedAt))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
var transport http.RoundTripper
|
||||
if a.httpClient != nil {
|
||||
transport = a.httpClient.Transport
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 10 * time.Minute}
|
||||
if err := selfupdate.DownloadVerified(ctx, client, base+asset, base+asset+".sha256", tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
if _, err := selfupdate.SwapExecutable(exePath, tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
a.updateMu.Lock()
|
||||
a.updateHandled = intent.RequestedAt
|
||||
a.updateMu.Unlock()
|
||||
a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "swapped"})
|
||||
logger.Infof("agent update to %s downloaded, verified and swapped in; restarting", intent.Target)
|
||||
// Best effort: flush the "swapped" report before the image is replaced
|
||||
// (success is ultimately proven by the new version's telemetry).
|
||||
_ = a.sendHeartbeat()
|
||||
a.stopXray()
|
||||
if err := execSelf(exePath); err != nil {
|
||||
// The exec failed before replacing the image: roll the old binary
|
||||
// back into place and keep running it.
|
||||
if rbErr := selfupdate.RollbackExecutable(exePath); rbErr != nil {
|
||||
logger.Warningf("agent update rollback failed: %v", rbErr)
|
||||
}
|
||||
fail(fmt.Errorf("restart into new binary: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// selfExePath resolves the agent's own binary path (os.Executable unless a
|
||||
// test overrode it).
|
||||
func (a *Agent) selfExePath() (string, error) {
|
||||
if a.exePathOverride != "" {
|
||||
return a.exePathOverride, nil
|
||||
}
|
||||
return selfupdate.ExecutablePath()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
||||
)
|
||||
|
||||
// Item 20: the agent consumes the master's update intent, downloads the
|
||||
// release asset for its architecture, verifies the sha256 sidecar, swaps
|
||||
// the binary keeping a .bak, and re-execs. Failures must leave the old
|
||||
// binary running and produce a "failed" report.
|
||||
|
||||
func fakeReleaseServer(t *testing.T, payload []byte, goodSum bool, hits *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
if !goodSum {
|
||||
sum = sha256.Sum256([]byte("something else"))
|
||||
}
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if hits != nil {
|
||||
hits.Add(1)
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, asset+".sha256"):
|
||||
_, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + " " + asset + "\n"))
|
||||
case strings.HasSuffix(r.URL.Path, asset):
|
||||
_, _ = w.Write(payload)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func newUpdateTestAgent(t *testing.T, masterURL string) (*Agent, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "xui-agent")
|
||||
if err := os.WriteFile(exe, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &Agent{
|
||||
config: &Config{MasterURL: masterURL, Token: "tok", NodeName: "n"},
|
||||
httpClient: http.DefaultClient,
|
||||
exePathOverride: exe,
|
||||
}
|
||||
return a, exe
|
||||
}
|
||||
|
||||
func stubExec(t *testing.T, err error) *atomic.Int32 {
|
||||
t.Helper()
|
||||
calls := &atomic.Int32{}
|
||||
orig := execSelf
|
||||
execSelf = func(string) error {
|
||||
calls.Add(1)
|
||||
return err
|
||||
}
|
||||
t.Cleanup(func() { execSelf = orig })
|
||||
return calls
|
||||
}
|
||||
|
||||
func TestAgentUpdateSwapsAndRestarts(t *testing.T) {
|
||||
srv := fakeReleaseServer(t, []byte("new-binary"), true, nil)
|
||||
defer srv.Close()
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
execCalls := stubExec(t, nil)
|
||||
|
||||
a.runUpdate(updateIntent{RequestedAt: 42, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"})
|
||||
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "new-binary" {
|
||||
t.Fatalf("exe after update = %q, want new-binary", got)
|
||||
}
|
||||
bak, _ := os.ReadFile(exe + ".bak")
|
||||
if string(bak) != "old-binary" {
|
||||
t.Fatalf(".bak = %q, want old-binary", bak)
|
||||
}
|
||||
if execCalls.Load() != 1 {
|
||||
t.Fatalf("execSelf calls = %d, want 1", execCalls.Load())
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep == nil || rep.State != "swapped" {
|
||||
t.Fatalf("report = %+v, want swapped", rep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentUpdateBadChecksumKeepsOldBinary(t *testing.T) {
|
||||
srv := fakeReleaseServer(t, []byte("new-binary"), false, nil)
|
||||
defer srv.Close()
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
execCalls := stubExec(t, nil)
|
||||
|
||||
a.runUpdate(updateIntent{RequestedAt: 43, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"})
|
||||
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "old-binary" {
|
||||
t.Fatalf("exe after failed update = %q, want old-binary", got)
|
||||
}
|
||||
if _, err := os.Stat(exe + ".bak"); !os.IsNotExist(err) {
|
||||
t.Fatal("no .bak may be created when verification fails")
|
||||
}
|
||||
if execCalls.Load() != 0 {
|
||||
t.Fatal("execSelf must not run when verification fails")
|
||||
}
|
||||
rep := a.currentUpdateReport()
|
||||
if rep == nil || rep.State != "failed" || !strings.Contains(rep.Error, "sha256 mismatch") {
|
||||
t.Fatalf("report = %+v, want failed with sha256 mismatch", rep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentUpdateAlreadyCurrentSkipsDownload(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := fakeReleaseServer(t, []byte("new-binary"), true, &hits)
|
||||
defer srv.Close()
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
stubExec(t, nil)
|
||||
|
||||
// The test binary's display version defaults to A1.0.0.
|
||||
a.runUpdate(updateIntent{RequestedAt: 44, Target: "A1.0.0", Tag: "V1.0.0", BaseURL: srv.URL + "/"})
|
||||
|
||||
if hits.Load() != 0 {
|
||||
t.Fatalf("release server hit %d times for an already-current agent", hits.Load())
|
||||
}
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "old-binary" {
|
||||
t.Fatalf("exe = %q, want untouched old-binary", got)
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep == nil || rep.State != "current" {
|
||||
t.Fatalf("report = %+v, want current", rep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentUpdateExecFailureRollsBack(t *testing.T) {
|
||||
srv := fakeReleaseServer(t, []byte("new-binary"), true, nil)
|
||||
defer srv.Close()
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
stubExec(t, errExecBoom)
|
||||
|
||||
a.runUpdate(updateIntent{RequestedAt: 45, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"})
|
||||
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "old-binary" {
|
||||
t.Fatalf("exe after exec failure = %q, want rolled-back old-binary", got)
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep == nil || rep.State != "failed" {
|
||||
t.Fatalf("report = %+v, want failed", rep)
|
||||
}
|
||||
}
|
||||
|
||||
var errExecBoom = &execBoomError{}
|
||||
|
||||
type execBoomError struct{}
|
||||
|
||||
func (*execBoomError) Error() string { return "exec boom" }
|
||||
@@ -35,6 +35,13 @@ var (
|
||||
buildCommit string
|
||||
buildDate string
|
||||
buildVersion string
|
||||
// displayVersion is this fork's own user-facing release number ("1.0.0"
|
||||
// in "V1.0.0" / "A1.0.0"), injected via `-ldflags -X` by release builds.
|
||||
// It is deliberately separate from buildVersion (the upstream-shaped
|
||||
// internal version): display surfaces (panel header, node list, agent
|
||||
// telemetry) read the V/A display version, while internal comparisons
|
||||
// and advertisements keep using GetBaseVersion/GetPanelVersion.
|
||||
displayVersion string
|
||||
)
|
||||
|
||||
// LogLevel represents the logging level for the application.
|
||||
@@ -103,6 +110,96 @@ func GetPanelVersion() string {
|
||||
return "dev+" + commit
|
||||
}
|
||||
|
||||
// defaultDisplayVersion is the display version reported when a build does
|
||||
// not inject one (plain `go build` / `go run`).
|
||||
const defaultDisplayVersion = "1.0.0"
|
||||
|
||||
// GetDisplayVersion returns the bare numeric display version ("1.0.0"):
|
||||
// the injected display version when the build provided one, else the
|
||||
// default. Display versions advance in single-digit carry steps
|
||||
// (1.0.9 -> 1.1.0 -> ... -> 1.9.9 -> 2.0.0); each segment stays 0-9.
|
||||
func GetDisplayVersion() string {
|
||||
if v := strings.TrimSpace(displayVersion); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultDisplayVersion
|
||||
}
|
||||
|
||||
// GetMasterDisplayVersion is the master's display version ("V1.0.0"),
|
||||
// shown in the panel's version slot.
|
||||
func GetMasterDisplayVersion() string {
|
||||
return "V" + GetDisplayVersion()
|
||||
}
|
||||
|
||||
// GetAgentDisplayVersion is the agent's display version ("A1.0.0"),
|
||||
// reported in agent telemetry and shown on agent node rows.
|
||||
func GetAgentDisplayVersion() string {
|
||||
return "A" + GetDisplayVersion()
|
||||
}
|
||||
|
||||
// ParseDisplayVersion parses a display version into its three numeric
|
||||
// segments. It accepts the bare form ("1.0.0") and the letter-prefixed
|
||||
// display forms ("V1.0.0", "A1.0.0", "v1.0.0"). Anything else — dev labels,
|
||||
// suffixed builds like "3.7.0-agent.1", wrong segment counts, non-numeric
|
||||
// segments — reports ok=false so callers can fall back instead of guessing.
|
||||
func ParseDisplayVersion(s string) (parts [3]int, ok bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if parsed, good := parseDisplayParts(s); good {
|
||||
return parsed, true
|
||||
}
|
||||
// Retry once without a single leading display letter (V/A, any case).
|
||||
if len(s) > 1 && (s[0] == 'V' || s[0] == 'v' || s[0] == 'A' || s[0] == 'a') {
|
||||
if parsed, good := parseDisplayParts(s[1:]); good {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return [3]int{}, false
|
||||
}
|
||||
|
||||
func parseDisplayParts(s string) ([3]int, bool) {
|
||||
var parts [3]int
|
||||
segs := strings.Split(s, ".")
|
||||
if len(segs) != 3 {
|
||||
return parts, false
|
||||
}
|
||||
for i, seg := range segs {
|
||||
if seg == "" {
|
||||
return parts, false
|
||||
}
|
||||
for _, r := range seg {
|
||||
if r < '0' || r > '9' {
|
||||
return parts, false
|
||||
}
|
||||
}
|
||||
n, err := strconv.Atoi(seg)
|
||||
if err != nil {
|
||||
return parts, false
|
||||
}
|
||||
parts[i] = n
|
||||
}
|
||||
return parts, true
|
||||
}
|
||||
|
||||
// CompareDisplayVersion compares two display versions segment by segment
|
||||
// (numeric, so the single-digit carry rule 1.0.9 < 1.1.0 holds naturally).
|
||||
// ok=false when either side is not a parseable display version.
|
||||
func CompareDisplayVersion(a, b string) (cmp int, ok bool) {
|
||||
pa, okA := ParseDisplayVersion(a)
|
||||
pb, okB := ParseDisplayVersion(b)
|
||||
if !okA || !okB {
|
||||
return 0, false
|
||||
}
|
||||
for i := range 3 {
|
||||
if pa[i] != pb[i] {
|
||||
if pa[i] < pb[i] {
|
||||
return -1, true
|
||||
}
|
||||
return 1, true
|
||||
}
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
// GetLogLevel returns the current logging level based on environment variables or defaults to Info.
|
||||
func GetLogLevel() LogLevel {
|
||||
if IsDebug() {
|
||||
|
||||
@@ -53,6 +53,78 @@ func TestGetBaseVersionBuildVersionOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayVersionDefaultsAndOverride(t *testing.T) {
|
||||
orig := displayVersion
|
||||
t.Cleanup(func() { displayVersion = orig })
|
||||
|
||||
displayVersion = ""
|
||||
if got := GetDisplayVersion(); got != "1.0.0" {
|
||||
t.Fatalf("default display version = %q, want 1.0.0", got)
|
||||
}
|
||||
if got := GetMasterDisplayVersion(); got != "V1.0.0" {
|
||||
t.Fatalf("default master display version = %q, want V1.0.0", got)
|
||||
}
|
||||
if got := GetAgentDisplayVersion(); got != "A1.0.0" {
|
||||
t.Fatalf("default agent display version = %q, want A1.0.0", got)
|
||||
}
|
||||
|
||||
displayVersion = "1.0.1"
|
||||
if got := GetMasterDisplayVersion(); got != "V1.0.1" {
|
||||
t.Fatalf("injected master display version = %q, want V1.0.1", got)
|
||||
}
|
||||
if got := GetAgentDisplayVersion(); got != "A1.0.1" {
|
||||
t.Fatalf("injected agent display version = %q, want A1.0.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDisplayVersion(t *testing.T) {
|
||||
valid := map[string][3]int{
|
||||
"1.0.0": {1, 0, 0},
|
||||
"V1.0.0": {1, 0, 0},
|
||||
"A1.0.9": {1, 0, 9},
|
||||
"v2.0.0": {2, 0, 0},
|
||||
" 1.2.3 ": {1, 2, 3},
|
||||
}
|
||||
for in, want := range valid {
|
||||
got, ok := ParseDisplayVersion(in)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("ParseDisplayVersion(%q) = %v, %v; want %v, true", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
invalid := []string{"", "3.7.0-agent.1", "dev+1a2b3c4d", "1.0", "1.0.0.1", "1.x.0", "V", "1..0"}
|
||||
for _, in := range invalid {
|
||||
if _, ok := ParseDisplayVersion(in); ok {
|
||||
t.Fatalf("ParseDisplayVersion(%q) ok=true, want false", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDisplayVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"1.0.0", "1.0.0", 0},
|
||||
{"V1.0.0", "A1.0.0", 0}, // prefix letters are flavour, not value
|
||||
{"1.0.9", "1.1.0", -1}, // single-digit carry step
|
||||
{"1.1.0", "1.0.9", 1}, // carry compares numerically, not lexically
|
||||
{"1.9.9", "2.0.0", -1}, // full carry
|
||||
{"A1.0.1", "A1.0.0", 1}, // agent series
|
||||
{"V1.0.1", "1.0.0", 1}, // release tag vs current
|
||||
{"2.0.0", "1.9.9", 1},
|
||||
{"1.0.0", "1.0.1", -1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := CompareDisplayVersion(tc.a, tc.b)
|
||||
if !ok || got != tc.want {
|
||||
t.Fatalf("CompareDisplayVersion(%q, %q) = %d, %v; want %d, true", tc.a, tc.b, got, ok, tc.want)
|
||||
}
|
||||
}
|
||||
if _, ok := CompareDisplayVersion("3.7.0-agent.1", "1.0.0"); ok {
|
||||
t.Fatal("unparseable side must report ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPortOverride(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -4,12 +4,21 @@ package model
|
||||
// 3x-ui node row and carries the per-agent egress configuration.
|
||||
//
|
||||
// Agents are the only writers of their own LastSeen; the panel owns everything
|
||||
// else. Raw tokens are never stored — only the SHA-256 hex of the token.
|
||||
// else. Authentication uses only the SHA-256 hex of the token; since the
|
||||
// next-core batch (item 19) the raw token is additionally kept recoverably
|
||||
// in TokenPlain — an operator-accepted tradeoff so the node edit page can
|
||||
// re-display the full install command.
|
||||
type AgentNode struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
TokenHash string `json:"-" gorm:"size:64;uniqueIndex"`
|
||||
TokenPrefix string `json:"tokenPrefix" gorm:"size:16;index"`
|
||||
Name string `json:"name"`
|
||||
// TokenPlain is a recoverable copy of the raw token: stored at
|
||||
// issuance, and backfilled from the first authenticated agent request
|
||||
// for rows created before this field existed (item 19). Existing
|
||||
// values are never overwritten by requests; only issuance (create /
|
||||
// panel-side re-issue) writes a fresh one. Never serialized.
|
||||
TokenPlain string `json:"-" gorm:"type:text"`
|
||||
Name string `json:"name"`
|
||||
// NodeID is the 3x-ui node whose inbounds this agent serves. Nil means the
|
||||
// token was issued but not yet bound to a node.
|
||||
NodeID *int `json:"nodeId" gorm:"index"`
|
||||
@@ -38,4 +47,17 @@ type AgentNode struct {
|
||||
// TelemetryAt is the unix-milli time of the last heartbeat that carried
|
||||
// a telemetry object.
|
||||
TelemetryAt int64 `json:"telemetryAt"`
|
||||
|
||||
// One-click update (item 20): the master records an update intent here;
|
||||
// the agent picks it up from its config poll, downloads + verifies +
|
||||
// swaps its binary, restarts itself and reports the outcome back on
|
||||
// the heartbeat. UpdateRequestedAt == 0 means no intent is pending.
|
||||
UpdateRequestedAt int64 `json:"updateRequestedAt"`
|
||||
UpdateTarget string `json:"updateTarget"` // A-form display target, e.g. "A1.0.1"
|
||||
UpdateTag string `json:"updateTag"` // release tag to download, e.g. "V1.0.1"
|
||||
// UpdateState: "" | pending | downloading | swapped | failed |
|
||||
// current | success (success = new A version observed by the master).
|
||||
UpdateState string `json:"updateState"`
|
||||
UpdateError string `json:"updateError"`
|
||||
UpdateStateAt int64 `json:"updateStateAt"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package selfupdate carries the shared mechanics of the fork's one-click
|
||||
// online update (item 20): release coordinates, sha256-verified download
|
||||
// and atomic executable replacement with a .bak rollback copy. It is used
|
||||
// by the agent (self-update on master intent) and by the master panel
|
||||
// (native self-update on systemd hosts). No new inbound ports anywhere:
|
||||
// everything rides the existing HTTPS endpoints.
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the release download base of the fork's own Gitea.
|
||||
// A release tag directory hangs directly below it:
|
||||
// <base><tag>/x-ui-linux-<arch> plus a "<asset>.sha256" sidecar.
|
||||
const DefaultBaseURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/"
|
||||
|
||||
// LatestReleaseAPI is the Gitea API endpoint for the newest release of the
|
||||
// fork's repository (the same source the panel update check reads).
|
||||
const LatestReleaseAPI = "https://git.881218.xyz/api/v1/repos/rogue/3x-ui-agent/releases/latest"
|
||||
|
||||
// BaseURLEnv overrides the release base (mirrors, tests). Read at call
|
||||
// time so a process never caches a stale override.
|
||||
const BaseURLEnv = "XUI_RELEASE_BASE_URL"
|
||||
|
||||
// BaseURL returns the effective release download base, always with a
|
||||
// trailing slash.
|
||||
func BaseURL() string {
|
||||
if v := strings.TrimSpace(os.Getenv(BaseURLEnv)); v != "" {
|
||||
return strings.TrimSuffix(v, "/") + "/"
|
||||
}
|
||||
return DefaultBaseURL
|
||||
}
|
||||
|
||||
// AssetName maps a GOARCH to the release asset name published for it.
|
||||
func AssetName(goarch string) (string, error) {
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
return "x-ui-linux-amd64", nil
|
||||
case "arm64":
|
||||
return "x-ui-linux-arm64", nil
|
||||
default:
|
||||
return "", fmt.Errorf("no release asset for architecture %q", goarch)
|
||||
}
|
||||
}
|
||||
|
||||
// LatestReleaseURLEnv overrides the release-lookup endpoint as a whole
|
||||
// (mirrors, tests), analogous to BaseURLEnv.
|
||||
const LatestReleaseURLEnv = "XUI_RELEASE_LATEST_URL"
|
||||
|
||||
// LatestReleaseURL returns the effective release-lookup endpoint.
|
||||
func LatestReleaseURL() string {
|
||||
if v := strings.TrimSpace(os.Getenv(LatestReleaseURLEnv)); v != "" {
|
||||
return v
|
||||
}
|
||||
return LatestReleaseAPI
|
||||
}
|
||||
|
||||
// FetchLatestReleaseTag returns the tag_name of the newest release.
|
||||
func FetchLatestReleaseTag(ctx context.Context, client *http.Client) (string, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, LatestReleaseURL(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("release lookup: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(body.TagName) == "" {
|
||||
return "", fmt.Errorf("release lookup: empty tag_name")
|
||||
}
|
||||
return strings.TrimSpace(body.TagName), nil
|
||||
}
|
||||
|
||||
// ParseSHA256Sidecar extracts the digest from a sha256sum-style sidecar
|
||||
// file ("<hex> <name>" or a bare "<hex>", possibly with trailing lines).
|
||||
func ParseSHA256Sidecar(data []byte) (string, bool) {
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return "", false
|
||||
}
|
||||
sum := strings.ToLower(fields[0])
|
||||
if len(sum) != sha256.Size*2 {
|
||||
return "", false
|
||||
}
|
||||
if _, err := hex.DecodeString(sum); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return sum, true
|
||||
}
|
||||
|
||||
// maxAssetBytes caps a single asset download (release binaries are ~55MB;
|
||||
// the cap only guards against a hostile or broken source looping forever).
|
||||
const maxAssetBytes = 1 << 30
|
||||
|
||||
// DownloadVerified streams fileURL to destPath while hashing it, then
|
||||
// compares the digest against the sha256 sidecar at sumURL. On any failure
|
||||
// the partial destination is removed and the error explains which step
|
||||
// broke, so the caller can keep running the old binary untouched.
|
||||
func DownloadVerified(ctx context.Context, client *http.Client, fileURL, sumURL, destPath string) error {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
sumReq, err := http.NewRequestWithContext(ctx, http.MethodGet, sumURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sumResp, err := client.Do(sumReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch checksum: %w", err)
|
||||
}
|
||||
sumBody, err := io.ReadAll(io.LimitReader(sumResp.Body, 4096))
|
||||
sumResp.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read checksum: %w", err)
|
||||
}
|
||||
if sumResp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("fetch checksum: HTTP %d", sumResp.StatusCode)
|
||||
}
|
||||
want, ok := ParseSHA256Sidecar(sumBody)
|
||||
if !ok {
|
||||
return fmt.Errorf("checksum file has no valid sha256 digest")
|
||||
}
|
||||
|
||||
fileReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileResp, err := client.Do(fileReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download asset: %w", err)
|
||||
}
|
||||
defer fileResp.Body.Close()
|
||||
if fileResp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download asset: HTTP %d", fileResp.StatusCode)
|
||||
}
|
||||
out, err := os.OpenFile(destPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp binary: %w", err)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
_, copyErr := io.Copy(io.MultiWriter(out, hasher), io.LimitReader(fileResp.Body, maxAssetBytes+1))
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(destPath)
|
||||
return fmt.Errorf("download asset: %w", copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(destPath)
|
||||
return fmt.Errorf("write temp binary: %w", closeErr)
|
||||
}
|
||||
if got := hex.EncodeToString(hasher.Sum(nil)); got != want {
|
||||
_ = os.Remove(destPath)
|
||||
return fmt.Errorf("sha256 mismatch: got %s, want %s", got, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwapExecutable atomically replaces the executable at exePath with the
|
||||
// file at newPath, keeping the previous binary at exePath+".bak" for
|
||||
// rollback. Both paths must live on the same filesystem (the caller
|
||||
// downloads into the executable's directory).
|
||||
func SwapExecutable(exePath, newPath string) (backupPath string, err error) {
|
||||
backupPath = exePath + ".bak"
|
||||
if err := os.Chmod(newPath, 0o755); err != nil {
|
||||
return "", fmt.Errorf("chmod new binary: %w", err)
|
||||
}
|
||||
_ = os.Remove(backupPath)
|
||||
if err := os.Rename(exePath, backupPath); err != nil {
|
||||
return "", fmt.Errorf("backup current binary: %w", err)
|
||||
}
|
||||
if err := os.Rename(newPath, exePath); err != nil {
|
||||
// Best effort: put the old binary back so the service keeps a
|
||||
// runnable executable at the expected path.
|
||||
_ = os.Rename(backupPath, exePath)
|
||||
return "", fmt.Errorf("install new binary: %w", err)
|
||||
}
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
// RollbackExecutable restores the .bak copy over exePath (used when the
|
||||
// post-swap restart fails before the new binary ever runs).
|
||||
func RollbackExecutable(exePath string) error {
|
||||
backupPath := exePath + ".bak"
|
||||
if _, err := os.Stat(backupPath); err != nil {
|
||||
return fmt.Errorf("no backup at %s: %w", backupPath, err)
|
||||
}
|
||||
return os.Rename(backupPath, exePath)
|
||||
}
|
||||
|
||||
// ExecutablePath resolves the running binary's real path.
|
||||
func ExecutablePath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
return resolved, nil
|
||||
}
|
||||
return exe, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSHA256Sidecar(t *testing.T) {
|
||||
sum := sha256.Sum256([]byte("payload"))
|
||||
hexSum := hex.EncodeToString(sum[:])
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{hexSum + " x-ui-linux-amd64\n", hexSum, true},
|
||||
{hexSum + "\n", hexSum, true},
|
||||
{strings.ToUpper(hexSum) + " *x-ui-linux-amd64", hexSum, true},
|
||||
{"", "", false},
|
||||
{"not-a-digest file", "", false},
|
||||
{hexSum[:40] + " file", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := ParseSHA256Sidecar([]byte(tc.in))
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Fatalf("ParseSHA256Sidecar(%q) = %q, %v; want %q, %v", tc.in, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetName(t *testing.T) {
|
||||
if name, err := AssetName("amd64"); err != nil || name != "x-ui-linux-amd64" {
|
||||
t.Fatalf("amd64: %q, %v", name, err)
|
||||
}
|
||||
if name, err := AssetName("arm64"); err != nil || name != "x-ui-linux-arm64" {
|
||||
t.Fatalf("arm64: %q, %v", name, err)
|
||||
}
|
||||
if _, err := AssetName("riscv64"); err == nil {
|
||||
t.Fatal("unknown arch must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapExecutableKeepsBackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "xui-agent")
|
||||
if err := os.WriteFile(exe, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := filepath.Join(dir, ".x-ui-update-1")
|
||||
if err := os.WriteFile(next, []byte("new-binary"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bak, err := SwapExecutable(exe, next)
|
||||
if err != nil {
|
||||
t.Fatalf("SwapExecutable: %v", err)
|
||||
}
|
||||
if bak != exe+".bak" {
|
||||
t.Fatalf("backup path = %q", bak)
|
||||
}
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "new-binary" {
|
||||
t.Fatalf("exe content = %q, want new-binary", got)
|
||||
}
|
||||
old, _ := os.ReadFile(bak)
|
||||
if string(old) != "old-binary" {
|
||||
t.Fatalf("bak content = %q, want old-binary", old)
|
||||
}
|
||||
info, _ := os.Stat(exe)
|
||||
if info.Mode().Perm()&0o100 == 0 {
|
||||
t.Fatal("new binary is not executable")
|
||||
}
|
||||
// Rollback restores the old binary.
|
||||
if err := RollbackExecutable(exe); err != nil {
|
||||
t.Fatalf("RollbackExecutable: %v", err)
|
||||
}
|
||||
got, _ = os.ReadFile(exe)
|
||||
if string(got) != "old-binary" {
|
||||
t.Fatalf("after rollback exe = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadVerified(t *testing.T) {
|
||||
payload := []byte("fake-release-binary")
|
||||
sum := sha256.Sum256(payload)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, ".sha256"):
|
||||
_, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + " x-ui-linux-amd64\n"))
|
||||
default:
|
||||
_, _ = w.Write(payload)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "dl")
|
||||
if err := DownloadVerified(context.Background(), srv.Client(), srv.URL+"/x-ui-linux-amd64", srv.URL+"/x-ui-linux-amd64.sha256", dest); err != nil {
|
||||
t.Fatalf("DownloadVerified: %v", err)
|
||||
}
|
||||
got, _ := os.ReadFile(dest)
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("downloaded content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadVerifiedRejectsBadChecksum(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".sha256") {
|
||||
_, _ = w.Write([]byte(strings.Repeat("0", 64) + " x-ui-linux-amd64\n"))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("tampered"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "dl")
|
||||
err := DownloadVerified(context.Background(), srv.Client(), srv.URL+"/a", srv.URL+"/a.sha256", dest)
|
||||
if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
|
||||
t.Fatalf("want sha256 mismatch error, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
||||
t.Fatal("partial download must be removed on mismatch")
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
@@ -51,6 +54,7 @@ func (a *AgentController) RegisterAdminRoutes(g *gin.RouterGroup) {
|
||||
g.DELETE("/tokens/:id", a.deleteToken)
|
||||
g.GET("/egress/:token", a.getEgress)
|
||||
g.PUT("/egress/:token", a.setEgress)
|
||||
g.GET("/install/:nodeId", a.getAgentInstall)
|
||||
}
|
||||
|
||||
func hashAgentToken(token string) string {
|
||||
@@ -76,6 +80,18 @@ func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
|
||||
if err := database.GetDB().Where("token_hash = ?", hashAgentToken(token)).First(&agent).Error; err != nil {
|
||||
return nil, false
|
||||
}
|
||||
// Item 19 backfill: the token has just passed the hash check, so for
|
||||
// rows created before plaintext storage existed, adopt the presented
|
||||
// token as the recoverable copy. The guarded update only fires while
|
||||
// token_plain is empty — an authenticated request can never overwrite
|
||||
// an already-stored token (only issuance writes a fresh one).
|
||||
if agent.TokenPlain == "" {
|
||||
if res := database.GetDB().Model(&model.AgentNode{}).
|
||||
Where("id = ? AND token_plain = ''", agent.Id).
|
||||
Update("token_plain", token); res.Error == nil && res.RowsAffected > 0 {
|
||||
agent.TokenPlain = token
|
||||
}
|
||||
}
|
||||
return &agent, true
|
||||
}
|
||||
|
||||
@@ -163,6 +179,73 @@ func (a *AgentController) updateAgentTelemetry(id uint, t *heartbeatTelemetry) {
|
||||
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// heartbeatUpdateStatus is the agent's report about a one-click update
|
||||
// intent (item 20), piggybacked on the heartbeat like telemetry.
|
||||
type heartbeatUpdateStatus struct {
|
||||
RequestedAt int64 `json:"requestedAt"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// applyAgentUpdateStatus folds an agent's update report into the stored
|
||||
// intent. Reports for a superseded intent (requested_at mismatch while an
|
||||
// intent is pending) are ignored. Terminal reports (failed / current)
|
||||
// clear the intent so the config poll stops re-offering it; the recorded
|
||||
// state stays visible on the node row.
|
||||
func (a *AgentController) applyAgentUpdateStatus(id uint, st *heartbeatUpdateStatus) {
|
||||
db := database.GetDB()
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if row.UpdateRequestedAt == 0 || st.RequestedAt != row.UpdateRequestedAt {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{"update_state_at": time.Now().UnixMilli()}
|
||||
switch st.State {
|
||||
case "downloading", "swapped":
|
||||
updates["update_state"] = st.State
|
||||
updates["update_error"] = ""
|
||||
case "failed":
|
||||
updates["update_state"] = "failed"
|
||||
updates["update_error"] = st.Error
|
||||
updates["update_requested_at"] = int64(0)
|
||||
case "current":
|
||||
updates["update_state"] = "current"
|
||||
updates["update_error"] = ""
|
||||
updates["update_requested_at"] = int64(0)
|
||||
default:
|
||||
return
|
||||
}
|
||||
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// confirmAgentUpdate marks a pending intent successful once the agent
|
||||
// reports an A-series version at or beyond the intent's target — the new
|
||||
// version appearing is the end-to-end success signal of the update.
|
||||
func (a *AgentController) confirmAgentUpdate(id uint, reportedVersion string) {
|
||||
if reportedVersion == "" {
|
||||
return
|
||||
}
|
||||
db := database.GetDB()
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if row.UpdateRequestedAt == 0 || row.UpdateTarget == "" {
|
||||
return
|
||||
}
|
||||
if cmp, ok := config.CompareDisplayVersion(reportedVersion, row.UpdateTarget); !ok || cmp < 0 {
|
||||
return
|
||||
}
|
||||
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"update_state": "success",
|
||||
"update_error": "",
|
||||
"update_requested_at": int64(0),
|
||||
"update_state_at": time.Now().UnixMilli(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (a *AgentController) heartbeat(c *gin.Context) {
|
||||
agent, ok := a.findAgent(bearerToken(c))
|
||||
if !ok {
|
||||
@@ -172,13 +255,22 @@ func (a *AgentController) heartbeat(c *gin.Context) {
|
||||
// Telemetry is optional and best-effort: a body that fails to parse
|
||||
// (or no body at all) must never fail the heartbeat itself.
|
||||
var req struct {
|
||||
Telemetry *heartbeatTelemetry `json:"telemetry"`
|
||||
Telemetry *heartbeatTelemetry `json:"telemetry"`
|
||||
UpdateStatus *heartbeatUpdateStatus `json:"updateStatus"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
a.touchAgent(uint(agent.Id), "")
|
||||
reportedVersion := agent.AgentVersion
|
||||
if req.Telemetry != nil {
|
||||
a.updateAgentTelemetry(uint(agent.Id), req.Telemetry)
|
||||
if req.Telemetry.AgentVersion != nil && *req.Telemetry.AgentVersion != "" {
|
||||
reportedVersion = *req.Telemetry.AgentVersion
|
||||
}
|
||||
}
|
||||
if req.UpdateStatus != nil {
|
||||
a.applyAgentUpdateStatus(uint(agent.Id), req.UpdateStatus)
|
||||
}
|
||||
a.confirmAgentUpdate(uint(agent.Id), reportedVersion)
|
||||
jsonObj(c, gin.H{"message": "ok"}, nil)
|
||||
}
|
||||
|
||||
@@ -204,10 +296,22 @@ func (a *AgentController) getConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
c.JSON(200, gin.H{
|
||||
payload := gin.H{
|
||||
"config_hash": hex.EncodeToString(sum[:]),
|
||||
"xray_config": json.RawMessage(raw),
|
||||
})
|
||||
}
|
||||
// Item 20: a pending one-click update intent rides along on the config
|
||||
// poll (no new channel — the agent already polls this endpoint).
|
||||
if agent.UpdateRequestedAt > 0 &&
|
||||
(agent.UpdateState == "pending" || agent.UpdateState == "downloading") {
|
||||
payload["agent_update"] = gin.H{
|
||||
"requested_at": agent.UpdateRequestedAt,
|
||||
"target": agent.UpdateTarget,
|
||||
"tag": agent.UpdateTag,
|
||||
"base_url": selfupdate.BaseURL(),
|
||||
}
|
||||
}
|
||||
c.JSON(200, payload)
|
||||
}
|
||||
|
||||
type agentTrafficReport struct {
|
||||
@@ -278,8 +382,9 @@ func (a *AgentController) reportTraffic(c *gin.Context) {
|
||||
jsonObj(c, gin.H{"message": "ok"}, nil)
|
||||
}
|
||||
|
||||
// issueAgentToken generates a new agent token bound to nodeID and stores only
|
||||
// its hash. The raw token is returned once and must be shown to the operator.
|
||||
// issueAgentToken generates a new agent token bound to nodeID. The hash is
|
||||
// what authenticates; the raw token is also kept recoverably (TokenPlain,
|
||||
// item 19) so the node edit page can re-display the install command.
|
||||
func issueAgentToken(nodeID *int, name string) (string, int, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
@@ -289,6 +394,7 @@ func issueAgentToken(nodeID *int, name string) (string, int, error) {
|
||||
agent := model.AgentNode{
|
||||
TokenHash: hashAgentToken(token),
|
||||
TokenPrefix: token[:12],
|
||||
TokenPlain: token,
|
||||
Name: name,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
@@ -299,7 +405,7 @@ func issueAgentToken(nodeID *int, name string) (string, int, error) {
|
||||
}
|
||||
|
||||
// createToken issues a new agent token bound to a node. The raw token is
|
||||
// returned once; only its hash is stored.
|
||||
// returned in this response and kept recoverably for the install command.
|
||||
func (a *AgentController) createToken(c *gin.Context) {
|
||||
var req struct {
|
||||
NodeID *int `json:"node_id"`
|
||||
@@ -332,6 +438,32 @@ func (a *AgentController) createToken(c *gin.Context) {
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// getAgentInstall returns what the node edit page needs to re-display the
|
||||
// full install command for an agent node (item 19): the agent's name and,
|
||||
// when the master holds a recoverable copy, its raw token. Legacy rows
|
||||
// whose token has not been backfilled yet answer hasToken=false with an
|
||||
// empty token — the UI then shows the command without the token and points
|
||||
// at /etc/x-ui/agent.json on the node itself.
|
||||
func (a *AgentController) getAgentInstall(c *gin.Context) {
|
||||
nodeID, err := strconv.Atoi(c.Param("nodeId"))
|
||||
if err != nil {
|
||||
jsonMsg(c, "Invalid node id", errRequest)
|
||||
return
|
||||
}
|
||||
var agent model.AgentNode
|
||||
if err := database.GetDB().Where("node_id = ?", nodeID).
|
||||
Order("last_seen DESC, id DESC").First(&agent).Error; err != nil {
|
||||
jsonMsg(c, "Agent not found for node", errRequest)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{
|
||||
"nodeId": nodeID,
|
||||
"name": agent.Name,
|
||||
"token": agent.TokenPlain,
|
||||
"hasToken": agent.TokenPlain != "",
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (a *AgentController) listTokens(c *gin.Context) {
|
||||
var agents []model.AgentNode
|
||||
_ = database.GetDB().Order("id DESC").Find(&agents).Error
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Item 19: agent tokens are kept recoverably so the node edit page can
|
||||
// re-display the full install command. Rows created before plaintext
|
||||
// storage existed are backfilled from the first request that passes the
|
||||
// token hash check — and only from such a request.
|
||||
|
||||
func setupBackfillTest(t *testing.T) (*gin.Engine, *model.Node) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
node := &model.Node{Name: "backfill-node", Address: "10.0.9.2"}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
engine := gin.New()
|
||||
ctrl := NewAgentController(engine.Group("/panel/api/agent"))
|
||||
ctrl.RegisterAdminRoutes(engine.Group("/panel/api/agent"))
|
||||
return engine, node
|
||||
}
|
||||
|
||||
func seedLegacyAgent(t *testing.T, nodeID int, token, plain string) *model.AgentNode {
|
||||
t.Helper()
|
||||
agent := &model.AgentNode{
|
||||
TokenHash: hashAgentToken(token),
|
||||
TokenPrefix: token[:12],
|
||||
TokenPlain: plain,
|
||||
Name: "backfill-node",
|
||||
NodeID: &nodeID,
|
||||
}
|
||||
if err := database.GetDB().Create(agent).Error; err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
func reloadAgent(t *testing.T, id int) model.AgentNode {
|
||||
t.Helper()
|
||||
var row model.AgentNode
|
||||
if err := database.GetDB().First(&row, id).Error; err != nil {
|
||||
t.Fatalf("reload agent: %v", err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func heartbeatWith(engine *gin.Engine, token string) int {
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
func TestIssueAgentTokenStoresPlaintext(t *testing.T) {
|
||||
_, node := setupBackfillTest(t)
|
||||
token, id, err := issueAgentToken(&node.Id, "backfill-node")
|
||||
if err != nil {
|
||||
t.Fatalf("issueAgentToken: %v", err)
|
||||
}
|
||||
row := reloadAgent(t, id)
|
||||
if row.TokenPlain != token {
|
||||
t.Fatalf("issued token not stored recoverably: TokenPlain=%q", row.TokenPlain)
|
||||
}
|
||||
if row.TokenHash != hashAgentToken(token) {
|
||||
t.Fatal("issued token hash mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTokenBackfillOnAuthenticatedRequest(t *testing.T) {
|
||||
engine, node := setupBackfillTest(t)
|
||||
const token = "legacy-token-backfill-00000000000000000001"
|
||||
agent := seedLegacyAgent(t, node.Id, token, "")
|
||||
|
||||
if code := heartbeatWith(engine, token); code != http.StatusOK {
|
||||
t.Fatalf("heartbeat with valid token: status %d", code)
|
||||
}
|
||||
if row := reloadAgent(t, agent.Id); row.TokenPlain != token {
|
||||
t.Fatalf("token not backfilled after authenticated request: %q", row.TokenPlain)
|
||||
}
|
||||
|
||||
// The install endpoint now serves the complete command material.
|
||||
req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/install/"+strconv.Itoa(node.Id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("install endpoint: status %d", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Obj struct {
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
HasToken bool `json:"hasToken"`
|
||||
} `json:"obj"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("install response not JSON: %v", err)
|
||||
}
|
||||
if !resp.Success || !resp.Obj.HasToken || resp.Obj.Token != token {
|
||||
t.Fatalf("install endpoint did not return the backfilled token: %+v", resp.Obj)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTokenNoBackfillOnFailedAuth(t *testing.T) {
|
||||
engine, node := setupBackfillTest(t)
|
||||
const token = "legacy-token-backfill-00000000000000000002"
|
||||
agent := seedLegacyAgent(t, node.Id, token, "")
|
||||
|
||||
if code := heartbeatWith(engine, "definitely-the-wrong-token"); code != http.StatusUnauthorized {
|
||||
t.Fatalf("heartbeat with wrong token: status %d, want 401", code)
|
||||
}
|
||||
if row := reloadAgent(t, agent.Id); row.TokenPlain != "" {
|
||||
t.Fatalf("failed authentication must not backfill, got %q", row.TokenPlain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTokenBackfillNeverOverwrites(t *testing.T) {
|
||||
engine, node := setupBackfillTest(t)
|
||||
const token = "legacy-token-backfill-00000000000000000003"
|
||||
agent := seedLegacyAgent(t, node.Id, token, "previously-stored-value")
|
||||
|
||||
if code := heartbeatWith(engine, token); code != http.StatusOK {
|
||||
t.Fatalf("heartbeat with valid token: status %d", code)
|
||||
}
|
||||
if row := reloadAgent(t, agent.Id); row.TokenPlain != "previously-stored-value" {
|
||||
t.Fatalf("existing plaintext was overwritten by a request: %q", row.TokenPlain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Item 20 (master side): a pending update intent rides the agent's config
|
||||
// poll; outcome reports come back on heartbeats; a heartbeat whose
|
||||
// telemetry reaches the target version closes the intent as successful.
|
||||
|
||||
const updToken = "update-status-token-0000000000000000000001"
|
||||
|
||||
func setupUpdateStatusTest(t *testing.T) (*gin.Engine, *model.AgentNode) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
db := database.GetDB()
|
||||
|
||||
node := &model.Node{Name: "upd-node", Address: "10.0.9.3"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
agent := &model.AgentNode{
|
||||
TokenHash: hashAgentToken(updToken),
|
||||
TokenPrefix: updToken[:12],
|
||||
Name: "upd-node",
|
||||
NodeID: &node.Id,
|
||||
AgentVersion: "A1.0.0",
|
||||
UpdateRequestedAt: 1700000000000,
|
||||
UpdateTarget: "A1.0.1",
|
||||
UpdateTag: "V1.0.1",
|
||||
UpdateState: "pending",
|
||||
}
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
engine := gin.New()
|
||||
NewAgentController(engine.Group("/panel/api/agent"))
|
||||
return engine, agent
|
||||
}
|
||||
|
||||
func postHeartbeat(t *testing.T, engine *gin.Engine, body string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+updToken)
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("heartbeat: status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCarriesPendingUpdateIntent(t *testing.T) {
|
||||
engine, _ := setupUpdateStatusTest(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/config", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+updToken)
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("config: status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
AgentUpdate *struct {
|
||||
RequestedAt int64 `json:"requested_at"`
|
||||
Target string `json:"target"`
|
||||
Tag string `json:"tag"`
|
||||
BaseURL string `json:"base_url"`
|
||||
} `json:"agent_update"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("config response not JSON: %v", err)
|
||||
}
|
||||
if resp.AgentUpdate == nil {
|
||||
t.Fatal("pending intent missing from config response")
|
||||
}
|
||||
if resp.AgentUpdate.Target != "A1.0.1" || resp.AgentUpdate.Tag != "V1.0.1" ||
|
||||
resp.AgentUpdate.RequestedAt != 1700000000000 ||
|
||||
!strings.HasSuffix(resp.AgentUpdate.BaseURL, "/") {
|
||||
t.Fatalf("intent payload wrong: %+v", resp.AgentUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFailedReportClearsIntent(t *testing.T) {
|
||||
engine, agent := setupUpdateStatusTest(t)
|
||||
postHeartbeat(t, engine, `{"updateStatus":{"requestedAt":1700000000000,"state":"failed","error":"sha256 mismatch"}}`)
|
||||
|
||||
row := reloadAgent(t, agent.Id)
|
||||
if row.UpdateState != "failed" || row.UpdateError != "sha256 mismatch" || row.UpdateRequestedAt != 0 {
|
||||
t.Fatalf("failed report not folded: %+v", row)
|
||||
}
|
||||
|
||||
// A stale report for the cleared intent must not resurrect it.
|
||||
postHeartbeat(t, engine, `{"updateStatus":{"requestedAt":1700000000000,"state":"downloading"}}`)
|
||||
row = reloadAgent(t, agent.Id)
|
||||
if row.UpdateState != "failed" {
|
||||
t.Fatalf("stale report changed state: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatNewVersionConfirmsSuccess(t *testing.T) {
|
||||
engine, agent := setupUpdateStatusTest(t)
|
||||
postHeartbeat(t, engine, `{"telemetry":{"agentVersion":"A1.0.1"}}`)
|
||||
|
||||
row := reloadAgent(t, agent.Id)
|
||||
if row.UpdateState != "success" || row.UpdateRequestedAt != 0 {
|
||||
t.Fatalf("success not confirmed: %+v", row)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,9 @@ func serveDistPage(c *gin.Context, name string) {
|
||||
}
|
||||
script := `<script data-cfasync="false"` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||
if name != "login.html" {
|
||||
escapedVer := jsEscape.Replace(config.GetPanelVersion())
|
||||
// The version slot shows the fork's display version (V-series);
|
||||
// internal version comparisons keep using GetPanelVersion.
|
||||
escapedVer := jsEscape.Replace(config.GetMasterDisplayVersion())
|
||||
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
||||
script += `;window.X_UI_DB_TYPE="` + config.GetDBKind() + `"`
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
@@ -907,14 +909,54 @@ type NodeUpdateResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// fetchLatestReleaseTag resolves the newest release tag of the fork's own
|
||||
// repository; a package variable so tests can stub the network.
|
||||
var fetchLatestReleaseTag = func(ctx context.Context) (string, error) {
|
||||
return selfupdate.FetchLatestReleaseTag(ctx, &http.Client{Timeout: 20 * time.Second})
|
||||
}
|
||||
|
||||
// requestAgentUpdate records a one-click update intent for an agent node
|
||||
// (item 20). The agent picks the intent up from its next config poll,
|
||||
// downloads + verifies the release binary for the latest tag, swaps and
|
||||
// restarts itself; the outcome comes back on heartbeats. The dev channel
|
||||
// has no meaning for agents and is ignored.
|
||||
func (s *NodeService) requestAgentUpdate(ctx context.Context, n *model.Node) error {
|
||||
db := database.GetDB()
|
||||
var ag model.AgentNode
|
||||
if err := db.Where("node_id = ?", n.Id).Order("last_seen DESC, id DESC").First(&ag).Error; err != nil {
|
||||
return errors.New("no agent bound to this node")
|
||||
}
|
||||
tag, err := fetchLatestReleaseTag(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check latest release: %w", err)
|
||||
}
|
||||
parts, ok := config.ParseDisplayVersion(tag)
|
||||
if !ok {
|
||||
return fmt.Errorf("latest release tag %q is not a V-series version", tag)
|
||||
}
|
||||
target := fmt.Sprintf("A%d.%d.%d", parts[0], parts[1], parts[2])
|
||||
if cmp, ok := config.CompareDisplayVersion(ag.AgentVersion, target); ok && cmp >= 0 {
|
||||
return fmt.Errorf("agent already up to date (%s)", ag.AgentVersion)
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
return db.Model(&model.AgentNode{}).Where("id = ?", ag.Id).Updates(map[string]any{
|
||||
"update_requested_at": now,
|
||||
"update_target": target,
|
||||
"update_tag": tag,
|
||||
"update_state": "pending",
|
||||
"update_error": "",
|
||||
"update_state_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdatePanels triggers the official self-updater on each given node. Only
|
||||
// enabled, online nodes are eligible — an offline node can't be reached, so it
|
||||
// is reported as skipped rather than silently dropped.
|
||||
//
|
||||
// Agent nodes take the item-20 intent path instead (see requestAgentUpdate):
|
||||
// the master cannot push to them, so "triggered" means the intent was
|
||||
// recorded for the agent's next poll.
|
||||
func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, error) {
|
||||
mgr := runtime.GetManager()
|
||||
if mgr == nil {
|
||||
return nil, fmt.Errorf("runtime manager unavailable")
|
||||
}
|
||||
results := make([]NodeUpdateResult, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
n, err := s.GetById(id)
|
||||
@@ -928,7 +970,21 @@ func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, err
|
||||
res.Error = "node is disabled"
|
||||
case n.Status != "online":
|
||||
res.Error = "node is offline"
|
||||
case s.IsAgentManaged(id):
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
err := s.requestAgentUpdate(ctx, n)
|
||||
cancel()
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
} else {
|
||||
res.OK = true
|
||||
}
|
||||
default:
|
||||
mgr := runtime.GetManager()
|
||||
if mgr == nil {
|
||||
res.Error = "runtime manager unavailable"
|
||||
break
|
||||
}
|
||||
remote, remoteErr := mgr.RemoteFor(n)
|
||||
if remoteErr != nil {
|
||||
res.Error = remoteErr.Error()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Item 20 (master side): UpdatePanels on an agent node records an update
|
||||
// intent on the bound agent row instead of pushing to a remote panel. The
|
||||
// intent targets the newest release tag (V-series) expressed as the
|
||||
// agent's A-series display version.
|
||||
func TestUpdatePanelsAgentIntent(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
origFetch := fetchLatestReleaseTag
|
||||
fetchLatestReleaseTag = func(context.Context) (string, error) { return "V1.0.1", nil }
|
||||
t.Cleanup(func() { fetchLatestReleaseTag = origFetch })
|
||||
|
||||
node := &model.Node{Id: 7501, Name: "agent-upd", Address: "", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
ag := &model.AgentNode{
|
||||
TokenHash: "hash-upd", Name: "agent-upd", NodeID: &node.Id,
|
||||
AgentVersion: "A1.0.0",
|
||||
}
|
||||
if err := db.Create(ag).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
|
||||
svc := &NodeService{}
|
||||
results, err := svc.UpdatePanels([]int{node.Id}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePanels: %v", err)
|
||||
}
|
||||
if len(results) != 1 || !results[0].OK {
|
||||
t.Fatalf("results = %+v, want one OK", results)
|
||||
}
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, ag.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.UpdateState != "pending" || row.UpdateTarget != "A1.0.1" ||
|
||||
row.UpdateTag != "V1.0.1" || row.UpdateRequestedAt == 0 {
|
||||
t.Fatalf("intent not recorded: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePanelsAgentAlreadyCurrent(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
origFetch := fetchLatestReleaseTag
|
||||
fetchLatestReleaseTag = func(context.Context) (string, error) { return "V1.0.1", nil }
|
||||
t.Cleanup(func() { fetchLatestReleaseTag = origFetch })
|
||||
|
||||
node := &model.Node{Id: 7502, Name: "agent-cur", Address: "", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
ag := &model.AgentNode{
|
||||
TokenHash: "hash-cur", Name: "agent-cur", NodeID: &node.Id,
|
||||
AgentVersion: "A1.0.1",
|
||||
}
|
||||
if err := db.Create(ag).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
|
||||
svc := &NodeService{}
|
||||
results, err := svc.UpdatePanels([]int{node.Id}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePanels: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].OK || !strings.Contains(results[0].Error, "already up to date") {
|
||||
t.Fatalf("results = %+v, want not-OK already-up-to-date", results)
|
||||
}
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, ag.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.UpdateRequestedAt != 0 {
|
||||
t.Fatalf("no intent may be recorded for a current agent: %+v", row)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
@@ -38,6 +39,11 @@ type NodeView struct {
|
||||
// traditional nodes and for agents that never reported telemetry.
|
||||
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
|
||||
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
|
||||
// Agent one-click update bookkeeping (item 20), agent nodes only:
|
||||
// the last intent's state/target and, on failure, its error.
|
||||
AgentUpdateState string `json:"agentUpdateState,omitempty" example:"failed"`
|
||||
AgentUpdateError string `json:"agentUpdateError,omitempty" example:""`
|
||||
AgentUpdateTarget string `json:"agentUpdateTarget,omitempty" example:"A1.0.1"`
|
||||
Guid string `json:"guid" example:"node-guid"`
|
||||
Status string `json:"status" example:"online"`
|
||||
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
|
||||
@@ -199,7 +205,17 @@ func annotateAgentTelemetry(views []*NodeView) {
|
||||
v.XrayVersion = ag.XrayVersion
|
||||
}
|
||||
if ag.AgentVersion != "" {
|
||||
v.PanelVersion = "agent " + ag.AgentVersion
|
||||
// New agents report the A-series display version, shown
|
||||
// verbatim; older agents report the internal build version
|
||||
// and keep the historical "agent " prefix.
|
||||
if _, ok := config.ParseDisplayVersion(ag.AgentVersion); ok && strings.HasPrefix(ag.AgentVersion, "A") {
|
||||
v.PanelVersion = ag.AgentVersion
|
||||
} else {
|
||||
v.PanelVersion = "agent " + ag.AgentVersion
|
||||
}
|
||||
v.AgentUpdateState = ag.UpdateState
|
||||
v.AgentUpdateError = ag.UpdateError
|
||||
v.AgentUpdateTarget = ag.UpdateTarget
|
||||
}
|
||||
v.UptimeSecs = ag.UptimeSecs
|
||||
v.LatencyMs = ag.LatencyMs
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
@@ -37,6 +38,13 @@ type PanelUpdateInfo struct {
|
||||
CurrentCommit string `json:"currentCommit,omitempty"`
|
||||
LatestCommit string `json:"latestCommit,omitempty"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
// Display versions (item 18/20): the V-series numbers the UI shows for
|
||||
// this fork. DisplayUpdateAvailable compares them with the
|
||||
// single-digit-carry segment rule — the raw tag stream above keeps its
|
||||
// historical semantics for everything else (nodes page, remotes).
|
||||
DisplayCurrentVersion string `json:"displayCurrentVersion,omitempty"`
|
||||
DisplayLatestVersion string `json:"displayLatestVersion,omitempty"`
|
||||
DisplayUpdateAvailable bool `json:"displayUpdateAvailable"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -137,12 +145,32 @@ func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
|
||||
return nil, err
|
||||
}
|
||||
current := config.GetBaseVersion()
|
||||
return &PanelUpdateInfo{
|
||||
info := &PanelUpdateInfo{
|
||||
Channel: "stable",
|
||||
CurrentVersion: current,
|
||||
LatestVersion: latest,
|
||||
UpdateAvailable: isNewerVersion(latest, current),
|
||||
}, nil
|
||||
}
|
||||
info.DisplayCurrentVersion = config.GetMasterDisplayVersion()
|
||||
if dispLatest, avail := displayUpdateFields(latest); dispLatest != "" {
|
||||
info.DisplayLatestVersion = dispLatest
|
||||
info.DisplayUpdateAvailable = avail
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// displayUpdateFields maps a raw latest release tag onto the V-series
|
||||
// display version and whether it is strictly newer than the running
|
||||
// display version. An unparseable tag (not a V-series release) yields ""
|
||||
// so the UI falls back to the raw fields.
|
||||
func displayUpdateFields(latestTag string) (string, bool) {
|
||||
parts, ok := config.ParseDisplayVersion(latestTag)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
disp := fmt.Sprintf("V%d.%d.%d", parts[0], parts[1], parts[2])
|
||||
cmp, ok := config.CompareDisplayVersion(disp, config.GetMasterDisplayVersion())
|
||||
return disp, ok && cmp > 0
|
||||
}
|
||||
|
||||
// devChannelActive reports whether self-update should track the rolling dev
|
||||
@@ -213,6 +241,16 @@ func (s *PanelService) GetUpdateStatus() *PanelUpdateStatus {
|
||||
}
|
||||
|
||||
func (s *PanelService) startUpdate(useDev bool) (int64, error) {
|
||||
// The dev channel keeps the historical update.sh path. The stable
|
||||
// channel uses the Go-native swap (item 20): the fork's own releases
|
||||
// carry bare binaries, not the tarball layout update.sh expects.
|
||||
if useDev {
|
||||
return s.startScriptUpdate(useDev)
|
||||
}
|
||||
return s.startNativeUpdate()
|
||||
}
|
||||
|
||||
func (s *PanelService) startScriptUpdate(useDev bool) (int64, error) {
|
||||
runID := time.Now().UnixNano()
|
||||
if !acquireUpdateSlot(runID) {
|
||||
return 0, fmt.Errorf("a panel update is already in progress")
|
||||
@@ -505,6 +543,154 @@ func commitsEqual(a, b string) bool {
|
||||
return strings.HasPrefix(b, a)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native stable-channel self-update (item 20).
|
||||
//
|
||||
// The fork's own releases ship bare binaries (x-ui-linux-<arch> + .sha256),
|
||||
// so the stable channel swaps the binary in Go instead of running
|
||||
// update.sh: resolve the newest V-series tag, download this host's asset
|
||||
// with sha256 verification, atomically rename it over the running binary
|
||||
// (old one kept as .bak), record success in the same status file the
|
||||
// update.sh path uses, then restart the systemd unit from a detached
|
||||
// process. Only systemd hosts are eligible — anywhere else the operator
|
||||
// updates manually, as before.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// nativeSystemdAvailable reports whether this process runs under systemd.
|
||||
// XUI_SELFUPDATE_SYSTEMD=1 forces the gate open (sandbox verification with
|
||||
// a stub systemctl on PATH).
|
||||
var nativeSystemdAvailable = func() bool {
|
||||
if os.Getenv("XUI_SELFUPDATE_SYSTEMD") == "1" {
|
||||
return true
|
||||
}
|
||||
_, err := os.Stat("/run/systemd/system")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// scheduleServiceRestart restarts the panel's unit from a detached
|
||||
// process, so the restart is not killed together with this process.
|
||||
// A variable so tests can stub it.
|
||||
var scheduleServiceRestart = func(unit string) error {
|
||||
cmd := exec.Command("sh", "-c", "sleep 1; systemctl restart "+shellQuote(unit))
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.Process.Release()
|
||||
}
|
||||
|
||||
func (s *PanelService) startNativeUpdate() (int64, error) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return 0, fmt.Errorf("panel web update is supported only on Linux installations")
|
||||
}
|
||||
if !nativeSystemdAvailable() {
|
||||
return 0, fmt.Errorf("panel online update requires a systemd environment; please update manually")
|
||||
}
|
||||
runID := time.Now().UnixNano()
|
||||
if !acquireUpdateSlot(runID) {
|
||||
return 0, fmt.Errorf("a panel update is already in progress")
|
||||
}
|
||||
go s.runNativeUpdate(runID)
|
||||
return runID, nil
|
||||
}
|
||||
|
||||
func (s *PanelService) runNativeUpdate(runID int64) {
|
||||
fail := func(format string, args ...any) {
|
||||
logger.Warning("panel self-update failed: ", fmt.Sprintf(format, args...))
|
||||
writeUpdateStatus(runID, updateStateFailed, 1)
|
||||
releaseUpdateSlot()
|
||||
}
|
||||
|
||||
client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Minute)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
tag, err := selfupdate.FetchLatestReleaseTag(ctx, client)
|
||||
if err != nil {
|
||||
fail("fetch latest release: %v", err)
|
||||
return
|
||||
}
|
||||
if _, avail := displayUpdateFields(tag); !avail {
|
||||
fail("latest release %s is not newer than current %s", tag, config.GetMasterDisplayVersion())
|
||||
return
|
||||
}
|
||||
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
fail("%v", err)
|
||||
return
|
||||
}
|
||||
exePath, err := selfupdate.ExecutablePath()
|
||||
if err != nil {
|
||||
fail("locate own binary: %v", err)
|
||||
return
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(exePath), fmt.Sprintf(".x-ui-update-%d", runID))
|
||||
base := selfupdate.BaseURL() + tag + "/"
|
||||
logger.Infof("panel self-update: downloading %s (%s)", tag, asset)
|
||||
if err := selfupdate.DownloadVerified(ctx, client, base+asset, base+asset+".sha256", tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
fail("download %s: %v", tag, err)
|
||||
return
|
||||
}
|
||||
bak, err := selfupdate.SwapExecutable(exePath, tmp)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
fail("swap binary: %v", err)
|
||||
return
|
||||
}
|
||||
logger.Infof("panel self-update: %s installed (previous binary kept at %s); restarting service", tag, bak)
|
||||
writeUpdateStatus(runID, updateStateSuccess, 0)
|
||||
releaseUpdateSlot()
|
||||
if err := scheduleServiceRestart(detectServiceUnit()); err != nil {
|
||||
logger.Warning("panel self-update: schedule restart failed (new binary takes effect at the next restart): ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// detectServiceUnit resolves this process's systemd unit: explicit
|
||||
// override, else the unit named in /proc/self/cgroup, else "x-ui".
|
||||
func detectServiceUnit() string {
|
||||
if v := strings.TrimSpace(os.Getenv("XUI_SERVICE_NAME")); v != "" {
|
||||
return v
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
||||
if unit := serviceUnitFromCgroup(string(data)); unit != "" {
|
||||
return unit
|
||||
}
|
||||
}
|
||||
return "x-ui"
|
||||
}
|
||||
|
||||
// serviceUnitFromCgroup extracts the first *.service component from
|
||||
// cgroup content, without the suffix ("system.slice/x-ui.service" ->
|
||||
// "x-ui").
|
||||
func serviceUnitFromCgroup(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
for _, part := range strings.Split(line, "/") {
|
||||
if strings.HasSuffix(part, ".service") {
|
||||
return strings.TrimSuffix(part, ".service")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeUpdateStatus records a native-update outcome in the same status
|
||||
// file and JSON shape the update.sh path produces for GetUpdateStatus.
|
||||
func writeUpdateStatus(runID int64, state string, exitCode int) {
|
||||
status := PanelUpdateStatus{
|
||||
RunID: strconv.FormatInt(runID, 10),
|
||||
State: state,
|
||||
ExitCode: exitCode,
|
||||
FinishedAt: time.Now().Unix(),
|
||||
}
|
||||
data, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
path := config.GetUpdateStatusFilePath()
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
_ = os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func resolveUpdateFolders() (string, string) {
|
||||
mainFolder := os.Getenv("XUI_MAIN_FOLDER")
|
||||
if mainFolder == "" {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Item 20: the stable channel compares V-series display versions with
|
||||
// the segment rule; the native self-update is systemd-gated and records
|
||||
// its outcome in the shared update status file.
|
||||
|
||||
func TestDisplayUpdateFields(t *testing.T) {
|
||||
// The test binary runs the default display version V1.0.0.
|
||||
if disp, avail := displayUpdateFields("V1.0.1"); disp != "V1.0.1" || !avail {
|
||||
t.Fatalf("V1.0.1 vs V1.0.0 = %q, %v; want V1.0.1, true", disp, avail)
|
||||
}
|
||||
if disp, avail := displayUpdateFields("V1.0.0"); disp != "V1.0.0" || avail {
|
||||
t.Fatalf("V1.0.0 vs V1.0.0 = %q, %v; want V1.0.0, false", disp, avail)
|
||||
}
|
||||
if disp, avail := displayUpdateFields("3.7.0-agent.1"); disp != "" || avail {
|
||||
t.Fatalf("legacy tag = %q, %v; want empty, false", disp, avail)
|
||||
}
|
||||
if _, avail := displayUpdateFields("V1.0.9"); !avail {
|
||||
t.Fatal("V1.0.9 must be newer than V1.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUnitFromCgroup(t *testing.T) {
|
||||
content := "0::/system.slice/x-ui.service\n"
|
||||
if got := serviceUnitFromCgroup(content); got != "x-ui" {
|
||||
t.Fatalf("unit = %q, want x-ui", got)
|
||||
}
|
||||
if got := serviceUnitFromCgroup("0::/user.slice/session-3.scope\n"); got != "" {
|
||||
t.Fatalf("unit = %q, want empty", got)
|
||||
}
|
||||
if got := serviceUnitFromCgroup(""); got != "" {
|
||||
t.Fatalf("unit = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeUpdateStatusRoundtrip(t *testing.T) {
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
writeUpdateStatus(424242, updateStateSuccess, 0)
|
||||
st := (&PanelService{}).GetUpdateStatus()
|
||||
if st.RunID != "424242" || st.State != updateStateSuccess || st.ExitCode != 0 {
|
||||
t.Fatalf("status roundtrip = %+v", st)
|
||||
}
|
||||
writeUpdateStatus(424243, updateStateFailed, 1)
|
||||
st = (&PanelService{}).GetUpdateStatus()
|
||||
if st.RunID != "424243" || st.State != updateStateFailed || st.ExitCode != 1 {
|
||||
t.Fatalf("status roundtrip = %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartUpdateStableRequiresSystemd(t *testing.T) {
|
||||
// Force the gate closed regardless of the host running the test.
|
||||
orig := nativeSystemdAvailable
|
||||
nativeSystemdAvailable = func() bool { return false }
|
||||
t.Cleanup(func() { nativeSystemdAvailable = orig })
|
||||
|
||||
_, err := (&PanelService{}).startNativeUpdate()
|
||||
if err == nil || !strings.Contains(err.Error(), "systemd") {
|
||||
t.Fatalf("want systemd gate error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func (t *Tgbot) prepareServerUsageInfo() string {
|
||||
onlines := t.inboundService.GetOnlineClients()
|
||||
|
||||
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
|
||||
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetMasterDisplayVersion())
|
||||
info += t.I18nBot("tgbot.messages.xrayVersion", "XrayVersion=="+fmt.Sprint(t.lastStatus.Xray.Version))
|
||||
|
||||
// get ip address
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "تخزين",
|
||||
"memory": "الذاكرة",
|
||||
"updatePanel": "تحديث البانل",
|
||||
"panelUpdateDesc": "ده هيحدث 3X-UI لآخر إصدار وهيعيد تشغيل خدمة البانل.",
|
||||
"panelUpdateDesc": "ده هيحدث L-UI لآخر إصدار وهيعيد تشغيل خدمة البانل.",
|
||||
"currentPanelVersion": "إصدار البانل الحالي",
|
||||
"latestPanelVersion": "أحدث إصدار للبانل",
|
||||
"panelUpToDate": "البانل محدث لآخر إصدار",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "مرسل",
|
||||
"received": "مستقبل",
|
||||
"panelUpdateDialog": "هل فعلاً عايز تحدث البانل؟",
|
||||
"panelUpdateDialogDesc": "ده هيحدث 3X-UI للإصدار #version# وهيعيد تشغيل البانل.",
|
||||
"panelUpdateDialogDesc": "ده هيحدث L-UI للإصدار #version# وهيعيد تشغيل البانل.",
|
||||
"panelUpdateStartedPopover": "بدأ تحديث البانل",
|
||||
"panelUpdateFailedTitle": "فشل تحديث البانل",
|
||||
"panelUpdateFailedDesc": "لم يكتمل التحديث بنجاح. تحقق من سجلات الخادم، أو نفّذ الأمر «x-ui update» من سطر الأوامر.",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "تحديث متاح",
|
||||
"updateConfirmTitle": "تحديث {count} عقدة إلى أحدث إصدار؟",
|
||||
"updateConfirmContent": "كل عقدة محددة ستنزّل أحدث إصدار وتعيد التشغيل عليه. يتم تحديث العقد المفعّلة والمتصلة فقط.",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "التحديث إلى قناة التطوير (أحدث كومِت)",
|
||||
"testConnection": "اختبار الاتصال",
|
||||
"connectionOk": "الاتصال شغال ({ms} ms)",
|
||||
@@ -1747,7 +1751,7 @@
|
||||
"report": "🕰 التقارير المجدولة: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ التاريخ والوقت: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 المضيف: {{ .Hostname }}\r\n",
|
||||
"version": "🚀 نسخة 3X-UI: {{ .Version }}\r\n",
|
||||
"version": "🚀 نسخة L-UI: {{ .Version }}\r\n",
|
||||
"xrayVersion": "📡 نسخة Xray: {{ .XrayVersion }}\r\n",
|
||||
"ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n",
|
||||
"ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "Storage",
|
||||
"memory": "RAM",
|
||||
"updatePanel": "Update Panel",
|
||||
"panelUpdateDesc": "This will update 3X-UI itself to the latest release and restart the panel service.",
|
||||
"panelUpdateDesc": "This will update L-UI itself to the latest release and restart the panel service.",
|
||||
"currentPanelVersion": "Current panel version",
|
||||
"latestPanelVersion": "Latest panel version",
|
||||
"panelUpToDate": "Panel is up to date",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "Sent",
|
||||
"received": "Received",
|
||||
"panelUpdateDialog": "Do you really want to update the panel?",
|
||||
"panelUpdateDialogDesc": "This will update 3X-UI to #version# and restart the panel service.",
|
||||
"panelUpdateDialogDesc": "This will update L-UI to #version# and restart the panel service.",
|
||||
"panelUpdateStartedPopover": "Panel update started",
|
||||
"panelUpdateFailedTitle": "Panel update failed",
|
||||
"panelUpdateFailedDesc": "The update did not finish successfully. Check the server logs, or run 'x-ui update' from the command line.",
|
||||
@@ -990,6 +990,10 @@
|
||||
"updateAvailable": "Update available",
|
||||
"updateConfirmTitle": "Update {count} node(s) to the latest version?",
|
||||
"updateConfirmContent": "Each selected node downloads the latest release and restarts onto it. Only enabled, online nodes are updated.",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "Update to Dev channel (latest commit)",
|
||||
"testConnection": "Test Connection",
|
||||
"connectionOk": "Connection OK ({ms} ms)",
|
||||
@@ -1769,7 +1773,7 @@
|
||||
"report": "🕰 Scheduled Reports: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Date&Time: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Host: {{ .Hostname }}\r\n",
|
||||
"version": "🚀 3X-UI Version: {{ .Version }}\r\n",
|
||||
"version": "🚀 L-UI Version: {{ .Version }}\r\n",
|
||||
"xrayVersion": "📡 Xray Version: {{ .XrayVersion }}\r\n",
|
||||
"ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n",
|
||||
"ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "Almacenamiento",
|
||||
"memory": "Memoria",
|
||||
"updatePanel": "Actualizar panel",
|
||||
"panelUpdateDesc": "Esto actualizará 3X-UI a la última versión y reiniciará el servicio del panel.",
|
||||
"panelUpdateDesc": "Esto actualizará L-UI a la última versión y reiniciará el servicio del panel.",
|
||||
"currentPanelVersion": "Versión actual del panel",
|
||||
"latestPanelVersion": "Última versión del panel",
|
||||
"panelUpToDate": "El panel está actualizado",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "Enviado",
|
||||
"received": "Recibido",
|
||||
"panelUpdateDialog": "¿Deseas actualizar el panel?",
|
||||
"panelUpdateDialogDesc": "Esto actualizará 3X-UI a la versión #version# y reiniciará el servicio del panel.",
|
||||
"panelUpdateDialogDesc": "Esto actualizará L-UI a la versión #version# y reiniciará el servicio del panel.",
|
||||
"panelUpdateStartedPopover": "Actualización del panel iniciada",
|
||||
"panelUpdateFailedTitle": "Error al actualizar el panel",
|
||||
"panelUpdateFailedDesc": "La actualización no se completó correctamente. Revisa los registros del servidor o ejecuta 'x-ui update' desde la línea de comandos.",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "Actualización disponible",
|
||||
"updateConfirmTitle": "¿Actualizar {count} nodo(s) a la última versión?",
|
||||
"updateConfirmContent": "Cada nodo seleccionado descarga la última versión y se reinicia con ella. Solo se actualizan los nodos habilitados y en línea.",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "Actualizar al canal de desarrollo (último commit)",
|
||||
"testConnection": "Probar conexión",
|
||||
"connectionOk": "Conexión correcta ({ms} ms)",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "ذخیرهسازی",
|
||||
"memory": "حافظه",
|
||||
"updatePanel": "بهروزرسانی پنل",
|
||||
"panelUpdateDesc": "این عملیات 3X-UI را به آخرین نسخه بهروزرسانی میکند و سرویس پنل را مجدداً راهاندازی میکند.",
|
||||
"panelUpdateDesc": "این عملیات L-UI را به آخرین نسخه بهروزرسانی میکند و سرویس پنل را مجدداً راهاندازی میکند.",
|
||||
"currentPanelVersion": "نسخه فعلی پنل",
|
||||
"latestPanelVersion": "آخرین نسخه پنل",
|
||||
"panelUpToDate": "پنل بهروز است",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "ارسال شده",
|
||||
"received": "دریافت شده",
|
||||
"panelUpdateDialog": "آیا مطمئن هستید که میخواهید پنل را بهروزرسانی کنید؟",
|
||||
"panelUpdateDialogDesc": "این 3X-UI را به نسخه #version# بهروزرسانی کرده و سرویس پنل را مجدداً راهاندازی میکند.",
|
||||
"panelUpdateDialogDesc": "این L-UI را به نسخه #version# بهروزرسانی کرده و سرویس پنل را مجدداً راهاندازی میکند.",
|
||||
"panelUpdateStartedPopover": "بهروزرسانی پنل آغاز شد",
|
||||
"panelUpdateFailedTitle": "بهروزرسانی پنل ناموفق بود",
|
||||
"panelUpdateFailedDesc": "بهروزرسانی با موفقیت به پایان نرسید. گزارشهای سرور را بررسی کنید یا دستور «x-ui update» را از خط فرمان اجرا کنید.",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "بهروزرسانی موجود",
|
||||
"updateConfirmTitle": "{count} نود به آخرین نسخه بهروزرسانی شوند؟",
|
||||
"updateConfirmContent": "هر نود انتخابشده آخرین نسخه را دانلود و روی آن ریاستارت میشود. فقط نودهای فعال و آنلاین بهروزرسانی میشوند.",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "بهروزرسانی به کانال دِو (آخرین کامیت)",
|
||||
"testConnection": "تست اتصال",
|
||||
"connectionOk": "اتصال موفق ({ms} میلیثانیه)",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "Penyimpanan",
|
||||
"memory": "Memori",
|
||||
"updatePanel": "Perbarui Panel",
|
||||
"panelUpdateDesc": "Ini akan memperbarui 3X-UI ke rilis terbaru dan me-restart layanan panel.",
|
||||
"panelUpdateDesc": "Ini akan memperbarui L-UI ke rilis terbaru dan me-restart layanan panel.",
|
||||
"currentPanelVersion": "Versi panel saat ini",
|
||||
"latestPanelVersion": "Versi panel terbaru",
|
||||
"panelUpToDate": "Panel sudah terbaru",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "Dikirim",
|
||||
"received": "Diterima",
|
||||
"panelUpdateDialog": "Apakah Anda benar-benar ingin memperbarui panel?",
|
||||
"panelUpdateDialogDesc": "Ini akan memperbarui 3X-UI ke #version# dan me-restart layanan panel.",
|
||||
"panelUpdateDialogDesc": "Ini akan memperbarui L-UI ke #version# dan me-restart layanan panel.",
|
||||
"panelUpdateStartedPopover": "Pembaruan panel dimulai",
|
||||
"panelUpdateFailedTitle": "Pembaruan panel gagal",
|
||||
"panelUpdateFailedDesc": "Pembaruan tidak selesai dengan sukses. Periksa log server, atau jalankan 'x-ui update' dari baris perintah.",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "Pembaruan tersedia",
|
||||
"updateConfirmTitle": "Perbarui {count} node ke versi terbaru?",
|
||||
"updateConfirmContent": "Setiap node terpilih mengunduh rilis terbaru dan memulai ulang. Hanya node aktif dan online yang diperbarui.",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "Perbarui ke kanal dev (commit terbaru)",
|
||||
"testConnection": "Tes Koneksi",
|
||||
"connectionOk": "Koneksi OK ({ms} ms)",
|
||||
@@ -1747,7 +1751,7 @@
|
||||
"report": "🕰 Laporan Terjadwal: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Tanggal & Waktu: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Host: {{ .Hostname }}\r\n",
|
||||
"version": "🚀 Versi 3X-UI: {{ .Version }}\r\n",
|
||||
"version": "🚀 Versi L-UI: {{ .Version }}\r\n",
|
||||
"xrayVersion": "📡 Versi Xray: {{ .XrayVersion }}\r\n",
|
||||
"ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n",
|
||||
"ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "ストレージ",
|
||||
"memory": "メモリ",
|
||||
"updatePanel": "パネルを更新",
|
||||
"panelUpdateDesc": "これにより3X-UIが最新リリースに更新され、パネルサービスが再起動されます。",
|
||||
"panelUpdateDesc": "これによりL-UIが最新リリースに更新され、パネルサービスが再起動されます。",
|
||||
"currentPanelVersion": "現在のパネルバージョン",
|
||||
"latestPanelVersion": "最新のパネルバージョン",
|
||||
"panelUpToDate": "パネルは最新です",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "送信",
|
||||
"received": "受信",
|
||||
"panelUpdateDialog": "本当にパネルを更新しますか?",
|
||||
"panelUpdateDialogDesc": "これにより3X-UIが#version#に更新され、パネルサービスが再起動されます。",
|
||||
"panelUpdateDialogDesc": "これによりL-UIが#version#に更新され、パネルサービスが再起動されます。",
|
||||
"panelUpdateStartedPopover": "パネルの更新を開始しました",
|
||||
"panelUpdateFailedTitle": "パネルの更新に失敗しました",
|
||||
"panelUpdateFailedDesc": "更新が正常に完了しませんでした。サーバーのログを確認するか、コマンドラインで「x-ui update」を実行してください。",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "更新あり",
|
||||
"updateConfirmTitle": "{count} 個のノードを最新バージョンに更新しますか?",
|
||||
"updateConfirmContent": "選択した各ノードは最新リリースをダウンロードして再起動します。有効かつオンラインのノードのみが更新されます。",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "開発チャンネルに更新(最新コミット)",
|
||||
"testConnection": "接続テスト",
|
||||
"connectionOk": "接続OK ({ms} ms)",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "Armazenamento",
|
||||
"memory": "Memória",
|
||||
"updatePanel": "Atualizar painel",
|
||||
"panelUpdateDesc": "Isso atualizará o 3X-UI para a versão mais recente e reiniciará o serviço do painel.",
|
||||
"panelUpdateDesc": "Isso atualizará o L-UI para a versão mais recente e reiniciará o serviço do painel.",
|
||||
"currentPanelVersion": "Versão atual do painel",
|
||||
"latestPanelVersion": "Última versão do painel",
|
||||
"panelUpToDate": "O painel está atualizado",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "Enviado",
|
||||
"received": "Recebido",
|
||||
"panelUpdateDialog": "Deseja realmente atualizar o painel?",
|
||||
"panelUpdateDialogDesc": "Isso atualizará o 3X-UI para #version# e reiniciará o serviço do painel.",
|
||||
"panelUpdateDialogDesc": "Isso atualizará o L-UI para #version# e reiniciará o serviço do painel.",
|
||||
"panelUpdateStartedPopover": "Atualização do painel iniciada",
|
||||
"panelUpdateFailedTitle": "Falha ao atualizar o painel",
|
||||
"panelUpdateFailedDesc": "A atualização não foi concluída com sucesso. Verifique os logs do servidor ou execute 'x-ui update' na linha de comando.",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "Atualização disponível",
|
||||
"updateConfirmTitle": "Atualizar {count} nó(s) para a versão mais recente?",
|
||||
"updateConfirmContent": "Cada nó selecionado baixa a versão mais recente e reinicia nela. Apenas nós ativos e online são atualizados.",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "Atualizar para o canal de desenvolvimento (último commit)",
|
||||
"testConnection": "Testar conexão",
|
||||
"connectionOk": "Conexão OK ({ms} ms)",
|
||||
@@ -1747,7 +1751,7 @@
|
||||
"report": "🕰 Relatórios agendados: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Data&Hora: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Host: {{ .Hostname }}\r\n",
|
||||
"version": "🚀 Versão 3X-UI: {{ .Version }}\r\n",
|
||||
"version": "🚀 Versão L-UI: {{ .Version }}\r\n",
|
||||
"xrayVersion": "📡 Versão Xray: {{ .XrayVersion }}\r\n",
|
||||
"ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n",
|
||||
"ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "Диск",
|
||||
"memory": "Память",
|
||||
"updatePanel": "Обновить панель",
|
||||
"panelUpdateDesc": "Это обновит 3X-UI до последнего релиза и перезапустит сервис панели.",
|
||||
"panelUpdateDesc": "Это обновит L-UI до последнего релиза и перезапустит сервис панели.",
|
||||
"currentPanelVersion": "Текущая версия панели",
|
||||
"latestPanelVersion": "Последняя версия панели",
|
||||
"panelUpToDate": "Панель обновлена",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "Отправлено",
|
||||
"received": "Получено",
|
||||
"panelUpdateDialog": "Вы действительно хотите обновить панель?",
|
||||
"panelUpdateDialogDesc": "Это обновит 3X-UI до версии #version# и перезапустит сервис панели.",
|
||||
"panelUpdateDialogDesc": "Это обновит L-UI до версии #version# и перезапустит сервис панели.",
|
||||
"panelUpdateStartedPopover": "Обновление панели началось",
|
||||
"panelUpdateFailedTitle": "Не удалось обновить панель",
|
||||
"panelUpdateFailedDesc": "Обновление не завершилось успешно. Проверьте журналы сервера или выполните 'x-ui update' в командной строке.",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "Доступно обновление",
|
||||
"updateConfirmTitle": "Обновить {count} узлов до последней версии?",
|
||||
"updateConfirmContent": "Каждый выбранный узел загрузит последний релиз и перезапустится. Обновляются только включённые узлы в сети.",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "Обновить до канала разработки (последний коммит)",
|
||||
"testConnection": "Проверить соединение",
|
||||
"connectionOk": "Соединение в порядке ({ms} мс)",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "Depolama",
|
||||
"memory": "RAM",
|
||||
"updatePanel": "Paneli Güncelle",
|
||||
"panelUpdateDesc": "Bu işlem 3X-UI'yi en son sürüme güncelleyecek ve panel servisini yeniden başlatacaktır.",
|
||||
"panelUpdateDesc": "Bu işlem L-UI'yi en son sürüme güncelleyecek ve panel servisini yeniden başlatacaktır.",
|
||||
"currentPanelVersion": "Mevcut panel sürümü",
|
||||
"latestPanelVersion": "Panelin en son sürümü",
|
||||
"panelUpToDate": "Panel güncel",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "Gönderilen",
|
||||
"received": "Alınan",
|
||||
"panelUpdateDialog": "Gerçekten paneli güncellemek istiyor musunuz?",
|
||||
"panelUpdateDialogDesc": "Bu işlem 3X-UI'yi #version# sürümüne güncelleyecek ve panel servisini yeniden başlatacaktır.",
|
||||
"panelUpdateDialogDesc": "Bu işlem L-UI'yi #version# sürümüne güncelleyecek ve panel servisini yeniden başlatacaktır.",
|
||||
"panelUpdateStartedPopover": "Panel güncellemesi başlatıldı",
|
||||
"panelUpdateFailedTitle": "Panel güncellemesi başarısız oldu",
|
||||
"panelUpdateFailedDesc": "Güncelleme başarıyla tamamlanamadı. Sunucu günlüklerini kontrol edin veya komut satırından 'x-ui update' çalıştırın.",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "Güncelleme mevcut",
|
||||
"updateConfirmTitle": "{count} düğüm en son sürüme güncellensin mi?",
|
||||
"updateConfirmContent": "Seçilen her düğüm en son sürümü indirir ve yeniden başlatılır. Yalnızca etkin ve çevrimiçi düğümler güncellenir.",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "Dev kanalına güncelle (son commit)",
|
||||
"testConnection": "Bağlantıyı Test Et",
|
||||
"connectionOk": "Bağlantı tamam ({ms} ms)",
|
||||
@@ -1747,7 +1751,7 @@
|
||||
"report": "🕰 Planlanmış Raporlar: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Tarih ve Saat: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Sunucu: {{ .Hostname }}",
|
||||
"version": "🚀 3X-UI Sürümü: {{ .Version }}\r\n",
|
||||
"version": "🚀 L-UI Sürümü: {{ .Version }}\r\n",
|
||||
"xrayVersion": "📡 Xray Sürümü: {{ .XrayVersion }}\r\n",
|
||||
"ipv6": "🌐 IPv6: {{ .IPv6 }}",
|
||||
"ipv4": "🌐 IPv4: {{ .IPv4 }}",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "Сховище",
|
||||
"memory": "Пам’ять",
|
||||
"updatePanel": "Оновити панель",
|
||||
"panelUpdateDesc": "Це оновить 3X-UI до останнього релізу та перезапустить сервіс панелі.",
|
||||
"panelUpdateDesc": "Це оновить L-UI до останнього релізу та перезапустить сервіс панелі.",
|
||||
"currentPanelVersion": "Поточна версія панелі",
|
||||
"latestPanelVersion": "Остання версія панелі",
|
||||
"panelUpToDate": "Панель оновлено",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "Відправлено",
|
||||
"received": "Отримано",
|
||||
"panelUpdateDialog": "Ви дійсно хочете оновити панель?",
|
||||
"panelUpdateDialogDesc": "Це оновить 3X-UI до #version# та перезапустить сервіс панелі.",
|
||||
"panelUpdateDialogDesc": "Це оновить L-UI до #version# та перезапустить сервіс панелі.",
|
||||
"panelUpdateStartedPopover": "Розпочато оновлення панелі",
|
||||
"panelUpdateFailedTitle": "Не вдалося оновити панель",
|
||||
"panelUpdateFailedDesc": "Оновлення не завершилося успішно. Перевірте журнали сервера або виконайте 'x-ui update' у командному рядку.",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "Доступне оновлення",
|
||||
"updateConfirmTitle": "Оновити {count} вузлів до останньої версії?",
|
||||
"updateConfirmContent": "Кожен вибраний вузол завантажить останній реліз і перезапуститься. Оновлюються лише увімкнені вузли в мережі.",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "Оновити до каналу розробки (останній коміт)",
|
||||
"testConnection": "Перевірити з'єднання",
|
||||
"connectionOk": "З'єднання в порядку ({ms} мс)",
|
||||
@@ -1747,7 +1751,7 @@
|
||||
"report": "🕰 Заплановані звіти: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Дата й час: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Хост: {{ .Hostname }}\r\n",
|
||||
"version": "🚀 3X-UI Версія: {{ .Version }}\r\n",
|
||||
"version": "🚀 L-UI Версія: {{ .Version }}\r\n",
|
||||
"xrayVersion": "📡 Xray Версія: {{ .XrayVersion }}\r\n",
|
||||
"ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n",
|
||||
"ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "Lưu trữ",
|
||||
"memory": "Bộ nhớ",
|
||||
"updatePanel": "Cập nhật Panel",
|
||||
"panelUpdateDesc": "Điều này sẽ cập nhật 3X-UI lên bản phát hành mới nhất và khởi động lại dịch vụ panel.",
|
||||
"panelUpdateDesc": "Điều này sẽ cập nhật L-UI lên bản phát hành mới nhất và khởi động lại dịch vụ panel.",
|
||||
"currentPanelVersion": "Phiên bản panel hiện tại",
|
||||
"latestPanelVersion": "Phiên bản panel mới nhất",
|
||||
"panelUpToDate": "Panel đã được cập nhật",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "Đã gửi",
|
||||
"received": "Đã nhận",
|
||||
"panelUpdateDialog": "Bạn có chắc muốn cập nhật panel không?",
|
||||
"panelUpdateDialogDesc": "Điều này sẽ cập nhật 3X-UI lên #version# và khởi động lại dịch vụ panel.",
|
||||
"panelUpdateDialogDesc": "Điều này sẽ cập nhật L-UI lên #version# và khởi động lại dịch vụ panel.",
|
||||
"panelUpdateStartedPopover": "Bắt đầu cập nhật panel",
|
||||
"panelUpdateFailedTitle": "Cập nhật panel thất bại",
|
||||
"panelUpdateFailedDesc": "Bản cập nhật không hoàn tất thành công. Hãy kiểm tra nhật ký máy chủ, hoặc chạy 'x-ui update' từ dòng lệnh.",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "Có bản cập nhật",
|
||||
"updateConfirmTitle": "Cập nhật {count} node lên phiên bản mới nhất?",
|
||||
"updateConfirmContent": "Mỗi node đã chọn sẽ tải bản phát hành mới nhất và khởi động lại. Chỉ các node đang bật và trực tuyến được cập nhật.",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "Cập nhật lên kênh phát triển (commit mới nhất)",
|
||||
"testConnection": "Kiểm tra kết nối",
|
||||
"connectionOk": "Kết nối OK ({ms} ms)",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "存储",
|
||||
"memory": "内存",
|
||||
"updatePanel": "更新面板",
|
||||
"panelUpdateDesc": "这将把 3X-UI 更新到最新版本并重启面板服务。",
|
||||
"panelUpdateDesc": "这将把 L-UI 更新到最新版本并重启面板服务。",
|
||||
"currentPanelVersion": "当前面板版本",
|
||||
"latestPanelVersion": "最新面板版本",
|
||||
"panelUpToDate": "面板已是最新",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "已发送",
|
||||
"received": "已接收",
|
||||
"panelUpdateDialog": "您确定要更新面板吗?",
|
||||
"panelUpdateDialogDesc": "这将把 3X-UI 更新到 #version# 并重启面板服务。",
|
||||
"panelUpdateDialogDesc": "这将把 L-UI 更新到 #version# 并重启面板服务。",
|
||||
"panelUpdateStartedPopover": "已开始更新面板",
|
||||
"panelUpdateFailedTitle": "面板更新失败",
|
||||
"panelUpdateFailedDesc": "更新未成功完成。请检查服务器日志,或在命令行运行「x-ui update」。",
|
||||
@@ -872,6 +872,10 @@
|
||||
"updateAvailable": "有可用更新",
|
||||
"updateConfirmTitle": "将 {count} 个节点更新到最新版本?",
|
||||
"updateConfirmContent": "每个所选节点会下载最新版本并重启。仅更新已启用且在线的节点。",
|
||||
"updateAgent": "更新 Agent",
|
||||
"updateAgentConfirmContent": "该节点上的 agent 将下载最新发行版、校验后重启切换;更新失败时节点继续运行当前版本。",
|
||||
"agentInstallCommand": "安装命令",
|
||||
"agentTokenNotStored": "该节点的 token 尚未存入主控,下面的命令不含 token。token 保存在节点本机的 /etc/x-ui/agent.json 中;agent 下次通过校验的请求后会自动回填到这里。",
|
||||
"updateDevChannel": "更新到开发通道(最新提交)",
|
||||
"testConnection": "测试连接",
|
||||
"connectionOk": "连接正常 ({ms} ms)",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"storage": "儲存",
|
||||
"memory": "記憶體",
|
||||
"updatePanel": "更新面板",
|
||||
"panelUpdateDesc": "這將把 3X-UI 更新到最新版本並重新啟動面板服務。",
|
||||
"panelUpdateDesc": "這將把 L-UI 更新到最新版本並重新啟動面板服務。",
|
||||
"currentPanelVersion": "目前面板版本",
|
||||
"latestPanelVersion": "最新面板版本",
|
||||
"panelUpToDate": "面板已是最新",
|
||||
@@ -146,7 +146,7 @@
|
||||
"sent": "已發送",
|
||||
"received": "已接收",
|
||||
"panelUpdateDialog": "您確定要更新面板嗎?",
|
||||
"panelUpdateDialogDesc": "這將把 3X-UI 更新到 #version# 並重新啟動面板服務。",
|
||||
"panelUpdateDialogDesc": "這將把 L-UI 更新到 #version# 並重新啟動面板服務。",
|
||||
"panelUpdateStartedPopover": "面板更新已開始",
|
||||
"panelUpdateFailedTitle": "面板更新失敗",
|
||||
"panelUpdateFailedDesc": "更新未成功完成。請檢查伺服器日誌,或在命令列執行「x-ui update」。",
|
||||
@@ -850,6 +850,10 @@
|
||||
"updateAvailable": "有可用更新",
|
||||
"updateConfirmTitle": "將 {count} 個節點更新到最新版本?",
|
||||
"updateConfirmContent": "每個所選節點會下載最新版本並重新啟動。僅更新已啟用且在線的節點。",
|
||||
"updateAgent": "Update Agent",
|
||||
"updateAgentConfirmContent": "The agent on this node will download the latest release, verify it, and restart onto it. If the update fails, the node keeps running the current version.",
|
||||
"agentInstallCommand": "Install Command",
|
||||
"agentTokenNotStored": "This node's token is not stored on the master yet, so the command below has no token. The token is saved on the node itself in /etc/x-ui/agent.json; it will be filled in here automatically after the agent's next authenticated request.",
|
||||
"updateDevChannel": "更新到開發通道(最新提交)",
|
||||
"testConnection": "測試連線",
|
||||
"connectionOk": "連線正常 ({ms} ms)",
|
||||
|
||||
Reference in New Issue
Block a user