Merge branch 'main' into feat-custom-multiselect

This commit is contained in:
Samuel Lorch 2023-04-11 19:42:46 +02:00 committed by GitHub
commit 1b2a1ec6e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 3050 additions and 969 deletions

6
.gitignore vendored
View file

@ -1,5 +1,9 @@
config.json
pending.json
nftables.conf
interfaces.conf
go.work
nfsense
nfsense.exe
nfsense.exe
out/*
out

View file

@ -12,6 +12,7 @@
},
"dependencies": {
"@intlify/unplugin-vue-i18n": "^0.8.2",
"@vee-validate/zod": "^4.8.4",
"@vueuse/core": "^9.13.0",
"@vueuse/head": "^1.1.15",
"axios": "^1.3.4",
@ -25,7 +26,9 @@
"vue": "^3.2.45",
"vue-i18n": "9",
"vue-router": "4",
"ws": "^8.13.0"
"vue-toast-notification": "^3.0",
"ws": "^8.13.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@iconify/json": "^2.2.30",

1380
client/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -5,9 +5,12 @@ import { authenticate, logout, checkAuthentication, setup } from "./api";
import IDashboard from '~icons/ri/dashboard-2-line';
import IRule from '~icons/material-symbols/rule-folder-outline-sharp';
import IAddress from '~icons/eos-icons/ip';
import IEthernet from '~icons/bi/ethernet';
import IService from '~icons/material-symbols/home-repair-service';
import ISNAT from '~icons/mdi/arrow-expand-right';
import IDNAT from '~icons/mdi/arrow-collapse-right';
import IConfig from '~icons/grommet-icons/document-config';
import IStaticRoutes from '~icons/material-symbols/drive-folder-upload-outline-sharp';
enum NavState { Open, Reduced, Collapsed };
const NavStateCount = 3;
@ -17,8 +20,11 @@ const navRoutes = {
"/firewall/forwardrules": { icon: IRule, caption: "Rules" },
"/firewall/sourcenatrules": { icon: ISNAT, caption: "SNAT" },
"/firewall/destinationnatrules": { icon: IDNAT, caption: "DNAT" },
"/network/interfaces": { icon: IEthernet, caption: "Interfaces" },
"/network/staticroutes": { icon: IStaticRoutes, caption: "Static Routes" },
"/object/addresses": { icon: IAddress, caption: "Addresses" },
"/object/services": { icon: IService, caption: "Services" },
"/config/config": { icon: IConfig, caption: "Config" },
};
enum AuthState { Unauthenticated, MfaRequired, Authenticated };

View file

@ -1,6 +1,9 @@
// import WebSocketServer from 'ws';
import JsonRPC from 'simple-jsonrpc-js';
import axios from "axios";
import { useToast } from 'vue-toast-notification';
const $toast = useToast();
let jrpc = new JsonRPC.connect_xhr('/api');
// let socket = new WebSocket("ws://"+ window.location.host +"/ws/api");
@ -21,6 +24,7 @@ export async function apiCall(method: string, params: Record<string, any>): Prom
if (ex.code === 401) {
UnauthorizedCallback();
} else {
$toast.error(method+ ': ' + ex.message);
console.debug("api call epic fail", ex);
}
return { Data: null, Error: ex};

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
const props = defineModel<{
title: string
validationSchema: Record<string, string | Function>,
sections: {
title: string
fields: {
key: string,
label: string,
as: string,
props: any,
default: any,
enabled?: (values: Record<string, any>) => Boolean,
rules?: (value: any) => true | string,
}[],
}[],
modelValue: any,
submit: (value: any) => boolean,
discard: () => void,
}>();
let { sections, submit, discard, validationSchema } = $(props);
</script>
<template>
<ValidationForm as="div" v-slot="{ values, handleSubmit }" @submit="submit" :validationSchema="validationSchema">
<template v-for="(section, index) in sections" :key="index">
<h4 v-if="section.title">{{ section.title }}</h4>
<div class="section">
<template v-for="(field, index) in section.fields" :key="index">
<template v-if="field.enabled ? field.enabled(values) : true">
<label :for="field.key" v-text="field.label" />
<Field v-if="field.as == 'NumberBox'" :name="field.key" :as="field.as" :rules="field.rules" v-bind="field.props" @update:modelValue="values[field.key] = Number(values[field.key])"/>
<Field v-else :name="field.key" :as="field.as" :rules="field.rules" v-bind="field.props"/>
<ErrorMessage :name="field.key" />
</template>
</template>
</div>
</template>
<div class="actions">
<div class="flex-grow"/>
<button @click="handleSubmit($event, submit)">Submit</button>
<div class="space"/>
<button @click="discard">Discard</button>
<div class="flex-grow"/>
</div>
<p>{{ values }}</p>
</ValidationForm>
</template>
<style scoped>
form {
display: flex;
flex-direction: column;
}
.section {
display: grid;
grid-template-columns: auto 1fr;
padding: 0.5rem;
gap: 0.5rem;
}
.actions {
flex-direction: row;
}
.space {
padding: 0.2rem;
}
.actions > button {
flex-grow: true;
}
h4,
p {
grid-column: 1 / 3;
}
h4 {
background-color: var(--cl-bg-hl);
padding: 0.3rem;
padding-left: 0.5rem;
;
}
</style>

View file

@ -15,15 +15,20 @@ const props = defineModel<{
sortSelf?: boolean,
sortBy?: string,
sortDesc?: boolean,
selection?: number[],
draggable?: boolean,
}>();
let { columns, data, sort, sortSelf, sortBy, sortDesc } = $(props);
let { columns, data, sort, sortSelf, sortBy, sortDesc, selection, draggable } = $(props);
const emit = defineEmits<{
(event: 'rowAction', index: number): void,
(event: 'selectionChanged'): void
(event: 'draggedRow', draggedRow: number, draggedOverRow: number): void,
}>();
let selection = $ref([] as number[]);
if (selection == undefined) {
selection = [];
}
const displayData = $computed(() => (sortSelf && sortBy !== '')
? data?.sort((a, b) => {
@ -97,7 +102,9 @@ function dragDropRow() {
data.splice(draggedRow, 1);
data.splice(draggedOverRow, 0, row);
data = data;
emit("draggedRow", draggedRow, draggedOverRow);
}
// Reset drag data
draggedRow = 0;
draggedOverRow = 0;
@ -123,7 +130,7 @@ function dragDropRow() {
</thead>
<tbody>
<tr v-for="(row, index) in displayData" :key="index"
draggable="true"
:draggable="draggable"
@click="() => rowSelection(index)"
@dblclick="() => emit('rowAction', index)"
@dragstart="() => draggedRow = index"

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
const props = defineModel<{
title: string,
loading: boolean,
columns?: {
heading: string,
path: string,
component?: Component,
}[],
data: Record<string, any>[],
tableProps: any,
selection?: number[],
}>();
let { title, loading, columns, data, selection, tableProps } = $(props);
const emit = defineEmits<{
(event: 'draggedRow', draggedRow: number, draggedOverRow: number): void,
}>();
async function draggedRow(draggedRow: number, draggedOverRow: number) {
emit("draggedRow", draggedRow, draggedOverRow);
}
</script>
<template>
<div>
<PageHeader :title="title">
<slot/>
</PageHeader>
<div v-if="loading" >Loading...</div>
<NiceTable v-else :columns="columns" v-model:selection="selection" @draggedRow="draggedRow" v-bind="tableProps" :data="data"/>
</div>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
const props = defineModel<{
modelValue: boolean,
}>();
let { modelValue } = $(props);
</script>
<template>
<div @click="() => modelValue = !modelValue">
<i-material-symbols-check-box-outline v-if="modelValue"/>
<i-material-symbols-check-box-outline-blank v-else/>
</div>
</template>
<style scoped>
div {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
const props = defineModel<{
modelValue: string,
}>();
let { modelValue } = $(props);
</script>
<template>
<textarea v-model="modelValue" rows="5"/>
</template>
<style scoped>
textarea {
resize: vertical;
}
</style>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
const props = defineModel<{
modelValue: number,
min?: number,
max?: number,
}>();
let { modelValue, min, max } = $(props);
</script>
<template>
<input type="number" v-model.number="modelValue" :min="min" :max="max">
</template>
<style scoped>
</style>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
const props = defineModel<{
options: {
name: string,
key: string,
icon: Component,
}[],
modelValue: number | string,
useIndex: boolean,
}>();
let { options, modelValue, useIndex } = $(props);
function setSelection(option: any, index: number){
if (useIndex) {
modelValue = index
} else {
modelValue = option.key
}
}
onMounted(async() => {
if (modelValue === undefined) {
if (useIndex) {
modelValue = 0
} else {
modelValue = options[0].key
}
}
});
</script>
<template>
<div>
<button class="option" v-for="(option, index) in options" :key="index" :class="{selected: modelValue == index || modelValue == option.key}" @click="setSelection(option, index)">
<i class="material-icons" v-if="option.icon">{{ option.icon }}</i>
{{ option.name }}
</button>
</div>
</template>
<style scoped>
div {
flex-flow: nowrap;
}
.selected {
background-color: var(--cl-bg-sl);
}
</style>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
const props = defineModel<{
modelValue: string,
}>();
let { modelValue } = $(props);
</script>
<template>
<input v-model="modelValue">
</template>
<style scoped>
</style>

View file

@ -1,44 +0,0 @@
<script setup lang="ts">
const props = defineModel<{
options: {
name: string,
icon: Component,
selected: boolean,
}[],
}>();
let { options } = $(props);
const emit = defineEmits<{
(event: 'selectionChanged'): void
}>();
function select(option: any) {
for(let opt of options) {
opt.selected = false;
}
option.selected = true;
emit('selectionChanged');
console.debug("selected", options);
}
</script>
<template>
<div>
<button class="option" v-for="(option, index) in options" :key="index" :class="{selected:option.selected}" @click="select(option)">
<i class="material-icons" v-if="option.icon">{{ option.icon }}</i>
{{ option.name }}
</button>
</div>
</template>
<style scoped>
div {
flex-flow: nowrap;
}
.selected {
background-color: var(--cl-bg-sl);
}
</style>

84
client/src/definitions.ts Normal file
View file

@ -0,0 +1,84 @@
import { toFormValidator } from '@vee-validate/zod';
import * as zod from 'zod';
export const editTypes: { [key: string]: { [key: string]: any } } = {
"firewall": {
name: "Firewall",
"forwardrules": {
name: "ForwardRule",
validationSchema: toFormValidator(
zod.object({
name: zod.string(),
verdict: zod.string(),
counter: zod.boolean(),
comment: zod.string().optional(),
}),
),
sections: [
{
fields: [
{ key: "name", label: "Name", as: "TextBox" },
{ key: "verdict", label: "Verdict", as: "PillBar", props: { options: [{ name: 'Accept', key: 'accept' }, { name: 'Drop', key: 'drop' }, { name: 'Continue', key: 'continue' }] } },
{ key: "counter", label: "Counter", as: "CheckBox", },
{ key: "comment", label: "Comment", as: "MultilineTextBox", },
],
},
],
},
},
"network": {
name: "Network",
"interfaces": {
name: "Interface",
validationSchema: toFormValidator(
zod.object({
name: zod.string(),
type: zod.string(),
hardware_interface: zod.string().optional(),
vlan_id: zod.number().optional(),
comment: zod.string().optional(),
}),
),
sections: [
{
fields: [
{ key: "name", label: "Name", as: "TextBox", default: "placeholder" },
{ key: "type", label: "Type", as: "PillBar", props: { options: [{ name: 'Hardware', key: 'hardware' }, { name: 'VLAN', key: 'vlan' }, { name: 'Bond', key: 'bond' }, { name: 'Bridge', key: 'bridge' }] } },
{ key: "hardware_device", label: "Hardware Device", as: "TextBox", enabled: (values: any) => (values["type"] == 'hardware') },
{ key: "vlan_parent", label: "VLAN Parent", as: "TextBox", enabled: (values: any) => (values["type"] == 'vlan') },
{ key: "vlan_id", label: "VLAN ID", as: "NumberBox", props: { min: 1, max: 4094 }, enabled: (values: any) => (values["type"] == 'vlan') },
{ key: "bond_members", label: "Bond Members", as: "TextBox", enabled: (values: any) => (values["type"] == 'bond') },
{ key: "bridge_members", label: "Bridge Members", as: "TextBox", enabled: (values: any) => (values["type"] == 'bridge') },
],
},
{
title: "Addressing",
fields: [
{ key: "addressing_mode", label: "Addressing Mode", as: "PillBar", props: { options: [{ name: 'None', key: 'none' }, { name: 'Static', key: 'static' }, { name: 'DHCP', key: 'dhcp' }] } },
{ key: "address", label: "Address", as: "TextBox", enabled: (values: any) => (values["addressing_mode"] == 'static') },
{ key: "comment", label: "Comment", as: "MultilineTextBox" },
],
}
],
},
"staticroutes": {
name: "StaticRoute",
validationSchema: toFormValidator(
zod.object({
name: zod.string(),
}),
),
sections: [
{
fields: [
{ key: "name", label: "Name", as: "TextBox", },
{ key: "interface", label: "Interface", as: "TextBox" },
{ key: "gateway", label: "Gateway", as: "TextBox" },
{ key: "destination", label: "Destination", as: "TextBox" },
{ key: "metric", label: "Metric", as: "NumberBox" },
],
},
],
},
},
};

View file

@ -3,4 +3,5 @@
.pad { padding: 0.5rem; }
.gap { gap: 0.5rem; }
.flex-grow { flex-grow: 1; }
.flex-row { flex-direction: row; }
.flex-row { flex-direction: row; }
.scroll { overflow-y:auto; }

View file

@ -47,7 +47,7 @@ th:hover {
}
th, td {
padding: 0.5rem;
border: 0.125rem solid var(--cl-fg);
border: 0.125rem solid var(--cl-bg-el);
}
th > *{
justify-content: center;
@ -77,6 +77,11 @@ button, .button {
background-color: var(--cl-bg-hl);
}
.button:disabled, button:disabled, .disabled {
background-color: var(--cl-bg-hl);
pointer-events: none;
}
input, textarea {
background-color: var(--cl-bg-hl);
border: 1px solid var(--cl-fg);

View file

@ -3,11 +3,21 @@ import './global-styles/components.css';
import './global-styles/colors.css';
import './global-styles/mlfe.css';
import './global-styles/transitions.css';
import 'vue-toast-notification/dist/theme-default.css';
import PillBar from "./components/inputs/PillBar.vue";
import TextBox from "./components/inputs/TextBox.vue";
import NumberBox from "./components/inputs/NumberBox.vue";
import MultilineTextBox from "./components/inputs/MultilineTextBox.vue";
import CheckBox from "./components/inputs/CheckBox.vue";
import { Form, Field, ErrorMessage } from 'vee-validate';
import App from './App.vue';
import { createHead } from '@vueuse/head';
import { createRouter, createWebHistory } from 'vue-router';
import routes from '~pages';
import ToastPlugin from 'vue-toast-notification';
const app = createApp(App);
const head = createHead();
@ -18,5 +28,16 @@ const router = createRouter({
app.use(router);
app.use(head);
app.use(ToastPlugin);
// Global Components
app.component('PillBar', PillBar);
app.component('TextBox', TextBox);
app.component('NumberBox', NumberBox);
app.component('MultilineTextBox', MultilineTextBox);
app.component('CheckBox', CheckBox);
app.component('ValidationForm', Form);
app.component('Field', Field);
app.component('ErrorMessage', ErrorMessage);
app.mount('#app');

View file

@ -1,15 +0,0 @@
<script setup lang="ts">
const props = $defineProps<{entity: string, id: string}>();
const { entity, id } = $(props);
const pageTypes: { [key: string]: any } = {
"rules": { title: "Rules" },
"addresses": { title: "Addresses"},
};
</script>
<template>
<div>
<PageHeader :title="pageTypes[entity].title"/>
{{ entity }} {{ id }}
</div>
</template>

View file

@ -1,90 +0,0 @@
<script setup lang="ts">
const props = $defineProps<{entity: string}>();
const { entity } = $(props);
const pageTypes: { [key: string]: any } = {
"rules": { title: "Rules" },
"addresses": { title: "Addresses"},
};
let searchTerm = $ref("");
</script>
<template>
<div>
<PageHeader :title="pageTypes[entity].title">
<input class="search-bar" placeholder="Search..." v-model="searchTerm"/>
<button>
<i-material-symbols-add/>
</button>
</PageHeader>
<NiceTable :columns="{fname: 'First Name', lname: 'Last Name'}" :sort-self="true" :data="[
{
fname: 'Haynes',
lname: 'Chavez'
}, {
fname: 'Brennan',
lname: 'Bradley'
}, {
fname: 'Blanchard',
lname: 'Thornton'
}, {
fname: 'Benjamin',
lname: 'Nash'
}, {
fname: 'Jan',
lname: 'Bradford'
}, {
fname: 'Zelma',
lname: 'Spears'
}, {
fname: 'Freeman',
lname: 'Page'
}, {
fname: 'Wilson',
lname: 'Carlson'
}, {
fname: 'Lewis',
lname: 'Fuentes'
}, {
fname: 'Vega',
lname: 'Villarreal'
}, {
fname: 'Carolyn',
lname: 'Cardenas'
}, {
fname: 'Angie',
lname: 'Adams'
}, {
fname: 'Richards',
lname: 'Leon'
}, {
fname: 'Velma',
lname: 'Fields'
}, {
fname: 'Witt',
lname: 'Lowe'
}, {
fname: 'Waters',
lname: 'Leblanc'
}, {
fname: 'Henry',
lname: 'Lloyd'
}, {
fname: 'Boone',
lname: 'Greer'
}, {
fname: 'Willis',
lname: 'Stark'
}, {
fname: 'Dickson',
lname: 'Spencer'
}
].filter(x => (`${x.fname} ${x.lname}`).toLowerCase().includes(searchTerm.toLowerCase()))"/>
</div>
</template>
<style scoped>
.page-content {
overflow-y: auto;
}
</style>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import { editTypes } from "../../../../definitions";
import getPlugins from '../../../../plugins';
const p = getPlugins();
const props = $defineProps<{subsystem: string, entity: string, id: string}>();
const { subsystem, entity, id } = $(props);
let data = $ref({} as {});
async function update() {
}
</script>
<template>
<div v-if="editTypes[subsystem][entity]">
<PageHeader :title="'Edit ' + editTypes[subsystem][entity].name">
<button @click="update">Update</button>
<button @click="$router.go(-1)">Discard</button>
</PageHeader>
<NiceForm class="scroll cl-secondary" :sections="editTypes[subsystem][entity].sections" v-model="data"/>
</div>
<div v-else>
<PageHeader title="Error"/>
No editType for this Entity
</div>
</template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import { editTypes } from "../../../../definitions";
import { apiCall } from "../../../../api";
import getPlugins from '../../../../plugins';
const p = getPlugins();
const props = $defineProps<{subsystem: string, entity: string}>();
const { subsystem, entity } = $(props);
let data = $ref({});
async function create(value: any) {
console.debug("value", value);
let res = await apiCall(editTypes[subsystem].name +".Create"+ editTypes[subsystem][entity].name, value);
if (res.Error === null) {
p.toast.success("Created " + editTypes[subsystem][entity].name);
p.router.go(-1);
} else {
console.debug("error", res);
}
}
</script>
<template>
<div v-if="editTypes[subsystem][entity]">
<PageHeader :title="'Create ' + editTypes[subsystem][entity].name">
</PageHeader>
<NiceForm class="scroll cl-secondary" :submit="create" :discard="() => $router.go(-1)" :sections="editTypes[subsystem][entity].sections" v-model="data"/>
</div>
<div v-else>
<PageHeader title="Error"/>
No editType for this Entity
</div>
</template>

View file

@ -0,0 +1,77 @@
<script setup lang="ts">
import { apiCall } from "../../api";
import getPlugins from '../../plugins';
const p = getPlugins();
let changelog = $ref([]);
let loading = $ref(false);
const columns = [
{heading: 'Path', path: 'path'},
{heading: 'Type', path: 'type'},
{heading: 'From', path: 'from'},
{heading: 'To', path: 'to'},
];
const displayData = $computed(() => {
let data: any;
data = [];
for (const change of changelog) {
data.push({
path: change.path,
type: change.type,
from: change.from,
to: change.to,
});
}
return data;
});
async function load(){
loading = true
let res = await apiCall("Config.GetPendingChangelog", {});
if (res.Error === null) {
console.debug("changelog", res.Data.Changelog);
changelog = res.Data.Changelog;
} else {
console.debug("error", res);
}
loading = false
}
async function apply(){
let res = await apiCall("Config.ApplyPendingChanges", {});
if (res.Error === null) {
console.debug("apply");
p.toast.success("Applied Pending Config");
} else {
console.debug("error", res);
}
load()
}
async function discard(){
let res = await apiCall("Config.DiscardPendingChanges", {});
if (res.Error === null) {
console.debug("discard");
p.toast.success("Discarded Pending Config");
} else {
console.debug("error", res);
}
load()
}
onMounted(async() => {
load();
});
</script>
<template>
<TableView title="Pending Changes" :columns="columns" :loading="loading" v-model:data="displayData" :table-props="{sort:true, sortSelf: true}">
<button @click="load">Refresh</button>
<button @click="apply">Apply</button>
<button @click="discard">Discard</button>
</TableView>
</template>

View file

@ -1,38 +1,67 @@
<script setup lang="ts">
import { apiCall } from "../../api";
import getPlugins from '../../plugins';
const p = getPlugins();
let rules = $ref([]);
let loading = $ref(false);
let selection = $ref([] as number[]);
const columns = [
{heading: 'Name', path: 'name'},
{heading: 'Source', path: 'match.source_addresses'},
{heading: 'Destination', path: 'match.destination_addresses'},
{heading: 'Service', path: 'match.services'},
{heading: 'Verdict', path: 'verdict'},
{heading: 'Counter', path: 'counter'},
{heading: 'Comment', path: 'comment'},
];
async function loadRules(){
async function load(){
let res = await apiCall("Firewall.GetDestinationNATRules", {});
if (res.Error === null) {
rules = res.Data.DestinationNATRules;
rules = res.Data.destination_nat_rules;
console.debug("rules", rules);
} else {
console.debug("error", res);
}
}
async function deleteRule(){
let res = await apiCall("Firewall.DeleteDestinationNATRule", {index: selection[0]});
if (res.Error === null) {
console.debug("deleted rule");
p.toast.success("Deleted Rule");
} else {
console.debug("error", res);
}
load();
}
async function draggedRow(draggedRow: number, draggedOverRow: number) {
console.log("dragged", draggedRow, draggedOverRow);
let res = await apiCall("Firewall.MoveDestinationNATRule", {index: draggedRow, to_index: draggedOverRow});
if (res.Error === null) {
console.debug("moved rule");
p.toast.success("Moved Rule");
} else {
console.debug("error", res);
}
load();
}
onMounted(async() => {
loadRules();
load();
});
</script>
<template>
<div>
<PageHeader title="DNAT Rules">
<button @click="loadRules">Load Rules</button>
</PageHeader>
<NiceTable :columns="columns" v-model:data="rules"/>
<TableView title="DNAT Rules" :columns="columns" :loading="loading" @draggedRow="draggedRow" v-model:selection="selection" v-model:data="rules" :table-props="{sort:true, sortSelf: true, draggable: true}">
<button @click="load">Refresh</button>
<router-link class="button" to="/firewall/destinationnatrules/edit">Create</router-link>
<router-link class="button" :class="{ disabled: selection.length != 1 }" :to="'/firewall/destinationnatrules/edit/' + selection[0]">Edit</router-link>
<button @click="deleteRule" :disabled="selection.length != 1">Delete</button>
</TableView>
</div>
</template>

View file

@ -1,7 +1,12 @@
<script setup lang="ts">
import { apiCall } from "../../api";
import getPlugins from '../../plugins';
const p = getPlugins();
let rules = $ref([]);
let loading = $ref(false);
let selection = $ref([] as number[]);
const columns = [
{heading: 'Name', path: 'name'},
{heading: 'Source', path: 'match.source_addresses'},
@ -12,27 +17,52 @@ const columns = [
{heading: 'Comment', path: 'comment'},
];
async function loadRules(){
async function load(){
let res = await apiCall("Firewall.GetForwardRules", {});
if (res.Error === null) {
rules = res.Data.ForwardRules;
rules = res.Data.forward_rules;
console.debug("rules", rules);
} else {
console.debug("error", res);
}
}
async function deleteRule(){
let res = await apiCall("Firewall.DeleteForwardRule", {index: selection[0]});
if (res.Error === null) {
console.debug("deleted rule");
p.toast.success("Deleted Rule");
} else {
console.debug("error", res);
}
load();
}
async function draggedRow(draggedRow: number, draggedOverRow: number) {
console.log("dragged", draggedRow, draggedOverRow);
let res = await apiCall("Firewall.MoveForwardRule", {index: draggedRow, to_index: draggedOverRow});
if (res.Error === null) {
console.debug("moved rule");
p.toast.success("Moved Rule");
} else {
console.debug("error", res);
}
load();
}
onMounted(async() => {
loadRules();
load();
});
</script>
<template>
<div>
<PageHeader title="Forward Rules">
<button @click="loadRules">Load Rules</button>
</PageHeader>
<NiceTable :columns="columns" v-model:data="rules"/>
<TableView title="Forward Rules" :columns="columns" :loading="loading" @draggedRow="draggedRow" v-model:selection="selection" v-model:data="rules" :table-props="{sort:true, sortSelf: true, draggable: true}">
<button @click="load">Refresh</button>
<router-link class="button" to="/firewall/forwardrules/edit">Create</router-link>
<router-link class="button" :class="{ disabled: selection.length != 1 }" :to="'/firewall/forwardrules/edit/' + selection[0]">Edit</router-link>
<button @click="deleteRule" :disabled="selection.length != 1">Delete</button>
</TableView>
</div>
</template>

View file

@ -1,38 +1,67 @@
<script setup lang="ts">
import { apiCall } from "../../api";
import getPlugins from '../../plugins';
const p = getPlugins();
let rules = $ref([]);
let loading = $ref(false);
let selection = $ref([] as number[]);
const columns = [
{heading: 'Name', path: 'name'},
{heading: 'Source', path: 'match.source_addresses'},
{heading: 'Destination', path: 'match.destination_addresses'},
{heading: 'Service', path: 'match.services'},
{heading: 'Verdict', path: 'verdict'},
{heading: 'Counter', path: 'counter'},
{heading: 'Comment', path: 'comment'},
];
async function loadRules(){
async function load(){
let res = await apiCall("Firewall.GetSourceNATRules", {});
if (res.Error === null) {
rules = res.Data.SourceNATRules;
rules = res.Data.source_nat_rules;
console.debug("rules", rules);
} else {
console.debug("error", res);
}
}
async function deleteRule(){
let res = await apiCall("Firewall.DeleteSourceNATRule", {index: selection[0]});
if (res.Error === null) {
console.debug("deleted rule");
p.toast.success("Deleted Rule");
} else {
console.debug("error", res);
}
load();
}
async function draggedRow(draggedRow: number, draggedOverRow: number) {
console.log("dragged", draggedRow, draggedOverRow);
let res = await apiCall("Firewall.MoveSourceNATRule", {index: draggedRow, to_index: draggedOverRow});
if (res.Error === null) {
console.debug("moved rule");
p.toast.success("Moved Rule");
} else {
console.debug("error", res);
}
load();
}
onMounted(async() => {
loadRules();
load();
});
</script>
<template>
<div>
<PageHeader title="SNAT Rules">
<button @click="loadRules">Load Rules</button>
</PageHeader>
<NiceTable :columns="columns" v-model:data="rules"/>
<TableView title="SNAT Rules" :columns="columns" :loading="loading" @draggedRow="draggedRow" v-model:selection="selection" v-model:data="rules" :table-props="{sort:true, sortSelf: true, draggable: true}">
<button @click="load">Refresh</button>
<router-link class="button" to="/firewall/sourcenatrules/edit">Create</router-link>
<router-link class="button" :class="{ disabled: selection.length != 1 }" :to="'/firewall/sourcenatrules/edit/' + selection[0]">Edit</router-link>
<button @click="deleteRule" :disabled="selection.length != 1">Delete</button>
</TableView>
</div>
</template>

View file

@ -1,39 +1,39 @@
<script setup lang="ts">
import { apiCall } from "../api";
async function doShit(){
apiCall("Firewall.GetForwardRules", {});
let links = $ref([]);
let loading = $ref(false);
async function load(){
loading = true
let res = await apiCall("Network.GetLinks", {});
if (res.Error === null) {
console.debug("links", res.Data.Links);
links = res.Data.Links;
} else {
console.debug("error", res);
}
loading = false
}
let name = $ref("");
let comment = $ref("");
let counter = $ref(false);
let options = $ref([{name: 'Accept'}, {name: 'Drop'}, {name: 'Continue'}]);
onMounted(async() => {
load();
});
</script>
<template>
<div style="overflow-y: auto;">
<PageHeader title="Dashboard">
<button @click="doShit">Example Buttons</button>
</PageHeader>
<form @submit="$event => $event.preventDefault()" class="cl-secondary">
<h3>Create Rule</h3>
<label for="name" v-text="'Name'"/>
<input name="name" v-model="name"/>
<label for="counter" v-text="'Counter'"/>
<input name="counter" type="checkbox" v-model="counter"/>
<label for="comment" v-text="'Comment'"/>
<textarea name="comment" v-model="comment"></textarea>
<label for="verdict" v-text="'Verdict'"/>
<pillbar :options="options" name="verdict" ></pillbar>
<button>Submit</button>
</form>
<Multiselect/>
asd
<div v-if="!loading" v-for="(link, index) in links" :key="index">
<p>{{ link.name }} {{ link.carrier_state }} {{ link.operational_state }}</p>
</div>
<div v-else>
Loading...
</div>
</div>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,66 @@
<script setup lang="ts">
import { apiCall } from "../../api";
let interfaces = $ref({});
let loading = $ref(false);
let selection = $ref([] as number[]);
const columns = [
{heading: 'Name', path: 'name'},
{heading: 'Type', path: 'type'},
{heading: 'Members', path: 'members'},
{heading: 'Addressing Mode', path: 'addressing_mode'},
{heading: 'Address', path: 'address'},
];
const displayData = $computed(() => {
let data: any;
data = [];
for (const name in interfaces) {
data.push({
name,
type: interfaces[name].type,
addressing_mode: interfaces[name].addressing_mode,
address: interfaces[name].address,
comment: interfaces[name].comment,
});
}
return data;
});
async function load(){
loading = true
let res = await apiCall("Network.GetInterfaces", {});
if (res.Error === null) {
console.debug("interfaces", res.Data.Interfaces);
interfaces = res.Data.Interfaces;
} else {
console.debug("error", res);
}
loading = false
}
async function deleteInterface(){
let res = await apiCall("Network.DeleteInterface", {name: displayData[selection[0]].name});
if (res.Error === null) {
console.debug("deleted interface");
} else {
console.debug("error", res);
}
load();
}
onMounted(async() => {
load();
});
</script>
<template>
<TableView title="Interfaces" :columns="columns" :loading="loading" v-model:selection="selection" v-model:data="displayData" :table-props="{sort:true, sortSelf: true}">
<button @click="load">Refresh</button>
<router-link class="button" to="/network/interfaces/edit">Create</router-link>
<router-link class="button" :class="{ disabled: selection.length != 1 }" :to="'/network/interfaces/edit/' + selection[0]">Edit</router-link>
<button @click="deleteInterface" :disabled="selection.length != 1">Delete</button>
</TableView>
</template>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import { apiCall } from "../../api";
let staticRoutes = $ref([]);
let loading = $ref(false);
let selection = $ref([] as number[]);
const columns = [
{heading: 'Name', path: 'name'},
{heading: 'Interface', path: 'interface'},
{heading: 'Gateway', path: 'gateway'},
{heading: 'Destination', path: 'destination'},
{heading: 'Metric', path: 'metric'},
];
async function load(){
loading = true
let res = await apiCall("Network.GetStaticRoutes", {});
if (res.Error === null) {
console.debug("staticRoutes", res.Data.StaticRoutes);
staticRoutes = res.Data.StaticRoutes;
} else {
console.debug("error", res);
}
loading = false
}
async function deleteStaticRoutes(){
let res = await apiCall("Network.DeleteStaticRoute", {index: selection[0]});
if (res.Error === null) {
console.debug("deleted static routes");
} else {
console.debug("error", res);
}
load();
}
onMounted(async() => {
load();
});
</script>
<template>
<TableView title="Static Routes" :columns="columns" :loading="loading" v-model:selection="selection" v-model:data="staticRoutes" :table-props="{sort:true, sortSelf: true}">
<button @click="load">Refresh</button>
<router-link class="button" to="/network/staticroutes/edit">Create</router-link>
<router-link class="button" :class="{ disabled: selection.length != 1 }" :to="'/network/staticroutes/edit/' + selection[0]">Edit</router-link>
<button @click="deleteStaticRoutes" :disabled="selection.length != 1">Delete</button>
</TableView>
</template>

View file

@ -2,6 +2,9 @@
import { apiCall } from "../../api";
let addresses = $ref([]);
let loading = $ref(false);
let selection = $ref([] as number[]);
const columns = [
{heading: 'Name', path: 'name'},
{heading: 'Type', path: 'type'},
@ -10,6 +13,7 @@ const columns = [
];
async function load(){
loading = true
let res = await apiCall("Object.GetAddresses", {});
if (res.Error === null) {
addresses = res.Data.Addresses;
@ -17,6 +21,52 @@ async function load(){
} else {
console.debug("error", res);
}
loading = false
}
const displayData = $computed(() => {
let data: any;
data = [];
for (const name in addresses) {
data.push({
name,
value: getAddressValue(addresses[name]),
type: addresses[name].type,
comment: addresses[name].comment,
});
}
return data;
});
function getAddressValue(s: any): string {
let value: string;
switch (s.type) {
case "host":
value = s.host;
break;
case "range":
value = s.range;
break;
case "network":
value = s.network;
break;
case "group":
value = s.children;
break;
default:
value = "unkown";
}
return value;
}
async function deleteAddress(){
let res = await apiCall("Object.DeleteAddress", {name: displayData[selection[0]].name});
if (res.Error === null) {
console.debug("deleted address");
} else {
console.debug("error", res);
}
load();
}
onMounted(async() => {
@ -26,10 +76,10 @@ onMounted(async() => {
</script>
<template>
<div>
<PageHeader title="Addresses">
<button @click="load">Load Addresses</button>
</PageHeader>
<NiceTable :columns="columns" v-model:data="addresses"/>
</div>
<TableView title="Addresses" :columns="columns" :loading="loading" v-model:selection="selection" v-model:data="displayData" :table-props="{sort:true, sortSelf: true}">
<button @click="load">Refresh</button>
<router-link class="button" to="/object/addresses/edit">Create</router-link>
<router-link class="button" :class="{ disabled: selection.length != 1 }" :to="'/object/addresses/edit/' + selection[0]">Edit</router-link>
<button @click="deleteAddress" :disabled="selection.length != 1">Delete</button>
</TableView>
</template>

View file

@ -1,7 +1,10 @@
<script setup lang="ts">
import { apiCall } from "../../api";
let services = $ref([]);
let services = $ref({});
let loading = $ref(false);
let selection = $ref([] as number[]);
const columns = [
{heading: 'Name', path: 'name'},
{heading: 'Type', path: 'type'},
@ -9,14 +12,66 @@ const columns = [
{heading: 'Comment', path: 'comment'},
];
const displayData = $computed(() => {
let data: any;
data = [];
for (const name in services) {
data.push({
name,
value: getServiceValue(services[name]),
type: services[name].type,
comment: services[name].comment,
});
}
return data;
});
function getServiceValue(s: any): string {
let value: string;
switch (s.type) {
case "tcp":
case "udp":
value = getServicePortRange(s);
break;
case "icmp":
value = "icmp";
break;
case "group":
value = s.children;
break;
default:
value = "unkown";
}
return value;
}
function getServicePortRange(s:any): string {
if (s.dport_end) {
return s.dport_start + "-" + s.dport_end;
}
return s.dport_start;
}
async function load(){
loading = true
let res = await apiCall("Object.GetServices", {});
if (res.Error === null) {
console.debug("services", res.Data.Services);
services = res.Data.Services;
console.debug("services", services);
} else {
console.debug("error", res);
}
loading = false
}
async function deleteService(){
let res = await apiCall("Object.DeleteService", {name: displayData[selection[0]].name});
if (res.Error === null) {
console.debug("deleted service");
} else {
console.debug("error", res);
}
load();
}
onMounted(async() => {
@ -26,10 +81,10 @@ onMounted(async() => {
</script>
<template>
<div>
<PageHeader title="services">
<button @click="load">Load Services</button>
</PageHeader>
<NiceTable :columns="columns" v-model:data="services"/>
</div>
<TableView title="Services" :columns="columns" :loading="loading" v-model:selection="selection" v-model:data="displayData" :table-props="{sort:true, sortSelf: true}">
<button @click="load">Refresh</button>
<router-link class="button" to="/object/services/edit">Create</router-link>
<router-link class="button" :class="{ disabled: selection.length != 1 }" :to="'/object/services/edit/' + selection[0]">Edit</router-link>
<button @click="deleteService" :disabled="selection.length != 1">Delete</button>
</TableView>
</template>

9
client/src/plugins.ts Normal file
View file

@ -0,0 +1,9 @@
import { useRouter } from 'vue-router';
import { useToast } from "vue-toast-notification";
export default function initiateCommonPlugins() {
const router = useRouter();
const toast = useToast();
return { router, toast };
}

4
go.mod
View file

@ -13,8 +13,12 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.11.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/klauspost/compress v1.10.3 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/r3labs/diff/v3 v3.0.1 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/text v0.6.0 // indirect

8
go.sum
View file

@ -23,6 +23,8 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
@ -48,6 +50,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/r3labs/diff/v3 v3.0.1 h1:CBKqf3XmNRHXKmdU7mZP1w7TV0pDyVCis1AUHtA4Xtg=
github.com/r3labs/diff/v3 v3.0.1/go.mod h1:f1S9bourRbiM66NskseyUdo0fTmEE0qKrikYJX63dgo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -56,6 +60,10 @@ github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
go4.org/netipx v0.0.0-20230125063823-8449b0a6169f h1:ketMxHg+vWm3yccyYiq+uK8D3fRmna2Fcj+awpQp84s=
go4.org/netipx v0.0.0-20230125063823-8449b0a6169f/go.mod h1:tgPU4N2u9RByaTN3NC2p9xOzyFpte4jYwsIIRF7XlSc=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=

View file

@ -0,0 +1,7 @@
package config
import "nfsense.net/nfsense/internal/config"
type Config struct {
ConfigManager *config.ConfigManager
}

View file

@ -0,0 +1,40 @@
package config
import (
"context"
"fmt"
"github.com/r3labs/diff/v3"
)
type GetPendingStatusResult struct {
Changed bool
}
func (c *Config) GetPendingStatus(ctx context.Context, params struct{}) (GetPendingStatusResult, error) {
return GetPendingStatusResult{
Changed: c.ConfigManager.AreChangesPending(),
}, nil
}
type GetPendingChangelogResult struct {
Changelog diff.Changelog
}
func (c *Config) GetPendingChangelog(ctx context.Context, params struct{}) (GetPendingChangelogResult, error) {
log, err := c.ConfigManager.GetPendingChangelog()
if err != nil {
return GetPendingChangelogResult{}, fmt.Errorf("Get Pending changelog %w", err)
}
return GetPendingChangelogResult{
Changelog: log,
}, nil
}
func (c *Config) ApplyPendingChanges(ctx context.Context, params struct{}) (struct{}, error) {
return struct{}{}, c.ConfigManager.ApplyPendingChanges()
}
func (c *Config) DiscardPendingChanges(ctx context.Context, params struct{}) (struct{}, error) {
return struct{}{}, c.ConfigManager.DiscardPendingConfig()
}

View file

@ -2,19 +2,85 @@ package firewall
import (
"context"
"fmt"
"nfsense.net/nfsense/internal/definitions"
)
type GetDestinationNATRulesParameters struct {
}
type GetDestinationNATRulesResult struct {
DestinationNATRules []definitions.DestinationNATRule
DestinationNATRules []definitions.DestinationNATRule `json:"destination_nat_rules"`
}
func (f *Firewall) GetDestinationNATRules(ctx context.Context, params GetForwardRulesParameters) (GetDestinationNATRulesResult, error) {
func (f *Firewall) GetDestinationNATRules(ctx context.Context, params struct{}) (GetDestinationNATRulesResult, error) {
return GetDestinationNATRulesResult{
DestinationNATRules: f.Conf.Firewall.DestinationNATRules,
DestinationNATRules: f.ConfigManager.GetPendingConfig().Firewall.DestinationNATRules,
}, nil
}
type CreateDestinationNATRuleParameters struct {
DestinationNATRule definitions.DestinationNATRule `json:"destination_nat_rule"`
}
func (f *Firewall) CreateDestinationNATRule(ctx context.Context, params CreateDestinationNATRuleParameters) (struct{}, error) {
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Firewall.DestinationNATRules = append(conf.Firewall.DestinationNATRules, params.DestinationNATRule)
return struct{}{}, t.Commit()
}
type UpdateDestinationNATRuleParameters struct {
Index uint64 `json:"index"`
DestinationNATRule definitions.DestinationNATRule `json:"destination_nat_rule"`
}
func (f *Firewall) UpdateDestinationNATRule(ctx context.Context, params UpdateDestinationNATRuleParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.DestinationNATRules) {
return struct{}{}, fmt.Errorf("DestinationNATRule does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Firewall.DestinationNATRules[params.Index] = params.DestinationNATRule
return struct{}{}, t.Commit()
}
type MoveDestinationNATRuleParameters struct {
Index uint64 `json:"index"`
ToIndex uint64 `json:"to_index"`
}
func (f *Firewall) MoveDestinationNATRule(ctx context.Context, params MoveDestinationNATRuleParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.DestinationNATRules) {
return struct{}{}, fmt.Errorf("DestinationNATRule does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
rule := conf.Firewall.DestinationNATRules[params.Index]
sliceWithoutRule := append(conf.Firewall.DestinationNATRules[:params.Index], conf.Firewall.DestinationNATRules[params.Index+1:]...)
newSlice := make([]definitions.DestinationNATRule, params.ToIndex+1)
copy(newSlice, sliceWithoutRule[:params.ToIndex])
newSlice[params.ToIndex] = rule
conf.Firewall.DestinationNATRules = append(newSlice, sliceWithoutRule[params.ToIndex:]...)
return struct{}{}, t.Commit()
}
type DeleteDestinationNATRuleParameters struct {
Index uint64 `json:"index"`
}
func (f *Firewall) DeleteDestinationNATRule(ctx context.Context, params DeleteDestinationNATRuleParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.DestinationNATRules) {
return struct{}{}, fmt.Errorf("DestinationNATRule does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Firewall.DestinationNATRules = append(conf.Firewall.DestinationNATRules[:params.Index], conf.Firewall.DestinationNATRules[params.Index+1:]...)
return struct{}{}, t.Commit()
}

View file

@ -1,9 +1,9 @@
package firewall
import (
"nfsense.net/nfsense/internal/definitions"
"nfsense.net/nfsense/internal/config"
)
type Firewall struct {
Conf *definitions.Config
ConfigManager *config.ConfigManager
}

View file

@ -2,19 +2,85 @@ package firewall
import (
"context"
"fmt"
"nfsense.net/nfsense/internal/definitions"
)
type GetForwardRulesParameters struct {
}
type GetForwardRulesResult struct {
ForwardRules []definitions.ForwardRule
ForwardRules []definitions.ForwardRule `json:"forward_rules"`
}
func (f *Firewall) GetForwardRules(ctx context.Context, params GetForwardRulesParameters) (GetForwardRulesResult, error) {
func (f *Firewall) GetForwardRules(ctx context.Context, params struct{}) (GetForwardRulesResult, error) {
return GetForwardRulesResult{
ForwardRules: f.Conf.Firewall.ForwardRules,
ForwardRules: f.ConfigManager.GetPendingConfig().Firewall.ForwardRules,
}, nil
}
type CreateForwardRuleParameters struct {
ForwardRule definitions.ForwardRule `json:"forward_rule"`
}
func (f *Firewall) CreateForwardRule(ctx context.Context, params CreateForwardRuleParameters) (struct{}, error) {
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Firewall.ForwardRules = append(conf.Firewall.ForwardRules, params.ForwardRule)
return struct{}{}, t.Commit()
}
type UpdateForwardRuleParameters struct {
Index uint64 `json:"index"`
ForwardRule definitions.ForwardRule `json:"forward_rule"`
}
func (f *Firewall) UpdateForwardRule(ctx context.Context, params UpdateForwardRuleParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.ForwardRules) {
return struct{}{}, fmt.Errorf("ForwardRule does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Firewall.ForwardRules[params.Index] = params.ForwardRule
return struct{}{}, t.Commit()
}
type MoveForwardRuleParameters struct {
Index uint64 `json:"index"`
ToIndex uint64 `json:"to_index"`
}
func (f *Firewall) MoveForwardRule(ctx context.Context, params MoveForwardRuleParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.ForwardRules) {
return struct{}{}, fmt.Errorf("ForwardRule does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
rule := conf.Firewall.ForwardRules[params.Index]
sliceWithoutRule := append(conf.Firewall.ForwardRules[:params.Index], conf.Firewall.ForwardRules[params.Index+1:]...)
newSlice := make([]definitions.ForwardRule, params.ToIndex+1)
copy(newSlice, sliceWithoutRule[:params.ToIndex])
newSlice[params.ToIndex] = rule
conf.Firewall.ForwardRules = append(newSlice, sliceWithoutRule[params.ToIndex:]...)
return struct{}{}, t.Commit()
}
type DeleteForwardRuleParameters struct {
Index uint64 `json:"index"`
}
func (f *Firewall) DeleteForwardRule(ctx context.Context, params DeleteForwardRuleParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.ForwardRules) {
return struct{}{}, fmt.Errorf("ForwardRule does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Firewall.ForwardRules = append(conf.Firewall.ForwardRules[:params.Index], conf.Firewall.ForwardRules[params.Index+1:]...)
return struct{}{}, t.Commit()
}

View file

@ -2,19 +2,85 @@ package firewall
import (
"context"
"fmt"
"nfsense.net/nfsense/internal/definitions"
)
type GetSourceNATRulesParameters struct {
}
type GetSourceNATRulesResult struct {
SourceNATRules []definitions.SourceNATRule
SourceNATRules []definitions.SourceNATRule `json:"source_nat_rules"`
}
func (f *Firewall) GetSourceNATRules(ctx context.Context, params GetForwardRulesParameters) (GetSourceNATRulesResult, error) {
func (f *Firewall) GetSourceNATRules(ctx context.Context, params struct{}) (GetSourceNATRulesResult, error) {
return GetSourceNATRulesResult{
SourceNATRules: f.Conf.Firewall.SourceNATRules,
SourceNATRules: f.ConfigManager.GetPendingConfig().Firewall.SourceNATRules,
}, nil
}
type CreateSourceNATRuleParameters struct {
SourceNATRule definitions.SourceNATRule `json:"source_nat_rule"`
}
func (f *Firewall) CreateSourceNATRule(ctx context.Context, params CreateSourceNATRuleParameters) (struct{}, error) {
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Firewall.SourceNATRules = append(conf.Firewall.SourceNATRules, params.SourceNATRule)
return struct{}{}, t.Commit()
}
type UpdateSourceNATRuleParameters struct {
Index uint64 `json:"index"`
SourceNATRule definitions.SourceNATRule `json:"source_nat_rule"`
}
func (f *Firewall) UpdateSourceNATRule(ctx context.Context, params UpdateSourceNATRuleParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.SourceNATRules) {
return struct{}{}, fmt.Errorf("SourceNATRule does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Firewall.SourceNATRules[params.Index] = params.SourceNATRule
return struct{}{}, t.Commit()
}
type MoveSourceNATRuleParameters struct {
Index uint64 `json:"index"`
ToIndex uint64 `json:"to_index"`
}
func (f *Firewall) MoveSourceNATRule(ctx context.Context, params MoveSourceNATRuleParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.SourceNATRules) {
return struct{}{}, fmt.Errorf("SourceNATRule does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
rule := conf.Firewall.SourceNATRules[params.Index]
sliceWithoutRule := append(conf.Firewall.SourceNATRules[:params.Index], conf.Firewall.SourceNATRules[params.Index+1:]...)
newSlice := make([]definitions.SourceNATRule, params.ToIndex+1)
copy(newSlice, sliceWithoutRule[:params.ToIndex])
newSlice[params.ToIndex] = rule
conf.Firewall.SourceNATRules = append(newSlice, sliceWithoutRule[params.ToIndex:]...)
return struct{}{}, t.Commit()
}
type DeleteSourceNATRuleParameters struct {
Index uint64 `json:"index"`
}
func (f *Firewall) DeleteSourceNATRule(ctx context.Context, params DeleteSourceNATRuleParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.SourceNATRules) {
return struct{}{}, fmt.Errorf("SourceNATRule does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Firewall.SourceNATRules = append(conf.Firewall.SourceNATRules[:params.Index], conf.Firewall.SourceNATRules[params.Index+1:]...)
return struct{}{}, t.Commit()
}

View file

@ -0,0 +1,86 @@
package network
import (
"context"
"fmt"
"nfsense.net/nfsense/internal/definitions"
"nfsense.net/nfsense/internal/networkd/dbus"
)
type GetLinksResult struct {
Links []dbus.Link
}
func (f *Network) GetLinks(ctx context.Context, params struct{}) (GetLinksResult, error) {
links, err := dbus.GetLinks(*f.DbusConn)
if err != nil {
return GetLinksResult{}, fmt.Errorf("Getting Links: %w", err)
}
return GetLinksResult{
Links: links,
}, nil
}
type GetInterfacesResult struct {
Interfaces map[string]definitions.Interface
}
func (f *Network) GetInterfaces(ctx context.Context, params struct{}) (GetInterfacesResult, error) {
return GetInterfacesResult{
Interfaces: f.ConfigManager.GetPendingConfig().Network.Interfaces,
}, nil
}
type CreateInterfaceParameters struct {
Name string
definitions.Interface
}
func (f *Network) CreateInterface(ctx context.Context, params CreateInterfaceParameters) (struct{}, error) {
_, ok := f.ConfigManager.GetPendingConfig().Network.Interfaces[params.Name]
if ok {
return struct{}{}, fmt.Errorf("Interface already Exists")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Network.Interfaces[params.Name] = params.Interface
return struct{}{}, t.Commit()
}
type UpdateInterfaceParameters struct {
Name string
definitions.Interface
}
func (f *Network) UpdateInterface(ctx context.Context, params UpdateInterfaceParameters) (struct{}, error) {
_, ok := f.ConfigManager.GetPendingConfig().Network.Interfaces[params.Name]
if !ok {
return struct{}{}, fmt.Errorf("Interface does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Network.Interfaces[params.Name] = params.Interface
return struct{}{}, t.Commit()
}
type DeleteInterfaceParameters struct {
Name string
}
func (f *Network) DeleteInterface(ctx context.Context, params DeleteInterfaceParameters) (struct{}, error) {
_, ok := f.ConfigManager.GetPendingConfig().Network.Interfaces[params.Name]
if !ok {
return struct{}{}, fmt.Errorf("Interface does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
delete(conf.Network.Interfaces, params.Name)
return struct{}{}, t.Commit()
}

View file

@ -0,0 +1,11 @@
package network
import (
"github.com/godbus/dbus/v5"
"nfsense.net/nfsense/internal/config"
)
type Network struct {
ConfigManager *config.ConfigManager
DbusConn *dbus.Conn
}

View file

@ -0,0 +1,59 @@
package network
import (
"context"
"fmt"
"nfsense.net/nfsense/internal/definitions"
)
type GetStaticRoutesResult struct {
StaticRoutes []definitions.StaticRoute
}
func (f *Network) GetStaticRoutes(ctx context.Context, params struct{}) (GetStaticRoutesResult, error) {
return GetStaticRoutesResult{
StaticRoutes: f.ConfigManager.GetPendingConfig().Network.StaticRoutes,
}, nil
}
func (f *Network) CreateStaticRoute(ctx context.Context, params definitions.StaticRoute) (struct{}, error) {
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Network.StaticRoutes = append(conf.Network.StaticRoutes, params)
return struct{}{}, t.Commit()
}
type UpdateStaticRouteParameters struct {
Index uint
definitions.StaticRoute
}
func (f *Network) UpdateStaticRoute(ctx context.Context, params UpdateStaticRouteParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.DestinationNATRules) {
return struct{}{}, fmt.Errorf("StaticRoute does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Network.StaticRoutes = append(conf.Network.StaticRoutes, params.StaticRoute)
return struct{}{}, t.Commit()
}
type DeleteStaticRouteParameters struct {
Index uint
}
func (f *Network) DeleteStaticRoute(ctx context.Context, params DeleteStaticRouteParameters) (struct{}, error) {
if int(params.Index) >= len(f.ConfigManager.GetPendingConfig().Firewall.DestinationNATRules) {
return struct{}{}, fmt.Errorf("StaticRoute does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Network.StaticRoutes = append(conf.Network.StaticRoutes[:params.Index], conf.Network.StaticRoutes[params.Index+1:]...)
return struct{}{}, t.Commit()
}

View file

@ -2,19 +2,70 @@ package object
import (
"context"
"fmt"
"nfsense.net/nfsense/internal/definitions"
)
type GetAddressesParameters struct {
}
type GetAddressesResult struct {
Addresses map[string]definitions.Address
}
func (f *Object) GetAddresses(ctx context.Context, params GetAddressesParameters) (GetAddressesResult, error) {
func (f *Object) GetAddresses(ctx context.Context, params struct{}) (GetAddressesResult, error) {
return GetAddressesResult{
Addresses: f.Conf.Object.Addresses,
Addresses: f.ConfigManager.GetPendingConfig().Object.Addresses,
}, nil
}
type CreateAddressParameters struct {
Name string
Address definitions.Address
}
func (f *Object) CreateAddress(ctx context.Context, params CreateAddressParameters) (struct{}, error) {
_, ok := f.ConfigManager.GetPendingConfig().Object.Addresses[params.Name]
if ok {
return struct{}{}, fmt.Errorf("Address already Exists")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Object.Addresses[params.Name] = params.Address
return struct{}{}, t.Commit()
}
type UpdateAddressParameters struct {
Name string
Address definitions.Address
}
func (f *Object) UpdateAddress(ctx context.Context, params UpdateAddressParameters) (struct{}, error) {
_, ok := f.ConfigManager.GetPendingConfig().Object.Addresses[params.Name]
if !ok {
return struct{}{}, fmt.Errorf("Address does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Object.Addresses[params.Name] = params.Address
return struct{}{}, t.Commit()
}
type DeleteAddressParameters struct {
Name string
}
func (f *Object) DeleteAddress(ctx context.Context, params DeleteAddressParameters) (struct{}, error) {
_, ok := f.ConfigManager.GetPendingConfig().Object.Addresses[params.Name]
if !ok {
return struct{}{}, fmt.Errorf("Interface does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
delete(conf.Object.Addresses, params.Name)
return struct{}{}, t.Commit()
}

View file

@ -1,7 +1,7 @@
package object
import "nfsense.net/nfsense/internal/definitions"
import "nfsense.net/nfsense/internal/config"
type Object struct {
Conf *definitions.Config
ConfigManager *config.ConfigManager
}

View file

@ -2,19 +2,70 @@ package object
import (
"context"
"fmt"
"nfsense.net/nfsense/internal/definitions"
)
type GetServicesParameters struct {
}
type GetServicesResult struct {
Services map[string]definitions.Service
}
func (f *Object) GetServices(ctx context.Context, params GetServicesParameters) (GetServicesResult, error) {
func (f *Object) GetServices(ctx context.Context, params struct{}) (GetServicesResult, error) {
return GetServicesResult{
Services: f.Conf.Object.Services,
Services: f.ConfigManager.GetPendingConfig().Object.Services,
}, nil
}
type CreateServiceParameters struct {
Name string
Service definitions.Service
}
func (f *Object) CreateService(ctx context.Context, params CreateServiceParameters) (struct{}, error) {
_, ok := f.ConfigManager.GetPendingConfig().Object.Services[params.Name]
if ok {
return struct{}{}, fmt.Errorf("Service already Exists")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Object.Services[params.Name] = params.Service
return struct{}{}, t.Commit()
}
type UpdateServiceParameters struct {
Name string
Service definitions.Service
}
func (f *Object) UpdateService(ctx context.Context, params UpdateServiceParameters) (struct{}, error) {
_, ok := f.ConfigManager.GetPendingConfig().Object.Services[params.Name]
if !ok {
return struct{}{}, fmt.Errorf("Service does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
conf.Object.Services[params.Name] = params.Service
return struct{}{}, t.Commit()
}
type DeleteServiceParameters struct {
Name string
}
func (f *Object) DeleteService(ctx context.Context, params DeleteServiceParameters) (struct{}, error) {
_, ok := f.ConfigManager.GetPendingConfig().Object.Services[params.Name]
if !ok {
return struct{}{}, fmt.Errorf("Interface does not Exist")
}
t, conf := f.ConfigManager.StartTransaction()
defer t.Discard()
delete(conf.Object.Services, params.Name)
return struct{}{}, t.Commit()
}

52
internal/config/apply.go Normal file
View file

@ -0,0 +1,52 @@
package config
import (
"fmt"
"os"
"golang.org/x/exp/slog"
"nfsense.net/nfsense/internal/definitions"
)
// ApplyPendingChanges Takes all pending Changes and Tries to Apply them using the Registered Apply Functions.
// In Case of error it Attempts to Revert to the Current Config
func (m *ConfigManager) ApplyPendingChanges() error {
slog.Info("Applying Pending Changes...")
for _, fn := range m.applyFunctions {
err := fn(*m.currentConfig, *m.pendingConfig)
if err != nil {
slog.Error("Applying Pending Changes", err)
err2 := revertToCurrent(m)
if err2 != nil {
slog.Error("Reverting Error", err2)
return fmt.Errorf("Apply Error %w; Reverting Error %w", err, err2)
}
return err
}
}
m.currentConfig = m.pendingConfig.Clone()
err := m.saveConfig(m.currentConfigFilePath, m.pendingConfig)
if err != nil {
return fmt.Errorf("Save Current Config: %w", err)
}
err = os.Remove(m.pendingConfigFilePath)
if err != nil {
return fmt.Errorf("Delete Pending Config: %w", err)
}
return nil
}
func revertToCurrent(m *ConfigManager) error {
for _, fn := range m.applyFunctions {
err := fn(*m.pendingConfig, *m.currentConfig)
if err != nil {
return err
}
}
return nil
}
func (m *ConfigManager) RegisterApplyFunction(fn func(currentConfig definitions.Config, pendingConfig definitions.Config) error) {
m.applyFunctions = append(m.applyFunctions, fn)
}

11
internal/config/diff.go Normal file
View file

@ -0,0 +1,11 @@
package config
import "github.com/r3labs/diff/v3"
func (m *ConfigManager) AreChangesPending() bool {
return diff.Changed(m.currentConfig, m.pendingConfig)
}
func (m *ConfigManager) GetPendingChangelog() (diff.Changelog, error) {
return diff.Diff(m.currentConfig, m.pendingConfig, diff.SliceOrdering(true))
}

View file

@ -0,0 +1,16 @@
package config
import (
"errors"
"os"
)
func (m *ConfigManager) DiscardPendingConfig() error {
m.pendingConfig = m.currentConfig.Clone()
err := os.Remove(m.pendingConfigFilePath)
if !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}

11
internal/config/get.go Normal file
View file

@ -0,0 +1,11 @@
package config
import "nfsense.net/nfsense/internal/definitions"
func (m *ConfigManager) GetCurrentConfig() definitions.Config {
return *m.currentConfig.Clone()
}
func (m *ConfigManager) GetPendingConfig() definitions.Config {
return *m.pendingConfig.Clone()
}

57
internal/config/load.go Normal file
View file

@ -0,0 +1,57 @@
package config
import (
"encoding/json"
"fmt"
"os"
"nfsense.net/nfsense/internal/definitions"
)
func (m *ConfigManager) LoadCurrentConfigFromDisk() error {
var config definitions.Config
configFile, err := os.Open(m.currentConfigFilePath)
if err != nil {
return fmt.Errorf("opening Config File %w", err)
}
defer configFile.Close()
jsonParser := json.NewDecoder(configFile)
jsonParser.DisallowUnknownFields()
err = jsonParser.Decode(&config)
if err != nil {
return fmt.Errorf("decoding Config File %w", err)
}
err = definitions.ValidateConfig(&config)
if err != nil {
return fmt.Errorf("validating Config: %w", err)
}
m.currentConfig = &config
return nil
}
func (m *ConfigManager) LoadPendingConfigFromDisk() error {
var config definitions.Config
configFile, err := os.Open(m.pendingConfigFilePath)
if err != nil {
return fmt.Errorf("opening Config File %w", err)
}
defer configFile.Close()
jsonParser := json.NewDecoder(configFile)
jsonParser.DisallowUnknownFields()
err = jsonParser.Decode(&config)
if err != nil {
return fmt.Errorf("decoding Config File %w", err)
}
err = definitions.ValidateConfig(&config)
if err != nil {
return fmt.Errorf("validating Config: %w", err)
}
m.pendingConfig = &config
return nil
}

View file

@ -0,0 +1,29 @@
package config
import (
"sync"
"nfsense.net/nfsense/internal/definitions"
)
type ConfigManager struct {
currentConfigFilePath string
pendingConfigFilePath string
currentConfig *definitions.Config
pendingConfig *definitions.Config
transactionMutex sync.Mutex
applyFunctions []func(currentConfig definitions.Config, pendingConfig definitions.Config) error
}
func CreateConfigManager() *ConfigManager {
manager := ConfigManager{
currentConfigFilePath: "config.json",
pendingConfigFilePath: "pending.json",
currentConfig: &definitions.Config{},
pendingConfig: &definitions.Config{},
}
return &manager
}

23
internal/config/save.go Normal file
View file

@ -0,0 +1,23 @@
package config
import (
"encoding/json"
"fmt"
"os"
"nfsense.net/nfsense/internal/definitions"
)
func (m *ConfigManager) saveConfig(path string, conf *definitions.Config) error {
data, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return fmt.Errorf("Marshal Config: %w", err)
}
err = os.WriteFile(path, data, 0644)
if err != nil {
return fmt.Errorf("Write Config: %w", err)
}
return nil
}

View file

@ -0,0 +1,62 @@
package config
import (
"fmt"
"sync"
"nfsense.net/nfsense/internal/definitions"
)
type ConfigTransaction struct {
finished bool
mutex sync.Mutex
configManager *ConfigManager
changes *definitions.Config
}
func (m *ConfigManager) StartTransaction() (*ConfigTransaction, *definitions.Config) {
m.transactionMutex.Lock()
confCopy := m.pendingConfig.Clone()
return &ConfigTransaction{
configManager: m,
changes: confCopy,
}, confCopy
}
func (t *ConfigTransaction) Commit() error {
t.mutex.Lock()
defer t.mutex.Unlock()
if t.finished {
return fmt.Errorf("transaction already finished")
}
t.finished = true
defer t.configManager.transactionMutex.Unlock()
err := definitions.ValidateConfig(t.changes)
if err != nil {
return fmt.Errorf("validating Config before Apply: %w", err)
}
err = t.configManager.saveConfig(t.configManager.pendingConfigFilePath, t.changes)
if err != nil {
return fmt.Errorf("Save Current Config: %w", err)
}
t.configManager.pendingConfig = t.changes.Clone()
return nil
}
// Discard Discards the Transaction.
// Is a noop if The Transaction Already Finished due to a Commit() or another Discard()
func (t *ConfigTransaction) Discard() {
t.mutex.Lock()
defer t.mutex.Unlock()
if !t.finished {
t.finished = true
t.configManager.transactionMutex.Unlock()
}
}

View file

@ -8,12 +8,12 @@ import (
)
type Address struct {
Type AddressType `json:"type" validate:"min=0,max=3"`
Comment string `json:"comment,omitempty"`
Host *netip.Addr `json:"host,omitempty" validate:"excluded_unless=Type 0"`
Range *netipx.IPRange `json:"range,omitempty" validate:"excluded_unless=Type 1"`
Network *IPNet `json:"network,omitempty" validate:"excluded_unless=Type 2"`
Children *[]string `json:"children,omitempty"`
Type AddressType `json:"type" validate:"min=0,max=3"`
Comment string `json:"comment,omitempty"`
Host *netip.Addr `json:"host,omitempty" validate:"excluded_unless=Type 0"`
Range *netipx.IPRange `json:"range,omitempty" validate:"excluded_unless=Type 1"`
NetworkAddress *IPNet `json:"network,omitempty" validate:"excluded_unless=Type 2"`
Children *[]string `json:"children,omitempty"`
}
type AddressType int
@ -21,7 +21,7 @@ type AddressType int
const (
Host AddressType = iota
Range
Network
NetworkAddress
AddressGroup
)
@ -33,7 +33,7 @@ func (t *AddressType) FromString(input string) AddressType {
return map[string]AddressType{
"host": Host,
"range": Range,
"network": Network,
"network": NetworkAddress,
"group": AddressGroup,
}[input]
}

View file

@ -1,6 +1,7 @@
package definitions
import (
"encoding/json"
"fmt"
"github.com/go-playground/validator/v10"
@ -11,6 +12,21 @@ type Config struct {
ConfigVersion uint64 `json:"config_version" validate:"required,eq=1"`
Firewall Firewall `json:"firewall" validate:"required,dive"`
Object Object `json:"object" validate:"required,dive"`
Network Network `json:"network" validate:"required,dive"`
}
// Clone TODO find a better way to deep copy
func (c *Config) Clone() *Config {
data, err := json.Marshal(c)
if err != nil {
panic(fmt.Errorf("Marshal Error: %w", err))
}
var clone Config
err = json.Unmarshal(data, &clone)
if err != nil {
panic(fmt.Errorf("Unmarshal Error: %w", err))
}
return &clone
}
func ValidateConfig(conf *Config) error {

View file

@ -0,0 +1,30 @@
package definitions
import (
"encoding/json"
"net"
)
type HardwareAddress struct {
net.HardwareAddr
}
// MarshalJSON for IPCIDR
func (i HardwareAddress) MarshalJSON() ([]byte, error) {
return json.Marshal(i.String())
}
// UnmarshalJSON for IPCIDR
func (i *HardwareAddress) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
mac, err := net.ParseMAC(s)
if err != nil {
return err
}
i.HardwareAddr = mac
return nil
}

View file

@ -0,0 +1,89 @@
package definitions
import (
"encoding/json"
)
type Interface struct {
Alias string `json:"alias,omitempty" validate:"min=0,max=3"`
Type InterfaceType `json:"type" validate:"min=0,max=3"`
AddressingMode InterfaceAddressingMode `json:"addressing_mode" validate:"min=0,max=2"`
Address *IPCIDR `json:"address,omitempty" validate:"excluded_unless=AddressingMode 1"`
HardwareDevice *string `json:"hardware_device,omitempty"`
// TODO fix Validator for int pointers with min=0,max=4094
VlanID *uint `json:"vlan_id,omitempty"`
VlanParent *string `json:"vlan_parent,omitempty"`
BondMembers *[]string `json:"bond_members,omitempty"`
BridgeMembers *[]string `json:"bridge_members,omitempty"`
Comment string `json:"comment,omitempty"`
}
type InterfaceType int
const (
Hardware InterfaceType = iota
Vlan
Bond
Bridge
)
func (t InterfaceType) String() string {
return [...]string{"hardware", "vlan", "bond", "bridge"}[t]
}
func (t *InterfaceType) FromString(input string) InterfaceType {
return map[string]InterfaceType{
"hardware": Hardware,
"vlan": Vlan,
"bond": Bond,
"bridge": Bridge,
}[input]
}
func (t InterfaceType) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}
func (t *InterfaceType) UnmarshalJSON(b []byte) error {
var s string
err := json.Unmarshal(b, &s)
if err != nil {
return err
}
*t = t.FromString(s)
return nil
}
type InterfaceAddressingMode int
const (
None InterfaceAddressingMode = iota
Static
Dhcp
)
func (t InterfaceAddressingMode) String() string {
return [...]string{"none", "static", "dhcp"}[t]
}
func (t *InterfaceAddressingMode) FromString(input string) InterfaceAddressingMode {
return map[string]InterfaceAddressingMode{
"none": None,
"static": Static,
"dhcp": Dhcp,
}[input]
}
func (t InterfaceAddressingMode) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}
func (t *InterfaceAddressingMode) UnmarshalJSON(b []byte) error {
var s string
err := json.Unmarshal(b, &s)
if err != nil {
return err
}
*t = t.FromString(s)
return nil
}

View file

@ -0,0 +1,32 @@
package definitions
import (
"encoding/json"
"net"
)
// IPCIDR is IP Address with the mask in CIDR format
type IPCIDR struct {
net.IPNet
}
// MarshalJSON for IPCIDR
func (i IPCIDR) MarshalJSON() ([]byte, error) {
return json.Marshal(i.String())
}
// UnmarshalJSON for IPCIDR
func (i *IPCIDR) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
ip, ipnet, err := net.ParseCIDR(s)
if err != nil {
return err
}
i.IPNet = *ipnet
i.IPNet.IP = ip
return nil
}

View file

@ -0,0 +1,6 @@
package definitions
type Network struct {
Interfaces map[string]Interface `json:"interfaces" validate:"required,dive"`
StaticRoutes []StaticRoute `json:"static_routes" validate:"required,dive"`
}

View file

@ -0,0 +1,13 @@
package definitions
import (
"net/netip"
)
type StaticRoute struct {
Name string `json:"name,omitempty"`
Interface string `json:"interface,omitempty"`
Gateway netip.Addr `json:"gateway,omitempty"`
Destination IPNet `json:"destination,omitempty"`
Metric uint `json:"metric,omitempty"`
}

View file

@ -81,7 +81,8 @@ func (h *Handler) HandleRequest(ctx context.Context, s *session.Session, r io.Re
defer func() {
if r := recover(); r != nil {
slog.Error("Recovered Panic Executing API Method", fmt.Errorf("%v", r), "method", req.Method, "id", req.ID, "stack", debug.Stack())
slog.Error("Recovered Panic Executing API Method", fmt.Errorf("%v", r), "method", req.Method, "params", fmt.Sprintf("%+v", params[2]), "id", req.ID, "stack", debug.Stack())
respondError(w, req.ID, ErrInternalError, fmt.Errorf("%v", r))
}
}()
res := method.handlerFunc.Call(params)
@ -89,8 +90,9 @@ func (h *Handler) HandleRequest(ctx context.Context, s *session.Session, r io.Re
if !res[1].IsNil() {
reqerr := res[1].Interface().(error)
slog.Error("API Method", reqerr, "method", req.Method, "id", req.ID)
slog.Error("API Method", reqerr, "method", req.Method, "id", req.ID, "params", fmt.Sprintf("%+v", params[2]))
respondError(w, req.ID, ErrInternalError, reqerr)
return nil
}
respondResult(w, req.ID, result)

View file

@ -0,0 +1,75 @@
package networkd
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"golang.org/x/exp/slog"
"nfsense.net/nfsense/internal/definitions"
)
const basepath = "/etc/systemd/network"
func ApplyNetworkdConfiguration(currentConfig definitions.Config, pendingConfig definitions.Config) error {
files, err := GenerateNetworkdConfiguration(pendingConfig)
if err != nil {
return fmt.Errorf("Generating Networkd Configuration: %w", err)
}
err = RemoveContents(basepath)
if err != nil {
return fmt.Errorf("Removing old Config Files: %w", err)
}
for _, file := range files {
f, err := os.Create(basepath + "/" + file.Name)
if err != nil {
return fmt.Errorf("creating File: %w", err)
}
_, err = f.WriteString(file.Content + "\n")
if err != nil {
return fmt.Errorf("writing File: %w", err)
}
err = f.Sync()
if err != nil {
return fmt.Errorf("syncing File: %w", err)
}
}
cmd := exec.Command("systemctl", "restart", "systemd-networkd")
var out bytes.Buffer
cmd.Stdout = &out
err = cmd.Run()
if err != nil {
return fmt.Errorf("restarting networkd: %w", err)
}
slog.Info("networkd output", "out", out.String())
return nil
}
func RemoveContents(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
names, err := d.Readdirnames(-1)
if err != nil {
return err
}
for _, name := range names {
err = os.RemoveAll(filepath.Join(dir, name))
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,171 @@
package networkd
import (
"bytes"
"fmt"
"golang.org/x/exp/slog"
"nfsense.net/nfsense/internal/definitions"
)
type NetworkdConfigFile struct {
Name string
Content string
}
type InterfaceWithName struct {
Name string
definitions.Interface
Vlans []string
StaticRoutes []definitions.StaticRoute
}
type BondMembership struct {
Name string
BondName string
}
type BridgeMembership struct {
Name string
BridgeName string
}
func GenerateNetworkdConfiguration(conf definitions.Config) ([]NetworkdConfigFile, error) {
files := []NetworkdConfigFile{}
// Step 1 Generate vlan netdev files
for name, inter := range conf.Network.Interfaces {
if inter.Type == definitions.Vlan {
buf := new(bytes.Buffer)
err := templates.ExecuteTemplate(buf, "create-vlan.netdev.tmpl", InterfaceWithName{
Name: name,
Interface: inter,
})
if err != nil {
return nil, fmt.Errorf("executing create-vlan.netdev.tmpl template: %w", err)
}
files = append(files, NetworkdConfigFile{
Name: fmt.Sprintf("10-create-vlan-%v.netdev", name),
Content: buf.String(),
})
}
}
// Step 2 Generate bond netdev files
for name, inter := range conf.Network.Interfaces {
if inter.Type == definitions.Bond {
buf := new(bytes.Buffer)
err := templates.ExecuteTemplate(buf, "create-bond.netdev.tmpl", InterfaceWithName{
Name: name,
Interface: inter,
})
if err != nil {
return nil, fmt.Errorf("executing create-bond.netdev.tmpl template: %w", err)
}
files = append(files, NetworkdConfigFile{
Name: fmt.Sprintf("20-create-bond-%v.netdev", name),
Content: buf.String(),
})
}
}
// Step 3 Generate bridge netdev files
for name, inter := range conf.Network.Interfaces {
if inter.Type == definitions.Bridge {
buf := new(bytes.Buffer)
err := templates.ExecuteTemplate(buf, "create-bridge.netdev.tmpl", InterfaceWithName{
Name: name,
Interface: inter,
})
if err != nil {
return nil, fmt.Errorf("executing create-bridge.netdev.tmpl template: %w", err)
}
files = append(files, NetworkdConfigFile{
Name: fmt.Sprintf("30-create-bridge-%v.netdev", name),
Content: buf.String(),
})
}
}
// Step 4 Generate Bond Members
for name, inter := range conf.Network.Interfaces {
if inter.Type == definitions.Bond && inter.BondMembers != nil {
for _, member := range *inter.BondMembers {
buf := new(bytes.Buffer)
err := templates.ExecuteTemplate(buf, "bond-membership.network.tmpl", BondMembership{
Name: member,
BondName: name,
})
if err != nil {
return nil, fmt.Errorf("executing bond-membership.network.tmpl template: %w", err)
}
files = append(files, NetworkdConfigFile{
Name: fmt.Sprintf("40-bond-membership-%v.network", name),
Content: buf.String(),
})
}
}
}
// Step 5 Generate Bridge Members
for name, inter := range conf.Network.Interfaces {
if inter.Type == definitions.Bridge && inter.BridgeMembers != nil {
for _, member := range *inter.BridgeMembers {
buf := new(bytes.Buffer)
err := templates.ExecuteTemplate(buf, "bridge-membership.network.tmpl", BridgeMembership{
Name: member,
BridgeName: name,
})
if err != nil {
return nil, fmt.Errorf("executing bridge-membership.network.tmpl template: %w", err)
}
files = append(files, NetworkdConfigFile{
Name: fmt.Sprintf("50-bridge-membership-%v.network", name),
Content: buf.String(),
})
}
}
}
// Step 6 Generate addressing network files
for name, inter := range conf.Network.Interfaces {
// Vlans
vlans := []string{}
if inter.Type != definitions.Vlan {
vlans := []string{}
for vlanName, vlanInter := range conf.Network.Interfaces {
if vlanInter.Type == definitions.Vlan {
if *vlanInter.VlanParent == name {
vlans = append(vlans, vlanName)
}
}
}
slog.Info("Vlans on interface", "interface", name, "count", len(vlans))
}
// Static Routes
staticRoutes := []definitions.StaticRoute{}
for _, route := range conf.Network.StaticRoutes {
if route.Interface == name {
staticRoutes = append(staticRoutes, route)
}
}
buf := new(bytes.Buffer)
err := templates.ExecuteTemplate(buf, "config-addressing.network.tmpl", InterfaceWithName{
Name: name,
Interface: inter,
Vlans: vlans,
StaticRoutes: staticRoutes,
})
if err != nil {
return nil, fmt.Errorf("executing config-addressing.network.tmpl template: %w", err)
}
files = append(files, NetworkdConfigFile{
Name: fmt.Sprintf("60-config-addressing-%v.network", name),
Content: buf.String(),
})
}
return files, nil
}

View file

@ -0,0 +1,48 @@
package dbus
import (
"fmt"
"github.com/godbus/dbus/v5"
"golang.org/x/exp/slog"
)
type Link struct {
Name string `json:"name"`
CarrierState string `json:"carrier_state"`
OperationalState string `json:"operational_state"`
}
func GetLinks(dbusConn dbus.Conn) ([]Link, error) {
managerObj := dbusConn.Object("org.freedesktop.network1", dbus.ObjectPath("/org/freedesktop/network1"))
var links [][]any
err := managerObj.Call("org.freedesktop.network1.Manager.ListLinks", 0).Store(&links)
if err != nil {
return nil, fmt.Errorf("Calling ListLinks %w", err)
}
slog.Info("Dbus Result", "links", links)
result := []Link{}
for _, link := range links {
name := link[1].(string)
path := link[2].(dbus.ObjectPath)
linkObj := dbusConn.Object("org.freedesktop.network1", path)
carrierState, err := linkObj.GetProperty("org.freedesktop.network1.Link.CarrierState")
if err != nil {
return nil, fmt.Errorf("GetProperty CarrierState %w", err)
}
operationalState, err := linkObj.GetProperty("org.freedesktop.network1.Link.OperationalState")
if err != nil {
return nil, fmt.Errorf("GetProperty OperationalState %w", err)
}
result = append(result, Link{
Name: name,
CarrierState: carrierState.String(),
OperationalState: operationalState.String(),
})
}
return result, nil
}

View file

@ -0,0 +1,19 @@
package networkd
import (
"embed"
"text/template"
)
//go:embed template
var templateFS embed.FS
var templates *template.Template
func init() {
var err error
templates, err = template.New("").ParseFS(templateFS, "template/*.tmpl")
if err != nil {
panic(err)
}
}

View file

@ -0,0 +1,9 @@
[Match]
{{- if eq .Type 0 }}
Name={{ .HardwareDevice }}
{{- else }}
Name={{ .Name }}
{{- end }}
[Network]
Bond={{ .BondName }}

View file

@ -0,0 +1,9 @@
[Match]
{{- if eq .Type 0 }}
Name={{ .HardwareDevice }}
{{- else }}
Name={{ .Name }}
{{- end }}
[Network]
Bridge={{ .BridgeName }}

View file

@ -0,0 +1,26 @@
[Match]
{{- if eq .Type 0 }}
Name={{ .HardwareDevice }}
{{- else }}
Name={{ .Name }}
{{- end }}
[Network]
LLMNR=no
{{- if eq .AddressingMode 1 }}
Address={{ .Address }}
{{- else if eq .AddressingMode 2 }}
DHCP=yes
{{- end }}
{{- range .Vlans }}
VLAN={{ . }}
{{- end}}
{{- range .StaticRoutes }}
[Route]
Destination={{ .Destination }}
Gateway={{ .Gateway }}
{{- if ne .Metric 0 }}
Metric={{ .Metric }}
{{- end }}
{{end}}

View file

@ -0,0 +1,6 @@
[NetDev]
Name={{ .Name }}
Kind=bond
[Bond]
Mode=active-backup

View file

@ -0,0 +1,3 @@
[NetDev]
Name={{ .Name }}
Kind=bridge

View file

@ -0,0 +1,6 @@
[NetDev]
Name={{ .Name }}
Kind=vlan
[VLAN]
Id={{ .VlanID }}

View file

@ -77,8 +77,8 @@ func GenerateAddressMatcher(allAddresses map[string]definitions.Address, match d
sourceAddresses = append(sourceAddresses, address.Host.String())
case definitions.Range:
sourceAddresses = append(sourceAddresses, address.Range.String())
case definitions.Network:
sourceAddresses = append(sourceAddresses, address.Network.String())
case definitions.NetworkAddress:
sourceAddresses = append(sourceAddresses, address.NetworkAddress.String())
default:
panic("invalid address type")
}
@ -90,8 +90,8 @@ func GenerateAddressMatcher(allAddresses map[string]definitions.Address, match d
destinationAddresses = append(destinationAddresses, address.Host.String())
case definitions.Range:
destinationAddresses = append(destinationAddresses, address.Range.String())
case definitions.Network:
destinationAddresses = append(destinationAddresses, address.Network.String())
case definitions.NetworkAddress:
destinationAddresses = append(destinationAddresses, address.NetworkAddress.String())
default:
panic("invalid address type")
}

View file

@ -8,7 +8,7 @@ import (
"golang.org/x/exp/slog"
"nfsense.net/nfsense/internal/definitions"
"nfsense.net/nfsense/internal/config"
"nfsense.net/nfsense/internal/jsonrpc"
"nfsense.net/nfsense/internal/session"
)
@ -18,7 +18,7 @@ var mux = http.NewServeMux()
var apiHandler *jsonrpc.Handler
var stopCleanup chan struct{}
func StartWebserver(conf *definitions.Config, _apiHandler *jsonrpc.Handler) {
func StartWebserver(configManager *config.ConfigManager, _apiHandler *jsonrpc.Handler) {
server.Addr = ":8080"
server.Handler = mux
apiHandler = _apiHandler

87
main.go
View file

@ -2,20 +2,22 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/godbus/dbus/v5"
"golang.org/x/exp/slog"
configAPI "nfsense.net/nfsense/internal/api/config"
"nfsense.net/nfsense/internal/api/firewall"
"nfsense.net/nfsense/internal/api/network"
"nfsense.net/nfsense/internal/api/object"
"nfsense.net/nfsense/internal/definitions"
"nfsense.net/nfsense/internal/config"
"nfsense.net/nfsense/internal/jsonrpc"
"nfsense.net/nfsense/internal/nftables"
"nfsense.net/nfsense/internal/networkd"
"nfsense.net/nfsense/internal/server"
)
@ -25,27 +27,41 @@ func main() {
slog.Info("Starting...")
conf, err := LoadConfiguration("config.json")
dbusConn, err := dbus.ConnectSystemBus()
if err != nil {
slog.Error("Loading Config", err)
slog.Error("Connecting to DBus", err)
// os.Exit(1)
}
defer dbusConn.Close()
configManager := config.CreateConfigManager()
configManager.RegisterApplyFunction(networkd.ApplyNetworkdConfiguration)
err = configManager.LoadCurrentConfigFromDisk()
if err != nil {
slog.Error("Loading Current Config", err)
os.Exit(1)
}
slog.Info("Config Loaded", "config", conf)
slog.Info("Config Loaded")
err = definitions.ValidateConfig(conf)
err = configManager.LoadPendingConfigFromDisk()
if err != nil {
slog.Error("Validating Config", err)
os.Exit(1)
if !errors.Is(err, os.ErrNotExist) {
slog.Error("Loading Pending Config", err)
}
err = configManager.DiscardPendingConfig()
if err != nil {
slog.Error("Discarding Pending Config", err)
os.Exit(1)
}
}
slog.Info("Validating Config...")
if *applyPtr {
slog.Info("Applying Config...")
err := apply(conf)
err := configManager.ApplyPendingChanges()
if err != nil {
slog.Error("Applying Config", err)
slog.Error("Applying Pending Config", err)
os.Exit(1)
}
slog.Info("Config Applied, Exiting...")
@ -54,10 +70,10 @@ func main() {
slog.Info("Setup API...")
apiHandler := jsonrpc.NewHandler(100 << 20)
RegisterAPIMethods(apiHandler, conf)
RegisterAPIMethods(apiHandler, configManager, dbusConn)
slog.Info("Starting Webserver...")
server.StartWebserver(conf, apiHandler)
server.StartWebserver(configManager, apiHandler)
slog.Info("Ready.")
@ -75,38 +91,9 @@ func main() {
slog.Info("Done")
}
func LoadConfiguration(file string) (*definitions.Config, error) {
var config definitions.Config
configFile, err := os.Open(file)
if err != nil {
return nil, fmt.Errorf("opening Config File %w", err)
}
defer configFile.Close()
jsonParser := json.NewDecoder(configFile)
jsonParser.DisallowUnknownFields()
err = jsonParser.Decode(&config)
if err != nil {
return nil, fmt.Errorf("decoding Config File %w", err)
}
return &config, nil
}
func RegisterAPIMethods(apiHandler *jsonrpc.Handler, conf *definitions.Config) {
apiHandler.Register("Firewall", &firewall.Firewall{Conf: conf})
apiHandler.Register("Object", &object.Object{Conf: conf})
}
func apply(conf *definitions.Config) error {
fileContent, err := nftables.GenerateNfTablesFile(*conf)
if err != nil {
return fmt.Errorf("Generating nftables file %w", err)
}
err = nftables.ApplyNfTablesFile(fileContent)
if err != nil {
return fmt.Errorf("Applying nftables %w", err)
}
slog.Info("Wrote nftables File!")
return nil
func RegisterAPIMethods(apiHandler *jsonrpc.Handler, configManager *config.ConfigManager, dbusConn *dbus.Conn) {
apiHandler.Register("Config", &configAPI.Config{ConfigManager: configManager})
apiHandler.Register("Firewall", &firewall.Firewall{ConfigManager: configManager})
apiHandler.Register("Network", &network.Network{ConfigManager: configManager, DbusConn: dbusConn})
apiHandler.Register("Object", &object.Object{ConfigManager: configManager})
}