Initial Commit
This commit is contained in:
commit
759b0f7814
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"template": "bolt-vite-react-ts"
|
||||||
|
}
|
7
.bolt/prompt
Normal file
7
.bolt/prompt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||||
|
|
||||||
|
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||||
|
|
||||||
|
Use icons from lucide-react for logos.
|
||||||
|
|
||||||
|
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.
|
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
FROM node:20-alpine as build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package.json and package-lock.json
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the rest of the application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy the build output to replace the default nginx contents
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy custom nginx config if needed
|
||||||
|
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose port 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
25
README.md
Normal file
25
README.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
Tailscale ACL Editor - a specialized web application that helps users create, edit, and manage access control lists for Tailscale networks.
|
||||||
|
|
||||||
|
Here's what it includes:
|
||||||
|
Core Functionality
|
||||||
|
|
||||||
|
Rule-based ACL Editor: Create and manage ACL rules with sources, destinations, and protocols
|
||||||
|
JSON Import/Export: Import existing ACL configurations and export your changes
|
||||||
|
Real-time Preview: See the JSON representation of your ACL rules as you build them
|
||||||
|
|
||||||
|
Technical Features
|
||||||
|
|
||||||
|
Source & Destination Selectors: Specialized components for defining network sources and destinations
|
||||||
|
Protocol Selection: Support for various network protocols (TCP, UDP, ICMP, etc.)
|
||||||
|
Validation: Comprehensive validation for IP addresses, CIDR notation, ports, and other values
|
||||||
|
Responsive UI: Clean, dark-themed interface that resembles Tailscale's design language
|
||||||
|
|
||||||
|
Implementation Details
|
||||||
|
|
||||||
|
Modern Stack: Built with React, TypeScript, and Vite for a fast development experience
|
||||||
|
Tailwind CSS: Styled with Tailwind for consistent, responsive design
|
||||||
|
Specialized Validation: Uses libraries like ip-address and is-cidr for accurate network validation
|
||||||
|
Containerized: Docker configuration for easy deployment and distribution
|
||||||
|
Type Safety: Comprehensive TypeScript definitions for ACL structures
|
||||||
|
|
||||||
|
The application provides a user-friendly interface for what would otherwise be a complex JSON editing task, making it easier for network administrators to define and maintain their Tailscale network access policies.
|
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
acl-editor:
|
||||||
|
restart: unless-stopped
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
labels:
|
||||||
|
caddy: ${DOMAIN}
|
||||||
|
caddy.reverse_proxy: "{{upstreams 80}}"
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
|
||||||
|
|
||||||
|
# Development
|
||||||
|
# acl-editor-dev:
|
||||||
|
# image: node:20-alpine
|
||||||
|
# working_dir: /app
|
||||||
|
# command: sh -c "npm install && npm run dev"
|
||||||
|
# ports:
|
||||||
|
# - "5173:5173"
|
||||||
|
# volumes:
|
||||||
|
# - ./:/app
|
||||||
|
# - /app/node_modules
|
||||||
|
# environment:
|
||||||
|
# - NODE_ENV=development
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
4310
package-lock.json
generated
Normal file
4310
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "vite-react-typescript-starter",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^9.0.5",
|
||||||
|
"is-cidr": "^5.0.3",
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.9.1",
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^9.9.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"typescript-eslint": "^8.3.0",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
12
src/App.tsx
Normal file
12
src/App.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ACLEditor from './components/ACLEditor'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#111] text-white py-8">
|
||||||
|
<ACLEditor />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
219
src/components/ACLEditor.tsx
Normal file
219
src/components/ACLEditor.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { ACLPolicy, ACLRule } from '../types/acl';
|
||||||
|
import RuleEditor from './RuleEditor';
|
||||||
|
import { PlusCircle, ExternalLink, Youtube, X, Upload, AlertCircle, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
const ACLEditor: React.FC = () => {
|
||||||
|
const [policy, setPolicy] = useState<ACLPolicy>({
|
||||||
|
acls: [
|
||||||
|
{
|
||||||
|
action: 'accept',
|
||||||
|
src: [],
|
||||||
|
dst: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const [showVideo, setShowVideo] = useState(false);
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const addRule = () => {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
acls: [
|
||||||
|
...prev.acls,
|
||||||
|
{
|
||||||
|
action: 'accept',
|
||||||
|
src: [],
|
||||||
|
dst: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRule = (index: number, updatedRule: ACLRule) => {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const newAcls = [...prev.acls];
|
||||||
|
newAcls[index] = updatedRule;
|
||||||
|
return { ...prev, acls: newAcls };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRule = (index: number) => {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const newAcls = prev.acls.filter((_, i) => i !== index);
|
||||||
|
return { ...prev, acls: newAcls };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportClick = () => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const content = e.target?.result as string;
|
||||||
|
const importedPolicy = JSON.parse(content);
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!importedPolicy.acls || !Array.isArray(importedPolicy.acls)) {
|
||||||
|
throw new Error('Invalid ACL format: missing or invalid "acls" array');
|
||||||
|
}
|
||||||
|
|
||||||
|
// More detailed validation could be added here
|
||||||
|
|
||||||
|
setPolicy(importedPolicy);
|
||||||
|
setImportError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setImportError(`Failed to import ACL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
setImportError('Failed to read the file');
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
|
||||||
|
// Reset the file input
|
||||||
|
if (event.target) {
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
// Create a blob with the JSON data
|
||||||
|
const jsonString = JSON.stringify(policy, null, 2);
|
||||||
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||||
|
|
||||||
|
// Create a URL for the blob
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Create a temporary anchor element and trigger download
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'tailscale-acl.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissError = () => {
|
||||||
|
setImportError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4">
|
||||||
|
<div className="flex justify-between items-center mb-6 border-b border-[#333] pb-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="text-xl font-medium">Tailscale ACL Editor</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="text-[#999] hover:text-white font-normal mr-4"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[#333] flex items-center justify-center text-white">
|
||||||
|
<span>U</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showVideo && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="relative w-full max-w-4xl">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowVideo(false)}
|
||||||
|
className="absolute -top-10 right-0 text-white hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
<div className="relative pb-[56.25%] h-0">
|
||||||
|
<iframe
|
||||||
|
className="absolute top-0 left-0 w-full h-full rounded-lg"
|
||||||
|
src="https://www.youtube.com/embed/Jn8_Sh4r8d4"
|
||||||
|
title="Tailscale ACL Tutorial"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importError && (
|
||||||
|
<div className="bg-red-900 border border-red-700 text-white px-4 py-3 rounded relative mb-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
{importError}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="absolute top-0 bottom-0 right-0 px-4"
|
||||||
|
onClick={dismissError}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-[#999] mb-2">Manage access control rules for your Tailnet. <a href="https://tailscale.com/kb/1018/acls" className="text-[#4f8cc9] hover:text-[#5f9cd9]">Learn more</a></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={addRule}
|
||||||
|
className="bg-[#333] hover:bg-[#444] text-white px-4 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Add Rule
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleImportClick}
|
||||||
|
className="bg-[#333] hover:bg-[#444] text-white px-4 py-2 rounded-md"
|
||||||
|
>
|
||||||
|
Import ACL
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 mb-6">
|
||||||
|
{policy.acls.map((rule, index) => (
|
||||||
|
<RuleEditor
|
||||||
|
key={index}
|
||||||
|
rule={rule}
|
||||||
|
onChange={(updatedRule) => updateRule(index, updatedRule)}
|
||||||
|
onRemove={() => removeRule(index)}
|
||||||
|
ruleNumber={index + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-lg font-medium mb-2">ACL JSON Preview</h2>
|
||||||
|
<pre className="bg-[#222] text-[#4f8cc9] p-4 rounded-md overflow-auto">
|
||||||
|
{JSON.stringify(policy, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ACLEditor;
|
182
src/components/DestinationSelector.tsx
Normal file
182
src/components/DestinationSelector.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { DestinationEntry, DestinationType } from '../types/acl';
|
||||||
|
import { PlusCircle, X, AlertCircle } from 'lucide-react';
|
||||||
|
import { isValidDestinationValue } from '../utils/validation';
|
||||||
|
|
||||||
|
interface DestinationSelectorProps {
|
||||||
|
destinations: DestinationEntry[];
|
||||||
|
onChange: (destinations: DestinationEntry[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DestinationSelector: React.FC<DestinationSelectorProps> = ({ destinations, onChange }) => {
|
||||||
|
const [newDestType, setNewDestType] = useState<DestinationType>('any');
|
||||||
|
const [newDestValue, setNewDestValue] = useState<string>('*');
|
||||||
|
const [newDestPorts, setNewDestPorts] = useState<string>('*');
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const destTypes: { value: DestinationType; label: string }[] = [
|
||||||
|
{ value: 'any', label: 'Any (*)' },
|
||||||
|
{ value: 'user', label: 'User' },
|
||||||
|
{ value: 'group', label: 'Group' },
|
||||||
|
{ value: 'ip', label: 'Tailscale IP' },
|
||||||
|
{ value: 'subnet', label: 'Subnet CIDR' },
|
||||||
|
{ value: 'host', label: 'Host' },
|
||||||
|
{ value: 'tag', label: 'Tag' },
|
||||||
|
{ value: 'autogroup', label: 'Autogroup' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const validateAndAddDestination = () => {
|
||||||
|
// Clear previous error
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
|
if (newDestType === 'any') {
|
||||||
|
onChange([...destinations, { type: 'any', value: '*', ports: '*' }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newDestValue.trim()) {
|
||||||
|
setValidationError('Value cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate based on destination type and port
|
||||||
|
if (!isValidDestinationValue(newDestType, newDestValue, newDestPorts)) {
|
||||||
|
// Determine if the error is with the destination or port
|
||||||
|
if (newDestPorts !== '*' && !isValidDestinationValue(newDestType, newDestValue, '*')) {
|
||||||
|
setValidationError(`Invalid ${newDestType} format`);
|
||||||
|
} else {
|
||||||
|
setValidationError('Invalid port format');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passes, add the destination
|
||||||
|
onChange([
|
||||||
|
...destinations,
|
||||||
|
{
|
||||||
|
type: newDestType,
|
||||||
|
value: newDestValue.trim(),
|
||||||
|
ports: newDestPorts.trim() || '*'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
setNewDestValue('');
|
||||||
|
setNewDestPorts('*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDestination = (index: number) => {
|
||||||
|
onChange(destinations.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDestTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const type = e.target.value as DestinationType;
|
||||||
|
setNewDestType(type);
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
|
// Set default value based on type
|
||||||
|
if (type === 'any') {
|
||||||
|
setNewDestValue('*');
|
||||||
|
} else if (type === 'group') {
|
||||||
|
setNewDestValue('group:');
|
||||||
|
} else if (type === 'tag') {
|
||||||
|
setNewDestValue('tag:');
|
||||||
|
} else if (type === 'autogroup') {
|
||||||
|
setNewDestValue('autogroup:');
|
||||||
|
} else {
|
||||||
|
setNewDestValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDestinationDisplay = (dest: DestinationEntry): string => {
|
||||||
|
if (dest.type === 'any') return '*:*';
|
||||||
|
return `${dest.value}:${dest.ports}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{destinations.map((dest, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center bg-[#2a5545] text-white px-3 py-1 rounded-full"
|
||||||
|
>
|
||||||
|
<span className="mr-1">{getDestinationDisplay(dest)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeDestination(index)}
|
||||||
|
className="text-[#ccc] hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{destinations.length === 0 && (
|
||||||
|
<div className="text-[#999] italic">No destinations added (required)</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<div className="flex items-center text-red-500 text-sm mb-2">
|
||||||
|
<AlertCircle size={14} className="mr-1" />
|
||||||
|
{validationError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
className="p-2 border border-[#444] bg-[#333] text-white rounded-md"
|
||||||
|
value={newDestType}
|
||||||
|
onChange={handleDestTypeChange}
|
||||||
|
>
|
||||||
|
{destTypes.map(type => (
|
||||||
|
<option key={type.value} value={type.value}>{type.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{newDestType !== 'any' && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`flex-1 p-2 border ${validationError && validationError.includes(newDestType) ? 'border-red-500' : 'border-[#444]'} bg-[#333] text-white rounded-md`}
|
||||||
|
value={newDestValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewDestValue(e.target.value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
placeholder={`Enter ${newDestType} value`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`w-32 p-2 border ${validationError && validationError.includes('port') ? 'border-red-500' : 'border-[#444]'} bg-[#333] text-white rounded-md`}
|
||||||
|
value={newDestPorts}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewDestPorts(e.target.value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Ports"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={validateAndAddDestination}
|
||||||
|
className="bg-[#4f8cc9] hover:bg-[#5f9cd9] text-white px-3 py-2 rounded-md flex items-center"
|
||||||
|
>
|
||||||
|
<PlusCircle size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-[#999] mt-1">
|
||||||
|
{newDestType === 'user' && "Example: user@example.com"}
|
||||||
|
{newDestType === 'group' && "Example: group:engineering"}
|
||||||
|
{newDestType === 'ip' && "Example: 100.101.102.103"}
|
||||||
|
{newDestType === 'subnet' && "Example: 192.168.1.0/24"}
|
||||||
|
{newDestType === 'host' && "Example: my-host"}
|
||||||
|
{newDestType === 'tag' && "Example: tag:production"}
|
||||||
|
{newDestType === 'autogroup' && "Example: autogroup:internet"}
|
||||||
|
{newDestType !== 'any' && "Ports: * (any), 22 (single), 80,443 (multiple), 1000-2000 (range)"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DestinationSelector;
|
101
src/components/RuleEditor.tsx
Normal file
101
src/components/RuleEditor.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ACLRule, Protocol, SourceEntry, DestinationEntry } from '../types/acl';
|
||||||
|
import SourceSelector from './SourceSelector';
|
||||||
|
import DestinationSelector from './DestinationSelector';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface RuleEditorProps {
|
||||||
|
rule: ACLRule;
|
||||||
|
onChange: (rule: ACLRule) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
ruleNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RuleEditor: React.FC<RuleEditorProps> = ({ rule, onChange, onRemove, ruleNumber }) => {
|
||||||
|
const protocols: Protocol[] = ['tcp', 'udp', 'icmp', 'sctp', 'igmp', 'esp', 'ah', 'gre', 'ipip', 'dccp', 'all'];
|
||||||
|
|
||||||
|
const handleProtoChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const value = e.target.value as Protocol | '';
|
||||||
|
onChange({
|
||||||
|
...rule,
|
||||||
|
proto: value === '' ? undefined : value as Protocol
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSourcesChange = (sources: SourceEntry[]) => {
|
||||||
|
onChange({
|
||||||
|
...rule,
|
||||||
|
src: sources
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDestinationsChange = (destinations: DestinationEntry[]) => {
|
||||||
|
onChange({
|
||||||
|
...rule,
|
||||||
|
dst: destinations
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[#333] rounded-lg p-4 bg-[#222] shadow-sm">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium">Rule #{ruleNumber}</h3>
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="text-[#999] hover:text-white"
|
||||||
|
title="Remove rule"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#999] mb-1">Action</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-2 border border-[#444] bg-[#333] text-white rounded-md"
|
||||||
|
value={rule.action}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<option value="accept">accept</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-[#777] mt-1">
|
||||||
|
Tailscale only supports 'accept' actions (deny by default)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#999] mb-1">Protocol (optional)</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-2 border border-[#444] bg-[#333] text-white rounded-md"
|
||||||
|
value={rule.proto || ''}
|
||||||
|
onChange={handleProtoChange}
|
||||||
|
>
|
||||||
|
<option value="">All protocols</option>
|
||||||
|
{protocols.map(proto => (
|
||||||
|
<option key={proto} value={proto}>{proto}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-[#999] mb-1">Sources (src)</label>
|
||||||
|
<SourceSelector
|
||||||
|
sources={rule.src}
|
||||||
|
onChange={handleSourcesChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-[#999] mb-1">Destinations (dst)</label>
|
||||||
|
<DestinationSelector
|
||||||
|
destinations={rule.dst}
|
||||||
|
onChange={handleDestinationsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleEditor;
|
157
src/components/SourceSelector.tsx
Normal file
157
src/components/SourceSelector.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { SourceEntry, SourceType } from '../types/acl';
|
||||||
|
import { PlusCircle, X, AlertCircle } from 'lucide-react';
|
||||||
|
import { isValidSourceValue } from '../utils/validation';
|
||||||
|
|
||||||
|
interface SourceSelectorProps {
|
||||||
|
sources: SourceEntry[];
|
||||||
|
onChange: (sources: SourceEntry[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SourceSelector: React.FC<SourceSelectorProps> = ({ sources, onChange }) => {
|
||||||
|
const [newSourceType, setNewSourceType] = useState<SourceType>('any');
|
||||||
|
const [newSourceValue, setNewSourceValue] = useState<string>('*');
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const sourceTypes: { value: SourceType; label: string }[] = [
|
||||||
|
{ value: 'any', label: 'Any (*)' },
|
||||||
|
{ value: 'user', label: 'User' },
|
||||||
|
{ value: 'group', label: 'Group' },
|
||||||
|
{ value: 'ip', label: 'Tailscale IP' },
|
||||||
|
{ value: 'subnet', label: 'Subnet CIDR' },
|
||||||
|
{ value: 'host', label: 'Host' },
|
||||||
|
{ value: 'tag', label: 'Tag' },
|
||||||
|
{ value: 'autogroup', label: 'Autogroup' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const validateAndAddSource = () => {
|
||||||
|
// Clear previous error
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
|
if (newSourceType === 'any') {
|
||||||
|
onChange([...sources, { type: 'any', value: '*' }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newSourceValue.trim()) {
|
||||||
|
setValidationError('Value cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate based on source type
|
||||||
|
if (!isValidSourceValue(newSourceType, newSourceValue)) {
|
||||||
|
setValidationError(`Invalid ${newSourceType} format`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If validation passes, add the source
|
||||||
|
onChange([...sources, { type: newSourceType, value: newSourceValue.trim() }]);
|
||||||
|
setNewSourceValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSource = (index: number) => {
|
||||||
|
onChange(sources.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSourceTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const type = e.target.value as SourceType;
|
||||||
|
setNewSourceType(type);
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
|
// Set default value based on type
|
||||||
|
if (type === 'any') {
|
||||||
|
setNewSourceValue('*');
|
||||||
|
} else if (type === 'group') {
|
||||||
|
setNewSourceValue('group:');
|
||||||
|
} else if (type === 'tag') {
|
||||||
|
setNewSourceValue('tag:');
|
||||||
|
} else if (type === 'autogroup') {
|
||||||
|
setNewSourceValue('autogroup:');
|
||||||
|
} else {
|
||||||
|
setNewSourceValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceDisplay = (source: SourceEntry): string => {
|
||||||
|
if (source.type === 'any') return '*';
|
||||||
|
if (source.type === 'group') return source.value;
|
||||||
|
if (source.type === 'tag') return source.value;
|
||||||
|
if (source.type === 'autogroup') return source.value;
|
||||||
|
return source.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{sources.map((source, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center bg-[#2a4562] text-white px-3 py-1 rounded-full"
|
||||||
|
>
|
||||||
|
<span className="mr-1">{getSourceDisplay(source)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeSource(index)}
|
||||||
|
className="text-[#ccc] hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sources.length === 0 && (
|
||||||
|
<div className="text-[#999] italic">No sources added (required)</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<div className="flex items-center text-red-500 text-sm mb-2">
|
||||||
|
<AlertCircle size={14} className="mr-1" />
|
||||||
|
{validationError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
className="p-2 border border-[#444] bg-[#333] text-white rounded-md"
|
||||||
|
value={newSourceType}
|
||||||
|
onChange={handleSourceTypeChange}
|
||||||
|
>
|
||||||
|
{sourceTypes.map(type => (
|
||||||
|
<option key={type.value} value={type.value}>{type.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{newSourceType !== 'any' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`flex-1 p-2 border ${validationError ? 'border-red-500' : 'border-[#444]'} bg-[#333] text-white rounded-md`}
|
||||||
|
value={newSourceValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewSourceValue(e.target.value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
placeholder={`Enter ${newSourceType} value`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={validateAndAddSource}
|
||||||
|
className="bg-[#4f8cc9] hover:bg-[#5f9cd9] text-white px-3 py-2 rounded-md flex items-center"
|
||||||
|
>
|
||||||
|
<PlusCircle size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-[#999] mt-1">
|
||||||
|
{newSourceType === 'user' && "Example: user@example.com"}
|
||||||
|
{newSourceType === 'group' && "Example: group:engineering"}
|
||||||
|
{newSourceType === 'ip' && "Example: 100.101.102.103"}
|
||||||
|
{newSourceType === 'subnet' && "Example: 192.168.1.0/24"}
|
||||||
|
{newSourceType === 'host' && "Example: my-host"}
|
||||||
|
{newSourceType === 'tag' && "Example: tag:production"}
|
||||||
|
{newSourceType === 'autogroup' && "Example: autogroup:member"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SourceSelector;
|
27
src/index.css
Normal file
27
src/index.css
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #111;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, input, button {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus, input:focus, button:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4f8cc9;
|
||||||
|
}
|
||||||
|
|
||||||
|
::placeholder {
|
||||||
|
color: #777;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background-color: #333;
|
||||||
|
color: white;
|
||||||
|
}
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
61
src/types/acl.ts
Normal file
61
src/types/acl.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
export type ACLAction = 'accept';
|
||||||
|
|
||||||
|
export type SourceType =
|
||||||
|
| 'any'
|
||||||
|
| 'user'
|
||||||
|
| 'group'
|
||||||
|
| 'ip'
|
||||||
|
| 'subnet'
|
||||||
|
| 'host'
|
||||||
|
| 'tag'
|
||||||
|
| 'autogroup';
|
||||||
|
|
||||||
|
export type DestinationType =
|
||||||
|
| 'any'
|
||||||
|
| 'user'
|
||||||
|
| 'group'
|
||||||
|
| 'ip'
|
||||||
|
| 'subnet'
|
||||||
|
| 'host'
|
||||||
|
| 'tag'
|
||||||
|
| 'autogroup';
|
||||||
|
|
||||||
|
export type Protocol =
|
||||||
|
| 'tcp'
|
||||||
|
| 'udp'
|
||||||
|
| 'icmp'
|
||||||
|
| 'sctp'
|
||||||
|
| 'igmp'
|
||||||
|
| 'esp'
|
||||||
|
| 'ah'
|
||||||
|
| 'gre'
|
||||||
|
| 'ipip'
|
||||||
|
| 'dccp'
|
||||||
|
| 'all';
|
||||||
|
|
||||||
|
export type PortDefinition =
|
||||||
|
| '*'
|
||||||
|
| number
|
||||||
|
| string; // For ranges like "1000-2000" or multiple ports "80,443"
|
||||||
|
|
||||||
|
export interface SourceEntry {
|
||||||
|
type: SourceType;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DestinationEntry {
|
||||||
|
type: DestinationType;
|
||||||
|
value: string;
|
||||||
|
ports: PortDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ACLRule {
|
||||||
|
action: ACLAction;
|
||||||
|
src: SourceEntry[];
|
||||||
|
proto?: Protocol;
|
||||||
|
dst: DestinationEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ACLPolicy {
|
||||||
|
acls: ACLRule[];
|
||||||
|
}
|
157
src/utils/validation.ts
Normal file
157
src/utils/validation.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for validating IP addresses and CIDR notation
|
||||||
|
*/
|
||||||
|
import { Address4, Address6 } from 'ip-address';
|
||||||
|
import isCidr from 'is-cidr';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an IPv4 address
|
||||||
|
* @param ip The IP address to validate
|
||||||
|
* @returns True if the IP is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export const isValidIPv4 = (ip: string): boolean => {
|
||||||
|
try {
|
||||||
|
return new Address4(ip).isValid();
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an IPv6 address
|
||||||
|
* @param ip The IP address to validate
|
||||||
|
* @returns True if the IP is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export const isValidIPv6 = (ip: string): boolean => {
|
||||||
|
try {
|
||||||
|
return new Address6(ip).isValid();
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an IP address (IPv4 or IPv6)
|
||||||
|
* @param ip The IP address to validate
|
||||||
|
* @returns True if the IP is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export const isValidIP = (ip: string): boolean => {
|
||||||
|
return isValidIPv4(ip) || isValidIPv6(ip);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a CIDR notation
|
||||||
|
* @param cidr The CIDR notation to validate
|
||||||
|
* @returns True if the CIDR is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export const isValidCIDR = (cidr: string): boolean => {
|
||||||
|
return isCidr(cidr) !== 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a port or port range
|
||||||
|
* @param port The port or port range to validate
|
||||||
|
* @returns True if the port is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export const isValidPort = (port: string): boolean => {
|
||||||
|
// If port is wildcard, it's valid
|
||||||
|
if (port === '*') return true;
|
||||||
|
|
||||||
|
// Check for a single port
|
||||||
|
if (/^\d+$/.test(port)) {
|
||||||
|
const portNum = parseInt(port, 10);
|
||||||
|
return portNum >= 1 && portNum <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a port range (e.g., 1000-2000)
|
||||||
|
if (/^\d+-\d+$/.test(port)) {
|
||||||
|
const [startStr, endStr] = port.split('-');
|
||||||
|
const start = parseInt(startStr, 10);
|
||||||
|
const end = parseInt(endStr, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
start >= 1 &&
|
||||||
|
start <= 65535 &&
|
||||||
|
end >= 1 &&
|
||||||
|
end <= 65535 &&
|
||||||
|
start <= end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for multiple ports (e.g., 80,443)
|
||||||
|
if (/^\d+(,\d+)*$/.test(port)) {
|
||||||
|
const ports = port.split(',');
|
||||||
|
return ports.every(p => {
|
||||||
|
const portNum = parseInt(p, 10);
|
||||||
|
return portNum >= 1 && portNum <= 65535;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an email address
|
||||||
|
* @param email The email address to validate
|
||||||
|
* @returns True if the email is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export const isValidEmail = (email: string): boolean => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a hostname
|
||||||
|
* @param hostname The hostname to validate
|
||||||
|
* @returns True if the hostname is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export const isValidHostname = (hostname: string): boolean => {
|
||||||
|
// Simplified hostname validation
|
||||||
|
const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||||
|
return hostnameRegex.test(hostname);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a source value based on its type
|
||||||
|
* @param type The source type
|
||||||
|
* @param value The source value
|
||||||
|
* @returns True if the value is valid for the given type, false otherwise
|
||||||
|
*/
|
||||||
|
export const isValidSourceValue = (type: string, value: string): boolean => {
|
||||||
|
if (type === 'any' && value === '*') return true;
|
||||||
|
if (type === 'user') return isValidEmail(value);
|
||||||
|
if (type === 'group') return value.startsWith('group:') && value.length > 6;
|
||||||
|
if (type === 'ip') return isValidIP(value);
|
||||||
|
if (type === 'subnet') return isValidCIDR(value);
|
||||||
|
if (type === 'host') return isValidHostname(value);
|
||||||
|
if (type === 'tag') return value.startsWith('tag:') && value.length > 4;
|
||||||
|
if (type === 'autogroup') return value.startsWith('autogroup:') && value.length > 10;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a destination value based on its type
|
||||||
|
* @param type The destination type
|
||||||
|
* @param value The destination value
|
||||||
|
* @param port The port value
|
||||||
|
* @returns True if the value and port are valid for the given type, false otherwise
|
||||||
|
*/
|
||||||
|
export const isValidDestinationValue = (
|
||||||
|
type: string,
|
||||||
|
value: string,
|
||||||
|
port: string
|
||||||
|
): boolean => {
|
||||||
|
if (!isValidPort(port)) return false;
|
||||||
|
|
||||||
|
if (type === 'any' && value === '*') return true;
|
||||||
|
if (type === 'user') return isValidEmail(value);
|
||||||
|
if (type === 'group') return value.startsWith('group:') && value.length > 6;
|
||||||
|
if (type === 'ip') return isValidIP(value);
|
||||||
|
if (type === 'subnet') return isValidCIDR(value);
|
||||||
|
if (type === 'host') return isValidHostname(value);
|
||||||
|
if (type === 'tag') return value.startsWith('tag:') && value.length > 4;
|
||||||
|
if (type === 'autogroup') return value.startsWith('autogroup:') && value.length > 10;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
11
tailwind.config.js
Normal file
11
tailwind.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['lucide-react'],
|
||||||
|
},
|
||||||
|
})
|
Loading…
x
Reference in New Issue
Block a user