Skip to content

Commit 492e71f

Browse files
committed
feat: restrict guest subnetwork access
1 parent 89a1421 commit 492e71f

3 files changed

Lines changed: 196 additions & 23 deletions

File tree

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
5050

5151
### 🎨 Modern Interface
5252

53-
- **Touch-Friendly** – Optimized for tablet, mobile, and desktop.
54-
- **Dark/Light Mode** – Follows system preference, with manual override.
53+
- **Touch-Friendly** – Optimized for tablet, mobile, and desktop
54+
- **Dark/Light Mode** – Follows system preference, with manual override
5555
- **Responsive Design** - Works seamlessly across all screen sizes
56-
- **Smooth Animations** – Semantic transitions for polished UX.
56+
- **Smooth Animations** – Semantic transitions for polished UX
5757
- **Real-time Notifications** - Instant feedback for all operations
5858

5959
### 🔧 Technical Features
@@ -126,6 +126,7 @@ To configure the WiFi QR code, you are required to configure the `WIFI_SSID` and
126126
| `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` | `string` |
127127
| `UNIFI_HAS_VALID_CERT` | Optional | Whether your UniFi controller uses a valid SSL certificate. This should normally be set to `true`, especially if you access the controller through a reverse proxy or another setup that provides trusted certificates (e.g., Let's Encrypt). **If you connect directly to the controller’s IP address (which usually serves a self-signed certificate), you may need to set this to `false`.** | `true` (default) | `bool` |
128128
| `UNIFI_SITE_ID` | Optional | Site ID of your UniFi controller. Using the value `default`, the backend will try to fetch the ID of the default site. | `default` (default) | `string` |
129+
| `GUEST_SUBNETWORK` | Optional | Restrict guest subnetwork access to UVM while still permitting access to the `/welcome` page, which users are redirected to from the UniFi captive portal. For more details, see [Rolling Vouchers and Kiosk Page](#rolling-vouchers-and-kiosk-page). | `10.0.5.0/24` | `IPv4 CIDR` |
129130
| `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) | `IPv4` |
130131
| `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) | `u16` |
131132
| `FRONTEND_TO_BACKEND_URL` | Optional | URL where the frontend will make its API requests to the backend. | `http://127.0.0.1` (default) | `URL` |
@@ -157,6 +158,7 @@ Rolling vouchers provide a seamless way to automatically generate guest network
157158
>
158159
> 1. Go to your UniFi Controller -> Insights -> Hotspot
159160
> 2. Set the **Success Landing Page** to: `https://your-uvm-domain.com/welcome`, the `/welcome` page of UVM
161+
> 3. To restrict UVM access to the guest subnetwork while still allowing access to `/welcome` set the `GUEST_SUBNETWORK` environment variable
160162
>
161163
> Without this configuration, vouchers **will not** automatically roll when guests connect.
162164
@@ -165,7 +167,7 @@ Rolling vouchers provide a seamless way to automatically generate guest network
165167
1. **Initial Setup**: Rolling vouchers are generated automatically when needed
166168
2. **Guest Connection**: When a guest connects to your network, they're redirected to the `/welcome` page
167169
3. **Automatic Rolling**: The welcome page triggers the creation of a new voucher for the next guest
168-
- Rolling vouchers are created with special naming conventions to distinguish them from manually created vouchers, making them easy to identify in your voucher management interface.
170+
- Rolling vouchers are created with special naming conventions to distinguish them from manually created vouchers, making them easy to identify in your voucher management interface
169171
4. **IP-Based Uniqueness**: Each IP address can only generate one voucher per session (prevents abuse from page reloads)
170172
5. **Daily Maintenance**: To prevent clutter, expired rolling vouchers are automatically deleted at midnight (based on your configured `TIMEZONE` in [Environment Variables](#environment-variables))
171173

@@ -193,8 +195,8 @@ The kiosk page (`/kiosk`) provides a guest-friendly interface displaying:
193195
- Verify Docker container has network access to UniFi controller
194196
- Check logs: `docker logs unifi-voucher-manager`
195197
- **The WiFi QR code button is disabled**
196-
- Check the [Environment Variables](#environment-variables) section and make sure you configured the variables required for the WiFi QR code.
197-
- Check the browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab).
198+
- Check the [Environment Variables](#environment-variables) section and make sure you configured the variables required for the WiFi QR code
199+
- Check the browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab)
198200

199201
### Getting Help
200202

frontend/src/middleware.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,63 @@
11
import { NextResponse, NextRequest } from "next/server";
2+
import { isInBlockedSubnet } from "@/utils/ipv4";
23

34
export const config = {
4-
matcher: "/rust-api/:path*",
5+
matcher: ["/", "/rust-api/:path*"],
56
};
67

78
const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1";
89
const DEFAULT_BACKEND_BIND_PORT = "8080";
910

1011
const IPV6_IPV4_MAPPED_PREFIX = "::ffff:";
1112

12-
export function middleware(request: NextRequest) {
13-
// Remove the /rust-api prefix and reconstruct the path for the backend
14-
const backendPath = request.nextUrl.pathname.replace(/^\/rust-api/, "/api");
15-
16-
const backendUrl =
17-
process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL;
18-
const backendPort =
19-
process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT;
20-
21-
const backendFullUrl = new URL(
22-
`${backendUrl}:${backendPort}${backendPath}${request.nextUrl.search}`,
23-
);
13+
const guestAllowedPaths = [
14+
"/welcome",
15+
"/rust-api/vouchers/rolling",
16+
"favicon.ico",
17+
"favicon.svg",
18+
];
2419

25-
const response = NextResponse.rewrite(backendFullUrl, { request });
20+
export function middleware(request: NextRequest) {
21+
const { pathname } = request.nextUrl;
2622

27-
// Forward the real client IP
23+
// Extract client IP
2824
let clientIp = request.headers.get("x-forwarded-for") || "";
2925

3026
// Strip IPv6 prefix if it's a mapped IPv4
3127
if (clientIp.startsWith(IPV6_IPV4_MAPPED_PREFIX)) {
3228
clientIp = clientIp.replace(IPV6_IPV4_MAPPED_PREFIX, "");
3329
}
3430

35-
response.headers.set("x-forwarded-for", clientIp);
36-
return response;
31+
// Restrict access based on GUEST_SUBNET env variable
32+
const guestSubnet = process.env.GUEST_SUBNET;
33+
if (guestSubnet) {
34+
if (
35+
!guestAllowedPaths.includes(pathname) &&
36+
isInBlockedSubnet(clientIp, guestSubnet)
37+
) {
38+
return new NextResponse("Access denied", { status: 403 });
39+
}
40+
}
41+
42+
if (pathname.startsWith("/rust-api")) {
43+
// Remove the /rust-api prefix and reconstruct the path for the backend
44+
const backendPath = request.nextUrl.pathname.replace(/^\/rust-api/, "/api");
45+
46+
const backendUrl =
47+
process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL;
48+
const backendPort =
49+
process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT;
50+
51+
const backendFullUrl = new URL(
52+
`${backendUrl}:${backendPort}${backendPath}${request.nextUrl.search}`,
53+
);
54+
55+
const response = NextResponse.rewrite(backendFullUrl, { request });
56+
57+
// Forward the real client IP
58+
response.headers.set("x-forwarded-for", clientIp);
59+
return response;
60+
}
61+
62+
return NextResponse.next();
3763
}

frontend/src/utils/ipv4.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Validate subnet format and values
2+
export function isValidSubnet(subnet: string): boolean {
3+
try {
4+
// Handle single IP addresses (treat as /32)
5+
if (!subnet.includes("/")) {
6+
return isValidIPAddress(subnet);
7+
}
8+
9+
// Parse the subnet (e.g., "10.0.5.0/24")
10+
const [subnetIp, prefixLength] = subnet.split("/");
11+
12+
if (!subnetIp || !prefixLength) {
13+
return false;
14+
}
15+
16+
// Validate prefix length
17+
const prefix = parseInt(prefixLength, 10);
18+
if (isNaN(prefix) || prefix < 0 || prefix > 32) {
19+
return false;
20+
}
21+
22+
// Validate IP address format
23+
if (!isValidIPAddress(subnetIp)) {
24+
return false;
25+
}
26+
27+
// Check if the IP is a valid network address
28+
const ipToInt = (ipAddr: string): number => {
29+
const parts = ipAddr.split(".");
30+
return (
31+
parts.reduce((acc, part) => {
32+
const num = parseInt(part, 10);
33+
return (acc << 8) + num;
34+
}, 0) >>> 0
35+
);
36+
};
37+
38+
const subnetIpInt = ipToInt(subnetIp);
39+
const mask = (0xffffffff << (32 - prefix)) >>> 0;
40+
const networkAddress = (subnetIpInt & mask) >>> 0;
41+
42+
// Check if the provided IP is actually the network address
43+
// Comment out these lines if you want to allow any IP in subnet notation
44+
if (subnetIpInt !== networkAddress) {
45+
console.warn(
46+
`IP ${subnetIp} is not a network address for /${prefix}. Expected: ${intToIp(networkAddress)}`,
47+
);
48+
return false;
49+
}
50+
51+
return true;
52+
} catch (error) {
53+
console.error(
54+
`Error checking subnet: ${error instanceof Error ? error.message : "Unknown error"}`,
55+
);
56+
return false;
57+
}
58+
}
59+
60+
// Utility export function to validate IP address format
61+
// Taken from https://stackoverflow.com/a/36760050
62+
export function isValidIPAddress(ip: string): boolean {
63+
const ipRegex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
64+
return ipRegex.test(ip);
65+
}
66+
67+
// Helper export function to convert integer back to IP string
68+
export function intToIp(int: number): string {
69+
return [
70+
(int >>> 24) & 255,
71+
(int >>> 16) & 255,
72+
(int >>> 8) & 255,
73+
int & 255,
74+
].join(".");
75+
}
76+
77+
// Robust subnet check with proper CIDR notation support
78+
export function isInBlockedSubnet(ip: string, subnet: string): boolean {
79+
if (!ip || !subnet) return false;
80+
81+
// Validate inputs first
82+
if (!isValidIPAddress(ip)) {
83+
console.warn(`Invalid IP address format: ${ip}`);
84+
return false;
85+
}
86+
87+
if (!isValidSubnet(subnet)) {
88+
console.warn(`Invalid subnet format: ${subnet}`);
89+
return false;
90+
}
91+
92+
try {
93+
// Normalize subnet (add /32 for single IPs)
94+
const normalizedSubnet = subnet.includes("/") ? subnet : `${subnet}/32`;
95+
const [subnetIp, prefixLength] = normalizedSubnet.split("/");
96+
const prefix = parseInt(prefixLength, 10);
97+
98+
// Convert IP addresses to 32-bit integers
99+
const ipToInt = (ipAddr: string): number => {
100+
const parts = ipAddr.split(".");
101+
return (
102+
parts.reduce((acc, part) => {
103+
const num = parseInt(part, 10);
104+
return (acc << 8) + num;
105+
}, 0) >>> 0
106+
);
107+
};
108+
109+
const targetIpInt = ipToInt(ip);
110+
const subnetIpInt = ipToInt(subnetIp);
111+
112+
// Create subnet mask
113+
const mask = (0xffffffff << (32 - prefix)) >>> 0;
114+
115+
// Check if the IP is in the subnet
116+
return (targetIpInt & mask) === (subnetIpInt & mask);
117+
} catch (error) {
118+
console.error(
119+
`Error checking subnet: ${error instanceof Error ? error.message : "Unknown error"}`,
120+
);
121+
return false;
122+
}
123+
}
124+
125+
// Enhanced version with support for multiple subnets
126+
export function isInAnyBlockedSubnet(ip: string, subnets: string[]): boolean {
127+
if (subnets.length === 0) return false;
128+
129+
// Validate IP once
130+
if (!isValidIPAddress(ip)) {
131+
console.warn(`Invalid IP address format: ${ip}`);
132+
return false;
133+
}
134+
135+
// Filter valid subnets and check
136+
const validSubnets = subnets.filter((subnet) => {
137+
const isValid = isValidSubnet(subnet);
138+
if (!isValid) {
139+
console.warn(`Skipping invalid subnet: ${subnet}`);
140+
}
141+
return isValid;
142+
});
143+
144+
return validSubnets.some((subnet) => isInBlockedSubnet(ip, subnet));
145+
}

0 commit comments

Comments
 (0)