198 lines
6.7 KiB
TypeScript
198 lines
6.7 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
|
|
─────────────────────────────────────────────────────────────────────────── */
|
|
const DOCKER_SOCKET = '/var/run/docker.sock'
|
|
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
|
|
}
|
|
|
|
function parseImage(fullImage: string): { registry: string; name: string; tag: string } {
|
|
// Strip digest if present
|
|
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)
|
|
}
|
|
|
|
// Detect registry
|
|
const parts = img.split('/')
|
|
const isCustomRegistry = parts[0].includes('.') || parts[0].includes(':')
|
|
const registry = isCustomRegistry ? parts[0] : 'docker.io'
|
|
|
|
// Normalise name for Docker Hub (no namespace → library/...)
|
|
let name = isCustomRegistry ? parts.slice(1).join('/') : img
|
|
if (registry === 'docker.io' && !name.includes('/')) name = `library/${name}`
|
|
|
|
return { registry, name, tag }
|
|
}
|
|
|
|
async function getRegistryToken(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 getRegistryToken(image)
|
|
const res = await axios.head(
|
|
`https://registry-1.docker.io/v2/${image}/manifests/${tag}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: [
|
|
'application/vnd.docker.distribution.manifest.v2+json',
|
|
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
'application/vnd.oci.image.index.v1+json',
|
|
].join(', '),
|
|
},
|
|
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) => {
|
|
try {
|
|
// List running containers
|
|
const containersRes = await axios.get('http://localhost/v1.41/containers/json', {
|
|
socketPath: DOCKER_SOCKET,
|
|
timeout: 5000,
|
|
})
|
|
|
|
const containers: ContainerInfo[] = []
|
|
|
|
for (const c of containersRes.data) {
|
|
const rawImage: string = c.Image ?? ''
|
|
const containerName: string = (c.Names?.[0] ?? '').replace(/^\//, '')
|
|
|
|
// Get repo digests from image inspect
|
|
let repoDigest: string | null = null
|
|
try {
|
|
const imgRes = await axios.get(`http://localhost/v1.41/images/${c.ImageID}/json`, {
|
|
socketPath: DOCKER_SOCKET,
|
|
timeout: 5000,
|
|
})
|
|
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'
|
|
|
|
let upToDate: boolean | null = null
|
|
if (isDockerHub && repoDigest) {
|
|
const latest = await getLatestDigest(name, tag)
|
|
if (latest) upToDate = latest === repoDigest
|
|
}
|
|
|
|
containers.push({
|
|
name: containerName,
|
|
image: rawImage.split(':')[0].split('/').slice(-1)[0], // short name
|
|
tag: rawImage.includes(':') ? rawImage.split(':')[1].split('@')[0] : 'latest',
|
|
repoDigest,
|
|
upToDate,
|
|
registry,
|
|
})
|
|
}
|
|
|
|
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
|