Source: RouterTailnet/tailnet/service.js

// @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;
    }
};