// @ts-check
/**
* Tailnet ObjectBase service — Type='tailnet', no Member row.
*
* Tailnet networks follow the same visibility model as projects and runners:
* - the owner group is tracked via Links.Type='memberA' (tailnet -> group),
* so a team can create as many networks as it wants (multiple nets/group)
* - the creator always gets `Visible admin`
* - a default visibility rule is created via `addVisibility` so job holders
* of the group see the network
*
* Runner assignment is explicit via Links.Type='tailnetRunner'
* (UID = tailnet, UIDTarget = runner). A runner may belong to several networks.
*
* Headscale state is derived, not stored here: `Data.headscaleUser` is
* computed deterministically from `Data.slug` (`net-<slug>`) and the
* project-bot resolves it against the Headscale API on demand.
*
* @import {ExpressRequestAuthorized} from '../../types.js'
*/
import { query, transaction, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import { getUID } from '../../utils/UUIDs.js';
import { isAdmin, isObjectVisible, isListAdmin } from '../../utils/authChecks.js';
import { addVisibility } from '../../utils/listVisibilty.js';
import { apiError } from '../../utils/apiEnvelope.js';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';
const TAILNET_TYPE = `'tailnet'`;
const tailnetSelect = `
SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo,
ObjectBase.Title, ObjectBase.Display, ObjectBase.Data, ObjectBase.UIDuser,
DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
FROM ObjectBase
`;
const SLUG_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
/**
* @param {any} row
* @param {{ groupUid?: string|null }} [extra]
*/
export const toTailnet = (row, extra = {}) => {
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : (row.Data || {});
return {
UID: HEX2uuid(row.UID),
Title: row.Title || row.Display || '',
Display: row.Display || row.Title || '',
Data: {
name: data.name ?? row.Title ?? '',
description: data.description ?? null,
slug: data.slug ?? null,
groupUID: data.groupUID ?? null,
headscaleUser: data.slug ? `net-${data.slug}` : null,
status: data.status ?? 'active',
createdAt: data.createdAt ?? null,
},
OrgUID: row.UIDBelongsTo ? HEX2uuid(row.UIDBelongsTo) : null,
source_updated_at: row.source_updated_at,
};
};
/**
* Resolve the owner group of a tailnet. `groupHex` may be a group/event UID or
* an org UID (root group). Falls back to the org root group when the given
* group is not a group/event object — exactly like project `resolveProjectGroup`.
* @param {string} groupHex
* @param {string} orgHex
*/
const resolveTailnetGroup = async (groupHex, orgHex) => {
const load = async (uidHex, types) => {
const rows = await query(
`SELECT ObjectBase.UID, Member.Display, Member.Data, ObjectBase.Title
FROM ObjectBase
INNER JOIN Member ON (Member.UID = ObjectBase.UID)
WHERE ObjectBase.UID=? AND ObjectBase.Type IN (${types})`,
[uidHex],
{ cast: ['UUID', 'json'] },
);
return rows[0] || null;
};
let group = await load(groupHex, `'group','event'`);
if (!group) {
group = await load(orgHex, `'group'`);
}
return group;
};
/**
* @param {string} tailnetHex
*/
const readTailnetGroupLink = async (tailnetHex) => {
const rows = await query(
`SELECT UIDTarget FROM Links WHERE UID=? AND Type='memberA' LIMIT 1`,
[tailnetHex],
);
return rows[0]?.UIDTarget ? HEX2uuid(rows[0].UIDTarget) : null;
};
/**
* @param {string} uidHex
* @param {string} orgHex
*/
const tailnetInOrg = async (uidHex, orgHex) => {
const rows = await query(
`SELECT UID FROM ObjectBase WHERE UID=? AND Type=${TAILNET_TYPE} AND UIDBelongsTo=?`,
[uidHex, orgHex],
);
return rows.length === 1;
};
/**
* @param {string} uidHex
*/
const readTailnetRow = async (uidHex) => {
const rows = await query(
`${tailnetSelect} WHERE ObjectBase.UID=? AND ObjectBase.Type=${TAILNET_TYPE}`,
[uidHex],
{ cast: ['json'] },
);
return rows[0] || null;
};
/**
* Slug uniqueness within the org (two teams must not collide on the same
* headscale user / MagicDNS subdomain).
* @param {string} slug
* @param {string} orgHex
* @param {string} [excludeHex]
*/
const slugTaken = async (slug, orgHex, excludeHex) => {
const rows = await query(
`${tailnetSelect}
WHERE ObjectBase.Type=${TAILNET_TYPE} AND ObjectBase.UIDBelongsTo=?
AND JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.slug')) = ?`,
excludeHex ? [orgHex, slug] : [orgHex, slug],
{ cast: ['json'] },
);
return rows.some((r) => !excludeHex || HEX2uuid(r.UID) !== excludeHex);
};
/**
* Create a tailnet network under an owner group.
*
* Mirrors the project/list creation pattern:
* - `memberA` link to the owner group
* - creator gets `Visible admin`
* - default visibility rule via `addVisibility` (group job holders see it)
*
* @param {ExpressRequestAuthorized} req
* @param {{ name?: string, description?: string, slug?: string, groupUID?: string, group_uid?: string }} fields
*/
export const createTailnet = async (req, fields) => {
try {
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
const UID = await getUID(req);
const name = (fields.name || 'Tailnet').trim().slice(0, 255);
const description = (fields.description || '').trim().slice(0, 2000) || null;
const slug = (fields.slug || '').trim().toLowerCase().slice(0, 63);
// Owner-Gruppe wie bei createProject auflösen: Route-Param :group ist
// kanonisch (PUT /tailnets/:group), Fallback auf body.groupUID/group_uid.
const groupUidInput = req.params?.group || fields.groupUID || fields.group_uid;
if (!groupUidInput) {
throw apiError(422, 'GROUP_REQUIRED', 'groupUID is required');
}
if (slug) {
if (!SLUG_RE.test(slug)) {
throw apiError(422, 'INVALID_SLUG', 'Slug darf nur a-z, 0-9 und Bindestriche enthalten (3-63 Zeichen)');
}
if (await slugTaken(slug, orgHex)) {
throw apiError(409, 'SLUG_TAKEN', 'Ein Tailnet-Netz mit diesem Slug existiert bereits in der Organisation');
}
}
const group = await resolveTailnetGroup(UUID2hex(groupUidInput), orgHex);
if (!group) {
throw apiError(422, 'GROUP_NOT_FOUND', 'Owner group not found');
}
const data = {
name,
description,
slug: slug || null,
groupUID: HEX2uuid(group.UID),
status: 'active',
createdAt: new Date().toISOString(),
};
await transaction(async (connection) => {
await connection.query(
`INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
VALUES (?, 'tailnet', ?, ?, ?, ?, 0, ?, ?)`,
[UID, orgHex, name, name, name, JSON.stringify(data), userHex],
);
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
[UID, group.UID, userHex],
);
await connection.query(
`INSERT INTO Visible (UID, Type, UIDUser) VALUES (?, 'admin', ?)`,
[UID, userHex],
);
});
if (group) {
await addVisibility(req, UID, group);
}
const row = await readTailnetRow(UID);
const groupUid = await readTailnetGroupLink(UID);
return { success: true, result: toTailnet(row, { groupUid }) };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* List tailnet networks of the current org the user can see (visible |
* changeable | admin). Org admins and bots see all networks. Optional
* `group_uid` filter (only networks of that owner group) — same as projects.
* @param {ExpressRequestAuthorized} req
*/
export const listTailnets = async (req) => {
try {
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
const admin = await isAdmin(req.session);
const groupFilter = typeof req.query.group_uid === 'string' && req.query.group_uid
? `AND JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.groupUID')) = ?`
: '';
const params = [orgHex];
if (!admin) params.push(userHex);
if (groupFilter) params.push(req.query.group_uid);
const rows = await query(
`${tailnetSelect}
INNER JOIN Visible ON (Visible.UID = ObjectBase.UID)
WHERE ObjectBase.Type=${TAILNET_TYPE} AND ObjectBase.UIDBelongsTo=?
${groupFilter}
${admin ? '' : 'AND Visible.UIDUser = ?'}
GROUP BY ObjectBase.UID
ORDER BY ObjectBase.SortName, ObjectBase.ValidFrom DESC`,
params,
{ cast: ['json'] },
);
const result = [];
for (const row of rows) {
const groupUid = await readTailnetGroupLink(row.UID);
result.push(toTailnet(row, { groupUid }));
}
return result;
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* @param {ExpressRequestAuthorized} req
* @param {string} tailnetUid
*/
export const getTailnet = async (req, tailnetUid) => {
try {
const uidHex = UUID2hex(tailnetUid);
const orgHex = UUID2hex(req.session.root);
if (!(await tailnetInOrg(uidHex, orgHex))) {
throw apiError(404, 'TAILNET_NOT_FOUND', 'Tailnet network not found in this organization');
}
if (!(await isObjectVisible(req, uidHex))) {
throw apiError(403, 'TAILNET_NOT_ACCESSIBLE', 'Tailnet network is not visible to this user');
}
const row = await readTailnetRow(uidHex);
if (!row) throw apiError(404, 'TAILNET_NOT_FOUND', 'Tailnet network not found in this organization');
const groupUid = await readTailnetGroupLink(uidHex);
return { success: true, result: toTailnet(row, { groupUid }) };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* @param {ExpressRequestAuthorized} req
* @param {string} tailnetUid
* @param {{ name?: string, description?: string, slug?: string }} fields
*/
export const updateTailnet = async (req, tailnetUid, fields) => {
try {
const uidHex = UUID2hex(tailnetUid);
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
if (!(await tailnetInOrg(uidHex, orgHex))) {
throw apiError(404, 'TAILNET_NOT_FOUND', 'Tailnet network not found in this organization');
}
if (!(await isAdmin(req.session))) {
if (!(await isListAdmin(req, HEX2uuid(uidHex)))) {
throw apiError(403, 'TAILNET_NOT_CHANGEABLE', 'Tailnet network is not changeable by this user');
}
}
const row = await readTailnetRow(uidHex);
if (!row) throw apiError(404, 'TAILNET_NOT_FOUND', 'Tailnet network not found in this organization');
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : { ...(row.Data || {}) };
if (fields.name !== undefined) {
data.name = String(fields.name).trim().slice(0, 255) || data.name;
}
if (fields.description !== undefined) {
data.description = String(fields.description).trim().slice(0, 2000) || null;
}
if (fields.slug !== undefined) {
const slug = String(fields.slug).trim().toLowerCase().slice(0, 63);
if (slug && !SLUG_RE.test(slug)) {
throw apiError(422, 'INVALID_SLUG', 'Slug darf nur a-z, 0-9 und Bindestriche enthalten');
}
if (slug && slug !== data.slug && await slugTaken(slug, orgHex, uidHex)) {
throw apiError(409, 'SLUG_TAKEN', 'Ein Tailnet-Netz mit diesem Slug existiert bereits in der Organisation');
}
data.slug = slug || null;
}
const name = data.name;
await query(
`UPDATE ObjectBase SET Title=?, Display=?, SortName=?, Data=?, UIDuser=? WHERE UID=? AND Type='tailnet'`,
[name, name, name, JSON.stringify(data), userHex, uidHex],
);
const updated = await readTailnetRow(uidHex);
const groupUid = await readTailnetGroupLink(uidHex);
return { success: true, result: toTailnet(updated, { groupUid }) };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Delete a tailnet network. Assigned runners are unlinked (no cascade to
* the runners themselves — they just lose this network assignment).
* @param {ExpressRequestAuthorized} req
* @param {string} tailnetUid
*/
export const deleteTailnet = async (req, tailnetUid) => {
try {
const uidHex = UUID2hex(tailnetUid);
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
if (!(await tailnetInOrg(uidHex, orgHex))) {
throw apiError(404, 'TAILNET_NOT_FOUND', 'Tailnet network not found in this organization');
}
if (!(await isAdmin(req.session))) {
if (!(await isListAdmin(req, HEX2uuid(uidHex)))) {
throw apiError(403, 'TAILNET_NOT_CHANGEABLE', 'Tailnet network is not changeable by this user');
}
}
await transaction(async (connection) => {
await connection.query(
`DELETE FROM Visible WHERE UID=?`,
[uidHex],
);
await connection.query(
`DELETE FROM Links WHERE UID=? AND Type IN ('memberA','tailnetRunner')`,
[uidHex],
);
await connection.query(
`DELETE FROM ObjectBase WHERE UID=? AND Type='tailnet'`,
[uidHex],
);
});
return { success: true, result: { UID: HEX2uuid(uidHex), deleted: true } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* List runners explicitly assigned to a tailnet network
* (Links.Type='tailnetRunner', UID=tailnet, UIDTarget=runner).
* @param {ExpressRequestAuthorized} req
* @param {string} tailnetUid
*/
export const listTailnetRunners = async (req, tailnetUid) => {
try {
const uidHex = UUID2hex(tailnetUid);
const orgHex = UUID2hex(req.session.root);
if (!(await tailnetInOrg(uidHex, orgHex))) {
throw apiError(404, 'TAILNET_NOT_FOUND', 'Tailnet network not found in this organization');
}
if (!(await isObjectVisible(req, uidHex))) {
throw apiError(403, 'TAILNET_NOT_ACCESSIBLE', 'Tailnet network is not visible to this user');
}
const rows = await query(
`SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo,
ObjectBase.Title, ObjectBase.Display, ObjectBase.Data, ObjectBase.UIDuser,
DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
FROM ObjectBase
INNER JOIN Links ON (Links.UID=? AND Links.Type='tailnetRunner' AND Links.UIDTarget=ObjectBase.UID)
WHERE ObjectBase.Type='runner' AND ObjectBase.UIDBelongsTo=?
GROUP BY ObjectBase.UID
ORDER BY ObjectBase.SortName, ObjectBase.ValidFrom DESC`,
[uidHex, orgHex],
{ cast: ['json'] },
);
return rows.map((row) => ({
UID: HEX2uuid(row.UID),
Title: row.Title || row.Display || '',
Display: row.Display || row.Title || '',
Data: (() => {
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : (row.Data || {});
return {
mode: data.mode ?? 'personal',
status: data.status ?? 'registered',
tailnetNodeId: data.tailnetNodeId ?? null,
tailnetIp: data.tailnetIp ?? null,
operatingSystem: data.operatingSystem ?? null,
architecture: data.architecture ?? null,
};
})(),
OrgUID: row.UIDBelongsTo ? HEX2uuid(row.UIDBelongsTo) : null,
source_updated_at: row.source_updated_at,
}));
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Assign a runner to a tailnet network (explicit link).
* @param {ExpressRequestAuthorized} req
* @param {string} tailnetUid
* @param {string} runnerUid
*/
export const addTailnetRunner = async (req, tailnetUid, runnerUid) => {
try {
const uidHex = UUID2hex(tailnetUid);
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
const runnerHex = UUID2hex(runnerUid);
if (!(await tailnetInOrg(uidHex, orgHex))) {
throw apiError(404, 'TAILNET_NOT_FOUND', 'Tailnet network not found in this organization');
}
const runnerRows = await query(
`SELECT UID FROM ObjectBase WHERE UID=? AND Type='runner' AND UIDBelongsTo=?`,
[runnerHex, orgHex],
);
if (runnerRows.length !== 1) {
throw apiError(404, 'RUNNER_NOT_FOUND', 'Runner not found in this organization');
}
if (!(await isAdmin(req.session))) {
if (!(await isListAdmin(req, HEX2uuid(uidHex)))) {
throw apiError(403, 'TAILNET_NOT_CHANGEABLE', 'Tailnet network is not changeable by this user');
}
}
await query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'tailnetRunner', ?, ?)`,
[uidHex, runnerHex, userHex],
);
return { success: true, result: { tailnetUID: HEX2uuid(uidHex), runnerUID: HEX2uuid(runnerHex), assigned: true } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Remove a runner from a tailnet network.
* @param {ExpressRequestAuthorized} req
* @param {string} tailnetUid
* @param {string} runnerUid
*/
export const removeTailnetRunner = async (req, tailnetUid, runnerUid) => {
try {
const uidHex = UUID2hex(tailnetUid);
const orgHex = UUID2hex(req.session.root);
const runnerHex = UUID2hex(runnerUid);
if (!(await tailnetInOrg(uidHex, orgHex))) {
throw apiError(404, 'TAILNET_NOT_FOUND', 'Tailnet network not found in this organization');
}
if (!(await isAdmin(req.session))) {
if (!(await isListAdmin(req, HEX2uuid(uidHex)))) {
throw apiError(403, 'TAILNET_NOT_CHANGEABLE', 'Tailnet network is not changeable by this user');
}
}
await query(
`DELETE FROM Links WHERE UID=? AND Type='tailnetRunner' AND UIDTarget=?`,
[uidHex, runnerHex],
);
return { success: true, result: { tailnetUID: HEX2uuid(uidHex), runnerUID: HEX2uuid(runnerHex), assigned: false } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};