diff --git a/.gitignore b/.gitignore index e7632d5..b693317 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ adolf/.env seafile/.env +marketplace-mcp/ diff --git a/Caddyfile b/Caddyfile index 35657ea..48adee1 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,5 +1,11 @@ +{ + servers { + protocols h1 h2 + } +} + haos.alogins.net { - reverse_proxy http://192.168.1.141:8123 { + reverse_proxy http://192.168.1.4:8123 { header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} @@ -16,7 +22,7 @@ doc.alogins.net { } zb.alogins.net { - reverse_proxy localhost:81 + reverse_proxy 192.168.1.4:81 } wiki.alogins.net { @@ -27,6 +33,10 @@ wiki.alogins.net { } } +lt.alogins.net { + reverse_proxy localhost:4321 +} + nn.alogins.net { reverse_proxy localhost:5678 } @@ -43,12 +53,41 @@ ai.alogins.net { reverse_proxy localhost:3125 } +news.alogins.net { + reverse_proxy localhost:8091 +} + +family.alogins.net { + reverse_proxy localhost:8099 +} + +lw.alogins.net { + reverse_proxy localhost:3012 +} + +todo.alogins.net { + reverse_proxy localhost:3457 +} + +ttt.alogins.net { + reverse_proxy localhost:3003 +} + openpi.alogins.net { root * /home/alvis/tmp/files/pi05_droid file_server browse } +dl.alogins.net { + @chiefx17 path /chief-x17.zip + handle @chiefx17 { + root * /mnt/misc/qbittorrent/downloads + file_server + } + respond 404 +} + vui3.alogins.net { @xhttp { @@ -69,6 +108,24 @@ vui3.alogins.net { respond 401 } +o.alogins.net { + handle /api/* { + reverse_proxy localhost:3078 + } + handle /admin* { + reverse_proxy localhost:3080 + } + handle /mlflow* { + reverse_proxy localhost:5000 + } + handle /airflow* { + reverse_proxy localhost:8080 + } + handle { + reverse_proxy localhost:3079 + } +} + vui4.alogins.net { reverse_proxy localhost:58959 } @@ -116,6 +173,54 @@ lk.alogins.net { reverse_proxy localhost:7880 } +lf.alogins.net { + reverse_proxy localhost:3200 +} + +llm.alogins.net { + reverse_proxy localhost:4000 +} + +overleaf.alogins.net { + reverse_proxy localhost:8089 +} + +voice.alogins.net { + reverse_proxy localhost:8882 +} + +iperf.alogins.net { + reverse_proxy localhost:8095 +} + +anki.alogins.net { + reverse_proxy localhost:8180 +} + +kb.alogins.net { + reverse_proxy localhost:4800 +} + +mood.alogins.net { + reverse_proxy localhost:5177 +} + +sync.alogins.net { + reverse_proxy localhost:8384 +} + +dav.alogins.net { + reverse_proxy localhost:5232 +} + +tor.alogins.net { + reverse_proxy localhost:8085 +} + +win.alogins.net { + reverse_proxy localhost:8006 +} + localhost:8042 { reverse_proxy localhost:8041 tls internal diff --git a/agap-mcp/Dockerfile b/agap-mcp/Dockerfile new file mode 100644 index 0000000..07c26e4 --- /dev/null +++ b/agap-mcp/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +RUN npm install -g @bitwarden/cli +COPY package.json ./ +RUN npm install --production +COPY src/ ./src/ +COPY start.sh ./ +RUN chmod +x start.sh +CMD ["./start.sh"] diff --git a/agap-mcp/docker-compose.yml b/agap-mcp/docker-compose.yml new file mode 100644 index 0000000..25c90ff --- /dev/null +++ b/agap-mcp/docker-compose.yml @@ -0,0 +1,27 @@ +services: + agap-mcp: + build: . + restart: unless-stopped + network_mode: host + environment: + - PORT=3100 + - NODE_TLS_REJECT_UNAUTHORIZED=0 + - HTTPS_PROXY= + - HTTP_PROXY= + - ALL_PROXY= + - https_proxy= + - http_proxy= + - all_proxy= + - BITWARDENCLI_APPDATA_DIR=/bw-data + - BW_EMAIL=allogn@gmail.com + - BW_PASSWORD=${BW_PASSWORD} + - VAULTWARDEN_URL=http://localhost:8041 + - GITEA_URL=http://localhost:3000 + - HA_URL=http://192.168.1.4:8123 + - ZABBIX_URL=http://192.168.1.4:81 + - RADICALE_URL=http://localhost:5232 + - RADICALE_USER=alvis + - KANBOARD_URL=http://localhost:4800 + - KANBOARD_BOT_USER=claude + volumes: + - /home/alvis/.config/Bitwarden CLI:/bw-data diff --git a/agap-mcp/package.json b/agap-mcp/package.json new file mode 100644 index 0000000..57a42a9 --- /dev/null +++ b/agap-mcp/package.json @@ -0,0 +1,10 @@ +{ + "name": "agap-mcp", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "express": "^4.19.0", + "zod": "^3.23.0" + } +} diff --git a/agap-mcp/src/gitea.js b/agap-mcp/src/gitea.js new file mode 100644 index 0000000..1a074ff --- /dev/null +++ b/agap-mcp/src/gitea.js @@ -0,0 +1,73 @@ +import { execSync } from 'child_process'; +import { writeFileSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const BASE = () => process.env.GITEA_URL || 'http://localhost:3000'; +let _token = null; + +export function initGitea(token) { + _token = token; + console.log('Gitea: ready'); +} + +function token() { + if (!_token) throw new Error('Gitea not initialized'); + return _token; +} + +async function api(path, opts = {}) { + const res = await fetch(`${BASE()}/api/v1${path}`, { + ...opts, + headers: { Authorization: `token ${token()}`, 'Content-Type': 'application/json', ...opts.headers }, + }); + if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); + return res.json(); +} + +export async function giteaListRepos() { + return api('/repos/search?limit=50').then(r => r.data.map(r => ({ + name: r.full_name, description: r.description, stars: r.stars_count, + }))); +} + +export async function giteaReadFile(repo, path, ref = 'HEAD') { + const data = await api(`/repos/${repo}/contents/${path}?ref=${ref}`); + return Buffer.from(data.content, 'base64').toString('utf8'); +} + +export async function giteaWikiList(repo = 'alvis/AgapHost') { + const data = await api(`/repos/${repo}/wiki/pages?limit=50`); + return data.map(p => ({ name: p.title, updated: p.last_commit?.created })); +} + +export async function giteaWikiRead(page, repo = 'alvis/AgapHost') { + const data = await api(`/repos/${repo}/wiki/page/${encodeURIComponent(page)}`); + return Buffer.from(data.content_base64, 'base64').toString('utf8'); +} + +export async function giteaWikiWrite(page, content, message, repo = 'alvis/AgapHost') { + const dir = join(tmpdir(), 'agap-mcp-wiki'); + const wikiUrl = `${BASE().replace('http://', `http://alvis:${token()}@`)}/alvis/AgapHost.wiki.git`; + + const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'agap-mcp', GIT_AUTHOR_EMAIL: 'allogn@gmail.com', GIT_COMMITTER_NAME: 'agap-mcp', GIT_COMMITTER_EMAIL: 'allogn@gmail.com' }; + + try { + execSync(`git -C ${dir} pull ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' }); + } catch { + execSync(`git clone ${wikiUrl} ${dir}`, { env: gitEnv, stdio: 'pipe' }); + } + + const file = join(dir, `${page}.md`); + writeFileSync(file, content); + execSync(`git -C ${dir} add "${page}.md"`, { env: gitEnv, stdio: 'pipe' }); + execSync(`git -C ${dir} commit -m "${message || `Update ${page}`}"`, { env: gitEnv, stdio: 'pipe' }); + execSync(`git -C ${dir} push ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' }); + return `${page} updated`; +} + +export async function giteaListIssues(repo, state = 'open') { + return api(`/repos/${repo}/issues?state=${state}&type=issues&limit=50`).then(issues => + issues.map(i => ({ number: i.number, title: i.title, state: i.state, labels: i.labels.map(l => l.name) })) + ); +} diff --git a/agap-mcp/src/homeassistant.js b/agap-mcp/src/homeassistant.js new file mode 100644 index 0000000..573f869 --- /dev/null +++ b/agap-mcp/src/homeassistant.js @@ -0,0 +1,45 @@ +const BASE = () => process.env.HA_URL || 'http://192.168.1.4:8123'; +let _token = null; + +export function initHA(token) { + _token = token; + console.log('Home Assistant: ready'); +} + +function token() { + if (!_token) throw new Error('HA not initialized'); + return _token; +} + +async function api(path, opts = {}) { + const res = await fetch(`${BASE()}/api${path}`, { + ...opts, + headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json', ...opts.headers }, + }); + if (!res.ok) throw new Error(`HA API ${path}: ${res.status}`); + return res.json(); +} + +export async function haGetState(entityId) { + const s = await api(`/states/${entityId}`); + return { entity_id: s.entity_id, state: s.state, attributes: s.attributes, last_changed: s.last_changed }; +} + +export async function haListEntities(domain) { + const states = await api('/states'); + const filtered = domain ? states.filter(s => s.entity_id.startsWith(`${domain}.`)) : states; + return filtered.map(s => ({ entity_id: s.entity_id, state: s.state, friendly_name: s.attributes.friendly_name })); +} + +export async function haCallService(domain, service, data = {}) { + return api(`/services/${domain}/${service}`, { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export async function haGetHistory(entityId, hours = 24) { + const start = new Date(Date.now() - hours * 3600 * 1000).toISOString(); + const data = await api(`/history/period/${start}?filter_entity_id=${entityId}&minimal_response=true`); + return data[0] || []; +} diff --git a/agap-mcp/src/kanboard.js b/agap-mcp/src/kanboard.js new file mode 100644 index 0000000..f18eb96 --- /dev/null +++ b/agap-mcp/src/kanboard.js @@ -0,0 +1,215 @@ +const BASE = () => process.env.KANBOARD_URL || 'http://localhost:4800'; +const BOT_USER = () => process.env.KANBOARD_BOT_USER || 'claude'; + +let _token = null; +let _botId = null; + +export function initKanboard(token) { + _token = token; + console.log('Kanboard: ready'); +} + +function token() { + if (!_token) throw new Error('Kanboard not initialized'); + return _token; +} + +// JSON-RPC over the app-wide token (login "jsonrpc:") — full access to all projects. +async function rpc(method, params = {}) { + const auth = Buffer.from(`jsonrpc:${token()}`).toString('base64'); + const res = await fetch(`${BASE()}/jsonrpc.php`, { + method: 'POST', + headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method, id: 1, params }), + }); + if (!res.ok) throw new Error(`Kanboard ${method}: ${res.status} ${await res.text()}`); + const json = await res.json(); + if (json.error) throw new Error(`Kanboard ${method}: ${json.error.message || JSON.stringify(json.error)}`); + return json.result; +} + +// Resolve + cache the bot user's id (default author for writes, filter for "my tasks"). +async function botUserId() { + if (_botId != null) return _botId; + const user = await rpc('getUserByName', { username: BOT_USER() }); + if (!user || !user.id) throw new Error(`Bot user not found in Kanboard: ${BOT_USER()}`); + _botId = parseInt(user.id); + return _botId; +} + +const STATUS = { open: 1, closed: 0 }; + +// --- Read / monitor --- + +export async function kbListProjects() { + const projects = await rpc('getAllProjects'); + return projects.map(p => ({ + id: parseInt(p.id), name: p.name, is_active: parseInt(p.is_active), + is_private: parseInt(p.is_private), owner_id: parseInt(p.owner_id), + })); +} + +export async function kbGetProject(projectId) { + const [project, columns, swimlanes] = await Promise.all([ + rpc('getProjectById', { project_id: projectId }), + rpc('getColumns', { project_id: projectId }), + rpc('getAllSwimlanes', { project_id: projectId }), + ]); + if (!project) throw new Error(`Project not found: ${projectId}`); + return { + id: parseInt(project.id), name: project.name, description: project.description, + is_active: parseInt(project.is_active), owner_id: parseInt(project.owner_id), + columns: columns.map(c => ({ id: parseInt(c.id), title: c.title, position: parseInt(c.position) })), + swimlanes: swimlanes.map(s => ({ id: parseInt(s.id), name: s.name, is_active: parseInt(s.is_active) })), + }; +} + +function compactTask(t) { + return { + id: parseInt(t.id), title: t.title, project_id: parseInt(t.project_id), + column_id: parseInt(t.column_id), swimlane_id: parseInt(t.swimlane_id), + owner_id: parseInt(t.owner_id), is_active: parseInt(t.is_active), + color_id: t.color_id, priority: parseInt(t.priority || 0), + date_due: parseInt(t.date_due) || 0, reference: t.reference || '', + }; +} + +export async function kbListTasks(projectId, status = 'open') { + let tasks = []; + if (status === 'all') { + const [open, closed] = await Promise.all([ + rpc('getAllTasks', { project_id: projectId, status_id: STATUS.open }), + rpc('getAllTasks', { project_id: projectId, status_id: STATUS.closed }), + ]); + tasks = [...open, ...closed]; + } else { + tasks = await rpc('getAllTasks', { project_id: projectId, status_id: STATUS[status] ?? STATUS.open }); + } + return tasks.map(compactTask); +} + +// Tasks assigned to the bot across every project. status: open | closed | all +export async function kbMyTasks(status = 'open') { + const botId = await botUserId(); + const projects = await rpc('getAllProjects'); + const out = []; + for (const p of projects) { + const tasks = await kbListTasks(parseInt(p.id), status); + for (const t of tasks) { + if (t.owner_id === botId) out.push({ ...t, project_name: p.name }); + } + } + return out; +} + +export async function kbGetTask(taskId) { + const task = await rpc('getTask', { task_id: taskId }); + if (!task) throw new Error(`Task not found: ${taskId}`); + const [subtasks, comments] = await Promise.all([ + rpc('getAllSubtasks', { task_id: taskId }), + rpc('getAllComments', { task_id: taskId }), + ]); + return { + task, + subtasks: (subtasks || []).map(s => ({ + id: parseInt(s.id), title: s.title, status: parseInt(s.status), + status_name: s.status_name, user_id: parseInt(s.user_id) || 0, assignee: s.name || s.username || null, + })), + comments: (comments || []).map(c => ({ + id: parseInt(c.id), user_id: parseInt(c.user_id), author: c.name || c.username, + date: parseInt(c.date_creation), content: c.comment, + })), + }; +} + +export async function kbSearchTasks(projectId, query) { + const tasks = await rpc('searchTasks', { project_id: projectId, query }); + return (tasks || []).map(compactTask); +} + +export async function kbListUsers() { + const users = await rpc('getAllUsers'); + return users.map(u => ({ + id: parseInt(u.id), username: u.username, name: u.name, + role: u.role, is_active: parseInt(u.is_active), email: u.email, + })); +} + +export async function kbProjectActivity(projectId) { + return rpc('getProjectActivity', { project_id: projectId }); +} + +// --- Write (creation/comments authored by the bot user) --- + +export async function kbCreateTask(params) { + const body = { ...params }; + if (body.creator_id == null) body.creator_id = await botUserId(); + const id = await rpc('createTask', body); + if (!id) throw new Error('createTask returned false (check required fields: title, project_id)'); + return { task_id: parseInt(id) }; +} + +export async function kbUpdateTask(params) { + const ok = await rpc('updateTask', params); + return { updated: !!ok }; +} + +// Move a card. project_id and swimlane_id are resolved from the task when omitted. +export async function kbMoveTask({ task_id, column_id, position = 1, swimlane_id, project_id }) { + if (project_id == null || swimlane_id == null) { + const task = await rpc('getTask', { task_id }); + if (!task) throw new Error(`Task not found: ${task_id}`); + if (project_id == null) project_id = parseInt(task.project_id); + if (swimlane_id == null) swimlane_id = parseInt(task.swimlane_id); + } + const ok = await rpc('moveTaskPosition', { project_id, task_id, column_id, position, swimlane_id }); + return { moved: !!ok }; +} + +export async function kbChangeTaskStatus(taskId, action) { + const method = action === 'close' ? 'closeTask' : 'openTask'; + const ok = await rpc(method, { task_id: taskId }); + return { [action === 'close' ? 'closed' : 'opened']: !!ok }; +} + +// Assign a task to a user (username or numeric id). +export async function kbAssignTask(taskId, owner) { + let ownerId = owner; + if (typeof owner === 'string' && !/^\d+$/.test(owner)) { + const user = await rpc('getUserByName', { username: owner }); + if (!user || !user.id) throw new Error(`User not found: ${owner}`); + ownerId = parseInt(user.id); + } + const ok = await rpc('updateTask', { id: taskId, owner_id: parseInt(ownerId) }); + return { assigned: !!ok, owner_id: parseInt(ownerId) }; +} + +export async function kbAddComment(taskId, content, userId) { + const uid = userId != null ? userId : await botUserId(); + const id = await rpc('createComment', { task_id: taskId, user_id: uid, content }); + if (!id) throw new Error('createComment returned false'); + return { comment_id: parseInt(id) }; +} + +export async function kbCreateSubtask(params) { + const body = { ...params }; + if (body.user_id == null) body.user_id = await botUserId(); + const id = await rpc('createSubtask', body); + if (!id) throw new Error('createSubtask returned false (required: task_id, title)'); + return { subtask_id: parseInt(id) }; +} + +export async function kbUpdateSubtask(params) { + const ok = await rpc('updateSubtask', params); + return { updated: !!ok }; +} + +export async function kbRemoveTask(taskId) { + const ok = await rpc('removeTask', { task_id: taskId }); + return { removed: !!ok }; +} + +export async function kbRemoveComment(commentId) { + const ok = await rpc('removeComment', { comment_id: commentId }); + return { removed: !!ok }; +} diff --git a/agap-mcp/src/radicale.js b/agap-mcp/src/radicale.js new file mode 100644 index 0000000..b6d6656 --- /dev/null +++ b/agap-mcp/src/radicale.js @@ -0,0 +1,198 @@ +import { randomUUID } from 'crypto'; + +const BASE = () => (process.env.RADICALE_URL || 'http://localhost:5232').replace(/\/$/, ''); +const DEFAULT_USER = () => process.env.RADICALE_USER || 'alvis'; + +let _password = null; + +export function initRadicale(password, _user) { + _password = password; + console.log('Radicale: ready'); +} + +function authHeader(user) { + if (!_password) throw new Error('Radicale not initialized'); + const u = user || DEFAULT_USER(); + return 'Basic ' + Buffer.from(`${u}:${_password}`).toString('base64'); +} + +async function dav(method, path, { user, headers = {}, body } = {}) { + const res = await fetch(`${BASE()}${path}`, { + method, + headers: { Authorization: authHeader(user), ...headers }, + body, + }); + const text = await res.text(); + if (!res.ok && res.status !== 207) { + throw new Error(`Radicale ${method} ${path}: ${res.status} ${text}`); + } + return { status: res.status, text, headers: res.headers }; +} + +function userPath(user) { + return `/${encodeURIComponent(user || DEFAULT_USER())}`; +} + +// Extract blocks; for each, pull href + displayname + resourcetype components +function parseMultistatus(xml) { + const responses = []; + const re = /<(?:[a-zA-Z0-9]+:)?response\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?response>/g; + let m; + while ((m = re.exec(xml)) !== null) { + const block = m[1]; + const href = (block.match(/<(?:[a-zA-Z0-9]+:)?href\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?href>/) || [])[1]; + const displayname = (block.match(/<(?:[a-zA-Z0-9]+:)?displayname\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?displayname>/) || [])[1]; + const isCalendar = /<(?:[a-zA-Z0-9]+:)?calendar\b/.test(block); + const isAddressbook = /<(?:[a-zA-Z0-9]+:)?addressbook\b/.test(block); + const isCollection = /<(?:[a-zA-Z0-9]+:)?collection\b/.test(block); + const getetag = (block.match(/<(?:[a-zA-Z0-9]+:)?getetag\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?getetag>/) || [])[1]; + const componentSet = []; + const csRe = /<(?:[a-zA-Z0-9]+:)?comp\b[^>]*\sname="([^"]+)"/g; + let cm; + while ((cm = csRe.exec(block)) !== null) componentSet.push(cm[1]); + responses.push({ href, displayname, isCalendar, isAddressbook, isCollection, getetag, componentSet }); + } + return responses; +} + +const PROPFIND_COLLECTIONS = ` + + + + + + + +`; + +const PROPFIND_EVENTS = ` + + + + + +`; + +export async function radicaleListCalendars(user) { + const { text } = await dav('PROPFIND', `${userPath(user)}/`, { + user, + headers: { Depth: '1', 'Content-Type': 'application/xml' }, + body: PROPFIND_COLLECTIONS, + }); + const items = parseMultistatus(text); + const out = []; + for (const r of items) { + if (!r.href || !r.isCalendar) continue; + // skip the parent (the user principal itself, which usually has no displayname) + const id = decodeURIComponent(r.href.replace(/\/$/, '').split('/').pop()); + if (!id || id === (user || DEFAULT_USER())) continue; + out.push({ + id, + displayname: r.displayname || null, + components: r.componentSet, + href: r.href, + }); + } + return out; +} + +function parseIcsField(ics, field, { component = 'VEVENT' } = {}) { + // restrict search to the named component block (default VEVENT) + const block = (() => { + if (!component) return ics; + const re = new RegExp(`BEGIN:${component}([\\s\\S]*?)END:${component}`); + const m = ics.match(re); + return m ? m[1] : ics; + })(); + const re = new RegExp(`(?:^|\\n)${field}(?:;[^:\\n]*)?:([^\\n\\r]*)`); + const m = block.match(re); + return m ? m[1].trim() : null; +} + +export async function radicaleListEvents(calendarId, user) { + const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/`; + const { text } = await dav('PROPFIND', path, { + user, + headers: { Depth: '1', 'Content-Type': 'application/xml' }, + body: PROPFIND_EVENTS, + }); + const items = parseMultistatus(text); + const out = []; + for (const r of items) { + if (!r.href || !r.href.endsWith('.ics')) continue; + const filename = decodeURIComponent(r.href.split('/').pop()); + // GET event to extract summary + start time + const ev = await dav('GET', r.href, { user }); + const summary = parseIcsField(ev.text, 'SUMMARY'); + const dtstart = parseIcsField(ev.text, 'DTSTART'); + const dtend = parseIcsField(ev.text, 'DTEND'); + const uid = parseIcsField(ev.text, 'UID'); + out.push({ filename, uid, summary, dtstart, dtend, etag: r.getetag, href: r.href }); + } + return out; +} + +export async function radicaleGetEvent(calendarId, filename, user) { + const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(filename)}`; + const { text } = await dav('GET', path, { user }); + return text; +} + +export async function radicaleCreateCalendar({ displayname, color, components = ['VEVENT'], user, id }) { + if (!displayname) throw new Error('displayname required'); + const calId = id || randomUUID(); + const compXml = components.map(c => ``).join(''); + const body = ` + + + + ${escapeXml(displayname)} + ${compXml} + ${color ? `${escapeXml(color)}` : ''} + + +`; + const path = `${userPath(user)}/${calId}/`; + await dav('MKCALENDAR', path, { + user, + headers: { 'Content-Type': 'application/xml' }, + body, + }); + return { id: calId, displayname, components }; +} + +export async function radicaleDeleteCalendar(calendarId, user) { + await dav('DELETE', `${userPath(user)}/${encodeURIComponent(calendarId)}/`, { user }); + return { deleted: calendarId }; +} + +export async function radicalePutEvent(calendarId, filename, ics, user) { + const name = filename.endsWith('.ics') ? filename : `${filename}.ics`; + const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(name)}`; + await dav('PUT', path, { + user, + headers: { 'Content-Type': 'text/calendar; charset=utf-8' }, + body: ics, + }); + return { put: name }; +} + +export async function radicaleDeleteEvent(calendarId, filename, user) { + const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(filename)}`; + await dav('DELETE', path, { user }); + return { deleted: filename }; +} + +export async function radicaleMoveEvent({ fromCalendar, toCalendar, filename, user }) { + if (!fromCalendar || !toCalendar || !filename) { + throw new Error('fromCalendar, toCalendar, filename required'); + } + const ics = await radicaleGetEvent(fromCalendar, filename, user); + await radicalePutEvent(toCalendar, filename, ics, user); + await radicaleDeleteEvent(fromCalendar, filename, user); + return { moved: filename, from: fromCalendar, to: toCalendar }; +} + +function escapeXml(s) { + return String(s).replace(/[<>&'"]/g, c => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c])); +} diff --git a/agap-mcp/src/server.js b/agap-mcp/src/server.js new file mode 100644 index 0000000..24dbe98 --- /dev/null +++ b/agap-mcp/src/server.js @@ -0,0 +1,442 @@ +import express from 'express'; +import { randomUUID } from 'crypto'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; + +import { initVaultwarden, vwGetPassword, vwGetItem, vwListItems, vwListOrgItems, vwCreateLogin, vwUpdatePassword } from './vaultwarden.js'; +import { initGitea, giteaListRepos, giteaReadFile, giteaWikiList, giteaWikiRead, giteaWikiWrite, giteaListIssues } from './gitea.js'; +import { initHA, haGetState, haListEntities, haCallService, haGetHistory } from './homeassistant.js'; +import { initZabbix, zabbixGetProblems, zabbixGetHosts, zabbixGetItems, zabbixGetTriggers } from './zabbix.js'; +import { initRadicale, radicaleListCalendars, radicaleListEvents, radicaleGetEvent, radicaleCreateCalendar, radicaleDeleteCalendar, radicalePutEvent, radicaleDeleteEvent, radicaleMoveEvent } from './radicale.js'; +import { initKanboard, kbListProjects, kbGetProject, kbListTasks, kbMyTasks, kbGetTask, kbSearchTasks, kbListUsers, kbProjectActivity, kbCreateTask, kbUpdateTask, kbMoveTask, kbChangeTaskStatus, kbAssignTask, kbAddComment, kbCreateSubtask, kbUpdateSubtask, kbRemoveTask, kbRemoveComment } from './kanboard.js'; + +const PORT = parseInt(process.env.PORT || '3100'); + +// --- Init services --- +async function init() { + await initVaultwarden(); + + // Fetch all tokens from org to avoid "more than one result" on duplicates + const orgItems = vwListOrgItems(); + const orgToken = (name) => { + const item = orgItems.find(i => i.name === name); + if (!item) throw new Error(`Token not found in org: ${name}`); + return item.login?.password; + }; + + const gitea_token = orgToken('GITEA_TOKEN'); + const ha_token = orgToken('HA_TOKEN'); + const zabbix_token = orgToken('ZABBIX_TOKEN'); + const radicale_password = orgToken('RADICALE_PASSWORD'); + const kanboard_token = orgToken('KANBOARD_TOKEN'); + + initGitea(gitea_token); + initHA(ha_token); + initZabbix(zabbix_token); + initRadicale(radicale_password); + initKanboard(kanboard_token); + +} + +// --- MCP Server factory (one per session — McpServer can't share transports) --- +function ok(text) { + return { content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }] }; +} + +function err(e) { + return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }; +} + +function createServer() { + const server = new McpServer({ name: 'agap-mcp', version: '1.0.0' }); + +// --- Vaultwarden tools --- + server.tool('vw_get_password', 'Get password for a Vaultwarden item by name', { name: z.string() }, + async ({ name }) => { + try { return ok(vwGetPassword(name)); } catch (e) { return err(e); } + }); + + server.tool('vw_get_item', 'Get full details of a Vaultwarden item (name, username, password, url, notes)', { name: z.string() }, + async ({ name }) => { + try { + const item = vwGetItem(name); + return ok({ name: item.name, username: item.login?.username, password: item.login?.password, url: item.login?.uris?.[0]?.uri, notes: item.notes }); + } catch (e) { return err(e); } + }); + + server.tool('vw_list_items', 'List Vaultwarden items. Searches personal vault by default; set org=true to search org/AI collection', { + search: z.string().optional(), + org: z.boolean().optional(), +}, async ({ search, org }) => { + try { + const items = org ? vwListOrgItems(search) : vwListItems(search); + return ok(items.map(i => ({ id: i.id, name: i.name, username: i.login?.username, url: i.login?.uris?.[0]?.uri }))); + } catch (e) { return err(e); } +}); + + server.tool('vw_create_login', 'Create a new login item in Vaultwarden AI collection', { + name: z.string(), + username: z.string().optional(), + password: z.string(), + url: z.string().optional(), + notes: z.string().optional(), +}, async (args) => { + try { return ok(vwCreateLogin(args)); } catch (e) { return err(e); } +}); + + server.tool('vw_update_password', 'Update the password of an existing Vaultwarden item', { + name: z.string().describe('Item name or ID'), + password: z.string(), +}, async ({ name, password }) => { + try { return ok(vwUpdatePassword(name, password)); } catch (e) { return err(e); } +}); + +// --- Gitea tools --- + server.tool('gitea_list_repos', 'List all Gitea repositories', {}, + async () => { + try { return ok(await giteaListRepos()); } catch (e) { return err(e); } + }); + + server.tool('gitea_read_file', 'Read a file from a Gitea repository', { + repo: z.string().describe('e.g. alvis/AgapHost'), + path: z.string().describe('file path in repo'), + ref: z.string().optional().describe('branch/tag/commit, default HEAD'), +}, async ({ repo, path, ref }) => { + try { return ok(await giteaReadFile(repo, path, ref)); } catch (e) { return err(e); } +}); + + server.tool('gitea_wiki_list', 'List wiki pages', { + repo: z.string().optional().describe('default: alvis/AgapHost'), +}, async ({ repo }) => { + try { return ok(await giteaWikiList(repo)); } catch (e) { return err(e); } +}); + + server.tool('gitea_wiki_read', 'Read a wiki page', { + page: z.string(), + repo: z.string().optional().describe('default: alvis/AgapHost'), +}, async ({ page, repo }) => { + try { return ok(await giteaWikiRead(page, repo)); } catch (e) { return err(e); } +}); + + server.tool('gitea_wiki_write', 'Write/update a wiki page', { + page: z.string(), + content: z.string(), + message: z.string().optional().describe('commit message'), + repo: z.string().optional().describe('default: alvis/AgapHost'), +}, async ({ page, content, message, repo }) => { + try { return ok(await giteaWikiWrite(page, content, message, repo)); } catch (e) { return err(e); } +}); + + server.tool('gitea_list_issues', 'List issues for a Gitea repo', { + repo: z.string().describe('e.g. alvis/AgapHost'), + state: z.enum(['open', 'closed', 'all']).optional(), +}, async ({ repo, state }) => { + try { return ok(await giteaListIssues(repo, state)); } catch (e) { return err(e); } +}); + +// --- Home Assistant tools --- + server.tool('ha_get_state', 'Get current state of a Home Assistant entity', { + entity_id: z.string().describe('e.g. light.living_room'), +}, async ({ entity_id }) => { + try { return ok(await haGetState(entity_id)); } catch (e) { return err(e); } +}); + + server.tool('ha_list_entities', 'List Home Assistant entities, optionally filtered by domain', { + domain: z.string().optional().describe('e.g. light, switch, sensor, binary_sensor'), +}, async ({ domain }) => { + try { return ok(await haListEntities(domain)); } catch (e) { return err(e); } +}); + + server.tool('ha_call_service', 'Call a Home Assistant service', { + domain: z.string().describe('e.g. light, switch, automation'), + service: z.string().describe('e.g. turn_on, turn_off, toggle'), + data: z.record(z.unknown()).optional().describe('service call data, e.g. {"entity_id": "light.x"}'), +}, async ({ domain, service, data }) => { + try { return ok(await haCallService(domain, service, data)); } catch (e) { return err(e); } +}); + + server.tool('ha_get_history', 'Get state history for a Home Assistant entity', { + entity_id: z.string(), + hours: z.number().optional().describe('hours of history, default 24'), +}, async ({ entity_id, hours }) => { + try { return ok(await haGetHistory(entity_id, hours)); } catch (e) { return err(e); } +}); + +// --- Zabbix tools --- + server.tool('zabbix_get_problems', 'Get current active problems in Zabbix', {}, + async () => { + try { return ok(await zabbixGetProblems()); } catch (e) { return err(e); } + }); + + server.tool('zabbix_get_hosts', 'List all monitored hosts in Zabbix with availability status', {}, + async () => { + try { return ok(await zabbixGetHosts()); } catch (e) { return err(e); } + }); + + server.tool('zabbix_get_items', 'Get monitored items and their latest values for a Zabbix host', { + hostid: z.string().describe('Zabbix host ID'), +}, async ({ hostid }) => { + try { return ok(await zabbixGetItems(hostid)); } catch (e) { return err(e); } +}); + + server.tool('zabbix_get_triggers', 'Get triggers for a Zabbix host or all hosts', { + hostid: z.string().optional().describe('Zabbix host ID, omit for all hosts'), +}, async ({ hostid }) => { + try { return ok(await zabbixGetTriggers(hostid)); } catch (e) { return err(e); } +}); + +// --- Radicale (CalDAV) tools --- + server.tool('radicale_list_calendars', 'List CalDAV calendars for a Radicale user (defaults to alvis)', { + user: z.string().optional(), + }, async ({ user }) => { + try { return ok(await radicaleListCalendars(user)); } catch (e) { return err(e); } + }); + + server.tool('radicale_list_events', 'List events in a Radicale calendar (returns filename, uid, summary, dtstart, dtend)', { + calendar_id: z.string().describe('Calendar UUID from radicale_list_calendars'), + user: z.string().optional(), + }, async ({ calendar_id, user }) => { + try { return ok(await radicaleListEvents(calendar_id, user)); } catch (e) { return err(e); } + }); + + server.tool('radicale_get_event', 'Get raw iCalendar text for a single event', { + calendar_id: z.string(), + filename: z.string().describe('e.g. ABCD-1234.ics'), + user: z.string().optional(), + }, async ({ calendar_id, filename, user }) => { + try { return ok(await radicaleGetEvent(calendar_id, filename, user)); } catch (e) { return err(e); } + }); + + server.tool('radicale_create_calendar', 'Create a new CalDAV calendar (MKCALENDAR). Returns the new calendar id.', { + displayname: z.string(), + color: z.string().optional().describe('e.g. #33CC66ff'), + components: z.array(z.string()).optional().describe('default ["VEVENT"]'), + id: z.string().optional().describe('explicit collection id; otherwise UUID generated'), + user: z.string().optional(), + }, async (args) => { + try { return ok(await radicaleCreateCalendar(args)); } catch (e) { return err(e); } + }); + + server.tool('radicale_delete_calendar', 'Delete a Radicale calendar collection', { + calendar_id: z.string(), + user: z.string().optional(), + }, async ({ calendar_id, user }) => { + try { return ok(await radicaleDeleteCalendar(calendar_id, user)); } catch (e) { return err(e); } + }); + + server.tool('radicale_put_event', 'PUT an iCalendar event into a calendar (create or replace)', { + calendar_id: z.string(), + filename: z.string().describe('Event filename, with or without .ics'), + ics: z.string().describe('Full VCALENDAR body'), + user: z.string().optional(), + }, async ({ calendar_id, filename, ics, user }) => { + try { return ok(await radicalePutEvent(calendar_id, filename, ics, user)); } catch (e) { return err(e); } + }); + + server.tool('radicale_delete_event', 'Delete an event from a calendar', { + calendar_id: z.string(), + filename: z.string(), + user: z.string().optional(), + }, async ({ calendar_id, filename, user }) => { + try { return ok(await radicaleDeleteEvent(calendar_id, filename, user)); } catch (e) { return err(e); } + }); + + server.tool('radicale_move_event', 'Move an event between calendars (copy then delete original)', { + from_calendar: z.string(), + to_calendar: z.string(), + filename: z.string(), + user: z.string().optional(), + }, async ({ from_calendar, to_calendar, filename, user }) => { + try { return ok(await radicaleMoveEvent({ fromCalendar: from_calendar, toCalendar: to_calendar, filename, user })); } catch (e) { return err(e); } + }); + +// --- Kanboard tools --- + server.tool('kanboard_list_projects', 'List all Kanboard projects (id, name, active, owner)', {}, + async () => { + try { return ok(await kbListProjects()); } catch (e) { return err(e); } + }); + + server.tool('kanboard_get_project', 'Get a Kanboard project with its columns and swimlanes (needed to move cards)', { + project_id: z.number().describe('Project ID'), +}, async ({ project_id }) => { + try { return ok(await kbGetProject(project_id)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_list_tasks', 'List tasks in a Kanboard project', { + project_id: z.number(), + status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'), +}, async ({ project_id, status }) => { + try { return ok(await kbListTasks(project_id, status)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_my_tasks', "List tasks assigned to the bot user (claude) across all projects", { + status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'), +}, async ({ status }) => { + try { return ok(await kbMyTasks(status)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_get_task', 'Get full detail of a Kanboard task, including its subtasks and comments', { + task_id: z.number(), +}, async ({ task_id }) => { + try { return ok(await kbGetTask(task_id)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_search_tasks', 'Search tasks in a project using Kanboard query syntax (e.g. "assignee:claude status:open", "due:today", "color:red")', { + project_id: z.number(), + query: z.string(), +}, async ({ project_id, query }) => { + try { return ok(await kbSearchTasks(project_id, query)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_list_users', 'List all Kanboard users (id, username, name, role)', {}, + async () => { + try { return ok(await kbListUsers()); } catch (e) { return err(e); } + }); + + server.tool('kanboard_project_activity', 'Get the recent activity stream for a project (who did what)', { + project_id: z.number(), +}, async ({ project_id }) => { + try { return ok(await kbProjectActivity(project_id)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_create_task', 'Create a Kanboard task (authored by claude). At minimum provide title and project_id', { + title: z.string(), + project_id: z.number(), + description: z.string().optional().describe('Markdown'), + column_id: z.number().optional(), + owner_id: z.number().optional().describe('Assignee user id'), + color_id: z.string().optional().describe('e.g. yellow, blue, red, green'), + date_due: z.string().optional().describe('YYYY-MM-DD'), + priority: z.number().optional(), + swimlane_id: z.number().optional(), +}, async (args) => { + try { return ok(await kbCreateTask(args)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_update_task', 'Update fields of a Kanboard task', { + id: z.number().describe('Task id'), + title: z.string().optional(), + description: z.string().optional(), + owner_id: z.number().optional(), + color_id: z.string().optional(), + date_due: z.string().optional().describe('YYYY-MM-DD'), + priority: z.number().optional(), + category_id: z.number().optional(), +}, async (args) => { + try { return ok(await kbUpdateTask(args)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_move_task', 'Move a task to a column/position (e.g. to "Work in progress" or "Done"). project_id and swimlane_id are auto-resolved if omitted', { + task_id: z.number(), + column_id: z.number().describe('Target column id (see kanboard_get_project)'), + position: z.number().optional().describe('default 1 (top)'), + swimlane_id: z.number().optional(), + project_id: z.number().optional(), +}, async (args) => { + try { return ok(await kbMoveTask(args)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_change_task_status', 'Open or close (mark done/archive) a task', { + task_id: z.number(), + action: z.enum(['open', 'close']), +}, async ({ task_id, action }) => { + try { return ok(await kbChangeTaskStatus(task_id, action)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_assign_task', 'Assign a task to a user (username or numeric id). Use "claude" to take it', { + task_id: z.number(), + owner: z.union([z.string(), z.number()]).describe('Username or user id'), +}, async ({ task_id, owner }) => { + try { return ok(await kbAssignTask(task_id, owner)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_add_comment', 'Add a comment to a task (authored by claude by default)', { + task_id: z.number(), + content: z.string().describe('Markdown'), + user_id: z.number().optional().describe('Override author; defaults to claude'), +}, async ({ task_id, content, user_id }) => { + try { return ok(await kbAddComment(task_id, content, user_id)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_create_subtask', 'Add a subtask to a task (good for tracking execution steps)', { + task_id: z.number(), + title: z.string(), + user_id: z.number().optional().describe('Assignee; defaults to claude'), + status: z.number().optional().describe('0=todo, 1=in progress, 2=done'), +}, async (args) => { + try { return ok(await kbCreateSubtask(args)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_update_subtask', 'Update a subtask (e.g. mark in-progress or done)', { + id: z.number().describe('Subtask id'), + task_id: z.number(), + title: z.string().optional(), + status: z.number().optional().describe('0=todo, 1=in progress, 2=done'), + user_id: z.number().optional(), +}, async (args) => { + try { return ok(await kbUpdateSubtask(args)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_remove_task', 'Permanently delete a task (irreversible)', { + task_id: z.number(), +}, async ({ task_id }) => { + try { return ok(await kbRemoveTask(task_id)); } catch (e) { return err(e); } +}); + + server.tool('kanboard_remove_comment', 'Permanently delete a comment (irreversible)', { + comment_id: z.number(), +}, async ({ comment_id }) => { + try { return ok(await kbRemoveComment(comment_id)); } catch (e) { return err(e); } +}); + + return server; +} + +// --- HTTP server (Streamable HTTP + legacy SSE) --- +const app = express(); +app.use(express.json()); + +const sseTransports = new Map(); + +// Streamable HTTP — stateless: fresh server per request, survives container restarts +app.all('/mcp', async (req, res) => { + try { + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on('close', () => transport.close()); + await createServer().connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (e) { + console.error('MCP request error:', e.message); + if (!res.headersSent) { + res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null }); + } + } +}); + +// Legacy SSE — kept for backward compatibility +app.get('/sse', async (req, res) => { + const transport = new SSEServerTransport('/messages', res); + sseTransports.set(transport.sessionId, transport); + res.on('close', () => sseTransports.delete(transport.sessionId)); + await createServer().connect(transport); +}); + +app.post('/messages', async (req, res) => { + const transport = sseTransports.get(req.query.sessionId); + if (!transport) return res.status(400).send('Unknown session'); + await transport.handlePostMessage(req, res); +}); + +app.get('/health', (_, res) => res.json({ status: 'ok', tools: 45 })); + +init() + .then(() => { + app.listen(PORT, () => console.log(`agap-mcp listening on :${PORT}`)); + }) + .catch(e => { + console.error('Init failed:', e.message); + process.exit(1); + }); diff --git a/agap-mcp/src/vaultwarden.js b/agap-mcp/src/vaultwarden.js new file mode 100644 index 0000000..46a1213 --- /dev/null +++ b/agap-mcp/src/vaultwarden.js @@ -0,0 +1,102 @@ +import { execFileSync } from 'child_process'; + +const BW = 'bw'; +const ORG_ID = '4bd75130-b4d3-48d4-a4cb-e52b70295a51'; +const AI_COLLECTION = '5be27a82-8475-4c38-96b2-fa94ec8c957b'; + +let _session = null; + +function bwEnv() { + const env = { ...process.env }; + for (const k of ['HTTPS_PROXY','HTTP_PROXY','ALL_PROXY','https_proxy','http_proxy','all_proxy']) + delete env[k]; + env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + return env; +} + +function run(args, input) { + return execFileSync(BW, args, { + env: bwEnv(), + encoding: 'utf8', + input, + stdio: input ? ['pipe','pipe','pipe'] : ['ignore','pipe','pipe'], + }).trim(); +} + +export async function initVaultwarden() { + const email = process.env.BW_EMAIL || 'allogn@gmail.com'; + const password = process.env.BW_PASSWORD; + + // Data dir is mounted from host — server already configured, skip bw config server + + let status = 'unauthenticated'; + try { + status = JSON.parse(run(['status'])).status; + } catch {} + + if (status === 'unauthenticated') { + run(['login', email, password, '--raw']); + } + + _session = run(['unlock', password, '--raw']); + run(['sync', '--session', _session]); + console.log('Vaultwarden: ready'); +} + +function session() { + if (!_session) throw new Error('Vaultwarden not initialized'); + return _session; +} + +export function vwGetPassword(name) { + return run(['get', 'password', name, '--session', session()]); +} + +export function vwGetItem(name) { + return JSON.parse(run(['get', 'item', name, '--session', session()])); +} + +export function vwListItems(search) { + const args = ['list', 'items', '--session', session()]; + if (search) args.push('--search', search); + return JSON.parse(run(args)); +} + +export function vwListOrgItems(search) { + const args = ['list', 'items', '--organizationid', ORG_ID, '--session', session()]; + if (search) args.push('--search', search); + return JSON.parse(run(args)); +} + +export function vwCreateLogin({ name, username, password, url, notes }) { + const item = { + organizationId: ORG_ID, + collectionIds: [AI_COLLECTION], + folderId: null, + type: 1, + name, + notes: notes || null, + favorite: false, + login: { + username: username || null, + password, + uris: url ? [{ match: null, uri: url }] : [], + }, + }; + const encoded = run(['encode'], JSON.stringify(item)); + return JSON.parse(run(['create', 'item', encoded, '--session', session()])); +} + +export function vwUpdatePassword(nameOrId, newPassword) { + let item; + try { + item = JSON.parse(run(['get', 'item', nameOrId, '--session', session()])); + } catch { + const items = vwListOrgItems(nameOrId); + item = items.find(i => i.name === nameOrId); + if (!item) throw new Error(`Item not found: ${nameOrId}`); + } + item.login.password = newPassword; + const encoded = run(['encode'], JSON.stringify(item)); + return JSON.parse(run(['edit', 'item', item.id, encoded, '--session', session()])); +} diff --git a/agap-mcp/src/zabbix.js b/agap-mcp/src/zabbix.js new file mode 100644 index 0000000..ef7d316 --- /dev/null +++ b/agap-mcp/src/zabbix.js @@ -0,0 +1,75 @@ +const BASE = () => `${process.env.ZABBIX_URL || 'http://localhost:81'}/api_jsonrpc.php`; +let _token = null; +let _reqId = 1; + +export function initZabbix(token) { + _token = token; + console.log('Zabbix: ready'); +} + +async function api(method, params = {}) { + const res = await fetch(BASE(), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${_token}` }, + body: JSON.stringify({ jsonrpc: '2.0', method, params, id: _reqId++ }), + }); + const data = await res.json(); + if (data.error) throw new Error(`Zabbix ${method}: ${data.error.data}`); + return data.result; +} + +export async function zabbixGetProblems() { + const problems = await api('problem.get', { + output: ['eventid', 'name', 'severity', 'clock', 'acknowledged'], + selectAcknowledges: 'count', + recent: true, + sortfield: 'eventid', + sortorder: 'DESC', + }); + return problems.map(p => ({ + id: p.eventid, + name: p.name, + severity: ['Not classified','Information','Warning','Average','High','Disaster'][+p.severity], + time: new Date(+p.clock * 1000).toISOString(), + acknowledged: +p.acknowledged > 0, + })); +} + +export async function zabbixGetHosts() { + return api('host.get', { + output: ['hostid', 'host', 'name', 'available', 'status'], + selectInterfaces: ['ip'], + }).then(hosts => hosts.map(h => ({ + id: h.hostid, + name: h.name || h.host, + available: ['Unknown','Available','Unavailable'][+h.available] || 'Unknown', + enabled: h.status === '0', + ip: h.interfaces?.[0]?.ip, + }))); +} + +export async function zabbixGetItems(hostid) { + return api('item.get', { + output: ['itemid', 'name', 'key_', 'lastvalue', 'units', 'lastclock'], + hostids: hostid, + monitored: true, + sortfield: 'name', + }).then(items => items.map(i => ({ + name: i.name, + key: i.key_, + value: i.lastvalue, + units: i.units, + updated: new Date(+i.lastclock * 1000).toISOString(), + }))); +} + +export async function zabbixGetTriggers(hostid) { + const params = { output: ['triggerid','description','priority','value'], active: 1, monitored: 1 }; + if (hostid) params.hostids = hostid; + return api('trigger.get', params).then(triggers => triggers.map(t => ({ + id: t.triggerid, + name: t.description, + severity: ['Not classified','Information','Warning','Average','High','Disaster'][+t.priority], + problem: t.value === '1', + }))); +} diff --git a/agap-mcp/start.sh b/agap-mcp/start.sh new file mode 100644 index 0000000..7b9d5ae --- /dev/null +++ b/agap-mcp/start.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exec node src/server.js diff --git a/anki/Dockerfile b/anki/Dockerfile new file mode 100644 index 0000000..2a1ab04 --- /dev/null +++ b/anki/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim + +RUN pip install --no-cache-dir anki + +EXPOSE 8080 + +VOLUME /anki_data + +ENV SYNC_BASE=/anki_data + +CMD ["python", "-m", "anki.syncserver"] diff --git a/anki/docker-compose.yml b/anki/docker-compose.yml new file mode 100644 index 0000000..caed19f --- /dev/null +++ b/anki/docker-compose.yml @@ -0,0 +1,17 @@ +name: anki +services: + app: + build: . + image: anki-sync-server:local + container_name: anki-sync-server + restart: always + ports: + - "127.0.0.1:8180:8080" + volumes: + - data:/anki_data + environment: + - SYNC_USER1=${ANKI_USER1:-admin:changeme} + - SYNC_BASE=/anki_data + +volumes: + data: diff --git a/family/Dockerfile b/family/Dockerfile new file mode 100644 index 0000000..717460e --- /dev/null +++ b/family/Dockerfile @@ -0,0 +1,6 @@ +FROM mediawiki:latest + +RUN apt-get update && apt-get install -y ffmpeg unzip && rm -rf /var/lib/apt/lists/* + +RUN curl -sL "https://extdist.wmflabs.org/dist/extensions/TimedMediaHandler-REL1_44-ef5edcf.tar.gz" \ + | tar -xz -C /var/www/html/extensions/ diff --git a/family/IMG_0448.JPG b/family/IMG_0448.JPG new file mode 100644 index 0000000..8749255 Binary files /dev/null and b/family/IMG_0448.JPG differ diff --git a/family/LocalSettings.php b/family/LocalSettings.php new file mode 100644 index 0000000..53a8423 --- /dev/null +++ b/family/LocalSettings.php @@ -0,0 +1,165 @@ + "$wgResourceBasePath/images/logo.jpg", + 'icon' => "$wgResourceBasePath/images/logo.jpg", +]; + +## UPO means: this is also a user preference option + +$wgEnableEmail = true; +$wgEnableUserEmail = true; # UPO + +$wgEmergencyContact = ""; +$wgPasswordSender = ""; + +$wgEnotifUserTalk = false; # UPO +$wgEnotifWatchlist = false; # UPO +$wgEmailAuthentication = true; + +## Database settings +$wgDBtype = "mysql"; +$wgDBserver = "db"; +$wgDBname = "mediawiki"; +$wgDBuser = "mw_k7px2q"; +$wgDBpassword = "Vt9#mLqR4wXn8bZ2"; + +# MySQL specific settings +$wgDBprefix = "fw"; +$wgDBssl = false; + +# MySQL table options to use during installation or update +$wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary"; + +# Shared database table +# This has no effect unless $wgSharedDB is also set. +$wgSharedTables[] = "actor"; + +## Shared memory settings +$wgMainCacheType = CACHE_ACCEL; +$wgMemCachedServers = []; + +## To enable image uploads, make sure the 'images' directory +## is writable, then set this to true: +$wgEnableUploads = true; +$wgMaxUploadSize = 20 * 1024 * 1024; // 20MB +$wgFileExtensions = array_merge( $wgFileExtensions, [ + 'png', 'gif', 'jpg', 'jpeg', 'webp', + 'mp4', 'webm', 'ogv', + 'mp3', 'ogg', 'oga', 'wav', 'flac', +] ); +$wgUseImageMagick = true; +$wgImageMagickConvertCommand = "/usr/bin/convert"; + +# InstantCommons allows wiki to use images from https://commons.wikimedia.org +$wgUseInstantCommons = false; + +# Periodically send a pingback to https://www.mediawiki.org/ with basic data +# about this MediaWiki instance. The Wikimedia Foundation shares this data +# with MediaWiki developers to help guide future development efforts. +$wgPingback = true; + +# Site language code, should be one of the list in ./includes/languages/data/Names.php +$wgLanguageCode = "ru"; + +# Time zone +$wgLocaltimezone = "UTC"; + +## Set $wgCacheDirectory to a writable directory on the web server +## to make your wiki go slightly faster. The directory should not +## be publicly accessible from the web. +#$wgCacheDirectory = "$IP/cache"; + +$wgSecretKey = "5156b70f5486793efade503a2301caf53b9fe241505868a90e33509814fb7d1f"; + +# Changing this will log out all existing sessions. +$wgAuthenticationTokenVersion = "1"; + +# Bust ResourceLoader CSS cache +$wgCacheEpoch = '20260403000001'; + +# Site upgrade key. Must be set to a string (default provided) to turn on the +# web installer while LocalSettings.php is in place +$wgUpgradeKey = "da11c2f2774f498f"; + +## For attaching licensing metadata to pages, and displaying an +## appropriate copyright notice / icon. GNU Free Documentation +## License and Creative Commons licenses are supported so far. +$wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright +$wgRightsUrl = ""; +$wgRightsText = ""; +$wgRightsIcon = ""; + +# Path to the GNU diff3 utility. Used for conflict resolution. +$wgDiff3 = "/usr/bin/diff3"; + +## Default skin: you can change the default skin. Use the internal symbolic +## names, e.g. 'vector' or 'monobook': +$wgDefaultSkin = "vector-2022"; + +# Enabled skins. +# The following skins were automatically enabled: +wfLoadSkin( 'MinervaNeue' ); +wfLoadSkin( 'MonoBook' ); +wfLoadSkin( 'Timeless' ); +wfLoadSkin( 'Vector' ); + + +wfLoadExtension( 'Cite' ); +wfLoadExtension( 'MultimediaViewer' ); +wfLoadExtension( 'ParserFunctions' ); +wfLoadExtension( 'VisualEditor' ); +wfLoadExtension( 'TimedMediaHandler' ); + +$wgDefaultUserOptions['visualeditor-enable'] = 1; +$wgDefaultUserOptions['visualeditor-editor'] = 'visualeditor'; +$wgVisualEditorParsoidAutoConfig = true; + +# End of automatically generated settings. +# Add more configuration options below. + +# Restrict all access to logged-in users only +$wgGroupPermissions['*']['read'] = false; +$wgGroupPermissions['*']['edit'] = false; +$wgGroupPermissions['*']['createaccount'] = false; + +# Only admins can create accounts +$wgGroupPermissions['sysop']['createaccount'] = true; diff --git a/family/__pycache__/migrate.cpython-312.pyc b/family/__pycache__/migrate.cpython-312.pyc new file mode 100644 index 0000000..f287b54 Binary files /dev/null and b/family/__pycache__/migrate.cpython-312.pyc differ diff --git a/family/docker-compose.yml b/family/docker-compose.yml new file mode 100644 index 0000000..25bfcac --- /dev/null +++ b/family/docker-compose.yml @@ -0,0 +1,32 @@ +services: + mediawiki: + build: . + image: mediawiki-tmh:latest + restart: unless-stopped + ports: + - "8099:80" + volumes: + - /mnt/ssd/dbs/wiki/mediawiki_images:/var/www/html/images + - ./LocalSettings.php:/var/www/html/LocalSettings.php # uncomment after initial setup + - ./IMG_0448.JPG:/var/www/html/images/logo.jpg + - ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini + environment: + MEDIAWIKI_DB_HOST: db + MEDIAWIKI_DB_NAME: mediawiki + MEDIAWIKI_DB_USER: mw_k7px2q + MEDIAWIKI_DB_PASSWORD: Vt9#mLqR4wXn8bZ2 + depends_on: + - db + + db: + image: mariadb:lts + restart: unless-stopped + environment: + MYSQL_DATABASE: mediawiki + MYSQL_USER: mw_k7px2q + MYSQL_PASSWORD: Vt9#mLqR4wXn8bZ2 + MYSQL_RANDOM_ROOT_PASSWORD: "yes" + volumes: + - /mnt/ssd/dbs/wiki/mediawiki_db:/var/lib/mysql + + diff --git a/family/migrate.py b/family/migrate.py new file mode 100644 index 0000000..0ec0263 --- /dev/null +++ b/family/migrate.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +"""OtterWiki → MediaWiki migration script.""" + +import argparse +import re +import subprocess +from pathlib import Path + +import requests + +REPO = Path('/mnt/ssd/dbs/otter/app-data/repository') +API = 'http://localhost:8099/api.php' + +_FN_DEF = re.compile(r'^\[(\^[^\]]+)\]:\s*(.*)') + +# Cached pandoc availability (None = not yet checked) +_PANDOC_AVAILABLE: bool | None = None + + +def _cap(s: str) -> str: + """Title-case: capitalize first letter of each word.""" + return s.title() if s else s + + +def _pandoc_available() -> bool: + global _PANDOC_AVAILABLE + if _PANDOC_AVAILABLE is None: + try: + _PANDOC_AVAILABLE = subprocess.run( + ['pandoc', '--version'], capture_output=True + ).returncode == 0 + except FileNotFoundError: + _PANDOC_AVAILABLE = False + return _PANDOC_AVAILABLE + + +# --------------------------------------------------------------------------- +# MediaWiki session +# --------------------------------------------------------------------------- + +def mw_login(user: str, password: str): + s = requests.Session() + r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'type': 'login', 'format': 'json'}) + token = r.json()['query']['tokens']['logintoken'] + s.post(API, data={'action': 'login', 'lgname': user, 'lgpassword': password, + 'lgtoken': token, 'format': 'json'}) + r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'format': 'json'}) + csrf = r.json()['query']['tokens']['csrftoken'] + return s, csrf + + +# --------------------------------------------------------------------------- +# Title determination +# --------------------------------------------------------------------------- + +def page_title(md_path: Path) -> str: + parts = md_path.relative_to(REPO).parts + if md_path.name == 'home.md' and len(parts) == 1: + return 'Заглавная страница' + return _cap(md_path.stem) + + +# --------------------------------------------------------------------------- +# Markdown → wikitext conversion +# --------------------------------------------------------------------------- + +def convert_pandoc(text: str) -> str: + return subprocess.run( + ['pandoc', '-f', 'markdown', '-t', 'mediawiki'], + input=text, capture_output=True, text=True + ).stdout + + +def convert_python(text: str, skip_info: bool = False) -> str: + lines = text.split('\n') + + # Collect footnote definitions in one pass + footnotes: dict[str, str] = {} + for line in lines: + m = _FN_DEF.match(line) + if m: + footnotes[m.group(1)] = m.group(2) + + def replace_fn(m): + key = m.group(0)[1:-1] # strip outer [ ] to match footnotes dict keys + content = footnotes.get(key, m.group(0)) + content = re.sub(r'\[([^\]^][^\]]*)\]\(([^)]+)\)', r'[\2 \1]', content) + return f'{content}' + + out = [] + for line in lines: + if _FN_DEF.match(line): + continue + + m = re.match(r'^(#{1,6})\s+(.*)', line) + if m: + eq = '=' * len(m.group(1)) + out.append(f'{eq} {m.group(2)} {eq}') + continue + + if re.match(r'^---+$', line.strip()): + out.append('----') + continue + + line = re.sub(r'^(\s*)-(\s)', r'\1*\2', line) + line = re.sub(r'\*\*\*(.+?)\*\*\*', r"'''''\1'''''", line) + line = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", line) + line = re.sub(r'\*(.+?)\*', r"''\1''", line) + line = re.sub(r'(?' + return result + + +def _split_cells(line: str) -> list[str]: + """Split a markdown table row on | but not inside [[ ]].""" + cells = [] + depth = 0 + current = [] + i = 0 + # Strip leading/trailing | + line = line.strip() + if line.startswith('|'): + line = line[1:] + if line.endswith('|'): + line = line[:-1] + while i < len(line): + if line[i:i+2] == '[[': + depth += 1 + current.append('[[') + i += 2 + elif line[i:i+2] == ']]': + depth -= 1 + current.append(']]') + i += 2 + elif line[i] == '|' and depth == 0: + cells.append(''.join(current).strip()) + current = [] + i += 1 + else: + current.append(line[i]) + i += 1 + cells.append(''.join(current).strip()) + return cells + + +def convert_tables(text: str, skip_info: bool = False) -> str: + lines = text.split('\n') + out = [] + in_table = False + skip_table = False + + for line in lines: + if re.match(r'^\|', line): + cells = _split_cells(line) + if all(re.match(r'^:?-+:?$', c) for c in cells if c): + # Separator row: start or continue table + if not in_table: + header_line = out.pop() if out else '' + hcells = _split_cells(header_line) + # Blank-header table (all-empty header cells) = OtterWiki info table + if skip_info and all(c == '' for c in hcells): + skip_table = True + in_table = True + else: + out += ['{| class="wikitable"', '|-', '! ' + ' !! '.join(hcells)] + in_table = True + skip_table = False + if not skip_table: + out.append('|-') + else: + if in_table: + if not skip_table: + out.append('|-') + out.append('| ' + ' || '.join(cells)) + # else: skip info table row + else: + out.append(line) + else: + if in_table: + if not skip_table: + out.append('|}') + in_table = False + skip_table = False + out.append(line) + + if in_table and not skip_table: + out.append('|}') + + return '\n'.join(out) + + +_PERSON_FIELD_MAP = { + 'родился': 'родился', 'родилась': 'родился', + 'умер': 'умер', 'умерла': 'умер', + 'отец': 'отец', 'мать': 'мать', + 'супруг': 'супруг', 'супруга': 'супруг', 'муж': 'супруг', 'жена': 'супруг', + 'дети': 'дети', 'ребёнок': 'дети', + 'братья': 'братья', 'брат': 'братья', 'сестра': 'братья', + 'сёстры': 'братья', 'сестры': 'братья', + 'место рождения': 'место_рождения', 'место_рождения': 'место_рождения', + 'прочее': 'прочее', +} +_PERSONA_PARAM_ORDER = ['родился', 'место_рождения', 'умер', 'отец', 'мать', 'супруг', 'дети', 'братья', 'прочее'] + +_PLACE_FIELD_MAP = { + 'тип': 'тип', + 'статус': 'статус', + 'страна': 'страна', + 'регион': 'регион', 'область': 'регион', + 'район': 'район', 'расположение': 'район', 'самоуправление': 'район', + 'река': 'река', + 'основана': 'основана', 'основан': 'основана', + 'население': 'население', + 'адрес': 'адрес', + 'период': 'период', 'годы': 'период', + 'жильцы': 'жильцы', 'жильцы/семья': 'жильцы', 'семейное имя': 'прочее', + 'латв. название': 'назв_латыш', + 'белор. название': 'назв_белор', + 'координаты': 'координаты', + 'сайт': 'сайт', + 'телефон': 'телефон', 'email': 'телефон', + 'полное название': 'прочее', 'классы': 'прочее', + 'штаб-квартира': 'прочее', 'сотрудников': 'прочее', +} +_PLACE_PARAM_ORDER = ['тип', 'статус', 'страна', 'регион', 'район', 'река', 'основана', + 'население', 'адрес', 'период', 'жильцы', 'назв_латыш', 'назв_белор', + 'координаты', 'сайт', 'телефон', 'прочее'] + + +def _extract_infobox(text: str, title: str, name_param: str, template: str, + field_map: dict, param_order: list) -> tuple[str, str]: + """Extract photo + blank-header info table; return ({{Template|...}}, cleaned_text).""" + lines = text.split('\n') + photo = None + fields: dict[str, str] = {} + remove: set[int] = set() + + for i, line in enumerate(lines): + if re.match(r'^\|', line): + break # reached info table — stop looking for photo + m = re.match(r'^\s*\[!\[[^\]]*\]\(\./(?:[^/)]+/)?([^)?]+?)(?:\?[^)]*)?\)\]', line) + if m: + photo = m.group(1) + remove.add(i) + break + + in_table = False + for i, line in enumerate(lines): + if re.match(r'^\|', line): + cells = _split_cells(line) + if all(re.match(r'^:?-+:?$', c) for c in cells if c): + if not in_table: + prev = i - 1 + if prev >= 0 and re.match(r'^\|', lines[prev]): + hcells = _split_cells(lines[prev]) + if all(c == '' for c in hcells): + in_table = True + remove.add(prev) + if in_table: + remove.add(i) + elif in_table: + remove.add(i) + if len(cells) >= 2: + key = re.sub(r'\*\*(.+?)\*\*', r'\1', cells[0]).strip().lower() + val = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", cells[1].strip()) + param = field_map.get(key) + if param and param not in fields: + fields[param] = val + elif in_table: + in_table = False + + if not photo and not fields: + return '', text + + parts = ['{{' + template, f'| {name_param:<16} = {title}'] + if photo: + parts.append(f'| фото = {photo}') + for param in param_order: + if param in fields: + parts.append(f'| {param:<16} = {fields[param]}') + infobox = '\n'.join(parts) + '\n}}' + cleaned = '\n'.join(line for i, line in enumerate(lines) if i not in remove) + return infobox, cleaned + + +def extract_person_infobox(text: str, title: str) -> tuple[str, str]: + return _extract_infobox(text, title, 'имя', 'Персона', _PERSON_FIELD_MAP, _PERSONA_PARAM_ORDER) + + +def extract_place_infobox(text: str, title: str) -> tuple[str, str]: + return _extract_infobox(text, title, 'название', 'Место', _PLACE_FIELD_MAP, _PLACE_PARAM_ORDER) + + +def strip_first_heading(text: str) -> str: + """Remove the first H1 line — MW displays the page title itself.""" + return re.sub(r'^#[^#][^\n]*\n?', '', text, count=1) + + +def convert(text: str, skip_info: bool = False, is_place: bool = False, title: str = '') -> str: + text = strip_first_heading(text) + infobox = '' + if skip_info: + infobox, text = extract_person_infobox(text, title) + elif is_place: + infobox, text = extract_place_infobox(text, title) + result = convert_pandoc(text) if _pandoc_available() else convert_python(text, skip_info=skip_info) + if infobox: + result = infobox + '\n' + result.lstrip('\n') + return result + + +# --------------------------------------------------------------------------- +# Post-processing +# --------------------------------------------------------------------------- + +def fix_links(text: str) -> str: + pattern = (r'\[\[([^\]|]+)\|' + r'(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)' + r'/([^\]]+)\]\]') + + def replace_link(m): + display = m.group(1).strip() + page = _cap(m.group(2).strip().lower()) + if display.lower() == page.lower(): + return f'[[{page}]]' + return f'[[{page}|{display}]]' + + text = re.sub(pattern, replace_link, text) + # Also handle bare section paths: [[Section/PageName]] → [[PageName]] + text = re.sub( + r'\[\[(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)/([^\]|]+)\]\]', + lambda m: f'[[{m.group(1).strip().lower().title()}]]', + text + ) + return text + + +def fix_images(text: str) -> str: + # Handle linked images: [![](./file.jpg?thumbnail=400)](./file.jpg) + # and plain images: ![alt](./file.jpg?thumbnail=400) + pattern = r'(?:\[)?!\[[^\]]*\]\(\./(?:[^/)]+/)?([^)?]+?)(\?[^)]*)?\)(?:\]\([^)]*\))?' + + def replace_img(m): + filename = m.group(1) + size_m = re.search(r'thumbnail=(\d+)', m.group(2) or '') + return f'[[File:{filename}|{size_m.group(1)}px]]' if size_m else f'[[File:{filename}]]' + + return re.sub(pattern, replace_img, text) + + +_CATEGORY_MAP = {'люди': 'Люди', 'места': 'Места', 'воспоминания': 'Воспоминания'} + + +def category_suffix(md_path: Path) -> str: + parts = md_path.relative_to(REPO).parts + if len(parts) == 1: + return '' if md_path.name == 'home.md' else '\n\n[[Category:Статьи]]' + cat = _CATEGORY_MAP.get(parts[0].lower()) + return f'\n\n[[Category:{cat}]]' if cat else '' + + +# --------------------------------------------------------------------------- +# MW operations +# --------------------------------------------------------------------------- + +def post_page(session, csrf: str, title: str, text: str, dry_run: bool) -> bool: + if dry_run: + print(f'[DRY] {title}') + return True + data = session.post(API, data={ + 'action': 'edit', 'title': title, 'text': text, + 'token': csrf, 'format': 'json' + }).json() + if 'error' in data: + print(f'[ERR] {title}: {data["error"].get("info", data["error"])}') + return False + print(f'[OK] {title}') + return True + + +def upload_image(session, csrf: str, image_path: Path, dry_run: bool) -> bool: + basename = image_path.name + if dry_run: + print(f'[DRY] File:{basename}') + return True + with open(image_path, 'rb') as f: + data = session.post(API, data={ + 'action': 'upload', 'filename': basename, + 'token': csrf, 'format': 'json', 'ignorewarnings': '1' + }, files={'file': f}).json() + if 'error' in data: + print(f'[ERR] File:{basename}: {data["error"].get("info", data["error"])}') + return False + if data.get('upload', {}).get('result') == 'Success': + print(f'[OK] File:{basename}') + else: + print(f'[SKIP] File:{basename} (already exists or no change)') + return True + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def collect_images() -> list[Path]: + images = [] + for folder in ('люди', 'места'): + p = REPO / folder + if p.exists(): + for ext in ('*.jpg', '*.jpeg', '*.JPG', '*.JPEG', '*.png', '*.PNG'): + images.extend(p.rglob(ext)) + return images + + +def main(): + parser = argparse.ArgumentParser(description='Migrate OtterWiki to MediaWiki') + parser.add_argument('--user', required=True) + parser.add_argument('--password', required=True) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + session = csrf = None + if not args.dry_run: + session, csrf = mw_login(args.user, args.password) + + pages_ok = pages_err = images_ok = images_err = 0 + + for md_path in sorted(REPO.rglob('*.md')): + title = page_title(md_path) + raw = md_path.read_text(encoding='utf-8') + parts = md_path.relative_to(REPO).parts + is_people = len(parts) > 0 and parts[0].lower() == 'люди' + is_place = len(parts) > 0 and parts[0].lower() == 'места' + wikitext = convert(raw, skip_info=is_people, is_place=is_place, title=title) + wikitext = fix_links(wikitext) + wikitext = fix_images(wikitext) + wikitext += category_suffix(md_path) + if post_page(session, csrf, title, wikitext, args.dry_run): + pages_ok += 1 + else: + pages_err += 1 + + for img in collect_images(): + if upload_image(session, csrf, img, args.dry_run): + images_ok += 1 + else: + images_err += 1 + + print(f'\nDone: {pages_ok} pages, {images_ok} images, {pages_err + images_err} errors') + + +if __name__ == '__main__': + main() diff --git a/family/uploads.ini b/family/uploads.ini new file mode 100644 index 0000000..c10f86c --- /dev/null +++ b/family/uploads.ini @@ -0,0 +1,2 @@ +upload_max_filesize = 20M +post_max_size = 25M diff --git a/freshrss/.env b/freshrss/.env new file mode 100644 index 0000000..58c0994 --- /dev/null +++ b/freshrss/.env @@ -0,0 +1,11 @@ +BASE_URL=https://news.alogins.net +ADMIN_EMAIL=allogn@gmail.com +ADMIN_PASSWORD=ff221f4d1! +ADMIN_API_PASSWORD=sfs32003r2 +# Published port if running locally +PUBLISHED_PORT=8091 +# Database credentials (not relevant if using default SQLite database) +DB_HOST=freshrss-db +DB_BASE=freshrss +DB_PASSWORD=freshrss1945133 +DB_USER=freshrss12 diff --git a/freshrss/docker-compose.yml b/freshrss/docker-compose.yml new file mode 100644 index 0000000..ece3397 --- /dev/null +++ b/freshrss/docker-compose.yml @@ -0,0 +1,36 @@ +services: + freshrss: + image: freshrss/freshrss:latest + # # Optional build section if you want to build the image locally: + # build: + # # Pick #latest (slow releases) or #edge (rolling release) or a specific release like #1.27.1 + # context: https://github.com/FreshRSS/FreshRSS.git#latest + # dockerfile: Docker/Dockerfile-Alpine + container_name: freshrss + hostname: freshrss + restart: unless-stopped + ports: + # If you want to open a port 8080 on the local machine: + - "8091:80" + logging: + options: + max-size: 10m + volumes: + - /mnt/dbs/freshrss/data:/var/www/FreshRSS/data + - /mnt/dbs/freshrss/extensions:/var/www/FreshRSS/extensions + - /mnt/dbs/freshrss/db:/var/lib/postgresql + environment: + POSTGRES_DB: ${DB_BASE:-freshrss} + POSTGRES_USER: ${DB_USER:-freshrss} + POSTGRES_PASSWORD: ${DB_PASSWORD:-freshrss} + TZ: Europe/Moscow + CRON_MIN: '3,33' + LISTEN: 0.0.0.0:80 + # Optional healthcheck section: + healthcheck: + test: ["CMD", "cli/health.php"] + timeout: 10s + start_period: 60s + start_interval: 11s + interval: 75s + retries: 3 diff --git a/immich-app/.env b/immich-app/.env index 92db2f0..62c897b 100644 --- a/immich-app/.env +++ b/immich-app/.env @@ -5,9 +5,9 @@ # You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables # The location where your uploaded files are stored -UPLOAD_LOCATION=/mnt/media/upload -THUMB_LOCATION=/mnt/ssd/media/thumbs -ENCODED_VIDEO_LOCATION=/mnt/ssd/media/encoded-video +UPLOAD_LOCATION=/mnt/smsg/media/upload +THUMB_LOCATION=/mnt/smsg/media/thumbs +ENCODED_VIDEO_LOCATION=/mnt/smsg/media/encoded-video # The location where your database files are stored. Network shares are not supported for the database DB_DATA_LOCATION=/mnt/ssd/media/postgres diff --git a/immich-app/backup.sh b/immich-app/backup.sh index 0de2a41..e8839ff 100755 --- a/immich-app/backup.sh +++ b/immich-app/backup.sh @@ -1,30 +1,42 @@ #!/usr/bin/env bash set -euo pipefail -BACKUP_DIR=/mnt/backups/media -DB_BACKUP_DIR="$BACKUP_DIR/backups" +BACKUP_DIR=/mnt/toshiba/backups/media LOG="$BACKUP_DIR/backup.log" -RETAIN_DAYS=14 +VERBOSE=0 -mkdir -p "$DB_BACKUP_DIR" - -echo "[$(date)] Starting Immich backup" >> "$LOG" - -# 1. Database dump (must come before file sync) -DUMP_FILE="$DB_BACKUP_DIR/immich-db-$(date +%Y%m%dT%H%M%S).sql.gz" -docker exec immich_postgres pg_dump --clean --if-exists \ - --dbname=immich --username=postgres | gzip > "$DUMP_FILE" -echo "[$(date)] DB dump: $DUMP_FILE" >> "$LOG" - -# 2. Rsync critical asset folders (skip thumbs and encoded-video — regeneratable) -for DIR in library upload profile; do - rsync -a --delete /mnt/media/upload/$DIR/ "$BACKUP_DIR/$DIR/" >> "$LOG" 2>&1 - echo "[$(date)] Synced $DIR" >> "$LOG" +for arg in "$@"; do + case $arg in + -v|--verbose) VERBOSE=1 ;; + esac done -# 3. Remove old DB dumps -find "$DB_BACKUP_DIR" -name "immich-db-*.sql.gz" -mtime +$RETAIN_DAYS -delete -echo "[$(date)] Cleaned dumps older than ${RETAIN_DAYS}d" >> "$LOG" +log() { echo "[$(date)] $*" >> "$LOG"; } +say() { [[ $VERBOSE -eq 1 ]] && echo "$*" || true; } + + +mkdir -p "$BACKUP_DIR" + +say "" +say "┌─────────────────────────────────────┐" +say "│ Immich Backup Starting │" +say "└─────────────────────────────────────┘" +say "" +log "Starting Immich backup" + +# Rsync critical asset folders (skip thumbs and encoded-video — regeneratable) +RSYNC_OPTS="-a --ignore-existing" +[[ $VERBOSE -eq 1 ]] && RSYNC_OPTS="$RSYNC_OPTS --info=progress2" + +for DIR in library upload profile; do + say " Syncing $DIR/ ..." + rsync $RSYNC_OPTS /mnt/smsg/media/upload/$DIR/ "$BACKUP_DIR/$DIR/" 2>&1 | \ + tee -a "$LOG" | { [[ $VERBOSE -eq 0 ]] && cat > /dev/null || cat; } + log "Synced $DIR" + say "" +done touch "$BACKUP_DIR/.last_sync" -echo "[$(date)] Immich backup complete" >> "$LOG" +log "Immich backup complete" +say "✓ Done" +say "" diff --git a/immich-app/docker-compose.yml b/immich-app/docker-compose.yml index 5cf736b..89334bc 100644 --- a/immich-app/docker-compose.yml +++ b/immich-app/docker-compose.yml @@ -30,6 +30,7 @@ services: - redis - database restart: always + mem_limit: 1500m healthcheck: disable: false @@ -37,15 +38,16 @@ services: container_name: immich_machine_learning # For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag. # Example tag: ${IMMICH_VERSION:-release}-cuda - image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} - # extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration - # file: hwaccel.ml.yml - # service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable + image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-cuda + extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration + file: hwaccel.ml.yml + service: cuda # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable volumes: - model-cache:/cache env_file: - .env restart: always + mem_limit: 750m healthcheck: disable: false @@ -55,6 +57,7 @@ services: healthcheck: test: redis-cli ping || exit 1 restart: always + mem_limit: 256m database: container_name: immich_postgres @@ -71,6 +74,7 @@ services: - ${DB_DATA_LOCATION}:/var/lib/postgresql/data shm_size: 128mb restart: always + mem_limit: 1500m volumes: model-cache: diff --git a/immich-app/hwaccel.ml.yml b/immich-app/hwaccel.ml.yml new file mode 100644 index 0000000..eaf8fef --- /dev/null +++ b/immich-app/hwaccel.ml.yml @@ -0,0 +1,57 @@ +# Configurations for hardware-accelerated machine learning + +# If using Unraid or another platform that doesn't allow multiple Compose files, +# you can inline the config for a backend by copying its contents +# into the immich-machine-learning service in the docker-compose.yml file. + +# See https://docs.immich.app/features/ml-hardware-acceleration for info on usage. + +services: + armnn: + devices: + - /dev/mali0:/dev/mali0 + volumes: + - /lib/firmware/mali_csffw.bin:/lib/firmware/mali_csffw.bin:ro # Mali firmware for your chipset (not always required depending on the driver) + - /usr/lib/libmali.so:/usr/lib/libmali.so:ro # Mali driver for your chipset (always required) + + rknn: + security_opt: + - systempaths=unconfined + - apparmor=unconfined + devices: + - /dev/dri:/dev/dri + + cpu: {} + + cuda: + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: + - gpu + + rocm: + group_add: + - video + devices: + - /dev/dri:/dev/dri + - /dev/kfd:/dev/kfd + + openvino: + device_cgroup_rules: + - 'c 189:* rmw' + devices: + - /dev/dri:/dev/dri + volumes: + - /dev/bus/usb:/dev/bus/usb + + openvino-wsl: + devices: + - /dev/dri:/dev/dri + - /dev/dxg:/dev/dxg + volumes: + - /dev/bus/usb:/dev/bus/usb + - /usr/lib/wsl:/usr/lib/wsl diff --git a/iperf3/CellularLab-v2.2.apk b/iperf3/CellularLab-v2.2.apk new file mode 100644 index 0000000..4e2cf28 Binary files /dev/null and b/iperf3/CellularLab-v2.2.apk differ diff --git a/iperf3/apk/CellularLab-v2.2.apk b/iperf3/apk/CellularLab-v2.2.apk new file mode 100644 index 0000000..4e2cf28 Binary files /dev/null and b/iperf3/apk/CellularLab-v2.2.apk differ diff --git a/iperf3/apk/index.html b/iperf3/apk/index.html new file mode 100644 index 0000000..c1a03f9 --- /dev/null +++ b/iperf3/apk/index.html @@ -0,0 +1,9 @@ + + +iperf3 Android + +

iperf3 for Android

+

Download CellularLab v2.2 (iperf3 client)

+

Server: iperf.alogins.net — port 5201

+ + diff --git a/iperf3/docker-compose.yml b/iperf3/docker-compose.yml new file mode 100644 index 0000000..877c323 --- /dev/null +++ b/iperf3/docker-compose.yml @@ -0,0 +1,18 @@ +services: + iperf3: + image: networkstatic/iperf3 + container_name: iperf3 + command: -s + ports: + - "5201:5201" + - "5201:5201/udp" + restart: unless-stopped + + iperf3-files: + image: nginx:alpine + container_name: iperf3-files + ports: + - "8095:80" + volumes: + - ./apk:/usr/share/nginx/html:ro + restart: unless-stopped diff --git a/kanboard/docker-compose.yml b/kanboard/docker-compose.yml new file mode 100644 index 0000000..d7c9828 --- /dev/null +++ b/kanboard/docker-compose.yml @@ -0,0 +1,17 @@ +name: kanboard +services: + app: + image: kanboard/kanboard:latest + container_name: kanboard + restart: always + ports: + - "127.0.0.1:4800:80" + volumes: + - data:/var/www/app/data + - plugins:/var/www/app/plugins + environment: + - PLUGIN_INSTALLER=true + +volumes: + data: + plugins: diff --git a/linkwarden/.env b/linkwarden/.env new file mode 100644 index 0000000..2321dd0 --- /dev/null +++ b/linkwarden/.env @@ -0,0 +1,473 @@ +NEXTAUTH_URL=https://lw.alogins.net/api/v1/auth +NEXTAUTH_SECRET=sdf2323frghjkj211 + +# Manual installation database settings +# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden +DATABASE_URL= + +# Docker installation database settings +POSTGRES_PASSWORD=KAf122!!fsdf2w + +# Additional Optional Settings +PAGINATION_TAKE_COUNT= +STORAGE_FOLDER= +AUTOSCROLL_TIMEOUT= +NEXT_PUBLIC_DISABLE_REGISTRATION=true +NEXT_PUBLIC_CREDENTIALS_ENABLED= +DISABLE_NEW_SSO_USERS= +MAX_LINKS_PER_USER= +ARCHIVE_TAKE_COUNT= +BROWSER_TIMEOUT= +IGNORE_URL_SIZE_LIMIT= +NEXT_PUBLIC_DEMO= +NEXT_PUBLIC_DEMO_USERNAME= +NEXT_PUBLIC_DEMO_PASSWORD= +NEXT_PUBLIC_ADMIN= +NEXT_PUBLIC_MAX_FILE_BUFFER= +PDF_MAX_BUFFER= +SCREENSHOT_MAX_BUFFER= +READABILITY_MAX_BUFFER= +PREVIEW_MAX_BUFFER= +MONOLITH_MAX_BUFFER= +MONOLITH_CUSTOM_OPTIONS= +IMPORT_LIMIT= +PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH= +PLAYWRIGHT_WS_URL= +MAX_WORKERS= +DISABLE_PRESERVATION= +NEXT_PUBLIC_RSS_POLLING_INTERVAL_MINUTES= +RSS_SUBSCRIPTION_LIMIT_PER_USER= +TEXT_CONTENT_LIMIT= +SEARCH_FILTER_LIMIT= +INDEX_TAKE_COUNT= +MEILI_TIMEOUT= +ALLOW_PRIVATE_NETWORK_ACCESS= +ALLOW_INSECURE_TLS= + +# AI Settings +NEXT_PUBLIC_OLLAMA_ENDPOINT_URL= +OLLAMA_MODEL= + +# https://ai-sdk.dev/providers/openai-compatible-providers +OPENAI_API_KEY= +OPENAI_MODEL= +# Optional: Set a custom OpenAI base URL and name (for third-party providers) +CUSTOM_OPENAI_BASE_URL= +CUSTOM_OPENAI_NAME= + +# https://sdk.vercel.ai/providers/ai-sdk-providers/azure +AZURE_API_KEY= +AZURE_RESOURCE_NAME= +AZURE_MODEL= + +# https://sdk.vercel.ai/providers/ai-sdk-providers/anthropic +ANTHROPIC_API_KEY= +ANTHROPIC_MODEL= + +# https://github.com/OpenRouterTeam/ai-sdk-provider +OPENROUTER_API_KEY= +OPENROUTER_MODEL= + +# https://ai-sdk.dev/providers/ai-sdk-providers/perplexity +PERPLEXITY_API_KEY= +PERPLEXITY_MODEL= + +# MeiliSearch Settings +MEILI_HOST= +MEILI_MASTER_KEY=FAfg24!@bbqq + +# AWS S3 Settings +SPACES_KEY= +SPACES_SECRET= +SPACES_ENDPOINT= +SPACES_BUCKET_NAME= +SPACES_REGION= +SPACES_FORCE_PATH_STYLE= + +# SMTP Settings +NEXT_PUBLIC_EMAIL_PROVIDER= +EMAIL_FROM= +EMAIL_SERVER= +BASE_URL= + +# Proxy settings +PROXY= +PROXY_USERNAME= +PROXY_PASSWORD= +PROXY_BYPASS= + +# PDF archive settings +PDF_MARGIN_TOP= +PDF_MARGIN_BOTTOM= + +################# +# SSO Providers # +################# + +# 42 School +NEXT_PUBLIC_FORTYTWO_ENABLED= +FORTYTWO_CUSTOM_NAME= +FORTYTWO_CLIENT_ID= +FORTYTWO_CLIENT_SECRET= + +# Apple +NEXT_PUBLIC_APPLE_ENABLED= +APPLE_CUSTOM_NAME= +APPLE_ID= +APPLE_SECRET= + +# Atlassian +NEXT_PUBLIC_ATLASSIAN_ENABLED= +ATLASSIAN_CUSTOM_NAME= +ATLASSIAN_CLIENT_ID= +ATLASSIAN_CLIENT_SECRET= +ATLASSIAN_SCOPE= + +# Auth0 +NEXT_PUBLIC_AUTH0_ENABLED= +AUTH0_CUSTOM_NAME= +AUTH0_ISSUER= +AUTH0_CLIENT_SECRET= +AUTH0_CLIENT_ID= + +# Authelia +NEXT_PUBLIC_AUTHELIA_ENABLED= +AUTHELIA_CLIENT_ID= +AUTHELIA_CLIENT_SECRET= +AUTHELIA_WELLKNOWN_URL= + +# Authentik +NEXT_PUBLIC_AUTHENTIK_ENABLED= +AUTHENTIK_CUSTOM_NAME= +AUTHENTIK_ISSUER= +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= + +# Azure AD B2C +NEXT_PUBLIC_AZURE_AD_B2C_ENABLED= +AZURE_AD_B2C_TENANT_NAME= +AZURE_AD_B2C_CLIENT_ID= +AZURE_AD_B2C_CLIENT_SECRET= +AZURE_AD_B2C_PRIMARY_USER_FLOW= + +# Azure AD +NEXT_PUBLIC_AZURE_AD_ENABLED= +AZURE_AD_CLIENT_ID= +AZURE_AD_CLIENT_SECRET= +AZURE_AD_TENANT_ID= + +# Battle.net +NEXT_PUBLIC_BATTLENET_ENABLED= +BATTLENET_CUSTOM_NAME= +BATTLENET_CLIENT_ID= +BATTLENET_CLIENT_SECRET= +BATTLENET_ISSUER= + +# Box +NEXT_PUBLIC_BOX_ENABLED= +BOX_CUSTOM_NAME= +BOX_CLIENT_ID= +BOX_CLIENT_SECRET= + +# Bungie +NEXT_PUBLIC_BUNGIE_ENABLED= +BUNGIE_CUSTOM_NAME= +BUNGIE_CLIENT_ID= +BUNGIE_CLIENT_SECRET= +BUNGIE_API_KEY= + +# Cognito +NEXT_PUBLIC_COGNITO_ENABLED= +COGNITO_CUSTOM_NAME= +COGNITO_CLIENT_ID= +COGNITO_CLIENT_SECRET= +COGNITO_ISSUER= + +# Coinbase +NEXT_PUBLIC_COINBASE_ENABLED= +COINBASE_CUSTOM_NAME= +COINBASE_CLIENT_ID= +COINBASE_CLIENT_SECRET= + +# Discord +NEXT_PUBLIC_DISCORD_ENABLED= +DISCORD_CUSTOM_NAME= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= + +# Dropbox +NEXT_PUBLIC_DROPBOX_ENABLED= +DROPBOX_CUSTOM_NAME= +DROPBOX_CLIENT_ID= +DROPBOX_CLIENT_SECRET= + +# DuendeIndentityServer6 +NEXT_PUBLIC_DUENDE_IDS6_ENABLED= +DUENDE_IDS6_CUSTOM_NAME= +DUENDE_IDS6_CLIENT_ID= +DUENDE_IDS6_CLIENT_SECRET= +DUENDE_IDS6_ISSUER= + +# EVE Online +NEXT_PUBLIC_EVEONLINE_ENABLED= +EVEONLINE_CUSTOM_NAME= +EVEONLINE_CLIENT_ID= +EVEONLINE_CLIENT_SECRET= + +# Facebook +NEXT_PUBLIC_FACEBOOK_ENABLED= +FACEBOOK_CUSTOM_NAME= +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= + +# FACEIT +NEXT_PUBLIC_FACEIT_ENABLED= +FACEIT_CUSTOM_NAME= +FACEIT_CLIENT_ID= +FACEIT_CLIENT_SECRET= + +# Foursquare +NEXT_PUBLIC_FOURSQUARE_ENABLED= +FOURSQUARE_CUSTOM_NAME= +FOURSQUARE_CLIENT_ID= +FOURSQUARE_CLIENT_SECRET= +FOURSQUARE_APIVERSION= + +# Freshbooks +NEXT_PUBLIC_FRESHBOOKS_ENABLED= +FRESHBOOKS_CUSTOM_NAME= +FRESHBOOKS_CLIENT_ID= +FRESHBOOKS_CLIENT_SECRET= + +# FusionAuth +NEXT_PUBLIC_FUSIONAUTH_ENABLED= +FUSIONAUTH_CUSTOM_NAME= +FUSIONAUTH_CLIENT_ID= +FUSIONAUTH_CLIENT_SECRET= +FUSIONAUTH_ISSUER= +FUSIONAUTH_TENANT_ID= + +# GitHub +NEXT_PUBLIC_GITHUB_ENABLED= +GITHUB_CUSTOM_NAME= +GITHUB_ID= +GITHUB_SECRET= + +# GitLab +NEXT_PUBLIC_GITLAB_ENABLED= +GITLAB_CUSTOM_NAME= +GITLAB_CLIENT_ID= +GITLAB_CLIENT_SECRET= +GITLAB_AUTH_URL= + +# Google +NEXT_PUBLIC_GOOGLE_ENABLED= +GOOGLE_CUSTOM_NAME= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# HubSpot +NEXT_PUBLIC_HUBSPOT_ENABLED= +HUBSPOT_CUSTOM_NAME= +HUBSPOT_CLIENT_ID= +HUBSPOT_CLIENT_SECRET= + +# IdentityServer4 +NEXT_PUBLIC_IDS4_ENABLED= +IDS4_CUSTOM_NAME= +IDS4_CLIENT_ID= +IDS4_CLIENT_SECRET= +IDS4_ISSUER= + +# Kakao +NEXT_PUBLIC_KAKAO_ENABLED= +KAKAO_CUSTOM_NAME= +KAKAO_CLIENT_ID= +KAKAO_CLIENT_SECRET= + +# Keycloak +NEXT_PUBLIC_KEYCLOAK_ENABLED= +KEYCLOAK_CUSTOM_NAME= +KEYCLOAK_ISSUER= +KEYCLOAK_CLIENT_ID= +KEYCLOAK_CLIENT_SECRET= + +# LINE +NEXT_PUBLIC_LINE_ENABLED= +LINE_CUSTOM_NAME= +LINE_CLIENT_ID= +LINE_CLIENT_SECRET= + +# LinkedIn +NEXT_PUBLIC_LINKEDIN_ENABLED= +LINKEDIN_CUSTOM_NAME= +LINKEDIN_CLIENT_ID= +LINKEDIN_CLIENT_SECRET= + +# Mailchimp +NEXT_PUBLIC_MAILCHIMP_ENABLED= +MAILCHIMP_CUSTOM_NAME= +MAILCHIMP_CLIENT_ID= +MAILCHIMP_CLIENT_SECRET= + +# Mail.ru +NEXT_PUBLIC_MAILRU_ENABLED= +MAILRU_CUSTOM_NAME= +MAILRU_CLIENT_ID= +MAILRU_CLIENT_SECRET= + +# Naver +NEXT_PUBLIC_NAVER_ENABLED= +NAVER_CUSTOM_NAME= +NAVER_CLIENT_ID= +NAVER_CLIENT_SECRET= + +# Netlify +NEXT_PUBLIC_NETLIFY_ENABLED= +NETLIFY_CUSTOM_NAME= +NETLIFY_CLIENT_ID= +NETLIFY_CLIENT_SECRET= + +# Okta +NEXT_PUBLIC_OKTA_ENABLED= +OKTA_CUSTOM_NAME= +OKTA_CLIENT_ID= +OKTA_CLIENT_SECRET= +OKTA_ISSUER= + +# OneLogin +NEXT_PUBLIC_ONELOGIN_ENABLED= +ONELOGIN_CUSTOM_NAME= +ONELOGIN_CLIENT_ID= +ONELOGIN_CLIENT_SECRET= +ONELOGIN_ISSUER= + +# Osso +NEXT_PUBLIC_OSSO_ENABLED= +OSSO_CUSTOM_NAME= +OSSO_CLIENT_ID= +OSSO_CLIENT_SECRET= +OSSO_ISSUER= + +# osu! +NEXT_PUBLIC_OSU_ENABLED= +OSU_CUSTOM_NAME= +OSU_CLIENT_ID= +OSU_CLIENT_SECRET= + +# Patreon +NEXT_PUBLIC_PATREON_ENABLED= +PATREON_CUSTOM_NAME= +PATREON_CLIENT_ID= +PATREON_CLIENT_SECRET= + +# Pinterest +NEXT_PUBLIC_PINTEREST_ENABLED= +PINTEREST_CUSTOM_NAME= +PINTEREST_CLIENT_ID= +PINTEREST_CLIENT_SECRET= + +# Pipedrive +NEXT_PUBLIC_PIPEDRIVE_ENABLED= +PIPEDRIVE_CUSTOM_NAME= +PIPEDRIVE_CLIENT_ID= +PIPEDRIVE_CLIENT_SECRET= + +# Reddit +NEXT_PUBLIC_REDDIT_ENABLED= +REDDIT_CUSTOM_NAME= +REDDIT_CLIENT_ID= +REDDIT_CLIENT_SECRET= + +# Salesforce +NEXT_PUBLIC_SALESFORCE_ENABLED= +SALESFORCE_CUSTOM_NAME= +SALESFORCE_CLIENT_ID= +SALESFORCE_CLIENT_SECRET= + +# Slack +NEXT_PUBLIC_SLACK_ENABLED= +SLACK_CUSTOM_NAME= +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= + +# Spotify +NEXT_PUBLIC_SPOTIFY_ENABLED= +SPOTIFY_CUSTOM_NAME= +SPOTIFY_CLIENT_ID= +SPOTIFY_CLIENT_SECRET= + +# Strava +NEXT_PUBLIC_STRAVA_ENABLED= +STRAVA_CUSTOM_NAME= +STRAVA_CLIENT_ID= +STRAVA_CLIENT_SECRET= + +# Synology +NEXT_PUBLIC_SYNOLOGY_ENABLED= +SYNOLOGY_CUSTOM_NAME= +SYNOLOGY_CLIENT_ID= +SYNOLOGY_CLIENT_SECRET= +SYNOLOGY_WELLKNOWN_URL= + +# Todoist +NEXT_PUBLIC_TODOIST_ENABLED= +TODOIST_CUSTOM_NAME= +TODOIST_CLIENT_ID= +TODOIST_CLIENT_SECRET= + +# Twitch +NEXT_PUBLIC_TWITCH_ENABLED= +TWITCH_CUSTOM_NAME= +TWITCH_CLIENT_ID= +TWITCH_CLIENT_SECRET= + +# United Effects +NEXT_PUBLIC_UNITED_EFFECTS_ENABLED= +UNITED_EFFECTS_CUSTOM_NAME= +UNITED_EFFECTS_CLIENT_ID= +UNITED_EFFECTS_CLIENT_SECRET= +UNITED_EFFECTS_ISSUER= + +# VK +NEXT_PUBLIC_VK_ENABLED= +VK_CUSTOM_NAME= +VK_CLIENT_ID= +VK_CLIENT_SECRET= + +# Wikimedia +NEXT_PUBLIC_WIKIMEDIA_ENABLED= +WIKIMEDIA_CUSTOM_NAME= +WIKIMEDIA_CLIENT_ID= +WIKIMEDIA_CLIENT_SECRET= + +# Wordpress.com +NEXT_PUBLIC_WORDPRESS_ENABLED= +WORDPRESS_CUSTOM_NAME= +WORDPRESS_CLIENT_ID= +WORDPRESS_CLIENT_SECRET= + +# Yandex +NEXT_PUBLIC_YANDEX_ENABLED= +YANDEX_CUSTOM_NAME= +YANDEX_CLIENT_ID= +YANDEX_CLIENT_SECRET= + +# Zitadel +NEXT_PUBLIC_ZITADEL_ENABLED= +ZITADEL_CUSTOM_NAME= +ZITADEL_CLIENT_ID= +ZITADEL_CLIENT_SECRET= +ZITADEL_ISSUER= + +# Zoho +NEXT_PUBLIC_ZOHO_ENABLED= +ZOHO_CUSTOM_NAME= +ZOHO_CLIENT_ID= +ZOHO_CLIENT_SECRET= + +# Zoom +NEXT_PUBLIC_ZOOM_ENABLED= +ZOOM_CUSTOM_NAME= +ZOOM_CLIENT_ID= +ZOOM_CLIENT_SECRET= diff --git a/linkwarden/.gitignore b/linkwarden/.gitignore new file mode 100644 index 0000000..e60392a --- /dev/null +++ b/linkwarden/.gitignore @@ -0,0 +1,3 @@ +data/ +meili_data/ +pgdata/ diff --git a/linkwarden/docker-compose.yml b/linkwarden/docker-compose.yml new file mode 100644 index 0000000..9843758 --- /dev/null +++ b/linkwarden/docker-compose.yml @@ -0,0 +1,28 @@ +services: + postgres: + image: postgres:16-alpine + env_file: .env + restart: always + volumes: + - /mnt/ssd/dbs/linkwarden/pgdata:/var/lib/postgresql/data + linkwarden: + env_file: .env + environment: + - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres + restart: always + # build: . # uncomment to build from source + image: ghcr.io/linkwarden/linkwarden:latest # comment to build from source + ports: + - 3012:3000 + volumes: + - /mnt/ssd/dbs/linkwarden/data:/data/data + depends_on: + - postgres + - meilisearch + meilisearch: + image: getmeili/meilisearch:v1.12.8 + restart: always + env_file: + - .env + volumes: + - /mnt/ssd/dbs/linkwarden/meili_data:/meili_data diff --git a/matrix/README.md b/matrix/README.md index 3618e0e..a18190b 100644 --- a/matrix/README.md +++ b/matrix/README.md @@ -26,6 +26,7 @@ Connect clients to: `https://mtx.alogins.net` | admin | yes | | elizaveta | no | | aleksandra | no | +| juris | no | ## Managing Users diff --git a/ollama/docker-compose.yml b/ollama/docker-compose.yml index 7519745..d461501 100644 --- a/ollama/docker-compose.yml +++ b/ollama/docker-compose.yml @@ -16,12 +16,3 @@ services: - OLLAMA_NUM_GPU=999 runtime: nvidia mem_limit: 4g - - ollama-cpu: - image: ollama/ollama - container_name: ollama-cpu - ports: - - "11435:11434" - volumes: - - /mnt/ssd/ai/ollama-cpu:/root/.ollama - restart: always diff --git a/openai/.env b/openai/.env new file mode 100644 index 0000000..d44520f --- /dev/null +++ b/openai/.env @@ -0,0 +1,2 @@ +LANGFUSE_PUBLIC_KEY=pk-lf-9a00d546-2fbd-4215-9b0e-0a54362e884b +LANGFUSE_SECRET_KEY=sk-lf-b91caabb-7544-4012-830e-2b9e409609d3 diff --git a/openwebui/docker-compose.yml b/openwebui/docker-compose.yml index 1e80e39..8d5d164 100644 --- a/openwebui/docker-compose.yml +++ b/openwebui/docker-compose.yml @@ -32,3 +32,4 @@ services: - AUDIO_TTS_OPENAI_API_KEY=dummy - AUDIO_TTS_MODEL=silero - AUDIO_TTS_VOICE=onyx + - ENABLE_API_KEYS=True diff --git a/pihole/docker-compose.yaml b/pihole/docker-compose.yaml deleted file mode 100644 index 24b40d0..0000000 --- a/pihole/docker-compose.yaml +++ /dev/null @@ -1,58 +0,0 @@ - -networks: - macvlan-br0: - driver: macvlan - driver_opts: - parent: br0 - ipam: - config: - - subnet: 192.168.1.0/24 - gateway: 192.168.1.1 - # ip_range: 192.168.1.192/27 - -services: - pihole: - container_name: pihole - image: pihole/pihole:latest - #ports: - # DNS Ports - #- "53:53/tcp" - #- "53:53/udp" - # Default HTTP Port - #- "80:80/tcp" - # Default HTTPs Port. FTL will generate a self-signed certificate - #- "443:443/tcp" - # Uncomment the below if using Pi-hole as your DHCP Server - #- "67:67/udp" - # Uncomment the line below if you are using Pi-hole as your NTP server - #- "123:123/udp" - - dns: - - 8.8.8.8 - - 1.1.1.1 - networks: - macvlan-br0: - ipv4_address: 192.168.1.2 - environment: - # Set the appropriate timezone for your location from - # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones, e.g: - TZ: 'Europe/Moscow' - # Set a password to access the web interface. Not setting one will result in a random password being assigned - FTLCONF_webserver_api_password: 'correct horse 123' - # If using Docker's default `bridge` network setting the dns listening mode should be set to 'ALL' - FTLCONF_dns_listeningMode: 'ALL' - # Volumes store your data between container upgrades - volumes: - # For persisting Pi-hole's databases and common configuration file - - '/mnt/ssd/dbs/pihole:/etc/pihole' - # Uncomment the below if you have custom dnsmasq config files that you want to persist. Not needed for most starting fresh with Pi-hole v6. If you're upgrading from v5 you and have used this directory before, you should keep it enabled for the first v6 container start to allow for a complete migration. It can be removed afterwards. Needs environment variable FTLCONF_misc_etc_dnsmasq_d: 'true' - #- './etc-dnsmasq.d:/etc/dnsmasq.d' - cap_add: - # See https://github.com/pi-hole/docker-pi-hole#note-on-capabilities - # Required if you are using Pi-hole as your DHCP server, else not needed - - NET_ADMIN - # Required if you are using Pi-hole as your NTP client to be able to set the host's system time - - SYS_TIME - # Optional, if Pi-hole should get some more processing time - - SYS_NICE - restart: unless-stopped diff --git a/qbittorrent/docker-compose.yml b/qbittorrent/docker-compose.yml new file mode 100644 index 0000000..34e20af --- /dev/null +++ b/qbittorrent/docker-compose.yml @@ -0,0 +1,17 @@ +services: + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + container_name: qbittorrent + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Moscow + - WEBUI_PORT=8085 + volumes: + - /mnt/misc/qbittorrent/config:/config + - /mnt/misc/qbittorrent/downloads:/downloads + ports: + - "8085:8085" + - "6881:6881" + - "6881:6881/udp" + restart: unless-stopped diff --git a/radicale/config b/radicale/config new file mode 100644 index 0000000..323d3f2 --- /dev/null +++ b/radicale/config @@ -0,0 +1,13 @@ +[server] +hosts = 0.0.0.0:5232 + +[auth] +type = htpasswd +htpasswd_filename = /config/users +htpasswd_encryption = bcrypt + +[storage] +filesystem_folder = /data/collections + +[logging] +level = info diff --git a/radicale/docker-compose.yml b/radicale/docker-compose.yml new file mode 100644 index 0000000..48b9422 --- /dev/null +++ b/radicale/docker-compose.yml @@ -0,0 +1,9 @@ +services: + radicale: + image: tomsquest/docker-radicale + restart: unless-stopped + ports: + - 5232:5232 + volumes: + - /mnt/ssd/dbs/radicale/data:/data + - /mnt/ssd/dbs/radicale/config:/config diff --git a/seafile/seafile-server.yml b/seafile/seafile-server.yml index 4bfd94b..c4e71ce 100644 --- a/seafile/seafile-server.yml +++ b/seafile/seafile-server.yml @@ -95,6 +95,8 @@ services: condition: service_healthy redis: condition: service_started + extra_hosts: + - "office.alogins.net:host-gateway" networks: - seafile-net diff --git a/syncthing/docker-compose.yml b/syncthing/docker-compose.yml new file mode 100644 index 0000000..76c7156 --- /dev/null +++ b/syncthing/docker-compose.yml @@ -0,0 +1,26 @@ +services: + syncthing: + image: syncthing/syncthing:latest + container_name: syncthing + hostname: agap + restart: unless-stopped + volumes: + - /mnt/misc/syncthing/config:/var/syncthing/config + - /mnt/misc/syncthing/data:/var/syncthing + ports: + - "127.0.0.1:8384:8384" # web UI (proxied by Caddy) + - "22000:22000/tcp" # sync protocol TCP + - "22000:22000/udp" # sync protocol UDP + - "21027:21027/udp" # local discovery + + relay: + image: syncthing/relaysrv:latest + container_name: syncthing-relay + restart: unless-stopped + env_file: relay.env + entrypoint: ["/bin/sh", "-c", "exec /bin/strelaysrv -listen=:22067 -status-srv=:22070 -pools= -token=$$RELAY_TOKEN"] + volumes: + - /mnt/misc/syncthing/relay-keys:/keys + ports: + - "22067:22067/tcp" # relay protocol + - "22070:22070/tcp" # status/metrics diff --git a/syncthing/relay.env b/syncthing/relay.env new file mode 100644 index 0000000..541b4a8 --- /dev/null +++ b/syncthing/relay.env @@ -0,0 +1 @@ +RELAY_TOKEN=04a4e751b42e1cec36f53ff2a99520d5f6403cdb520f7392 diff --git a/vikunja/docker-compose.yml b/vikunja/docker-compose.yml new file mode 100644 index 0000000..eb2795b --- /dev/null +++ b/vikunja/docker-compose.yml @@ -0,0 +1,13 @@ +services: + vikunja: + image: vikunja/vikunja:2.2.2 + environment: + VIKUNJA_SERVICE_PUBLICURL: https://todo.alogins.net/ + VIKUNJA_SERVICE_JWTSECRET: 13122c95a1fa87bc5f4aefbfc415f7b6e3c2c9e9ba3f784e905c514fec19ae9d9b69 + VIKUNJA_DATABASE_PATH: /db/vikunja.db + ports: + - 3457:3456 + volumes: + - /mnt/ssd/dbs/vikunja/files:/app/vikunja/files + - /mnt/ssd/dbs/vikunja/db:/db + restart: unless-stopped diff --git a/wiki/migrate.py b/wiki/migrate.py deleted file mode 100644 index ea20581..0000000 --- a/wiki/migrate.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env python3 -"""OtterWiki → MediaWiki migration script.""" - -import argparse -import re -import subprocess -from pathlib import Path - -import requests - -REPO = Path('/mnt/ssd/dbs/otter/app-data/repository') -API = 'http://localhost:8099/api.php' - -_FN_DEF = re.compile(r'^\[(\^[^\]]+)\]:\s*(.*)') - -# Cached pandoc availability (None = not yet checked) -_PANDOC_AVAILABLE: bool | None = None - - -def _cap(s: str) -> str: - """Capitalize first character, leave rest unchanged.""" - return s[0].upper() + s[1:] if s else s - - -def _pandoc_available() -> bool: - global _PANDOC_AVAILABLE - if _PANDOC_AVAILABLE is None: - try: - _PANDOC_AVAILABLE = subprocess.run( - ['pandoc', '--version'], capture_output=True - ).returncode == 0 - except FileNotFoundError: - _PANDOC_AVAILABLE = False - return _PANDOC_AVAILABLE - - -# --------------------------------------------------------------------------- -# MediaWiki session -# --------------------------------------------------------------------------- - -def mw_login(user: str, password: str): - s = requests.Session() - r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'type': 'login', 'format': 'json'}) - token = r.json()['query']['tokens']['logintoken'] - s.post(API, data={'action': 'login', 'lgname': user, 'lgpassword': password, - 'lgtoken': token, 'format': 'json'}) - r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'format': 'json'}) - csrf = r.json()['query']['tokens']['csrftoken'] - return s, csrf - - -# --------------------------------------------------------------------------- -# Title determination -# --------------------------------------------------------------------------- - -def page_title(md_path: Path) -> str: - parts = md_path.relative_to(REPO).parts - if md_path.name == 'home.md' and len(parts) == 1: - return 'Заглавная страница' - return _cap(md_path.stem) - - -# --------------------------------------------------------------------------- -# Markdown → wikitext conversion -# --------------------------------------------------------------------------- - -def convert_pandoc(text: str) -> str: - return subprocess.run( - ['pandoc', '-f', 'markdown', '-t', 'mediawiki'], - input=text, capture_output=True, text=True - ).stdout - - -def convert_python(text: str) -> str: - lines = text.split('\n') - - # Collect footnote definitions in one pass - footnotes: dict[str, str] = {} - for line in lines: - m = _FN_DEF.match(line) - if m: - footnotes[m.group(1)] = m.group(2) - - def replace_fn(m): - key = m.group(0) - return f'{footnotes.get(key, key)}' - - out = [] - for line in lines: - if _FN_DEF.match(line): - continue - - m = re.match(r'^(#{1,6})\s+(.*)', line) - if m: - eq = '=' * len(m.group(1)) - out.append(f'{eq} {m.group(2)} {eq}') - continue - - if re.match(r'^---+$', line.strip()): - out.append('----') - continue - - line = re.sub(r'\*\*\*(.+?)\*\*\*', r"'''''\1'''''", line) - line = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", line) - line = re.sub(r'\*(.+?)\*', r"''\1''", line) - line = re.sub(r'\[\^\S+?\]', replace_fn, line) - out.append(line) - - return convert_tables('\n'.join(out)) - - -def convert_tables(text: str) -> str: - lines = text.split('\n') - out = [] - in_table = False - - for line in lines: - if re.match(r'^\|', line): - cells = [c.strip() for c in line.strip().strip('|').split('|')] - if all(re.match(r'^:?-+:?$', c) for c in cells if c): - if not in_table: - header_line = out.pop() if out else '' - hcells = [c.strip() for c in header_line.strip().strip('|').split('|')] - out += ['{| class="wikitable"', '|-', '! ' + ' !! '.join(hcells)] - in_table = True - out.append('|-') - else: - if in_table: - out.append('| ' + ' || '.join(cells)) - else: - out.append(line) - else: - if in_table: - out.append('|}') - in_table = False - out.append(line) - - if in_table: - out.append('|}') - - return '\n'.join(out) - - -def convert(text: str) -> str: - return convert_pandoc(text) if _pandoc_available() else convert_python(text) - - -# --------------------------------------------------------------------------- -# Post-processing -# --------------------------------------------------------------------------- - -def fix_links(text: str) -> str: - pattern = (r'\[\[([^\]|]+)\|' - r'(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)' - r'/([^\]]+)\]\]') - - def replace_link(m): - display = m.group(1).strip() - page = _cap(m.group(2).strip()) - if display == page or display == (page[0].lower() + page[1:] if page else ''): - return f'[[{page}]]' - return f'[[{page}|{display}]]' - - return re.sub(pattern, replace_link, text) - - -def fix_images(text: str) -> str: - pattern = r'!\[[^\]]*\]\(\./[^/)]+/([^)?]+)(\?[^)]*)?\)' - - def replace_img(m): - filename = m.group(1) - size_m = re.search(r'thumbnail=(\d+)', m.group(2) or '') - return f'[[File:{filename}|{size_m.group(1)}px]]' if size_m else f'[[File:{filename}]]' - - return re.sub(pattern, replace_img, text) - - -_CATEGORY_MAP = {'люди': 'Люди', 'места': 'Места', 'воспоминания': 'Воспоминания'} - - -def category_suffix(md_path: Path) -> str: - parts = md_path.relative_to(REPO).parts - if len(parts) == 1: - return '' if md_path.name == 'home.md' else '\n\n[[Category:Статьи]]' - cat = _CATEGORY_MAP.get(parts[0].lower()) - return f'\n\n[[Category:{cat}]]' if cat else '' - - -# --------------------------------------------------------------------------- -# MW operations -# --------------------------------------------------------------------------- - -def post_page(session, csrf: str, title: str, text: str, dry_run: bool) -> bool: - if dry_run: - print(f'[DRY] {title}') - return True - data = session.post(API, data={ - 'action': 'edit', 'title': title, 'text': text, - 'token': csrf, 'format': 'json' - }).json() - if 'error' in data: - print(f'[ERR] {title}: {data["error"].get("info", data["error"])}') - return False - print(f'[OK] {title}') - return True - - -def upload_image(session, csrf: str, image_path: Path, dry_run: bool) -> bool: - basename = image_path.name - if dry_run: - print(f'[DRY] File:{basename}') - return True - with open(image_path, 'rb') as f: - data = session.post(API, data={ - 'action': 'upload', 'filename': basename, - 'token': csrf, 'format': 'json', 'ignorewarnings': '1' - }, files={'file': f}).json() - if 'error' in data: - print(f'[ERR] File:{basename}: {data["error"].get("info", data["error"])}') - return False - if data.get('upload', {}).get('result') == 'Success': - print(f'[OK] File:{basename}') - else: - print(f'[SKIP] File:{basename} (already exists or no change)') - return True - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def collect_images() -> list[Path]: - images = [] - for folder in ('люди', 'места'): - p = REPO / folder - if p.exists(): - for ext in ('*.jpg', '*.jpeg', '*.JPG', '*.JPEG', '*.png', '*.PNG'): - images.extend(p.rglob(ext)) - return images - - -def main(): - parser = argparse.ArgumentParser(description='Migrate OtterWiki to MediaWiki') - parser.add_argument('--user', required=True) - parser.add_argument('--password', required=True) - parser.add_argument('--dry-run', action='store_true') - args = parser.parse_args() - - session = csrf = None - if not args.dry_run: - session, csrf = mw_login(args.user, args.password) - - pages_ok = pages_err = images_ok = images_err = 0 - - for md_path in sorted(REPO.rglob('*.md')): - title = page_title(md_path) - raw = md_path.read_text(encoding='utf-8') - wikitext = convert(raw) - wikitext = fix_links(wikitext) - wikitext = fix_images(wikitext) - wikitext += category_suffix(md_path) - if post_page(session, csrf, title, wikitext, args.dry_run): - pages_ok += 1 - else: - pages_err += 1 - - for img in collect_images(): - if upload_image(session, csrf, img, args.dry_run): - images_ok += 1 - else: - images_err += 1 - - print(f'\nDone: {pages_ok} pages, {images_ok} images, {pages_err + images_err} errors') - - -if __name__ == '__main__': - main() diff --git a/windows/docker-compose.yml b/windows/docker-compose.yml new file mode 100644 index 0000000..39b4ceb --- /dev/null +++ b/windows/docker-compose.yml @@ -0,0 +1,28 @@ +services: + windows: + image: dockurr/windows + container_name: windows + environment: + VERSION: "tiny11" + RAM_SIZE: "2G" + CPU_CORES: "2" + DISK_SIZE: "64G" + USERNAME: "alvis" + PASSWORD: "alvis" + LANGUAGE: "English" + REGION: "en-US" + KEYBOARD: "en-US" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - "8006:8006" + - "3389:3389/tcp" + - "3389:3389/udp" + volumes: + - /mnt/ssd/dbs/windows/storage:/storage + - /mnt/misc/qbittorrent/downloads:/data + restart: unless-stopped + stop_grace_period: 2m diff --git a/zabbix/docker-compose.yml b/zabbix/docker-compose.yml index 7bc0c26..122f21f 100644 --- a/zabbix/docker-compose.yml +++ b/zabbix/docker-compose.yml @@ -17,6 +17,8 @@ services: restart: unless-stopped ports: - "10051:10051" + extra_hosts: + - "haos.alogins.net:192.168.1.3" environment: DB_SERVER_HOST: postgres-server DB_SERVER_PORT: 5432