syco.me Homelab Dashboard

This commit is contained in:
2026-05-10 21:23:42 +02:00
parent 933e492d15
commit 90de2c1674
45 changed files with 6666 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
router.get('/stats', async (_req, res) => {
try {
const host = process.env.ADGUARD_HOST
const user = process.env.ADGUARD_USER
const pass = process.env.ADGUARD_PASSWORD
if (!host) {
res.status(503).json({ error: 'ADGUARD_HOST not configured' })
return
}
const u = user?.trim() ?? ''
const p = pass?.trim() ?? ''
const b64 = Buffer.from(`${u}:${p}`).toString('base64')
const statsRes = await axios.get(`${host}/control/stats`, {
headers: {
Authorization: `Basic ${b64}`,
'User-Agent': 'Mozilla/5.0',
},
maxRedirects: 0,
})
const d = statsRes.data
const queries: number[] = d.dns_queries ?? []
const blocked: number[] = d.blocked_filtering ?? []
const total: number = d.num_dns_queries ?? 0
const totalBlocked: number = d.num_blocked_filtering ?? 0
const timeSlots = queries.map((q, i) => ({ queries: q, blocked: blocked[i] ?? 0 }))
res.json({
totalQueries: total,
blockedQueries: totalBlocked,
blockedPercent: total > 0 ? ((totalBlocked / total) * 100).toFixed(1) : '0.0',
timeSlots,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
router.get('/calendar', async (req, res) => {
const today = new Date()
const defaultStart = new Date(today.getFullYear(), today.getMonth(), 1).toISOString().slice(0, 10)
const defaultEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0).toISOString().slice(0, 10)
const start = (req.query.start as string) || defaultStart
const end = (req.query.end as string) || defaultEnd
const calParams = { start, end, unmonitored: false }
const sonarrHeaders = process.env.SONARR_API_KEY ? { 'X-Api-Key': process.env.SONARR_API_KEY } : null
const results = await Promise.allSettled([
process.env.RADARR_HOST && process.env.RADARR_API_KEY
? axios.get(`${process.env.RADARR_HOST}/api/v3/calendar`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
params: calParams,
})
: Promise.resolve(null),
process.env.SONARR_HOST && sonarrHeaders
? axios.get(`${process.env.SONARR_HOST}/api/v3/calendar`, {
headers: sonarrHeaders,
params: calParams,
})
: Promise.resolve(null),
process.env.SONARR_HOST && sonarrHeaders
? axios.get(`${process.env.SONARR_HOST}/api/v3/series`, {
headers: sonarrHeaders,
})
: Promise.resolve(null),
process.env.LIDARR_HOST && process.env.LIDARR_API_KEY
? axios.get(`${process.env.LIDARR_HOST}/api/v1/calendar`, {
headers: { 'X-Api-Key': process.env.LIDARR_API_KEY },
params: calParams,
})
: Promise.resolve(null),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const radarrData: any[] = results[0].status === 'fulfilled' && results[0].value?.data ? results[0].value.data : []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sonarrData: any[] = results[1].status === 'fulfilled' && results[1].value?.data ? results[1].value.data : []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const seriesList: any[] = results[2].status === 'fulfilled' && results[2].value?.data ? results[2].value.data : []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lidarrData: any[] = results[3].status === 'fulfilled' && results[3].value?.data ? results[3].value.data : []
const seriesById: Record<number, string> = {}
for (const s of seriesList) seriesById[s.id] = s.title
const items = [
...radarrData.map(m => ({
date: (m.digitalRelease ?? m.physicalRelease ?? m.inCinemas ?? '').slice(0, 10),
title: m.title,
subtitle: m.year ? String(m.year) : '',
type: 'movie' as const,
downloaded: m.hasFile ?? false,
})).filter(m => m.date),
...sonarrData.map(e => ({
date: (e.airDate ?? '').slice(0, 10),
title: seriesById[e.seriesId] ?? e.title,
subtitle: `S${String(e.seasonNumber).padStart(2,'0')}E${String(e.episodeNumber).padStart(2,'0')}${e.title && e.title !== 'TBA' ? ` · ${e.title}` : ''}`,
type: 'episode' as const,
downloaded: e.hasFile ?? false,
})).filter(e => e.date),
...lidarrData.map(a => ({
date: (a.releaseDate ?? '').slice(0, 10),
title: a.artist?.artistName ?? '',
subtitle: a.title,
type: 'album' as const,
downloaded: a.statistics?.trackFileCount > 0,
})).filter(a => a.date),
].sort((a, b) => a.date.localeCompare(b.date))
res.json({ items })
})
router.get('/stats', async (_req, res) => {
const results = await Promise.allSettled([
process.env.RADARR_HOST && process.env.RADARR_API_KEY ? Promise.all([
axios.get(`${process.env.RADARR_HOST}/api/v3/movie`, { headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }),
axios.get(`${process.env.RADARR_HOST}/api/v3/queue/status`, { headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }),
]) : Promise.resolve(null),
process.env.SONARR_HOST && process.env.SONARR_API_KEY ? Promise.all([
axios.get(`${process.env.SONARR_HOST}/api/v3/series`, { headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }),
axios.get(`${process.env.SONARR_HOST}/api/v3/wanted/missing?pageSize=1`, { headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }),
axios.get(`${process.env.SONARR_HOST}/api/v3/queue/status`, { headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }),
]) : Promise.resolve(null),
process.env.LIDARR_HOST && process.env.LIDARR_API_KEY ? Promise.all([
axios.get(`${process.env.LIDARR_HOST}/api/v1/artist`, { headers: { 'X-Api-Key': process.env.LIDARR_API_KEY } }),
axios.get(`${process.env.LIDARR_HOST}/api/v1/wanted/missing?pageSize=1`, { headers: { 'X-Api-Key': process.env.LIDARR_API_KEY } }),
axios.get(`${process.env.LIDARR_HOST}/api/v1/queue/status`, { headers: { 'X-Api-Key': process.env.LIDARR_API_KEY } }),
]) : Promise.resolve(null),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const radarr = results[0].status === 'fulfilled' && results[0].value ? results[0].value as any[] : null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sonarr = results[1].status === 'fulfilled' && results[1].value ? results[1].value as any[] : null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lidarr = results[2].status === 'fulfilled' && results[2].value ? results[2].value as any[] : null
res.json({
radarr: radarr ? {
movies: radarr[0].data.length,
missing: radarr[0].data.filter((m: { hasFile: boolean; monitored: boolean }) => !m.hasFile && m.monitored).length,
queue: radarr[1].data.totalCount ?? 0,
} : null,
sonarr: sonarr ? {
series: sonarr[0].data.length,
missing: sonarr[1].data.totalRecords ?? 0,
queue: sonarr[2].data.totalCount ?? 0,
} : null,
lidarr: lidarr ? {
artists: lidarr[0].data.length,
missing: lidarr[1].data.totalRecords ?? 0,
queue: lidarr[2].data.totalCount ?? 0,
} : null,
})
})
export default router
+62
View File
@@ -0,0 +1,62 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
router.get('/stats', async (_req, res) => {
try {
const host = process.env.AUTHENTIK_HOST
const token = process.env.AUTHENTIK_TOKEN
if (!host || !token) {
res.status(503).json({ error: 'AUTHENTIK_HOST / AUTHENTIK_TOKEN not configured' })
return
}
const headers = { Authorization: `Bearer ${token.trim()}` }
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const [usersRes, loginsRes, failedRes, failedCountRes] = await Promise.all([
axios.get(`${host}/api/v3/core/users/`, { headers, params: { page_size: 1, attributes: '' } }),
axios.get(`${host}/api/v3/events/events/`, { headers, params: { action: 'login', ordering: '-created', page_size: 5 } }),
axios.get(`${host}/api/v3/events/events/`, { headers, params: { action: 'login_failed', ordering: '-created', page_size: 5 } }),
axios.get(`${host}/api/v3/events/events/`, { headers, params: { action: 'login_failed', created__gte: since24h, page_size: 1 } }),
])
type AuthentikEvent = {
created: string
user: { username: string }
client_ip: string
}
const toEntry = (e: AuthentikEvent, success: boolean) => ({
username: e.user?.username ?? 'unknown',
created: e.created,
clientIp: e.client_ip ?? '',
success,
})
const combined = [
...(loginsRes.data.results ?? []).map((e: AuthentikEvent) => toEntry(e, true)),
...(failedRes.data.results ?? []).map((e: AuthentikEvent) => toEntry(e, false)),
]
.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
.reduce<ReturnType<typeof toEntry>[]>((acc, entry) => {
const dupe = acc.find(x => x.created === entry.created && x.username === entry.username)
if (dupe) { if (!entry.success) dupe.success = false }
else acc.push(entry)
return acc
}, [])
.slice(0, 5)
res.json({
userCount: usersRes.data.pagination?.count ?? 0,
failedLast24h: failedCountRes.data.pagination?.count ?? 0,
recentLogins: combined,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+68
View File
@@ -0,0 +1,68 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
router.get('/stats', async (_req, res) => {
try {
const host = process.env.CROWDSEC_HOST
const key = process.env.CROWDSEC_API_KEY
if (!host || !key) {
res.status(503).json({ error: 'CROWDSEC_HOST / CROWDSEC_API_KEY not configured' })
return
}
const headers = { 'X-Api-Key': key.trim() }
// Bouncers can only access /v1/decisions — alerts require machine/agent auth
const [activeRes, weekRes] = await Promise.all([
axios.get(`${host}/v1/decisions`, { headers, params: { limit: 500000 } }),
axios.get(`${host}/v1/decisions`, { headers, params: { limit: 500000, since: '168h' } }),
])
type Decision = {
id?: number
origin?: string
type?: string
scope?: string
value?: string
scenario?: string
created_at?: string
until?: string
}
const active: Decision[] = Array.isArray(activeRes.data) ? activeRes.data : []
const week: Decision[] = Array.isArray(weekRes.data) ? weekRes.data : []
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
const alertsLast24h = active.filter(d => d.created_at && new Date(d.created_at) > oneDayAgo).length
const originCount: Record<string, number> = {}
for (const d of active) {
const o = d.origin ?? 'unknown'
originCount[o] = (originCount[o] ?? 0) + 1
}
const origins = Object.entries(originCount)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => ({ name, count }))
const recent = [...active]
.filter(d => d.created_at)
.sort((a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime())
.slice(0, 6)
.map(d => ({ value: d.value ?? '?', scenario: d.scenario ?? d.origin ?? '?', created_at: d.created_at! }))
res.json({
activeBans: active.length,
alertsLast24h,
blocksThisWeek: week.length,
origins,
recent,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+66
View File
@@ -0,0 +1,66 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
const HISTORY_SIZE = 20
const history: { ts: number; rx: number; tx: number }[] = []
function soap(action: string, service: string, body = ''): string {
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${action} xmlns:u="${service}">${body}</u:${action}></s:Body></s:Envelope>`
}
function tag(xml: string, name: string): string {
const m = xml.match(new RegExp(`<(?:[^:>]*:)?${name}>([^<]*)<`))
return m?.[1]?.trim() ?? ''
}
async function soapReq(host: string, path: string, service: string, action: string): Promise<string> {
const res = await axios.post(`${host}:49000${path}`, soap(action, service), {
headers: {
'Content-Type': 'text/xml; charset="utf-8"',
SOAPAction: `"${service}#${action}"`,
},
timeout: 5000,
})
return res.data as string
}
router.get('/status', async (_req, res) => {
try {
const host = process.env.FRITZBOX_HOST
if (!host) {
res.status(503).json({ error: 'FRITZBOX_HOST not configured' })
return
}
const [addonXml, ipXml, statusXml] = await Promise.all([
soapReq(host, '/igdupnp/control/WANCommonIFC1',
'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', 'GetAddonInfos'),
soapReq(host, '/igdupnp/control/WANIPConn1',
'urn:schemas-upnp-org:service:WANIPConnection:1', 'GetExternalIPAddress'),
soapReq(host, '/igdupnp/control/WANIPConn1',
'urn:schemas-upnp-org:service:WANIPConnection:1', 'GetStatusInfo'),
])
// Bytes/sec — FritzBox provides instantaneous rates
const rxRate = Number(tag(addonXml, 'NewByteReceiveRate') || tag(addonXml, 'NewBytesReceiveRate') || '0')
const txRate = Number(tag(addonXml, 'NewByteSendRate') || tag(addonXml, 'NewBytesSendRate') || '0')
history.push({ ts: Date.now(), rx: rxRate, tx: txRate })
if (history.length > HISTORY_SIZE) history.shift()
res.json({
connected: tag(statusXml, 'NewConnectionStatus') === 'Connected',
externalIp: tag(ipXml, 'NewExternalIPAddress'),
rxMbps: parseFloat((rxRate * 8 / 1_000_000).toFixed(2)),
txMbps: parseFloat((txRate * 8 / 1_000_000).toFixed(2)),
history: history.map(h => ({ rx: h.rx, tx: h.tx })),
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+48
View File
@@ -0,0 +1,48 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
router.get('/nodes', async (_req, res) => {
try {
const host = process.env.HEADSCALE_HOST
const key = process.env.HEADSCALE_API_KEY
if (!host || !key) {
res.status(503).json({ error: 'HEADSCALE_HOST / HEADSCALE_API_KEY not configured' })
return
}
const response = await axios.get(`${host}/api/v1/node`, {
headers: { Authorization: `Bearer ${key.trim()}` },
})
type HsNode = {
id: string
name: string
ipAddresses: string[]
online: boolean
lastSeen: string
user?: { name: string }
}
const nodes: HsNode[] = response.data?.nodes ?? []
res.json({
total: nodes.length,
online: nodes.filter(n => n.online).length,
nodes: nodes.map(n => ({
id: n.id,
name: n.name,
ip: n.ipAddresses?.[0] ?? '',
online: n.online,
lastSeen: n.lastSeen,
user: n.user?.name ?? '',
})),
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+99
View File
@@ -0,0 +1,99 @@
import { Router } from 'express'
import { io, Socket } from 'socket.io-client'
const router = Router()
interface MonitorInfo { id: number; name: string; type: string; active: boolean }
interface HeartbeatInfo { status: number; ping: number | null; beats: number[] }
let socket: Socket | null = null
let monitors: Record<number, MonitorInfo> = {}
let heartbeats: Record<number, HeartbeatInfo> = {}
let ready = false
const BEAT_HISTORY = 30
function startSocket() {
const host = process.env.KUMA_HOST
const user = process.env.KUMA_USER
const pass = process.env.KUMA_PASSWORD
if (!host || !user || !pass) return
socket = io(host, { transports: ['websocket'], reconnection: true })
socket.on('connect', () => {
socket!.emit('login', { username: user, password: pass, token: '' }, (res: { ok: boolean }) => {
if (!res.ok) console.error('[kuma] login failed')
})
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
socket.on('monitorList', (data: Record<string, any>) => {
monitors = {}
for (const m of Object.values(data)) {
monitors[m.id] = { id: m.id, name: m.name, type: m.type, active: m.active === true || m.active === 1 }
}
ready = true
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
socket.on('heartbeatList', (monitorId: number, beats: any[]) => {
if (!Array.isArray(beats) || beats.length === 0) return
const last = beats.at(-1)
const slice = beats.slice(-BEAT_HISTORY).map(b => b.status)
heartbeats[monitorId] = { status: last.status, ping: last.ping ?? null, beats: slice }
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
socket.on('heartbeat', (beat: any) => {
if (beat?.monitorID == null) return
const prev = heartbeats[beat.monitorID]
const beats = [...(prev?.beats ?? []), beat.status].slice(-BEAT_HISTORY)
heartbeats[beat.monitorID] = { status: beat.status, ping: beat.ping ?? null, beats }
})
socket.on('disconnect', () => { ready = false })
socket.on('connect_error', (err) => console.error('[kuma] connect error:', err.message))
}
function waitReady(timeoutMs = 6000): Promise<void> {
if (ready) return Promise.resolve()
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
const poll = setInterval(() => {
if (ready) { clearInterval(poll); resolve() }
else if (Date.now() > deadline) { clearInterval(poll); reject(new Error('Kuma not ready')) }
}, 100)
})
}
router.get('/monitors', async (_req, res) => {
try {
if (!socket) startSocket()
await waitReady()
const list = Object.values(monitors)
.filter(m => m.active)
.map(m => ({
id: m.id,
name: m.name,
type: m.type,
status: heartbeats[m.id]?.status ?? -1,
ping: heartbeats[m.id]?.ping ?? null,
beats: heartbeats[m.id]?.beats ?? [],
}))
.sort((a, b) => a.status - b.status)
res.json({
total: list.length,
up: list.filter(m => m.status === 1).length,
down: list.filter(m => m.status === 0).length,
monitors: list,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+51
View File
@@ -0,0 +1,51 @@
import { Router } from 'express'
import axios from 'axios'
import https from 'https'
const router = Router()
const agent = new https.Agent({ rejectUnauthorized: false })
router.get('/status', async (_req, res) => {
try {
const host = process.env.PROXMOX_HOST
const token = process.env.PROXMOX_TOKEN
if (!host || !token) {
res.status(503).json({ error: 'PROXMOX_HOST / PROXMOX_TOKEN not configured' })
return
}
const headers = { Authorization: token }
const nodesRes = await axios.get(`${host}/api2/json/nodes`, { headers, httpsAgent: agent })
const node: string = nodesRes.data.data[0].node
const [statusRes, lxcRes, qemuRes, storageRes] = await Promise.all([
axios.get(`${host}/api2/json/nodes/${node}/status`, { headers, httpsAgent: agent }),
axios.get(`${host}/api2/json/nodes/${node}/lxc`, { headers, httpsAgent: agent }),
axios.get(`${host}/api2/json/nodes/${node}/qemu`, { headers, httpsAgent: agent }),
axios.get(`${host}/api2/json/nodes/${node}/storage`, { headers, httpsAgent: agent }),
])
const s = statusRes.data.data
type StorageEntry = { storage: string; type: string; active: number; enabled: number; used: number; total: number }
const storages = (storageRes.data.data as StorageEntry[])
.filter(st => st.active && st.enabled && st.total > 0 && st.storage !== 'nas')
.map(st => ({ name: st.storage, type: st.type, used: st.used, total: st.total }))
res.json({
node,
uptime: s.uptime,
cpu: s.cpu,
memory: { used: s.memory.used, total: s.memory.total },
storages,
lxcCount: (lxcRes.data.data as { status: string }[]).filter(c => c.status === 'running').length,
vmCount: (qemuRes.data.data as { status: string }[]).filter(v => v.status === 'running').length,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+74
View File
@@ -0,0 +1,74 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
let sid: string | null = null
let sidExpiry = 0
async function getCookie(): Promise<string> {
if (sid && Date.now() < sidExpiry) return sid
const host = process.env.QBT_HOST
const user = process.env.QBT_USER
const pass = process.env.QBT_PASSWORD
if (!host || !user || !pass) throw new Error('QBT_HOST / QBT_USER / QBT_PASSWORD not configured')
const res = await axios.post(
`${host}/api/v2/auth/login`,
new URLSearchParams({ username: user, password: pass }),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': host, 'Origin': host } }
)
const cookie = (res.headers['set-cookie'] ?? []).find((c: string) => c.startsWith('SID='))
if (!cookie || res.data === 'Fails.') throw new Error('qBittorrent login failed')
sid = cookie.split(';')[0]
sidExpiry = Date.now() + 55 * 60 * 1000
return sid
}
router.get('/stats', async (_req, res) => {
try {
const host = process.env.QBT_HOST
if (!host) { res.status(503).json({ error: 'QBT_HOST not configured' }); return }
const cookie = await getCookie()
const headers = { Cookie: cookie, Referer: host, Origin: host }
const [infoRes, torrentsRes] = await Promise.all([
axios.get(`${host}/api/v2/transfer/info`, { headers }),
axios.get(`${host}/api/v2/torrents/info`, { headers }),
])
const info = infoRes.data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const torrents: any[] = torrentsRes.data ?? []
const downloading = torrents.filter(t => ['downloading', 'stalledDL', 'metaDL', 'forcedDL'].includes(t.state))
const seeding = torrents.filter(t => ['uploading', 'stalledUP', 'forcedUP'].includes(t.state))
const paused = torrents.filter(t => ['pausedDL', 'pausedUP'].includes(t.state))
res.json({
dlSpeed: info.dl_info_speed ?? 0,
ulSpeed: info.ul_info_speed ?? 0,
downloading: downloading.length,
seeding: seeding.length,
paused: paused.length,
total: torrents.length,
active: downloading.slice(0, 5).map(t => ({
name: t.name,
progress: t.progress,
dlSpeed: t.dlspeed,
size: t.size,
state: t.state,
})),
})
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('login failed')) sid = null
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+103
View File
@@ -0,0 +1,103 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
let cachedSid: string | null = null
let sidExpiry = 0
async function getSid(): Promise<string> {
if (cachedSid && Date.now() < sidExpiry) return cachedSid
const host = process.env.SYNOLOGY_HOST
const res = await axios.get(`${host}/webapi/auth.cgi`, {
params: {
api: 'SYNO.API.Auth',
version: 3,
method: 'login',
account: process.env.SYNOLOGY_USER,
passwd: process.env.SYNOLOGY_PASSWORD,
session: 'dashboard',
format: 'sid',
},
})
if (!res.data.success) throw new Error(`Synology login failed: ${JSON.stringify(res.data.error)}`)
cachedSid = res.data.data.sid as string
sidExpiry = Date.now() + 20 * 60 * 1000
return cachedSid
}
router.get('/storage', async (_req, res) => {
try {
const host = process.env.SYNOLOGY_HOST
if (!host) {
res.status(503).json({ error: 'SYNOLOGY_HOST not configured' })
return
}
const sid = await getSid()
const storageRes = await axios.get(`${host}/webapi/entry.cgi`, {
params: {
api: 'SYNO.Storage.CGI.Storage',
version: 1,
method: 'load_info',
_sid: sid,
},
})
if (!storageRes.data.success) {
cachedSid = null
throw new Error(`Synology storage error: ${JSON.stringify(storageRes.data.error)}`)
}
const d = storageRes.data.data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const volumes: any[] = d?.volumes ?? d?.vol_info ?? d?.storage?.volumes ?? []
res.json({
volumes: volumes.map(v => ({
id: v.vol_path ?? v.volume_path ?? v.id,
label: v.vol_path ?? v.display_name ?? v.id,
// DSM 7: sizes live under v.size.{total,used}
used: Number(v.size?.used ?? v.size_used ?? 0),
total: Number(v.size?.total ?? v.size_total ?? 0),
})),
})
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('login failed')) cachedSid = null
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
router.get('/info', async (_req, res) => {
try {
const host = process.env.SYNOLOGY_HOST
if (!host) { res.status(503).json({ error: 'SYNOLOGY_HOST not configured' }); return }
const sid = await getSid()
const r = await axios.get(`${host}/webapi/entry.cgi`, {
params: { api: 'SYNO.DSM.Info', version: 2, method: 'getinfo', _sid: sid },
})
if (!r.data.success) throw new Error(`DSM info error: ${JSON.stringify(r.data.error)}`)
const d = r.data.data ?? {}
res.json({
model: d.model ?? '',
dsmVersion: d.version ?? '',
uptime: Number(d.uptime ?? 0),
temperature: d.temperature != null ? Number(d.temperature) : null,
})
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('login failed')) cachedSid = null
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+84
View File
@@ -0,0 +1,84 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
let vwCookie: string | null = null
let vwCookieExpiry = 0
async function getCookie(): Promise<string> {
if (vwCookie && Date.now() < vwCookieExpiry) return vwCookie
const host = process.env.VAULTWARDEN_HOST
const token = process.env.VAULTWARDEN_ADMIN_TOKEN
if (!host || !token) throw new Error('VAULTWARDEN_HOST / VAULTWARDEN_ADMIN_TOKEN not configured')
const res = await axios.post(
`${host}/admin`,
new URLSearchParams({ token: token.trim() }),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
maxRedirects: 0,
validateStatus: s => s === 303 || s === 200,
}
)
const setCookieHeader = res.headers['set-cookie']
const match = (Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader ?? ''])
.find(c => c.startsWith('VW_ADMIN='))
if (!match) throw new Error('Admin login failed — check VAULTWARDEN_ADMIN_TOKEN')
vwCookie = match.split(';')[0]
vwCookieExpiry = Date.now() + 18 * 60 * 1000
return vwCookie
}
router.get('/stats', async (_req, res) => {
try {
const host = process.env.VAULTWARDEN_HOST
if (!host) { res.status(503).json({ error: 'VAULTWARDEN_HOST not configured' }); return }
const cookie = await getCookie()
const [usersRes, diagRes] = await Promise.all([
axios.get(`${host}/admin/users`, { headers: { Cookie: cookie, Accept: 'application/json' } }),
axios.get(`${host}/admin/diagnostics`, { headers: { Cookie: cookie, Accept: 'application/json' } }),
])
type VwUser = {
id: string
email: string
name?: string
lastActive?: string
creationDate?: string
userEnabled?: boolean
twoFactorEnabled?: boolean
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const users: any[] = usersRes.data ?? []
const diag = diagRes.data ?? {}
res.json({
version: diag.version ?? null,
signupsAllowed: diag.config?.signups_allowed ?? null,
userCount: users.length,
users: users.map(u => ({
email: u.email,
name: u.name || u.email,
enabled: u.userEnabled ?? true,
lastActive: u.lastActive ?? null,
created: u.creationDate ?? null,
twoFa: u.twoFactorEnabled ?? false,
cipherCount: u.cipherCount ?? u.cipher_count ?? null,
attachCount: u.attachmentCount ?? u.attachment_count ?? null,
})),
})
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('login failed')) vwCookie = null
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router