mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-10-23 00:02:10 +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
|
||||
|
||||
- **Touch-Friendly** – Optimized for tablet, mobile, and desktop.
|
||||
- **Dark/Light Mode** – Follows system preference, with manual override.
|
||||
- **Touch-Friendly** – Optimized for tablet, mobile, and desktop
|
||||
- **Dark/Light Mode** – Follows system preference, with manual override
|
||||
- **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
|
||||
|
||||
### 🔧 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_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` |
|
||||
| `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_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` |
|
||||
@@ -157,6 +158,7 @@ Rolling vouchers provide a seamless way to automatically generate guest network
|
||||
>
|
||||
> 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
|
||||
> 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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
- 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)
|
||||
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
|
||||
- Check logs: `docker logs unifi-voucher-manager`
|
||||
- **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 browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab).
|
||||
- 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)
|
||||
|
||||
### Getting Help
|
||||
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { isInBlockedSubnet } from "@/utils/ipv4";
|
||||
|
||||
export const config = {
|
||||
matcher: "/rust-api/:path*",
|
||||
matcher: ["/", "/rust-api/:path*"],
|
||||
};
|
||||
|
||||
const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1";
|
||||
@@ -9,7 +10,36 @@ const DEFAULT_BACKEND_BIND_PORT = "8080";
|
||||
|
||||
const IPV6_IPV4_MAPPED_PREFIX = "::ffff:";
|
||||
|
||||
const guestAllowedPaths = [
|
||||
"/welcome",
|
||||
"/rust-api/vouchers/rolling",
|
||||
"favicon.ico",
|
||||
"favicon.svg",
|
||||
];
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Extract client IP
|
||||
let clientIp = request.headers.get("x-forwarded-for") || "";
|
||||
|
||||
// Strip IPv6 prefix if it's a mapped IPv4
|
||||
if (clientIp.startsWith(IPV6_IPV4_MAPPED_PREFIX)) {
|
||||
clientIp = clientIp.replace(IPV6_IPV4_MAPPED_PREFIX, "");
|
||||
}
|
||||
|
||||
// Restrict access based on GUEST_SUBNET env variable
|
||||
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");
|
||||
|
||||
@@ -25,13 +55,9 @@ export function middleware(request: NextRequest) {
|
||||
const response = NextResponse.rewrite(backendFullUrl, { request });
|
||||
|
||||
// Forward the real client IP
|
||||
let clientIp = request.headers.get("x-forwarded-for") || "";
|
||||
|
||||
// Strip IPv6 prefix if it's a mapped IPv4
|
||||
if (clientIp.startsWith(IPV6_IPV4_MAPPED_PREFIX)) {
|
||||
clientIp = clientIp.replace(IPV6_IPV4_MAPPED_PREFIX, "");
|
||||
}
|
||||
|
||||
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