setup unifi wlans

This commit is contained in:
2026-02-22 19:12:34 -06:00
parent 70284bc14e
commit 3c89f24189
66 changed files with 7393 additions and 110 deletions
@@ -5,12 +5,14 @@ export enum ValueType {
GENERIC_SECRET = "generic_secret",
BITLOCKER_KEY = "bitlocker_key",
PASSWORD = "password",
MULTI_CREDENTIAL = "multi_credential",
}
export interface CredentialTypeField {
id: string; // I.e. "clientId", "clientSecret", etc.
name: string; // I.e. "Client ID", "Client Secret", etc.
required: boolean;
subFields?: CredentialTypeField[]; // For multi-credential fields, defines the sub-fields that are required
secure: boolean; // Whether this field should be stored encrypted in the database
valueType: ValueType; // For future extensibility, currently all fields are strings
}
@@ -18,4 +20,5 @@ export interface CredentialTypeField {
export interface CredentialField {
fieldId: string; // I.e. "clientId", "clientSecret", etc.
value: string; // Encrypted value stored in the database
subCredentials?: string[]; // For multi-credential fields, the IDs of the sub-credentials that are associated with this field
}
+29 -8
View File
@@ -1,7 +1,19 @@
import { Collection } from "@discordjs/collection";
import { CredentialField, CredentialTypeField } from "./credentialTypeDefs";
import {
CredentialField,
CredentialTypeField,
ValueType,
} from "./credentialTypeDefs";
import GenericError from "../../Errors/GenericError";
export interface ValidatedField {
fieldId: string;
value: string;
secure: boolean;
isMultiCredential?: boolean;
subCredentials?: string[];
}
/**
* Field Validator
*
@@ -11,19 +23,16 @@ import GenericError from "../../Errors/GenericError";
* If all the credentials pass, it will return a processed version of the submitted fields including fields that need to be
* stored securely (encrypted) and fields that do not.
*
* Multi-credential fields are handled specially — they don't carry a direct value but instead
* reference sub-credential IDs.
*
* @param fields - The fields in object form that are being submitted.
* @param acceptableFields - The acceptable field to be compared against.
*/
export const fieldValidator = async (
fields: CredentialField[],
acceptableFields: CredentialTypeField[],
): Promise<
{
fieldId: string;
value: string;
secure: boolean;
}[]
> => {
): Promise<ValidatedField[]> => {
const afCollection = new Collection(acceptableFields.map((f) => [f.id, f]));
await Promise.all(
@@ -45,6 +54,18 @@ export const fieldValidator = async (
return fields.map((field) => {
const matchingField = afCollection.get(field.fieldId)!;
// Multi-credential fields don't carry a direct value;
// they reference sub-credential IDs instead.
if (matchingField.valueType === ValueType.MULTI_CREDENTIAL) {
return {
fieldId: field.fieldId,
value: "",
secure: false,
isMultiCredential: true,
subCredentials: field.subCredentials ?? [],
};
}
return {
fieldId: field.fieldId,
value: field.value,
@@ -0,0 +1,31 @@
import UserController from "../../controllers/UserController";
export const processObjectValuePerms = async <T>(
obj: T,
scope: string, // e.g. "unifi.wifi.read"
user: UserController,
): Promise<Partial<T>> => {
let result: Partial<T> = {};
for (const key in obj) {
if (await user.hasPermission(`${scope}.${key}`)) {
result[key] = obj[key];
}
}
return result;
};
export const processObjectPermMap = async <T extends Record<string, unknown>>(
obj: T,
scope: string,
user: UserController,
): Promise<Record<keyof T, boolean>> => {
const result = {} as Record<keyof T, boolean>;
for (const key in obj) {
result[key] = await user.hasPermission(`${scope}.${key}`);
}
return result;
};
+952
View File
@@ -0,0 +1,952 @@
import axios, { AxiosInstance } from "axios";
import https from "https";
import {
ApGroup,
ApRadioWifiUsage,
ApWifiLimits,
CreateSiteOptions,
Device,
DeviceRadio,
DeviceState,
DeviceUplink,
Network,
PrivatePSK,
PrivatePSKCreateInput,
SiteListItem,
SiteOverview,
SubsystemHealth,
SysInfo,
UserGroup,
UserGroupCreateInput,
WlanConf,
WlanConfRaw,
WlanConfUpdate,
WlanGroup,
WlanGroupCreateInput,
} from "./unifiTypes";
export class UnifiClient {
private client: AxiosInstance;
constructor(baseURL: string) {
this.client = axios.create({
baseURL,
validateStatus: (s) => s >= 200 && s < 400,
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
});
}
private persistSession(res: { headers: Record<string, unknown> }): void {
// Cookies
const raw = res.headers["set-cookie"];
if (raw) {
const cookies = (Array.isArray(raw) ? raw : [raw]) as string[];
const cookieString = cookies.map((c) => c.split(";")[0]).join("; ");
this.client.defaults.headers.common["Cookie"] = cookieString;
}
// CSRF token (UniFi OS)
const csrf = res.headers["x-csrf-token"];
if (typeof csrf === "string") {
this.client.defaults.headers.common["X-CSRF-Token"] = csrf;
}
}
async login(username: string, password: string): Promise<void> {
const body = { username, password };
try {
// UniFi OS
const res = await this.client.post("/api/auth/login", body);
console.log("Login OK (UniFi OS)", res.status);
this.persistSession(res);
} catch (e) {
// Legacy controller
console.log("UniFi OS login failed, trying legacy...");
const res = await this.client.post("/api/login", body);
console.log("Login OK (legacy)", res.status);
this.persistSession(res);
}
}
private async fetchWlanConfRaw(site: string): Promise<WlanConfRaw[]> {
const paths = [
`/proxy/network/api/s/${site}/rest/wlanconf`,
`/api/s/${site}/rest/wlanconf`,
];
for (const path of paths) {
try {
const res = await this.client.get(path);
const data = (res.data?.data ?? res.data) as WlanConfRaw[];
console.log(`Fetched wlan from ${path}`);
return data;
} catch (e) {
console.log(
`Failed ${path}:`,
axios.isAxiosError(e) ? e.response?.status : e,
);
}
}
throw new Error("Could not fetch WLAN config from any known path");
}
private static parseWlanConf(w: any): WlanConf {
return {
id: w._id,
name: (w.name || w.ssid || "").toString(),
siteId: w.site_id ?? "",
enabled: w.enabled ?? true,
security: w.security ?? "open",
wpaMode: w.wpa_mode ?? "",
wpaEnc: w.wpa_enc ?? "",
wpa3Support: w.wpa3_support ?? false,
wpa3Transition: w.wpa3_transition ?? false,
wpa3FastRoaming: w.wpa3_fast_roaming ?? false,
wpa3Enhanced192: w.wpa3_enhanced_192 ?? false,
passphrase: typeof w.x_passphrase === "string" ? w.x_passphrase : null,
passphraseAutogenerated: w.passphrase_autogenerated ?? false,
hideSSID: w.hide_ssid ?? false,
isGuest: w.is_guest ?? false,
band: w.wlan_band ?? "both",
bands: w.wlan_bands ?? [],
networkconfId: w.networkconf_id ?? "",
usergroupId: w.usergroup_id ?? "",
apGroupIds: w.ap_group_ids ?? [],
apGroupMode: w.ap_group_mode ?? "devices",
pmfMode: w.pmf_mode ?? "disabled",
groupRekey: w.group_rekey ?? 0,
dtimMode: w.dtim_mode ?? "default",
dtimNg: w.dtim_ng ?? 1,
dtimNa: w.dtim_na ?? 3,
dtim6e: w.dtim_6e ?? 3,
l2Isolation: w.l2_isolation ?? false,
fastRoamingEnabled: w.fast_roaming_enabled ?? false,
bssTransition: w.bss_transition ?? false,
uapsdEnabled: w.uapsd_enabled ?? false,
iappEnabled: w.iapp_enabled ?? false,
proxyArp: w.proxy_arp ?? false,
mcastenhanceEnabled: w.mcastenhance_enabled ?? false,
macFilterEnabled: w.mac_filter_enabled ?? false,
macFilterPolicy: w.mac_filter_policy ?? "allow",
macFilterList: w.mac_filter_list ?? [],
radiusDasEnabled: w.radius_das_enabled ?? false,
radiusMacAuthEnabled: w.radius_mac_auth_enabled ?? false,
radiusMacaclFormat: w.radius_macacl_format ?? "none_lower",
minrateSettingPreference: w.minrate_setting_preference ?? "auto",
minrateNgEnabled: w.minrate_ng_enabled ?? false,
minrateNgDataRateKbps: w.minrate_ng_data_rate_kbps ?? 1000,
minrateNgAdvertisingRates: w.minrate_ng_advertising_rates ?? false,
minrateNaEnabled: w.minrate_na_enabled ?? false,
minrateNaDataRateKbps: w.minrate_na_data_rate_kbps ?? 6000,
minrateNaAdvertisingRates: w.minrate_na_advertising_rates ?? false,
settingPreference: w.setting_preference ?? "auto",
no2ghzOui: w.no2ghz_oui ?? false,
privatePreSharedKeysEnabled: w.private_preshared_keys_enabled ?? false,
privatePreSharedKeys: w.private_preshared_keys ?? [],
saeGroups: w.sae_groups ?? [],
saePsk: w.sae_psk ?? [],
schedule: w.schedule ?? [],
scheduleWithDuration: w.schedule_with_duration ?? [],
bcFilterList: w.bc_filter_list ?? [],
externalId: w.external_id ?? null,
};
}
async getWlanConf(site: string): Promise<WlanConf[]> {
const raw = await this.fetchWlanConfRaw(site);
return raw.map(UnifiClient.parseWlanConf);
}
async updateWlanConf(
site: string,
wlanId: string,
updates: WlanConfUpdate,
): Promise<WlanConf> {
const paths = [
`/proxy/network/api/s/${site}/rest/wlanconf/${wlanId}`,
`/api/s/${site}/rest/wlanconf/${wlanId}`,
];
// Fetch current WLAN to check if a RADIUS profile is configured.
// The controller rejects RADIUS fields when no profile is set.
const currentWlans = await this.getWlanConf(site);
const currentWlan = currentWlans.find((w) => w.id === wlanId);
const hasRadius =
currentWlan?.security === "wpaeap" || updates.security === "wpaeap";
if (!hasRadius) {
delete updates.radius_das_enabled;
delete updates.radius_mac_auth_enabled;
}
for (const path of paths) {
try {
const res = await this.client.put(path, updates);
const raw = (res.data?.data?.[0] ?? res.data) as any;
return UnifiClient.parseWlanConf(raw);
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
// Try next path on 404/401, throw on other errors
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw new Error(
`Failed to update WLAN ${wlanId}: ${e.response.status} ${JSON.stringify(e.response.data)}`,
);
}
}
}
throw new Error("Could not update WLAN config from any known path");
}
async getAllSites(): Promise<SiteListItem[]> {
const paths = ["/proxy/network/api/self/sites", "/api/self/sites"];
for (const path of paths) {
try {
const res = await this.client.get(path);
const raw = (res.data?.data ?? res.data) as any[];
return raw.map(
(s: any): SiteListItem => ({
id: s._id,
name: s.name,
description: s.desc ?? "",
deviceCount: s.device_count ?? 0,
role: s.role ?? "",
}),
);
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw e;
}
}
}
throw new Error("Could not fetch sites from any known path");
}
async getSiteOverview(site: string): Promise<SiteOverview> {
const prefixes = ["/proxy/network", ""];
for (const prefix of prefixes) {
try {
const [healthRes, sysInfoRes, sitesRes] = await Promise.all([
this.client.get(`${prefix}/api/s/${site}/stat/health`),
this.client.get(`${prefix}/api/s/${site}/stat/sysinfo`),
this.client.get(`${prefix}/api/self/sites`),
]);
const healthRaw = (healthRes.data?.data ?? healthRes.data) as any[];
const sysInfoRaw = (sysInfoRes.data?.data?.[0] ??
sysInfoRes.data) as any;
const sitesRaw = (sitesRes.data?.data ?? sitesRes.data) as any[];
const siteRaw = sitesRaw.find((s: any) => s.name === site);
if (!siteRaw) throw new Error(`Site "${site}" not found in sites list`);
const health: SubsystemHealth[] = healthRaw.map((h: any) => ({
subsystem: h.subsystem,
status: h.status,
numUser: h.num_user,
numGuest: h.num_guest,
numIot: h.num_iot,
txBytesR: h["tx_bytes-r"],
rxBytesR: h["rx_bytes-r"],
numAp: h.num_ap,
numSw: h.num_sw,
numGw: h.num_gw,
numAdopted: h.num_adopted,
numDisconnected: h.num_disconnected,
numPending: h.num_pending,
numDisabled: h.num_disabled,
}));
const sysInfo: SysInfo = {
name: sysInfoRaw.name,
hostname: sysInfoRaw.hostname,
version: sysInfoRaw.version,
build: sysInfoRaw.build,
timezone: sysInfoRaw.timezone,
uptime: sysInfoRaw.uptime,
ipAddresses: sysInfoRaw.ip_addrs ?? [],
updateAvailable: sysInfoRaw.update_available ?? false,
isCloudConsole: sysInfoRaw.is_cloud_console ?? false,
dataRetentionDays: sysInfoRaw.data_retention_days ?? 0,
informPort: sysInfoRaw.inform_port,
httpsPort: sysInfoRaw.https_port,
unsupportedDeviceCount: sysInfoRaw.unsupported_device_count ?? 0,
};
return {
site: {
id: siteRaw._id,
name: siteRaw.name,
description: siteRaw.desc ?? "",
deviceCount: siteRaw.device_count ?? 0,
role: siteRaw.role ?? "",
},
health,
sysInfo,
};
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw e;
}
}
}
throw new Error("Could not fetch site overview from any known path");
}
private static parseDeviceState(state: number): DeviceState {
const map: Record<number, DeviceState> = {
0: "disconnected",
1: "connected",
2: "pending",
4: "adopting",
5: "adopting",
};
return map[state] ?? "unknown";
}
async getDevices(site: string): Promise<Device[]> {
const paths = [
`/proxy/network/api/s/${site}/stat/device`,
`/api/s/${site}/stat/device`,
];
for (const path of paths) {
try {
const res = await this.client.get(path);
const raw = (res.data?.data ?? res.data) as any[];
return raw.map((d: any): Device => {
const uplink: DeviceUplink | null = d.uplink
? {
type: d.uplink.type,
mac: d.uplink.uplink_mac,
ip: d.uplink.uplink_remote_ip,
uplinkRemotePort: d.uplink.uplink_remote_port,
speed: d.uplink.speed,
fullDuplex: d.uplink.full_duplex,
}
: null;
const radios: DeviceRadio[] = (d.radio_table ?? []).map(
(r: any, i: number) => {
const stats = d.radio_table_stats?.[i] ?? {};
return {
name: r.name ?? r.radio,
radio: r.radio,
channel: r.channel,
txPower: r.tx_power,
txPowerMode: r.tx_power_mode,
minRssiEnabled: r.min_rssi_enabled ?? false,
numSta: stats.num_sta ?? 0,
satisfaction: stats.satisfaction ?? null,
};
},
);
return {
id: d._id,
mac: d.mac,
ip: d.ip ?? "",
name: d.name ?? d.mac,
model: d.model,
shortname: d.shortname ?? d.model,
type: d.type,
version: d.version ?? "",
serial: d.serial ?? "",
state: UnifiClient.parseDeviceState(d.state),
adopted: d.adopted ?? false,
uptime: d.uptime ?? 0,
lastSeen: d.last_seen ?? 0,
upgradable: d.upgradable ?? false,
satisfaction: d.satisfaction ?? null,
numClients: d.num_sta ?? 0,
numUserClients: d["user-num_sta"] ?? 0,
numGuestClients: d["guest-num_sta"] ?? 0,
txBytes: d.tx_bytes ?? 0,
rxBytes: d.rx_bytes ?? 0,
uplink,
radios,
modelInLts: d.model_in_lts ?? false,
modelInEol: d.model_in_eol ?? false,
};
});
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw e;
}
}
}
throw new Error("Could not fetch devices from any known path");
}
async getNetworks(site: string): Promise<Network[]> {
const paths = [
`/proxy/network/api/s/${site}/rest/networkconf`,
`/api/s/${site}/rest/networkconf`,
];
for (const path of paths) {
try {
const res = await this.client.get(path);
const raw = (res.data?.data ?? res.data) as any[];
return raw.map(
(n: any): Network => ({
id: n._id,
name: n.name ?? "",
purpose: n.purpose ?? "corporate",
enabled: n.enabled ?? true,
ipSubnet: n.ip_subnet ?? null,
vlan: n.vlan != null ? Number(n.vlan) : null,
vlanEnabled: n.vlan_enabled ?? false,
isNat: n.is_nat ?? false,
domainName: n.domain_name ?? null,
networkGroup: n.networkgroup ?? null,
dhcpdEnabled: n.dhcpd_enabled ?? false,
dhcpdStart: n.dhcpd_start ?? null,
dhcpdStop: n.dhcpd_stop ?? null,
dhcpdLeasetime: n.dhcpd_leasetime ?? null,
dhcpRelayEnabled: n.dhcp_relay_enabled ?? false,
dhcpGuardEnabled: n.dhcpguard_enabled ?? false,
igmpSnooping: n.igmp_snooping ?? false,
ipv6Enabled: n.ipv6_enabled ?? false,
ipv6InterfaceType: n.ipv6_interface_type ?? null,
internetAccessEnabled: n.internet_access_enabled ?? null,
}),
);
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw e;
}
}
}
throw new Error("Could not fetch networks from any known path");
}
async createSite(description: string): Promise<SiteListItem> {
const paths = [
"/proxy/network/api/s/default/cmd/sitemgr",
"/api/s/default/cmd/sitemgr",
];
const body = { cmd: "add-site", desc: description };
for (const path of paths) {
try {
const res = await this.client.post(path, body);
const raw = (res.data?.data?.[0] ?? res.data) as any;
return {
id: raw._id,
name: raw.name,
description: raw.desc ?? description,
deviceCount: raw.device_count ?? 0,
role: raw.role ?? "",
};
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw new Error(
`Failed to create site: ${e.response.status} ${JSON.stringify(e.response.data)}`,
);
}
}
}
throw new Error("Could not create site from any known path");
}
// --- WLAN Groups ---
async getWlanGroups(site: string): Promise<WlanGroup[]> {
const paths = [
`/proxy/network/api/s/${site}/rest/wlangroup`,
`/api/s/${site}/rest/wlangroup`,
];
for (const path of paths) {
try {
const res = await this.client.get(path);
const raw = (res.data?.data ?? res.data) as any[];
return raw.map(
(g: any): WlanGroup => ({
id: g._id,
name: g.name ?? "",
siteId: g.site_id ?? "",
noDelete: g.attr_no_delete ?? false,
noEdit: g.attr_no_edit ?? false,
hidden: g.attr_hidden ?? false,
}),
);
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
)
throw e;
}
}
throw new Error("Could not fetch WLAN groups from any known path");
}
async createWlanGroup(
site: string,
input: WlanGroupCreateInput,
): Promise<WlanGroup> {
const paths = [
`/proxy/network/api/s/${site}/rest/wlangroup`,
`/api/s/${site}/rest/wlangroup`,
];
const body: Record<string, unknown> = { name: input.name };
for (const path of paths) {
try {
const res = await this.client.post(path, body);
const raw = (res.data?.data?.[0] ?? res.data) as any;
return {
id: raw._id,
name: raw.name ?? input.name,
siteId: raw.site_id ?? "",
noDelete: raw.attr_no_delete ?? false,
noEdit: raw.attr_no_edit ?? false,
hidden: raw.attr_hidden ?? false,
};
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw new Error(
`Failed to create WLAN group: ${e.response.status} ${JSON.stringify(e.response.data)}`,
);
}
}
}
throw new Error("Could not create WLAN group from any known path");
}
// --- User Groups (Speed Profiles) ---
async getUserGroups(site: string): Promise<UserGroup[]> {
const paths = [
`/proxy/network/api/s/${site}/rest/usergroup`,
`/api/s/${site}/rest/usergroup`,
];
for (const path of paths) {
try {
const res = await this.client.get(path);
const raw = (res.data?.data ?? res.data) as any[];
return raw.map(
(g: any): UserGroup => ({
id: g._id,
name: g.name ?? "",
siteId: g.site_id ?? "",
noDelete: g.attr_no_delete ?? false,
downloadLimitKbps: g.qos_rate_max_down ?? -1,
uploadLimitKbps: g.qos_rate_max_up ?? -1,
}),
);
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
)
throw e;
}
}
throw new Error("Could not fetch user groups from any known path");
}
async createUserGroup(
site: string,
input: UserGroupCreateInput,
): Promise<UserGroup> {
const paths = [
`/proxy/network/api/s/${site}/rest/usergroup`,
`/api/s/${site}/rest/usergroup`,
];
const body: Record<string, unknown> = { name: input.name };
if (input.downloadLimitKbps !== undefined)
body.qos_rate_max_down = input.downloadLimitKbps;
if (input.uploadLimitKbps !== undefined)
body.qos_rate_max_up = input.uploadLimitKbps;
for (const path of paths) {
try {
const res = await this.client.post(path, body);
const raw = (res.data?.data?.[0] ?? res.data) as any;
return {
id: raw._id,
name: raw.name ?? input.name,
siteId: raw.site_id ?? "",
noDelete: raw.attr_no_delete ?? false,
downloadLimitKbps: raw.qos_rate_max_down ?? -1,
uploadLimitKbps: raw.qos_rate_max_up ?? -1,
};
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw new Error(
`Failed to create user group: ${e.response.status} ${JSON.stringify(e.response.data)}`,
);
}
}
}
throw new Error("Could not create user group from any known path");
}
// --- AP Groups ---
async getApGroups(site: string): Promise<ApGroup[]> {
const paths = [
`/proxy/network/v2/api/site/${site}/apgroups`,
`/v2/api/site/${site}/apgroups`,
];
for (const path of paths) {
try {
const res = await this.client.get(path);
const raw = (res.data?.data ?? res.data) as any[];
return raw.map(
(g: any): ApGroup => ({
id: g._id,
name: g.name ?? "",
deviceMacs: g.device_macs ?? [],
noDelete: g.attr_no_delete ?? false,
forWlanconf: g.for_wlanconf ?? false,
}),
);
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
)
throw e;
}
}
throw new Error("Could not fetch AP groups from any known path");
}
async createApGroup(
site: string,
name: string,
deviceMacs: string[],
forWlanconf: boolean = false,
): Promise<ApGroup> {
const paths = [
`/proxy/network/v2/api/site/${site}/apgroups`,
`/v2/api/site/${site}/apgroups`,
];
const body = {
name,
device_macs: deviceMacs,
for_wlanconf: forWlanconf,
};
for (const path of paths) {
try {
const res = await this.client.post(path, body);
const raw = (res.data?.data?.[0] ?? res.data) as any;
return {
id: raw._id,
name: raw.name ?? name,
deviceMacs: raw.device_macs ?? deviceMacs,
noDelete: raw.attr_no_delete ?? false,
forWlanconf: raw.for_wlanconf ?? forWlanconf,
};
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw new Error(
`Failed to create AP group: ${e.response.status} ${JSON.stringify(e.response.data)}`,
);
}
}
}
throw new Error("Could not create AP group from any known path");
}
async updateApGroup(
site: string,
groupId: string,
deviceMacs: string[],
): Promise<ApGroup> {
const paths = [
`/proxy/network/v2/api/site/${site}/apgroups/${groupId}`,
`/v2/api/site/${site}/apgroups/${groupId}`,
];
const body = {
name: "devices_ap_group",
device_macs: deviceMacs,
for_wlanconf: true,
};
for (const path of paths) {
try {
const res = await this.client.put(path, body);
const raw = (res.data?.data?.[0] ?? res.data) as any;
return {
id: raw._id ?? groupId,
name: raw.name ?? "devices_ap_group",
deviceMacs: raw.device_macs ?? deviceMacs,
noDelete: raw.attr_no_delete ?? false,
forWlanconf: raw.for_wlanconf ?? true,
};
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw new Error(
`Failed to update AP group: ${e.response.status} ${JSON.stringify(e.response.data)}`,
);
}
}
}
throw new Error("Could not update AP group from any known path");
}
// --- Access Points ---
async getAccessPoints(site: string): Promise<Device[]> {
const devices = await this.getDevices(site);
return devices.filter((d) => d.type === "uap");
}
// --- WiFi Limits ---
async getWifiLimits(site: string): Promise<ApWifiLimits[]> {
const SSID_LIMIT_PER_RADIO = 8;
const paths = [
`/proxy/network/api/s/${site}/stat/device`,
`/api/s/${site}/stat/device`,
];
for (const path of paths) {
try {
const res = await this.client.get(path);
const raw = (res.data?.data ?? res.data) as any[];
const aps = raw.filter((d: any) => d.type === "uap");
return aps.map((ap: any): ApWifiLimits => {
const vapTable: any[] = ap.vap_table ?? [];
const radioMap = new Map<string, { wlanNames: Set<string> }>();
for (const vap of vapTable) {
if (!vap.up || !vap.radio) continue;
if (!radioMap.has(vap.radio)) {
radioMap.set(vap.radio, { wlanNames: new Set() });
}
if (vap.essid) {
radioMap.get(vap.radio)!.wlanNames.add(vap.essid);
}
}
const radioBandMap: Record<string, string> = {
ng: "2g",
na: "5g",
"6e": "6e",
};
const radios: ApRadioWifiUsage[] = Array.from(radioMap.entries()).map(
([radio, data]): ApRadioWifiUsage => ({
radio,
band: radioBandMap[radio] ?? radio,
activeWlans: data.wlanNames.size,
limit: SSID_LIMIT_PER_RADIO,
remaining: Math.max(
0,
SSID_LIMIT_PER_RADIO - data.wlanNames.size,
),
wlanNames: Array.from(data.wlanNames),
}),
);
return {
apId: ap._id,
apName: ap.name ?? ap.mac,
mac: ap.mac,
model: ap.model ?? "",
radios,
};
});
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
)
throw e;
}
}
throw new Error("Could not fetch WiFi limits from any known path");
}
// --- Private Pre-Shared Keys ---
private static parsePPSKs(raw: any[]): PrivatePSK[] {
return raw.map(
(p: any): PrivatePSK => ({
key: p.key ?? "",
name: p.name ?? "",
mac: p.mac ?? null,
vlanId: p.vlan_id ?? null,
}),
);
}
async getPrivatePSKs(site: string, wlanId: string): Promise<PrivatePSK[]> {
const paths = [
`/proxy/network/api/s/${site}/rest/wlanconf/${wlanId}`,
`/api/s/${site}/rest/wlanconf/${wlanId}`,
];
for (const path of paths) {
try {
const res = await this.client.get(path);
const raw = (res.data?.data?.[0] ?? res.data) as any;
return UnifiClient.parsePPSKs(raw.private_preshared_keys ?? []);
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
)
throw e;
}
}
throw new Error("Could not fetch PPSKs from any known path");
}
async createPrivatePSK(
site: string,
wlanId: string,
psk: PrivatePSKCreateInput,
): Promise<PrivatePSK[]> {
const paths = [
`/proxy/network/api/s/${site}/rest/wlanconf/${wlanId}`,
`/api/s/${site}/rest/wlanconf/${wlanId}`,
];
// Fetch current PPSKs
let currentPpsks: any[] = [];
for (const path of paths) {
try {
const res = await this.client.get(path);
const raw = (res.data?.data?.[0] ?? res.data) as any;
currentPpsks = (raw.private_preshared_keys ?? []) as any[];
break;
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
)
throw e;
}
}
const newPsk: Record<string, unknown> = {
key: psk.key,
name: psk.name,
};
if (psk.mac) newPsk.mac = psk.mac;
if (psk.vlanId !== undefined) newPsk.vlan_id = psk.vlanId;
currentPpsks.push(newPsk);
// Update WLAN with new PPSKs
for (const path of paths) {
try {
const res = await this.client.put(path, {
private_preshared_keys: currentPpsks,
private_preshared_keys_enabled: true,
});
const raw = (res.data?.data?.[0] ?? res.data) as any;
return UnifiClient.parsePPSKs(raw.private_preshared_keys ?? []);
} catch (e) {
if (!axios.isAxiosError(e)) throw e;
if (
e.response &&
e.response.status !== 404 &&
e.response.status !== 401
) {
throw new Error(
`Failed to create PPSK: ${e.response.status} ${JSON.stringify(e.response.data)}`,
);
}
}
}
throw new Error("Could not create PPSK from any known path");
}
}
+434
View File
@@ -0,0 +1,434 @@
export interface WlanConfRaw {
_id: string;
name?: string;
ssid?: string;
x_passphrase?: string;
[key: string]: unknown;
}
export interface WlanConf {
id: string;
name: string;
siteId: string;
enabled: boolean;
security: string;
wpaMode: string;
wpaEnc: string;
wpa3Support: boolean;
wpa3Transition: boolean;
wpa3FastRoaming: boolean;
wpa3Enhanced192: boolean;
passphrase: string | null;
passphraseAutogenerated: boolean;
hideSSID: boolean;
isGuest: boolean;
band: string;
bands: string[];
networkconfId: string;
usergroupId: string;
apGroupIds: string[];
apGroupMode: string;
pmfMode: string;
groupRekey: number;
dtimMode: string;
dtimNg: number;
dtimNa: number;
dtim6e: number;
l2Isolation: boolean;
fastRoamingEnabled: boolean;
bssTransition: boolean;
uapsdEnabled: boolean;
iappEnabled: boolean;
proxyArp: boolean;
mcastenhanceEnabled: boolean;
macFilterEnabled: boolean;
macFilterPolicy: string;
macFilterList: string[];
radiusDasEnabled: boolean;
radiusMacAuthEnabled: boolean;
radiusMacaclFormat: string;
minrateSettingPreference: string;
minrateNgEnabled: boolean;
minrateNgDataRateKbps: number;
minrateNgAdvertisingRates: boolean;
minrateNaEnabled: boolean;
minrateNaDataRateKbps: number;
minrateNaAdvertisingRates: boolean;
settingPreference: string;
no2ghzOui: boolean;
privatePreSharedKeysEnabled: boolean;
privatePreSharedKeys: unknown[];
saeGroups: unknown[];
saePsk: unknown[];
schedule: unknown[];
scheduleWithDuration: unknown[];
bcFilterList: unknown[];
externalId: string | null;
}
export interface WlanConfUpdate {
name?: string;
x_passphrase?: string;
enabled?: boolean;
security?: "wpapsk" | "wpaeap" | "open" | "osen";
wpa_mode?: "wpa2" | "wpa3" | "wpa2wpa3";
wpa_enc?: "ccmp" | "gcmp" | "ccmp-gcmp";
hide_ssid?: boolean;
mac_filter_enabled?: boolean;
mac_filter_policy?: "allow" | "deny";
is_guest?: boolean;
l2_isolation?: boolean;
fast_roaming_enabled?: boolean;
bss_transition?: boolean;
uapsd_enabled?: boolean;
group_rekey?: number;
dtim_mode?: "default" | "custom";
dtim_ng?: number;
dtim_na?: number;
minrate_ng_enabled?: boolean;
minrate_na_enabled?: boolean;
radius_das_enabled?: boolean;
radius_mac_auth_enabled?: boolean;
pmf_mode?: "disabled" | "optional" | "required";
wlan_band?: "both" | "2g" | "5g";
usergroup_id?: string;
proxy_arp?: boolean;
mcastenhance_enabled?: boolean;
mac_filter_list?: string[];
no2ghz_oui?: boolean;
ap_group_ids?: string[];
ap_group_mode?: string;
}
/**
* CamelCase update input matching the WlanConf return shape.
* Accepted by the API and converted to WlanConfUpdate (snake_case) before
* being sent to the UniFi controller.
*/
export interface WlanConfUpdateInput {
name?: string;
passphrase?: string;
enabled?: boolean;
security?: "wpapsk" | "wpaeap" | "open" | "osen";
wpaMode?: "wpa2" | "wpa3" | "wpa2wpa3";
wpaEnc?: "ccmp" | "gcmp" | "ccmp-gcmp";
hideSSID?: boolean;
macFilterEnabled?: boolean;
macFilterPolicy?: "allow" | "deny";
isGuest?: boolean;
l2Isolation?: boolean;
fastRoamingEnabled?: boolean;
bssTransition?: boolean;
uapsdEnabled?: boolean;
groupRekey?: number;
dtimMode?: "default" | "custom";
dtimNg?: number;
dtimNa?: number;
minrateNgEnabled?: boolean;
minrateNaEnabled?: boolean;
radiusDasEnabled?: boolean;
radiusMacAuthEnabled?: boolean;
pmfMode?: "disabled" | "optional" | "required";
band?: "both" | "2g" | "5g";
usergroupId?: string;
proxyArp?: boolean;
mcastenhanceEnabled?: boolean;
macFilterList?: string[];
no2ghzOui?: boolean;
apGroupIds?: string[];
apGroupMode?: string;
}
/**
* Converts a camelCase WlanConfUpdateInput to the snake_case WlanConfUpdate
* expected by the UniFi controller API.
*/
export function toWlanConfUpdate(input: WlanConfUpdateInput): WlanConfUpdate {
const result: WlanConfUpdate = {};
if (input.name !== undefined) result.name = input.name;
if (input.passphrase !== undefined) result.x_passphrase = input.passphrase;
if (input.enabled !== undefined) result.enabled = input.enabled;
if (input.security !== undefined) result.security = input.security;
if (input.wpaMode !== undefined) result.wpa_mode = input.wpaMode;
if (input.wpaEnc !== undefined) result.wpa_enc = input.wpaEnc;
if (input.hideSSID !== undefined) result.hide_ssid = input.hideSSID;
if (input.macFilterEnabled !== undefined)
result.mac_filter_enabled = input.macFilterEnabled;
if (input.macFilterPolicy !== undefined)
result.mac_filter_policy = input.macFilterPolicy;
if (input.isGuest !== undefined) result.is_guest = input.isGuest;
if (input.l2Isolation !== undefined) result.l2_isolation = input.l2Isolation;
if (input.fastRoamingEnabled !== undefined)
result.fast_roaming_enabled = input.fastRoamingEnabled;
if (input.bssTransition !== undefined)
result.bss_transition = input.bssTransition;
if (input.uapsdEnabled !== undefined)
result.uapsd_enabled = input.uapsdEnabled;
if (input.groupRekey !== undefined) result.group_rekey = input.groupRekey;
if (input.dtimMode !== undefined) result.dtim_mode = input.dtimMode;
if (input.dtimNg !== undefined) result.dtim_ng = input.dtimNg;
if (input.dtimNa !== undefined) result.dtim_na = input.dtimNa;
if (input.minrateNgEnabled !== undefined)
result.minrate_ng_enabled = input.minrateNgEnabled;
if (input.minrateNaEnabled !== undefined)
result.minrate_na_enabled = input.minrateNaEnabled;
if (input.radiusDasEnabled !== undefined)
result.radius_das_enabled = input.radiusDasEnabled;
if (input.radiusMacAuthEnabled !== undefined)
result.radius_mac_auth_enabled = input.radiusMacAuthEnabled;
if (input.pmfMode !== undefined) result.pmf_mode = input.pmfMode;
if (input.band !== undefined) result.wlan_band = input.band;
if (input.usergroupId !== undefined) result.usergroup_id = input.usergroupId;
if (input.proxyArp !== undefined) result.proxy_arp = input.proxyArp;
if (input.mcastenhanceEnabled !== undefined)
result.mcastenhance_enabled = input.mcastenhanceEnabled;
if (input.macFilterList !== undefined)
result.mac_filter_list = input.macFilterList;
if (input.no2ghzOui !== undefined) result.no2ghz_oui = input.no2ghzOui;
if (input.apGroupIds !== undefined) result.ap_group_ids = input.apGroupIds;
if (input.apGroupMode !== undefined) result.ap_group_mode = input.apGroupMode;
return result;
}
// --- Site overview types ---
export interface SubsystemHealth {
subsystem: "wlan" | "wan" | "www" | "lan" | "vpn";
status: "ok" | "warn" | "error" | "unknown";
numUser?: number;
numGuest?: number;
numIot?: number;
txBytesR?: number;
rxBytesR?: number;
// WLAN-specific
numAp?: number;
// LAN-specific
numSw?: number;
// WAN-specific
numGw?: number;
// Shared device counts
numAdopted?: number;
numDisconnected?: number;
numPending?: number;
numDisabled?: number;
}
export interface SysInfo {
name: string;
hostname: string;
version: string;
build: string;
timezone: string;
uptime: number;
ipAddresses: string[];
updateAvailable: boolean;
isCloudConsole: boolean;
dataRetentionDays: number;
informPort: number;
httpsPort: number;
unsupportedDeviceCount: number;
}
export interface SiteInfo {
id: string;
name: string;
description: string;
deviceCount: number;
role: string;
}
export interface SiteOverview {
site: SiteInfo;
health: SubsystemHealth[];
sysInfo: SysInfo;
}
// --- Device types ---
export type DeviceType = "uap" | "usw" | "ugw" | "uxg" | "ubb" | "udm";
export type DeviceState =
| "connected"
| "disconnected"
| "pending"
| "adopting"
| "unknown";
export interface DeviceUplink {
type?: string;
mac?: string;
ip?: string;
uplinkRemotePort?: number;
speed?: number;
fullDuplex?: boolean;
}
export interface DeviceRadio {
name: string;
radio: string;
channel: number;
txPower: number;
txPowerMode: string;
minRssiEnabled: boolean;
numSta: number;
satisfaction: number | null;
}
export interface Device {
id: string;
mac: string;
ip: string;
name: string;
model: string;
shortname: string;
type: DeviceType;
version: string;
serial: string;
state: DeviceState;
adopted: boolean;
uptime: number;
lastSeen: number;
upgradable: boolean;
satisfaction: number | null;
numClients: number;
numUserClients: number;
numGuestClients: number;
txBytes: number;
rxBytes: number;
uplink: DeviceUplink | null;
radios: DeviceRadio[];
modelInLts: boolean;
modelInEol: boolean;
}
// --- Network types ---
export type NetworkPurpose =
| "corporate"
| "vlan-only"
| "wan"
| "vpn-client"
| "remote-user-vpn"
| "site-vpn";
export interface Network {
id: string;
name: string;
purpose: NetworkPurpose;
enabled: boolean;
ipSubnet: string | null;
vlan: number | null;
vlanEnabled: boolean;
isNat: boolean;
domainName: string | null;
networkGroup: string | null;
dhcpdEnabled: boolean;
dhcpdStart: string | null;
dhcpdStop: string | null;
dhcpdLeasetime: number | null;
dhcpRelayEnabled: boolean;
dhcpGuardEnabled: boolean;
igmpSnooping: boolean;
ipv6Enabled: boolean;
ipv6InterfaceType: string | null;
internetAccessEnabled: boolean | null;
}
// --- Site create types ---
export interface CreateSiteOptions {
/** Human-readable description / display name for the site */
description: string;
}
// --- Site list types ---
export interface SiteListItem {
id: string;
name: string;
description: string;
deviceCount: number;
role: string;
}
// --- WLAN Group types ---
export interface WlanGroup {
id: string;
name: string;
siteId: string;
noDelete: boolean;
noEdit: boolean;
hidden: boolean;
}
export interface WlanGroupCreateInput {
name: string;
}
// --- AP Group types ---
export interface ApGroup {
id: string;
name: string;
deviceMacs: string[];
noDelete: boolean;
forWlanconf: boolean;
}
// --- User Group (Speed Profile) types ---
export interface UserGroup {
id: string;
name: string;
siteId: string;
noDelete: boolean;
/** Download rate limit in Kbps. -1 means unlimited. */
downloadLimitKbps: number;
/** Upload rate limit in Kbps. -1 means unlimited. */
uploadLimitKbps: number;
}
export interface UserGroupCreateInput {
name: string;
/** Download rate limit in Kbps. -1 or omit for unlimited. */
downloadLimitKbps?: number;
/** Upload rate limit in Kbps. -1 or omit for unlimited. */
uploadLimitKbps?: number;
}
// --- Private PSK types ---
export interface PrivatePSK {
key: string;
name: string;
mac: string | null;
vlanId: number | null;
}
export interface PrivatePSKCreateInput {
key: string;
name: string;
mac?: string;
vlanId?: number;
}
// --- WiFi Limit types ---
export interface ApRadioWifiUsage {
radio: string;
band: string;
activeWlans: number;
limit: number;
remaining: number;
wlanNames: string[];
}
export interface ApWifiLimits {
apId: string;
apName: string;
mac: string;
model: string;
radios: ApRadioWifiUsage[];
}