Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,16 @@ jobs:
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
LDFLAGS="-X github.com/bilalbayram/opensnitch-web/internal/version.Version=${VERSION} -X github.com/bilalbayram/opensnitch-web/internal/version.BuildTime=${BUILD_TIME}"
go build -ldflags "${LDFLAGS}" -o opensnitch-web-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/opensnitch-web
if [ "${{ matrix.goos }}" = "linux" ] && [ "${{ matrix.goarch }}" = "arm64" ]; then
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o opensnitchd-router-linux-arm64 ./cmd/opensnitchd-router
fi

- uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.goos }}-${{ matrix.goarch }}
path: opensnitch-web-${{ matrix.goos }}-${{ matrix.goarch }}
path: |
opensnitch-web-${{ matrix.goos }}-${{ matrix.goarch }}
opensnitchd-router-*

release:
name: Create Release
Expand All @@ -102,14 +107,15 @@ jobs:
merge-multiple: true

- name: Generate checksums
run: sha256sum opensnitch-web-* > checksums.txt
run: sha256sum opensnitch-web-* opensnitchd-router-* > checksums.txt

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
prerelease: ${{ contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-rc') }}
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') }}
generate_release_notes: true
files: |
opensnitch-web-*
opensnitchd-router-*
checksums.txt
deploy/opensnitch-web.service
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all build proto frontend clean run dev embed verify-embed install uninstall
.PHONY: all build proto frontend clean run dev embed verify-embed install uninstall daemon-router-arm64

VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
Expand Down Expand Up @@ -30,6 +30,9 @@ verify-embed: embed
build: embed
CGO_ENABLED=1 go build -ldflags '$(LDFLAGS)' -o bin/opensnitch-web ./cmd/opensnitch-web

daemon-router-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags '$(LDFLAGS)' -o bin/opensnitchd-router-linux-arm64 ./cmd/opensnitchd-router

# Run the server (dev mode — serves from web/dist)
run:
CGO_ENABLED=1 go run -ldflags '$(LDFLAGS)' ./cmd/opensnitch-web
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,22 @@ Replace `192.168.1.0/24` with your router subnet and `8080` with your configured

The provisioner performs a connectivity check after deployment and will show a warning in the UI if the router cannot reach the server.

## Managed router daemon v1

OpenWrt routers can also be upgraded from the legacy HTTP ingest agent to the managed `opensnitchd-router` runtime. v1 is intentionally limited:

- Router-local processes use the normal gRPC `AskRule` prompt flow.
- Forwarded LAN traffic is observed continuously and can be enforced with explicit rules, but unknown forwarded flows are allowed by default.
- Forwarded traffic never opens live prompts in v1. Use generated or manually created device-scoped rules instead.
- Only `aarch64` OpenWrt targets are supported in v1.

The managed runtime connects to gRPC with the existing router API key in the `x-router-api-key` metadata header. If the server cannot infer a LAN-reachable gRPC endpoint automatically, set `server.grpc_public_addr` before upgrading routers.

```bash
# Build the OpenWrt router daemon artifact
make daemon-router-arm64
```

## Development

Built with Go 1.22 (Chi, gRPC, SQLite) and React 19 (Vite, TypeScript, Tailwind CSS 4).
Expand Down
35 changes: 20 additions & 15 deletions cmd/opensnitch-web/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,27 @@ func main() {
// Wire up prompter → WebSocket broadcasts
p.OnNewPrompt = func(prompt *prompter.PendingPrompt) {
conn := prompt.Connection
routerManaged := false
if router, err := database.GetRouterByLinkedNodeAddr(prompt.NodeAddr); err == nil {
routerManaged = router.DaemonMode == db.RouterDaemonModeRouterDaemon
}
hub.BroadcastEvent(ws.EventPromptRequest, map[string]interface{}{
"id": prompt.ID,
"node_addr": prompt.NodeAddr,
"created_at": prompt.CreatedAt.Format("2006-01-02 15:04:05"),
"process": conn.GetProcessPath(),
"dst_host": conn.GetDstHost(),
"dst_ip": conn.GetDstIp(),
"dst_port": conn.GetDstPort(),
"protocol": conn.GetProtocol(),
"src_ip": conn.GetSrcIp(),
"src_port": conn.GetSrcPort(),
"uid": conn.GetUserId(),
"pid": conn.GetProcessId(),
"args": conn.GetProcessArgs(),
"cwd": conn.GetProcessCwd(),
"checksums": conn.GetProcessChecksums(),
"id": prompt.ID,
"node_addr": prompt.NodeAddr,
"created_at": prompt.CreatedAt.Format("2006-01-02 15:04:05"),
"router_managed": routerManaged,
"process": conn.GetProcessPath(),
"dst_host": conn.GetDstHost(),
"dst_ip": conn.GetDstIp(),
"dst_port": conn.GetDstPort(),
"protocol": conn.GetProtocol(),
"src_ip": conn.GetSrcIp(),
"src_port": conn.GetSrcPort(),
"uid": conn.GetUserId(),
"pid": conn.GetProcessId(),
"args": conn.GetProcessArgs(),
"cwd": conn.GetProcessCwd(),
"checksums": conn.GetProcessChecksums(),
})
}

Expand Down
190 changes: 190 additions & 0 deletions cmd/opensnitchd-router/firewall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package main

import (
"bytes"
"fmt"
"os/exec"
"strconv"
"strings"
)

const (
nftTable = "opensnitch-router"
nftOutputChain = "output"
nftForwardChain = "forward"
)

func (d *daemon) ensureFirewall() error {
if err := d.nftRun("add", "table", "inet", nftTable); err != nil && !nftAlreadyExists(err) {
return err
}
if err := d.nftRun("add", "chain", "inet", nftTable, nftOutputChain, "{", "type", "filter", "hook", "output", "priority", "0;", "policy", "accept;", "}"); err != nil && !nftAlreadyExists(err) {
return err
}
if err := d.nftRun("add", "chain", "inet", nftTable, nftForwardChain, "{", "type", "filter", "hook", "forward", "priority", "0;", "policy", "accept;", "}"); err != nil && !nftAlreadyExists(err) {
return err
}
return nil
}

func (d *daemon) disableFirewall() error {
err := d.nftRun("delete", "table", "inet", nftTable)
if err != nil && strings.Contains(err.Error(), "No such file or directory") {
return nil
}
return err
}

func (d *daemon) reloadFirewallState() error {
if err := d.ensureFirewall(); err != nil {
return err
}
if err := d.flushChain(nftOutputChain); err != nil {
return err
}
return d.rebuildForwardRules()
}

func (d *daemon) rebuildForwardRules() error {
if !d.isFirewallEnabled() {
return nil
}
if err := d.ensureFirewall(); err != nil {
return err
}
if err := d.flushChain(nftForwardChain); err != nil {
return err
}

for _, rule := range d.snapshotRules() {
spec := compileForwardRule(rule)
if spec == nil {
continue
}
if err := d.installForwardRule(spec); err != nil {
return err
}
}
return nil
}

func (d *daemon) installLocalDecision(flow *localFlow, action string) error {
if !d.isFirewallEnabled() {
return nil
}
if err := d.ensureFirewall(); err != nil {
return err
}

args := []string{"add", "rule", "inet", nftTable, nftOutputChain}
if flow.SrcIP != "" {
args = append(args, sourceAddressMatch(flow.SrcIP)...)
}
if flow.DstIP != "" {
args = append(args, nftAddressMatch(flow.DstIP)...)
}
if flow.Protocol != "" {
args = append(args, protocolMatch(flow.Protocol)...)
}
if flow.SrcPort > 0 {
args = append(args, portMatch(flow.Protocol, "sport", int(flow.SrcPort))...)
}
if flow.DstPort > 0 {
args = append(args, portMatch(flow.Protocol, "dport", int(flow.DstPort))...)
}
args = append(args, nftVerdict(action))
return d.nftRun(args...)
}

func (d *daemon) installForwardRule(spec *forwardRuleSpec) error {
args := []string{"add", "rule", "inet", nftTable, nftForwardChain}
args = append(args, sourceAddressMatch(spec.SourceIP)...)
if spec.DestIP != "" {
args = append(args, nftAddressMatch(spec.DestIP)...)
}
if spec.Protocol != "" {
args = append(args, protocolMatch(spec.Protocol)...)
}
if spec.Port != "" {
args = append(args, portMatch(spec.Protocol, "dport", mustAtoi(spec.Port))...)
}
args = append(args, nftVerdict(spec.Action))
return d.nftRun(args...)
}

func protocolMatch(protocol string) []string {
switch strings.ToLower(strings.TrimSpace(protocol)) {
case "tcp", "tcp6":
return []string{"meta", "l4proto", "tcp"}
case "udp", "udp6":
return []string{"meta", "l4proto", "udp"}
case "icmp", "icmp6":
return []string{"meta", "l4proto", "icmp"}
default:
return nil
}
}

func sourceAddressMatch(ip string) []string {
if strings.Contains(ip, ":") {
return []string{"ip6", "saddr", ip}
}
return []string{"ip", "saddr", ip}
}

func nftAddressMatch(ip string) []string {
if strings.Contains(ip, ":") {
return []string{"ip6", "daddr", ip}
}
return []string{"ip", "daddr", ip}
}

func portMatch(protocol, side string, port int) []string {
if port <= 0 {
return nil
}
switch strings.ToLower(strings.TrimSpace(protocol)) {
case "udp", "udp6":
return []string{"udp", side, fmt.Sprintf("%d", port)}
default:
return []string{"tcp", side, fmt.Sprintf("%d", port)}
}
}

func nftVerdict(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "deny":
return "drop"
case "reject":
return "reject"
default:
return "accept"
}
}

func (d *daemon) flushChain(chain string) error {
err := d.nftRun("flush", "chain", "inet", nftTable, chain)
if err != nil && strings.Contains(err.Error(), "No such file or directory") {
return nil
}
return err
}

func (d *daemon) nftRun(args ...string) error {
cmd := exec.Command("nft", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("nft %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
}
return nil
}

func nftAlreadyExists(err error) bool {
return err != nil && strings.Contains(err.Error(), "File exists")
}

func mustAtoi(value string) int {
n, _ := strconv.Atoi(strings.TrimSpace(value))
return n
}
Loading
Loading