242 lines
8.4 KiB
TypeScript
242 lines
8.4 KiB
TypeScript
import { Router } from 'express'
|
|
import axios from 'axios'
|
|
import { githubRepos } from '../config/watchlist'
|
|
|
|
const router = Router()
|
|
|
|
/* ── Simple in-memory cache ── */
|
|
interface CacheEntry<T> { data: T; ts: number }
|
|
const cache: Record<string, CacheEntry<unknown>> = {}
|
|
function fromCache<T>(key: string, ttlMs: number): T | null {
|
|
const entry = cache[key] as CacheEntry<T> | undefined
|
|
if (entry && Date.now() - entry.ts < ttlMs) return entry.data
|
|
return null
|
|
}
|
|
function toCache<T>(key: string, data: T) { cache[key] = { data, ts: Date.now() } }
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
Docker image updates via Portainer
|
|
─────────────────────────────────────────────────────────────────────────── */
|
|
const HUB_TTL = 60 * 60 * 1000 // 1 hour
|
|
|
|
interface ContainerInfo {
|
|
name: string
|
|
image: string
|
|
tag: string
|
|
repoDigest: string | null
|
|
upToDate: boolean | null // null = couldn't determine
|
|
registry: string
|
|
endpoint: string
|
|
}
|
|
|
|
function portainerHeaders() {
|
|
return { 'X-API-Key': process.env.PORTAINER_TOKEN ?? '' }
|
|
}
|
|
|
|
function parseImage(fullImage: string): { registry: string; name: string; tag: string } {
|
|
const [imgPart] = fullImage.split('@')
|
|
const colonIdx = imgPart.lastIndexOf(':')
|
|
const slashIdx = imgPart.indexOf('/')
|
|
|
|
let tag = 'latest'
|
|
let img = imgPart
|
|
if (colonIdx > slashIdx) {
|
|
tag = imgPart.slice(colonIdx + 1)
|
|
img = imgPart.slice(0, colonIdx)
|
|
}
|
|
|
|
const parts = img.split('/')
|
|
const isCustomRegistry = parts[0].includes('.') || parts[0].includes(':')
|
|
const registry = isCustomRegistry ? parts[0] : 'docker.io'
|
|
|
|
let name = isCustomRegistry ? parts.slice(1).join('/') : img
|
|
if (registry === 'docker.io' && !name.includes('/')) name = `library/${name}`
|
|
|
|
return { registry, name, tag }
|
|
}
|
|
|
|
const MANIFEST_ACCEPT = [
|
|
'application/vnd.docker.distribution.manifest.v2+json',
|
|
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
'application/vnd.oci.image.index.v1+json',
|
|
].join(', ')
|
|
|
|
async function getDockerHubToken(image: string): Promise<string> {
|
|
const res = await axios.get(
|
|
`https://auth.docker.io/token?service=registry.docker.io&scope=repository:${image}:pull`,
|
|
{ timeout: 8000 }
|
|
)
|
|
return res.data.token as string
|
|
}
|
|
|
|
async function getLatestDigest(image: string, tag: string): Promise<string | null> {
|
|
const cacheKey = `digest:${image}:${tag}`
|
|
const cached = fromCache<string>(cacheKey, HUB_TTL)
|
|
if (cached) return cached
|
|
|
|
try {
|
|
const token = await getDockerHubToken(image)
|
|
const res = await axios.head(
|
|
`https://registry-1.docker.io/v2/${image}/manifests/${tag}`,
|
|
{ headers: { Authorization: `Bearer ${token}`, Accept: MANIFEST_ACCEPT }, timeout: 10000 }
|
|
)
|
|
const digest = res.headers['docker-content-digest'] as string | undefined
|
|
if (digest) toCache(cacheKey, digest)
|
|
return digest ?? null
|
|
} catch { return null }
|
|
}
|
|
|
|
async function getGhcrLatestDigest(image: string, tag: string): Promise<string | null> {
|
|
const cacheKey = `ghcr:${image}:${tag}`
|
|
const cached = fromCache<string>(cacheKey, HUB_TTL)
|
|
if (cached) return cached
|
|
|
|
try {
|
|
const ghToken = process.env.GITHUB_TOKEN
|
|
const authHeaders: Record<string, string> = ghToken
|
|
? { Authorization: `Bearer ${ghToken}` }
|
|
: {}
|
|
|
|
const tokenRes = await axios.get(
|
|
`https://ghcr.io/token?service=ghcr.io&scope=repository:${image}:pull`,
|
|
{ headers: authHeaders, timeout: 8000 }
|
|
)
|
|
const token = tokenRes.data.token as string
|
|
|
|
const res = await axios.head(
|
|
`https://ghcr.io/v2/${image}/manifests/${tag}`,
|
|
{ headers: { Authorization: `Bearer ${token}`, Accept: MANIFEST_ACCEPT }, timeout: 10000 }
|
|
)
|
|
const digest = res.headers['docker-content-digest'] as string | undefined
|
|
if (digest) toCache(cacheKey, digest)
|
|
return digest ?? null
|
|
} catch { return null }
|
|
}
|
|
|
|
router.get('/docker', async (_req, res) => {
|
|
const host = process.env.PORTAINER_HOST
|
|
if (!host) {
|
|
res.status(503).json({ error: 'PORTAINER_HOST not configured' })
|
|
return
|
|
}
|
|
|
|
try {
|
|
const headers = portainerHeaders()
|
|
|
|
// Get all endpoints
|
|
const endpointsRes = await axios.get(`${host}/api/endpoints`, { headers, timeout: 8000 })
|
|
const endpoints: { Id: number; Name: string }[] = endpointsRes.data
|
|
|
|
const containers: ContainerInfo[] = []
|
|
|
|
for (const endpoint of endpoints) {
|
|
let endpointContainers: Record<string, unknown>[] = []
|
|
try {
|
|
const cRes = await axios.get(
|
|
`${host}/api/endpoints/${endpoint.Id}/docker/containers/json`,
|
|
{ headers, timeout: 8000 }
|
|
)
|
|
endpointContainers = cRes.data
|
|
} catch { continue }
|
|
|
|
for (const c of endpointContainers) {
|
|
const rawImage = (c.Image as string) ?? ''
|
|
const containerName = ((c.Names as string[])?.[0] ?? '').replace(/^\//, '')
|
|
|
|
// Get repo digests via image inspect
|
|
let repoDigest: string | null = null
|
|
try {
|
|
const imgRes = await axios.get(
|
|
`${host}/api/endpoints/${endpoint.Id}/docker/images/${c.ImageID}/json`,
|
|
{ headers, timeout: 8000 }
|
|
)
|
|
const digests: string[] = imgRes.data.RepoDigests ?? []
|
|
const matched = digests.find(d => d.split('@')[0].includes(rawImage.split(':')[0]))
|
|
repoDigest = matched?.split('@')[1] ?? digests[0]?.split('@')[1] ?? null
|
|
} catch { /* ignore */ }
|
|
|
|
const { registry, name, tag } = parseImage(rawImage)
|
|
const isDockerHub = registry === 'docker.io'
|
|
const isGhcr = registry === 'ghcr.io'
|
|
|
|
let upToDate: boolean | null = null
|
|
if (isDockerHub && repoDigest) {
|
|
const latest = await getLatestDigest(name, tag)
|
|
if (latest) upToDate = latest === repoDigest
|
|
} else if (isGhcr && repoDigest) {
|
|
const latest = await getGhcrLatestDigest(name, tag)
|
|
if (latest) upToDate = latest === repoDigest
|
|
}
|
|
|
|
containers.push({
|
|
name: containerName,
|
|
image: rawImage.split(':')[0].split('/').slice(-1)[0],
|
|
tag: rawImage.includes(':') ? rawImage.split(':')[1].split('@')[0] : 'latest',
|
|
repoDigest,
|
|
upToDate,
|
|
registry,
|
|
endpoint: endpoint.Name,
|
|
})
|
|
}
|
|
}
|
|
|
|
res.json({ containers })
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : 'Unknown error'
|
|
res.status(500).json({ error: msg })
|
|
}
|
|
})
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
GitHub release tracking
|
|
─────────────────────────────────────────────────────────────────────────── */
|
|
const GH_TTL = 15 * 60 * 1000 // 15 min
|
|
|
|
interface GithubRelease {
|
|
repo: string
|
|
version: string
|
|
name: string
|
|
publishedAt: string
|
|
url: string
|
|
}
|
|
|
|
router.get('/github', async (_req, res) => {
|
|
try {
|
|
const token = process.env.GITHUB_TOKEN
|
|
const headers: Record<string, string> = {
|
|
Accept: 'application/vnd.github+json',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
}
|
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
|
|
const releases: GithubRelease[] = await Promise.all(
|
|
githubRepos.map(async repo => {
|
|
const cacheKey = `gh:${repo}`
|
|
const cached = fromCache<GithubRelease>(cacheKey, GH_TTL)
|
|
if (cached) return cached
|
|
|
|
const r = await axios.get(
|
|
`https://api.github.com/repos/${repo}/releases/latest`,
|
|
{ headers, timeout: 8000 }
|
|
)
|
|
const release: GithubRelease = {
|
|
repo,
|
|
version: r.data.tag_name,
|
|
name: r.data.name || r.data.tag_name,
|
|
publishedAt: r.data.published_at,
|
|
url: r.data.html_url,
|
|
}
|
|
toCache(cacheKey, release)
|
|
return release
|
|
})
|
|
)
|
|
|
|
res.json({ releases })
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : 'Unknown error'
|
|
res.status(500).json({ error: msg })
|
|
}
|
|
})
|
|
|
|
export default router
|