Skip to content

Commit cb8b440

Browse files
authored
guest registar staging app (#1026)
Deploys app that can connect to UDM and receive real time events on clients' status and network connection state. #1002
2 parents 9cb1fac + 763bce2 commit cb8b440

33 files changed

+1093
-23
lines changed

.pnp.cjs

Lines changed: 325 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@ha/guest-registrar-unifi",
3+
"version": "0.0.1",
4+
"description": "Register guests upon connecting phone devices to the Unifi guest network.",
5+
"dependencies": {
6+
"@ha/logger": "workspace:^0.0.1",
7+
"@ha/mqtt-client": "workspace:^0.0.1",
8+
"axios": "^1.8.4",
9+
"lodash": "^4.17.21",
10+
"ws": "^8.18.1"
11+
},
12+
"devDependencies": {
13+
"@ha/build-ts": "workspace:^1.0.0",
14+
"@ha/configuration-api": "workspace:^0.0.1",
15+
"@ha/configuration-workspace": "workspace:^0.0.1",
16+
"@ha/docker": "workspace:^0.0.1",
17+
"@ha/nx-executors": "workspace:^0.1.0",
18+
"@types/jest": "^29.5.14",
19+
"@types/node": "^18.19.76",
20+
"@types/nodemon": "^3.1.1",
21+
"esbuild": "^0.25.2",
22+
"esbuild-register": "^3.6.0",
23+
"nodemon": "^3.1.9",
24+
"nx": "^20.7.0"
25+
}
26+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "guest-registrar-unifi",
3+
"sourceRoot": "{projectRoot}/src",
4+
"projectType": "application",
5+
"targets": {
6+
"start": {
7+
"executor": "nx:run-commands",
8+
"options": {
9+
"command": "yarn node --trace-warnings --require esbuild-register scripts/nodemon.ts",
10+
"cwd": "{projectRoot}"
11+
}
12+
},
13+
"compile": {
14+
"executor": "@ha/nx-executors:invoke",
15+
"options": {
16+
"cwd": "{projectRoot}",
17+
"module": "scripts/compile.ts"
18+
}
19+
},
20+
"package": {
21+
"executor": "@ha/nx-executors:invoke",
22+
"options": {
23+
"cwd": "{projectRoot}",
24+
"module": "scripts/package.ts"
25+
}
26+
},
27+
"publish": {
28+
"executor": "@ha/nx-executors:invoke",
29+
"options": {
30+
"cwd": "{projectRoot}",
31+
"module": "scripts/publish.ts"
32+
}
33+
}
34+
},
35+
"tags": [],
36+
"implicitDependencies": []
37+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import build from "@ha/build-ts"
2+
import type { ConfigurationApi } from "@ha/configuration-api"
3+
4+
import type { Configuration } from "@ha/configuration-workspace"
5+
6+
const run = async (
7+
configurationApi: ConfigurationApi<Configuration>,
8+
): Promise<void> => {
9+
await build()
10+
}
11+
12+
export default run
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import nodemon from "nodemon"
2+
import path from "path"
3+
4+
const run = async () => {
5+
nodemon({
6+
verbose: true,
7+
ignore: ["*.test.js", "fixtures/*"],
8+
watch: [path.join(__dirname, "..", "src")],
9+
script: path.join(__dirname, "..", "src", "index.ts"),
10+
execMap: {
11+
js: "yarn node --require esbuild-register",
12+
ts: "yarn node --require esbuild-register",
13+
tsx: "yarn node --require esbuild-register",
14+
},
15+
})
16+
.on("start", () => {
17+
console.log("nodemon started")
18+
})
19+
.on("restart", (files) => {
20+
console.log("nodemon restarted")
21+
})
22+
.on("exit", () => {
23+
console.log("exiting")
24+
})
25+
.on("quit", () => console.log("quiting"))
26+
.on("crash", () => console.log("crashed"))
27+
}
28+
29+
if (require.main === module) {
30+
run()
31+
}
32+
33+
export default run
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { ConfigurationApi } from "@ha/configuration-api"
2+
import type { Configuration } from "@ha/configuration-workspace"
3+
import createClient from "@ha/docker"
4+
import path from "path"
5+
6+
const run = async (
7+
configurationApi: ConfigurationApi<Configuration>,
8+
): Promise<void> => {
9+
const docker = await createClient(configurationApi)
10+
await docker.build(`guest-registrar-unifi:latest`, {
11+
context: path.join(__dirname, "../"),
12+
dockerFile: path.join("src", "Dockerfile"),
13+
})
14+
}
15+
16+
export default run
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { ConfigurationApi } from "@ha/configuration-api"
2+
import type { Configuration } from "@ha/configuration-workspace"
3+
import createClient from "@ha/docker"
4+
5+
const run = async (
6+
configurationApi: ConfigurationApi<Configuration>,
7+
): Promise<void> => {
8+
const docker = await createClient(configurationApi)
9+
await docker.pushImage(`guest-registrar-unifi:latest`)
10+
}
11+
12+
export default run
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM node:22.14.0-alpine
2+
3+
WORKDIR /app
4+
5+
ENV NODE_ENV production
6+
7+
COPY dist dist
8+
9+
ENTRYPOINT [ "node", "dist/index.js" ]
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { logger } from "@ha/logger"
2+
import axios from "axios"
3+
import https from "https"
4+
import { isEmpty, throttle } from "lodash"
5+
import { env } from "node:process"
6+
import WebSocket from "ws"
7+
8+
interface IHandleWebSocketMessage {
9+
(messageType: string, payload: any): void
10+
}
11+
12+
const connectWebSocket = async (
13+
messageHandlers: Array<IHandleWebSocketMessage>,
14+
) => {
15+
const { UNIFI_IP, UNIFI_USERNAME, UNIFI_PASSWORD } = env
16+
logger.debug(
17+
`Connecting to Unifi controller at ${UNIFI_IP} with username ${UNIFI_USERNAME}`,
18+
)
19+
const api = axios.create({
20+
baseURL: `https://${UNIFI_IP}`,
21+
withCredentials: true,
22+
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
23+
})
24+
25+
const cookieJar = {}
26+
const response = await api.post("/api/auth/login", {
27+
username: UNIFI_USERNAME,
28+
password: UNIFI_PASSWORD,
29+
rememberMe: true,
30+
remember: true,
31+
})
32+
const cookies = response.headers["set-cookie"]
33+
if (cookies) {
34+
cookies.forEach((cookie) => {
35+
const [key, value] = cookie.split(";")[0].split("=")
36+
cookieJar[key] = value
37+
})
38+
}
39+
const cookieString = Object.entries(cookieJar)
40+
.map(([key, value]) => `${key}=${value}`)
41+
.join("; ")
42+
43+
const ws = new WebSocket(
44+
`wss://${UNIFI_IP}/proxy/network/wss/s/default/events`,
45+
{
46+
headers: {
47+
Cookie: cookieString,
48+
Upgrade: "websocket",
49+
},
50+
rejectUnauthorized: false,
51+
},
52+
)
53+
54+
const connectionTimeout = setTimeout(() => {
55+
if (ws.readyState !== WebSocket.OPEN) {
56+
ws.terminate()
57+
logger.info("Connection attempt timed out, retrying...")
58+
setTimeout(connectWebSocket, 5000)
59+
}
60+
}, 10000)
61+
62+
ws.on("open", () => {
63+
clearTimeout(connectionTimeout)
64+
logger.info("WebSocket connection established")
65+
66+
// Set up a keepalive ping
67+
const pingInterval = setInterval(() => {
68+
if (ws.readyState === WebSocket.OPEN) {
69+
ws.ping()
70+
} else {
71+
clearInterval(pingInterval)
72+
}
73+
}, 30000)
74+
})
75+
76+
ws.on("message", (data) => {
77+
const event = JSON.parse(data)
78+
if (event.data && event.meta && event.meta.message) {
79+
const msgType = event.meta.message
80+
switch (msgType) {
81+
case "sta:sync":
82+
for (const handler of messageHandlers) {
83+
try {
84+
handler(msgType, event.data)
85+
} catch (error) {
86+
logger.error(`Error in message handler: ${error}`)
87+
}
88+
}
89+
break
90+
default:
91+
break
92+
}
93+
}
94+
})
95+
96+
ws.on("close", (code, reason) => {
97+
logger.debug(`WebSocket closed: ${code} - ${reason}`)
98+
logger.info("Connection closed, attempting to reconnect...")
99+
setTimeout(connectWebSocket, 5000)
100+
})
101+
102+
ws.on("error", (error) => {
103+
logger.error(`WebSocket error: ${error.message}`)
104+
if (ws.readyState !== WebSocket.OPEN) {
105+
ws.terminate()
106+
setTimeout(connectWebSocket, 5000)
107+
}
108+
})
109+
}
110+
111+
const run = async () => {
112+
logger.info("Starting guest-registrar-unifi application...")
113+
// const mqtt = await createMqtt()
114+
const handleClientStat = throttle(
115+
(type: string, payload: any) => {
116+
if (type !== "sta:sync") {
117+
return
118+
}
119+
logger.debug("Handling client stat")
120+
const data = payload as Array<{
121+
confidence?: number
122+
dev_cat?: number
123+
dev_family?: number
124+
mac: string
125+
name: string
126+
hostname: string
127+
network: string
128+
os_name?: number
129+
is_guest: boolean
130+
}>
131+
const guestDeviceTrackers = data
132+
.filter((client) => client.is_guest)
133+
.filter(
134+
(client) =>
135+
(((client.dev_cat === 1 || client.dev_family === 9) &&
136+
client?.confidence) ??
137+
0 >= 50) ||
138+
/.*phone.*/i.test(client.hostname),
139+
)
140+
if (!isEmpty(guestDeviceTrackers)) {
141+
console.debug(
142+
`${guestDeviceTrackers.length} guest device trackers found: ${guestDeviceTrackers.map((client) => `[${client.hostname}, ${client.mac}]`).join(", ")}`,
143+
)
144+
}
145+
},
146+
{ leading: true, trailing: false, wait: 60000 },
147+
)
148+
const handlers = [handleClientStat]
149+
await connectWebSocket(handlers)
150+
}
151+
152+
if (require.main === module) {
153+
run()
154+
}
155+
156+
export default run
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"include": [
5+
"src/**/*.ts",
6+
"scripts/**/*.ts"
7+
],
8+
"types": ["@types/node", "@types/jest", "@types/nodemon"]
9+
}
10+
}

apps/home-assistant-app-daemon/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
"@ha/configuration-api": "workspace:^0.0.1",
77
"@ha/configuration-workspace": "workspace:^0.0.1",
88
"@ha/docker": "workspace:^0.0.1",
9-
"@ha/jsonnet": "workspace:^0.0.1",
10-
"@ha/kubectl": "workspace:^1.0.0",
119
"@ha/nx-executors": "workspace:^0.1.0",
1210
"nx": "^20.7.0"
1311
}

clusters/staging/apps.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,25 @@ spec:
3535
---
3636
apiVersion: kustomize.toolkit.fluxcd.io/v1
3737
kind: Kustomization
38+
metadata:
39+
name: guest-registrar-unifi
40+
namespace: default
41+
spec:
42+
dependsOn:
43+
- name: sealed-secrets
44+
- name: secrets
45+
- name: mqtt
46+
interval: 10m0s
47+
sourceRef:
48+
kind: GitRepository
49+
name: default
50+
path: ./deployments/staging/guest-registrar-unifi
51+
prune: true
52+
wait: true
53+
timeout: 5m0s
54+
---
55+
apiVersion: kustomize.toolkit.fluxcd.io/v1
56+
kind: Kustomization
3857
metadata:
3958
name: grafana
4059
namespace: default
@@ -165,3 +184,4 @@ spec:
165184
timeout: 5m0s
166185
---
167186

187+

0 commit comments

Comments
 (0)