mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-10-23 08:12:15 +00:00
feat: restrict guest subnetwork access
This commit is contained in:
14
README.md
14
README.md
@@ -50,10 +50,10 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
|
|||||||
|
|
||||||
### 🎨 Modern Interface
|
### 🎨 Modern Interface
|
||||||
|
|
||||||
- **Touch-Friendly** – Optimized for tablet, mobile, and desktop.
|
- **Touch-Friendly** – Optimized for tablet, mobile, and desktop
|
||||||
- **Dark/Light Mode** – Follows system preference, with manual override.
|
- **Dark/Light Mode** – Follows system preference, with manual override
|
||||||
- **Responsive Design** - Works seamlessly across all screen sizes
|
- **Responsive Design** - Works seamlessly across all screen sizes
|
||||||
- **Smooth Animations** – Semantic transitions for polished UX.
|
- **Smooth Animations** – Semantic transitions for polished UX
|
||||||
- **Real-time Notifications** - Instant feedback for all operations
|
- **Real-time Notifications** - Instant feedback for all operations
|
||||||
|
|
||||||
### 🔧 Technical Features
|
### 🔧 Technical Features
|
||||||
@@ -126,6 +126,7 @@ To configure the WiFi QR code, you are required to configure the `WIFI_SSID` and
|
|||||||
| `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` | `string` |
|
| `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` | `string` |
|
||||||
| `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` |
|
| `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` |
|
||||||
| `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` |
|
| `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` |
|
||||||
|
| `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` |
|
||||||
| `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) | `IPv4` |
|
| `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) | `IPv4` |
|
||||||
| `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) | `u16` |
|
| `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) | `u16` |
|
||||||
| `FRONTEND_TO_BACKEND_URL` | Optional | URL where the frontend will make its API requests to the backend. | `http://127.0.0.1` (default) | `URL` |
|
| `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
|
|||||||
>
|
>
|
||||||
> 1. Go to your UniFi Controller -> Insights -> Hotspot
|
> 1. Go to your UniFi Controller -> Insights -> Hotspot
|
||||||
> 2. Set the **Success Landing Page** to: `https://your-uvm-domain.com/welcome`, the `/welcome` page of UVM
|
> 2. Set the **Success Landing Page** to: `https://your-uvm-domain.com/welcome`, the `/welcome` page of UVM
|
||||||
|
> 3. To restrict UVM access to the guest subnetwork while still allowing access to `/welcome` set the `GUEST_SUBNETWORK` environment variable
|
||||||
>
|
>
|
||||||
> Without this configuration, vouchers **will not** automatically roll when guests connect.
|
> Without this configuration, vouchers **will not** automatically roll when guests connect.
|
||||||
|
|
||||||
@@ -165,7 +167,7 @@ Rolling vouchers provide a seamless way to automatically generate guest network
|
|||||||
1. **Initial Setup**: Rolling vouchers are generated automatically when needed
|
1. **Initial Setup**: Rolling vouchers are generated automatically when needed
|
||||||
2. **Guest Connection**: When a guest connects to your network, they're redirected to the `/welcome` page
|
2. **Guest Connection**: When a guest connects to your network, they're redirected to the `/welcome` page
|
||||||
3. **Automatic Rolling**: The welcome page triggers the creation of a new voucher for the next guest
|
3. **Automatic Rolling**: The welcome page triggers the creation of a new voucher for the next guest
|
||||||
- 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.
|
- 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
|
||||||
4. **IP-Based Uniqueness**: Each IP address can only generate one voucher per session (prevents abuse from page reloads)
|
4. **IP-Based Uniqueness**: Each IP address can only generate one voucher per session (prevents abuse from page reloads)
|
||||||
5. **Daily Maintenance**: To prevent clutter, expired rolling vouchers are automatically deleted at midnight (based on your configured `TIMEZONE` in [Environment Variables](#environment-variables))
|
5. **Daily Maintenance**: To prevent clutter, expired rolling vouchers are automatically deleted at midnight (based on your configured `TIMEZONE` in [Environment Variables](#environment-variables))
|
||||||
|
|
||||||
@@ -193,8 +195,8 @@ The kiosk page (`/kiosk`) provides a guest-friendly interface displaying:
|
|||||||
- Verify Docker container has network access to UniFi controller
|
- Verify Docker container has network access to UniFi controller
|
||||||
- Check logs: `docker logs unifi-voucher-manager`
|
- Check logs: `docker logs unifi-voucher-manager`
|
||||||
- **The WiFi QR code button is disabled**
|
- **The WiFi QR code button is disabled**
|
||||||
- Check the [Environment Variables](#environment-variables) section and make sure you configured the variables required for the WiFi QR code.
|
- Check the [Environment Variables](#environment-variables) section and make sure you configured the variables required for the WiFi QR code
|
||||||
- Check the browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab).
|
- Check the browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab)
|
||||||
|
|
||||||
### Getting Help
|
### Getting Help
|
||||||
|
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
import { NextResponse, NextRequest } from "next/server";
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import { isInBlockedSubnet } from "@/utils/ipv4";
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: "/rust-api/:path*",
|
matcher: ["/", "/rust-api/:path*"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1";
|
const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1";
|
||||||
@@ -9,22 +10,17 @@ const DEFAULT_BACKEND_BIND_PORT = "8080";
|
|||||||
|
|
||||||
const IPV6_IPV4_MAPPED_PREFIX = "::ffff:";
|
const IPV6_IPV4_MAPPED_PREFIX = "::ffff:";
|
||||||
|
|
||||||
|
const guestAllowedPaths = [
|
||||||
|
"/welcome",
|
||||||
|
"/rust-api/vouchers/rolling",
|
||||||
|
"favicon.ico",
|
||||||
|
"favicon.svg",
|
||||||
|
];
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
// Remove the /rust-api prefix and reconstruct the path for the backend
|
const { pathname } = request.nextUrl;
|
||||||
const backendPath = request.nextUrl.pathname.replace(/^\/rust-api/, "/api");
|
|
||||||
|
|
||||||
const backendUrl =
|
// Extract client IP
|
||||||
process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL;
|
|
||||||
const backendPort =
|
|
||||||
process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT;
|
|
||||||
|
|
||||||
const backendFullUrl = new URL(
|
|
||||||
`${backendUrl}:${backendPort}${backendPath}${request.nextUrl.search}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = NextResponse.rewrite(backendFullUrl, { request });
|
|
||||||
|
|
||||||
// Forward the real client IP
|
|
||||||
let clientIp = request.headers.get("x-forwarded-for") || "";
|
let clientIp = request.headers.get("x-forwarded-for") || "";
|
||||||
|
|
||||||
// Strip IPv6 prefix if it's a mapped IPv4
|
// Strip IPv6 prefix if it's a mapped IPv4
|
||||||
@@ -32,6 +28,36 @@ export function middleware(request: NextRequest) {
|
|||||||
clientIp = clientIp.replace(IPV6_IPV4_MAPPED_PREFIX, "");
|
clientIp = clientIp.replace(IPV6_IPV4_MAPPED_PREFIX, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
response.headers.set("x-forwarded-for", clientIp);
|
// Restrict access based on GUEST_SUBNET env variable
|
||||||
return response;
|
const guestSubnet = process.env.GUEST_SUBNET;
|
||||||
|
if (guestSubnet) {
|
||||||
|
if (
|
||||||
|
!guestAllowedPaths.includes(pathname) &&
|
||||||
|
isInBlockedSubnet(clientIp, guestSubnet)
|
||||||
|
) {
|
||||||
|
return new NextResponse("Access denied", { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/rust-api")) {
|
||||||
|
// Remove the /rust-api prefix and reconstruct the path for the backend
|
||||||
|
const backendPath = request.nextUrl.pathname.replace(/^\/rust-api/, "/api");
|
||||||
|
|
||||||
|
const backendUrl =
|
||||||
|
process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL;
|
||||||
|
const backendPort =
|
||||||
|
process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT;
|
||||||
|
|
||||||
|
const backendFullUrl = new URL(
|
||||||
|
`${backendUrl}:${backendPort}${backendPath}${request.nextUrl.search}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = NextResponse.rewrite(backendFullUrl, { request });
|
||||||
|
|
||||||
|
// Forward the real client IP
|
||||||
|
response.headers.set("x-forwarded-for", clientIp);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
145
frontend/src/utils/ipv4.ts
Normal file
145
frontend/src/utils/ipv4.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// Validate subnet format and values
|
||||||
|
export function isValidSubnet(subnet: string): boolean {
|
||||||
|
try {
|
||||||
|
// Handle single IP addresses (treat as /32)
|
||||||
|
if (!subnet.includes("/")) {
|
||||||
|
return isValidIPAddress(subnet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the subnet (e.g., "10.0.5.0/24")
|
||||||
|
const [subnetIp, prefixLength] = subnet.split("/");
|
||||||
|
|
||||||
|
if (!subnetIp || !prefixLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate prefix length
|
||||||
|
const prefix = parseInt(prefixLength, 10);
|
||||||
|
if (isNaN(prefix) || prefix < 0 || prefix > 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IP address format
|
||||||
|
if (!isValidIPAddress(subnetIp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the IP is a valid network address
|
||||||
|
const ipToInt = (ipAddr: string): number => {
|
||||||
|
const parts = ipAddr.split(".");
|
||||||
|
return (
|
||||||
|
parts.reduce((acc, part) => {
|
||||||
|
const num = parseInt(part, 10);
|
||||||
|
return (acc << 8) + num;
|
||||||
|
}, 0) >>> 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subnetIpInt = ipToInt(subnetIp);
|
||||||
|
const mask = (0xffffffff << (32 - prefix)) >>> 0;
|
||||||
|
const networkAddress = (subnetIpInt & mask) >>> 0;
|
||||||
|
|
||||||
|
// Check if the provided IP is actually the network address
|
||||||
|
// Comment out these lines if you want to allow any IP in subnet notation
|
||||||
|
if (subnetIpInt !== networkAddress) {
|
||||||
|
console.warn(
|
||||||
|
`IP ${subnetIp} is not a network address for /${prefix}. Expected: ${intToIp(networkAddress)}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error checking subnet: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility export function to validate IP address format
|
||||||
|
// Taken from https://stackoverflow.com/a/36760050
|
||||||
|
export function isValidIPAddress(ip: string): boolean {
|
||||||
|
const ipRegex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
|
||||||
|
return ipRegex.test(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper export function to convert integer back to IP string
|
||||||
|
export function intToIp(int: number): string {
|
||||||
|
return [
|
||||||
|
(int >>> 24) & 255,
|
||||||
|
(int >>> 16) & 255,
|
||||||
|
(int >>> 8) & 255,
|
||||||
|
int & 255,
|
||||||
|
].join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Robust subnet check with proper CIDR notation support
|
||||||
|
export function isInBlockedSubnet(ip: string, subnet: string): boolean {
|
||||||
|
if (!ip || !subnet) return false;
|
||||||
|
|
||||||
|
// Validate inputs first
|
||||||
|
if (!isValidIPAddress(ip)) {
|
||||||
|
console.warn(`Invalid IP address format: ${ip}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidSubnet(subnet)) {
|
||||||
|
console.warn(`Invalid subnet format: ${subnet}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Normalize subnet (add /32 for single IPs)
|
||||||
|
const normalizedSubnet = subnet.includes("/") ? subnet : `${subnet}/32`;
|
||||||
|
const [subnetIp, prefixLength] = normalizedSubnet.split("/");
|
||||||
|
const prefix = parseInt(prefixLength, 10);
|
||||||
|
|
||||||
|
// Convert IP addresses to 32-bit integers
|
||||||
|
const ipToInt = (ipAddr: string): number => {
|
||||||
|
const parts = ipAddr.split(".");
|
||||||
|
return (
|
||||||
|
parts.reduce((acc, part) => {
|
||||||
|
const num = parseInt(part, 10);
|
||||||
|
return (acc << 8) + num;
|
||||||
|
}, 0) >>> 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetIpInt = ipToInt(ip);
|
||||||
|
const subnetIpInt = ipToInt(subnetIp);
|
||||||
|
|
||||||
|
// Create subnet mask
|
||||||
|
const mask = (0xffffffff << (32 - prefix)) >>> 0;
|
||||||
|
|
||||||
|
// Check if the IP is in the subnet
|
||||||
|
return (targetIpInt & mask) === (subnetIpInt & mask);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error checking subnet: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced version with support for multiple subnets
|
||||||
|
export function isInAnyBlockedSubnet(ip: string, subnets: string[]): boolean {
|
||||||
|
if (subnets.length === 0) return false;
|
||||||
|
|
||||||
|
// Validate IP once
|
||||||
|
if (!isValidIPAddress(ip)) {
|
||||||
|
console.warn(`Invalid IP address format: ${ip}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter valid subnets and check
|
||||||
|
const validSubnets = subnets.filter((subnet) => {
|
||||||
|
const isValid = isValidSubnet(subnet);
|
||||||
|
if (!isValid) {
|
||||||
|
console.warn(`Skipping invalid subnet: ${subnet}`);
|
||||||
|
}
|
||||||
|
return isValid;
|
||||||
|
});
|
||||||
|
|
||||||
|
return validSubnets.some((subnet) => isInBlockedSubnet(ip, subnet));
|
||||||
|
}
|
Reference in New Issue
Block a user