├── .version ├── backend ├── internal │ ├── web │ │ ├── public │ │ │ ├── version │ │ │ ├── favicon.png │ │ │ └── assets │ │ │ │ ├── index.css │ │ │ │ ├── MacHistory.js │ │ │ │ ├── History.js │ │ │ │ ├── HostPage.js │ │ │ │ └── Config.js │ │ ├── index.go │ │ ├── templates │ │ │ └── index.html │ │ └── webgui.go │ ├── check │ │ ├── error.go │ │ ├── network.go │ │ └── file.go │ ├── api │ │ ├── functions.go │ │ ├── api-network.go │ │ ├── routes.go │ │ ├── api-history.go │ │ ├── config.go │ │ ├── api-system.go │ │ └── api-hosts.go │ ├── portscan │ │ └── scan.go │ ├── conf │ │ ├── start.go │ │ ├── write.go │ │ └── read.go │ ├── routines │ │ ├── trim-history.go │ │ ├── restart-scan.go │ │ └── scan-routine.go │ ├── gdb │ │ ├── edit.go │ │ ├── select.go │ │ └── start.go │ ├── notify │ │ └── shout.go │ ├── models │ │ └── models.go │ ├── prometheus │ │ └── prometheus.go │ ├── influx │ │ └── influx.go │ └── arp │ │ └── arpscan.go ├── configs │ ├── postinstall.sh │ ├── install.sh │ ├── watchyourlan │ └── watchyourlan.service ├── Makefile ├── LICENSE ├── cmd │ └── WatchYourLAN │ │ └── main.go ├── .goreleaser.yaml ├── go.mod └── docs │ ├── swagger.yaml │ ├── swagger.json │ └── docs.go ├── .gitignore ├── frontend ├── src │ ├── vite-env.d.ts │ ├── index.tsx │ ├── components │ │ ├── Search.tsx │ │ ├── HistShow.tsx │ │ ├── Config │ │ │ ├── Donate.tsx │ │ │ ├── Prometheus.tsx │ │ │ ├── Influx.tsx │ │ │ ├── About.tsx │ │ │ ├── Basic.tsx │ │ │ └── Scan.tsx │ │ ├── MacHistory.tsx │ │ ├── HostPage │ │ │ ├── HistCard.tsx │ │ │ ├── Ping.tsx │ │ │ └── HostCard.tsx │ │ ├── Body │ │ │ ├── TableHead.tsx │ │ │ ├── CardHead.tsx │ │ │ └── TableRow.tsx │ │ ├── Filter.tsx │ │ └── Header.tsx │ ├── functions │ │ ├── history.ts │ │ ├── search.ts │ │ ├── atstart.ts │ │ ├── filter.ts │ │ ├── sort.ts │ │ ├── api.ts │ │ └── exports.ts │ ├── App.css │ ├── pages │ │ ├── Config.tsx │ │ ├── Body.tsx │ │ ├── HostPage.tsx │ │ └── History.tsx │ └── App.tsx ├── tsconfig.json ├── .gitignore ├── Makefile ├── vite.config.ts ├── index.html ├── package.json ├── tsconfig.node.json ├── tsconfig.app.json └── README.md ├── assets ├── logo.png ├── Screenshot_1.png ├── Screenshot_2.png ├── Screenshot_3.png ├── Screenshot_4.png ├── Screenshot_5.png ├── Screenshot_v0.6.png ├── Screenshot_Gotify.png ├── Screenshot 2024-08-29 at 01-41-12 WatchYourLAN.png └── Screenshot 2024-08-29 at 11-17-59 WatchYourLAN.png ├── .github ├── FUNDING.yml └── workflows │ ├── readme-docker.yml │ ├── binary-release.yml │ ├── dev-docker-io.yml │ ├── new-dev-docker.yml │ └── main-docker-all.yml ├── Dockerfile ├── FAQ.md ├── LICENSE ├── docker-compose.yml ├── docs ├── API.md └── VLAN_ARP_SCAN.md ├── docker-compose-auth.yml ├── CHANGELOG.md └── README.md /.version: -------------------------------------------------------------------------------- 1 | backend/internal/web/public/version -------------------------------------------------------------------------------- /backend/internal/web/public/version: -------------------------------------------------------------------------------- 1 | VERSION=2.1.4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore local data 2 | data/ 3 | tmp/ 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /backend/configs/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | systemctl daemon-reload -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/Screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/assets/Screenshot_1.png -------------------------------------------------------------------------------- /assets/Screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/assets/Screenshot_2.png -------------------------------------------------------------------------------- /assets/Screenshot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/assets/Screenshot_3.png -------------------------------------------------------------------------------- /assets/Screenshot_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/assets/Screenshot_4.png -------------------------------------------------------------------------------- /assets/Screenshot_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/assets/Screenshot_5.png -------------------------------------------------------------------------------- /assets/Screenshot_v0.6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/assets/Screenshot_v0.6.png -------------------------------------------------------------------------------- /assets/Screenshot_Gotify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/assets/Screenshot_Gotify.png -------------------------------------------------------------------------------- /backend/configs/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cp watchyourlan /usr/bin/ 4 | cp watchyourlan.service /lib/systemd/system/ -------------------------------------------------------------------------------- /backend/internal/web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/backend/internal/web/public/favicon.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: ['https://boosty.to/aceberg/donate', 'https://github.com/aceberg#donate'] 4 | -------------------------------------------------------------------------------- /assets/Screenshot 2024-08-29 at 01-41-12 WatchYourLAN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/assets/Screenshot 2024-08-29 at 01-41-12 WatchYourLAN.png -------------------------------------------------------------------------------- /assets/Screenshot 2024-08-29 at 11-17-59 WatchYourLAN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/HEAD/assets/Screenshot 2024-08-29 at 11-17-59 WatchYourLAN.png -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from 'solid-js/web' 3 | import App from './App.tsx' 4 | 5 | const root = document.getElementById('root') 6 | 7 | render(() => , root!) 8 | -------------------------------------------------------------------------------- /backend/configs/watchyourlan: -------------------------------------------------------------------------------- 1 | #!/sbin/openrc-run 2 | name="WatchYourLAN" 3 | description="Lightweight network IP scanner with web GUI" 4 | command="/usr/bin/watchyourlan" 5 | command_background=true 6 | pidfile="/run/watchyourlan.pid" -------------------------------------------------------------------------------- /backend/internal/web/index.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func indexHandler(c *gin.Context) { 10 | 11 | c.HTML(http.StatusOK, "index.html", true) 12 | } 13 | -------------------------------------------------------------------------------- /backend/internal/check/error.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | ) 7 | 8 | // IfError prints error, if it is not nil 9 | func IfError(err error) bool { 10 | if err == nil { 11 | return false 12 | } 13 | 14 | slog.Error(fmt.Sprintf("%v", err)) 15 | return true 16 | } 17 | -------------------------------------------------------------------------------- /backend/configs/watchyourlan.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=watchyourlan 3 | Documentation=https://github.com/aceberg/WatchYourLAN 4 | After=network-online.target 5 | Wants=network-online.target 6 | 7 | [Service] 8 | ExecStart=/usr/bin/watchyourlan -d /etc/watchyourlan/ 9 | Restart=on-failure 10 | 11 | [Install] 12 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /backend/internal/api/functions.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/aceberg/WatchYourLAN/internal/gdb" 7 | "github.com/aceberg/WatchYourLAN/internal/models" 8 | ) 9 | 10 | func getHostByID(idStr string) (oneHost models.Host) { 11 | 12 | id, _ := strconv.Atoi(idStr) 13 | oneHost = gdb.SelectByID(id) 14 | 15 | return oneHost 16 | } 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/Makefile: -------------------------------------------------------------------------------- 1 | PKG_NAME=WatchYourLAN 2 | USR_NAME=aceberg 3 | 4 | build: 5 | npm run build && \ 6 | rm ../backend/internal/web/public/assets/* && \ 7 | cp -r dist/assets ../backend/internal/web/public 8 | 9 | replace: 10 | cd ../backend/internal/web/public/assets/ && \ 11 | cat index.js | sed 's/assets/fs\/public\/assets/g;s/http:\/\/0.0.0.0:8840//' > tmp && \ 12 | mv tmp index.js 13 | 14 | all: build replace -------------------------------------------------------------------------------- /frontend/src/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import { searchFunc } from "../functions/search"; 2 | 3 | function Search() { 4 | 5 | const handleSearch = (s: string) => { 6 | searchFunc(s); 7 | }; 8 | 9 | return ( 10 | handleSearch(e.target.value)} class="form-control" placeholder="Search" style="max-width: 10em;" title="Search"> 11 | ) 12 | } 13 | 14 | export default Search 15 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import solid from 'vite-plugin-solid' 3 | 4 | export default defineConfig({ 5 | plugins: [solid()], 6 | build: { 7 | rollupOptions: { 8 | output: { 9 | entryFileNames: `assets/[name].js`, 10 | chunkFileNames: `assets/[name].js`, 11 | assetFileNames: `assets/[name].[ext]` 12 | } 13 | } 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /backend/internal/check/network.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | 7 | "github.com/aceberg/WatchYourLAN/internal/models" 8 | ) 9 | 10 | // DNS - returns DNS names of a host 11 | func DNS(host models.Host) (name, dns string) { 12 | 13 | dnsNames, _ := net.LookupAddr(host.IP) 14 | 15 | if len(dnsNames) > 0 { 16 | name = dnsNames[0] 17 | dns = strings.Join(dnsNames, " ") 18 | } 19 | 20 | return name, dns 21 | } 22 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | WatchYourLAN 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /backend/Makefile: -------------------------------------------------------------------------------- 1 | mod: 2 | rm go.mod || true && \ 3 | rm go.sum || true && \ 4 | go mod init github.com/aceberg/WatchYourLAN && \ 5 | go mod tidy 6 | 7 | run: 8 | cd cmd/WatchYourLAN/ && \ 9 | sudo \ 10 | go run . #-n http://192.168.2.3:8850 11 | 12 | fmt: 13 | go fmt ./... 14 | 15 | lint: 16 | golangci-lint run 17 | golint ./... 18 | 19 | check: fmt lint 20 | 21 | swag: 22 | swag init -g main.go --dir cmd/WatchYourLAN,internal/api,internal/models -------------------------------------------------------------------------------- /backend/internal/portscan/scan.go: -------------------------------------------------------------------------------- 1 | package portscan 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "time" 7 | ) 8 | 9 | // IsOpen - check one tcp port 10 | func IsOpen(host, port string) bool { 11 | 12 | timeout := 3 * time.Second 13 | target := fmt.Sprintf("%s:%s", host, port) 14 | 15 | conn, err := net.DialTimeout("tcp", target, timeout) 16 | 17 | if err == nil { 18 | err = conn.Close() 19 | if err == nil { 20 | return true 21 | } 22 | } 23 | 24 | return false 25 | } 26 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "watchyourlan", 3 | "private": true, 4 | "version": "2.1.4", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@solid-primitives/scheduled": "^1.5.2", 13 | "@solidjs/router": "^0.15.3", 14 | "solid-js": "^1.9.5" 15 | }, 16 | "devDependencies": { 17 | "typescript": "~5.7.2", 18 | "vite": "^6.2.0", 19 | "vite-plugin-solid": "^2.11.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/functions/history.ts: -------------------------------------------------------------------------------- 1 | import { apiGetHistory, apiGetHistoryByDate } from "./api"; 2 | import { Host } from "./exports"; 3 | 4 | export async function getHistoryForMac(mac: string, date: string) { 5 | let h:Host[] = []; 6 | if (date === "") { 7 | h = await apiGetHistory(mac); 8 | } else { 9 | h = await apiGetHistoryByDate(mac, date); 10 | } 11 | 12 | if (h != null) { 13 | h.sort((a:Host, b:Host) => (a.Date < b.Date ? 1 : -1)); 14 | return h; 15 | } 16 | return []; 17 | } -------------------------------------------------------------------------------- /frontend/src/components/HistShow.tsx: -------------------------------------------------------------------------------- 1 | import { setShow, show } from "../functions/exports"; 2 | 3 | function HistShow(_props: any) { 4 | 5 | const handleSaveShow = (showStr: string) => { 6 | localStorage.setItem(_props.name, showStr); 7 | 8 | setShow(+showStr); 9 | show() == 0 ? setShow(200) : ''; 10 | }; 11 | 12 | return ( 13 | handleSaveShow(e.target.value)} placeholder="Show elements" title="Nomber of elements to show" style="max-width: 10em;"> 14 | ) 15 | } 16 | 17 | export default HistShow 18 | -------------------------------------------------------------------------------- /backend/internal/web/public/assets/index.css: -------------------------------------------------------------------------------- 1 | :root{--transparent-light: #ffffff15}.my-btn{height:100%;background-color:#0000;color:var(--bs-primary);text-align:center;cursor:pointer;padding:1px;border-radius:15%}.my-btn:hover{background-color:var(--transparent-light)}.my-box-on:before,.my-box-off:before{content:url("data:image/svg+xml;utf8,");border-left:thin solid black}.my-box-on{background-color:var(--bs-success)}.my-box-off{background-color:var(--bs-gray-500)}.my-box-on:hover,.my-box-off:hover{background-color:#0000001a} 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx 2 | 3 | FROM --platform=$BUILDPLATFORM golang:alpine AS builder 4 | 5 | COPY --from=xx / / 6 | 7 | WORKDIR /src 8 | 9 | COPY backend/go.mod backend/go.sum ./ 10 | RUN go mod download 11 | 12 | COPY backend/ . 13 | 14 | ARG TARGETPLATFORM 15 | RUN CGO_ENABLED=0 xx-go build -ldflags='-w -s' -o /WatchYourLAN ./cmd/WatchYourLAN 16 | 17 | 18 | FROM alpine 19 | 20 | WORKDIR /app 21 | 22 | RUN apk add --no-cache arp-scan tzdata \ 23 | && mkdir /data 24 | 25 | COPY --from=builder /WatchYourLAN /app/ 26 | 27 | ENTRYPOINT ["./WatchYourLAN"] 28 | -------------------------------------------------------------------------------- /backend/internal/conf/start.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "github.com/aceberg/WatchYourLAN/internal/check" 5 | "github.com/aceberg/WatchYourLAN/internal/models" 6 | ) 7 | 8 | // AppConfig - app config 9 | var AppConfig models.Conf 10 | 11 | // Start - initial config 12 | func Start(dirPath, nodePath string) { 13 | 14 | confPath := dirPath + "/config_v2.yaml" 15 | check.Path(confPath) 16 | 17 | AppConfig = read(confPath) 18 | 19 | AppConfig.DirPath = dirPath 20 | AppConfig.ConfPath = confPath 21 | AppConfig.DBPath = dirPath + "/scan.db" 22 | if nodePath != "" { 23 | AppConfig.NodePath = nodePath 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /backend/internal/web/templates/index.html: -------------------------------------------------------------------------------- 1 | {{ define "index.html" }} 2 | 3 | 4 | 5 | WatchYourLAN 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | {{ end }} -------------------------------------------------------------------------------- /.github/workflows/readme-docker.yml: -------------------------------------------------------------------------------- 1 | name: README-to-DockerHub 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main" ] 7 | paths: 8 | - 'README.md' 9 | 10 | env: 11 | IMAGE_NAME: watchyourlan 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Sync README.md to DockerHub 22 | uses: ms-jpq/sync-dockerhub-readme@v1 23 | with: 24 | username: ${{ secrets.DOCKER_USERNAME }} 25 | password: ${{ secrets.DOCKER_PASSWORD }} 26 | repository: ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }} 27 | readme: "./README.md" 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/binary-release.yml: -------------------------------------------------------------------------------- 1 | name: Binary-release 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [created] 7 | 8 | jobs: 9 | generate: 10 | name: Create release-artifacts 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - name: Checkout the repository 14 | uses: actions/checkout@master 15 | 16 | - uses: actions/setup-go@v4 17 | with: 18 | go-version: 'stable' 19 | - run: go version 20 | 21 | - uses: goreleaser/goreleaser-action@v6 22 | with: 23 | distribution: goreleaser 24 | version: latest 25 | args: release --clean 26 | workdir: backend 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /backend/internal/routines/trim-history.go: -------------------------------------------------------------------------------- 1 | package routines 2 | 3 | import ( 4 | "log/slog" 5 | "time" 6 | 7 | "github.com/aceberg/WatchYourLAN/internal/conf" 8 | "github.com/aceberg/WatchYourLAN/internal/gdb" 9 | ) 10 | 11 | // HistoryTrim - routine for History 12 | func HistoryTrim() { 13 | 14 | go func() { 15 | for { 16 | time.Sleep(time.Duration(1) * time.Hour) // Every hour 17 | 18 | hours := conf.AppConfig.TrimHist 19 | nowMinus := time.Now().Add(-time.Duration(hours) * time.Hour) 20 | date := nowMinus.Format("2006-01-02 15:04:05") 21 | 22 | slog.Info("Removing all History before", "date", date) 23 | 24 | n := gdb.DeleteOldHistory(date) 25 | slog.Info("Removed records from History", "n", n) 26 | } 27 | }() 28 | } 29 | -------------------------------------------------------------------------------- /backend/internal/web/public/assets/MacHistory.js: -------------------------------------------------------------------------------- 1 | import{C as y,D as f,E as v,o as m,G as H,e as u,S as d,t as D,b as I,s as b,H as h,l as M,F as g}from"./index.js";async function w(a,r){let e=[];return r===""?e=await y(a):e=await f(a,r),e!=null?(e.sort((s,t)=>s.Date");function F(a){const[r,e]=v([]);let s;return m(async()=>{const t=await w(a.mac,a.date);e(t),s=setInterval(async()=>{const o=await w(a.mac,a.date);e(o)},6e4)}),H(()=>{clearInterval(s)}),u(g,{each:r,children:(t,o)=>u(d,{get when(){return o(){var c="Date:"+t.Date+` 2 | Iface:`+t.Iface+` 3 | IP:`+t.IP+` 4 | Known:`+t.Known,l=t.Now===0?"my-box-off":"my-box-on";return c!==n.e&&b(i,"title",n.e=c),l!==n.t&&h(i,n.t=l),n},{e:void 0,t:void 0}),i}})})}export{F as M}; 5 | -------------------------------------------------------------------------------- /frontend/src/components/Config/Donate.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | function Donate() { 4 | 5 | return ( 6 |
7 |
Donations and Work
8 |
9 |

Every donation is truly appreciated!

10 |

I am offering self-hosted and mobile apps development for a small price. 11 | The Open Source apps I created are listed in my portfolio. They are all human-coded, not generated by AI. Please, contact me, if you are interested.

12 |
13 |
14 | ) 15 | } 16 | 17 | export default Donate -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "preserve", 17 | "jsxImportSource": "solid-js", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | /* VARS */ 2 | :root { 3 | --transparent-light: #ffffff15; 4 | } 5 | 6 | /* Sort button */ 7 | .my-btn { 8 | height: 100%; 9 | background-color: #00000000; 10 | color: var(--bs-primary); 11 | text-align: center; 12 | cursor: pointer; 13 | padding: 1px; 14 | border-radius: 15%; 15 | } 16 | .my-btn:hover { 17 | background-color: var(--transparent-light); 18 | } 19 | 20 | /* History box */ 21 | .my-box-on::before, .my-box-off::before { 22 | content: url("data:image/svg+xml;utf8,"); 23 | border-left: thin solid black; 24 | } 25 | .my-box-on { 26 | background-color: var(--bs-success); 27 | } 28 | .my-box-off { 29 | background-color: var(--bs-gray-500); 30 | } 31 | .my-box-on:hover, .my-box-off:hover { 32 | background-color: #0000001a; 33 | } -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | ```bash 4 | $ npm install # or pnpm install or yarn install 5 | ``` 6 | 7 | ### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) 8 | 9 | ## Available Scripts 10 | 11 | In the project directory, you can run: 12 | 13 | ### `npm run dev` 14 | 15 | Runs the app in the development mode.
16 | Open [http://localhost:5173](http://localhost:5173) to view it in the browser. 17 | 18 | ### `npm run build` 19 | 20 | Builds the app for production to the `dist` folder.
21 | It correctly bundles Solid in production mode and optimizes the build for the best performance. 22 | 23 | The build is minified and the filenames include the hashes.
24 | Your app is ready to be deployed! 25 | 26 | ## Deployment 27 | 28 | Learn more about deploying your application with the [documentations](https://vite.dev/guide/static-deploy.html) 29 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## Allow custom MAC vendor overrides 4 | Issues [#169](https://github.com/aceberg/WatchYourLAN/issues/169), [#185](https://github.com/aceberg/WatchYourLAN/issues/185) 5 | 6 | WatchYourLAN is using `arp-scan`, so most of its options are available to WYL users. 7 | 8 | 1. Prepare a [mac-vendor.txt](https://manpages.debian.org/testing/arp-scan/mac-vendor.5.en.html) file with additional MACs and put it in a mounted WYL directory. 9 | 2. If you are using `IFACES` variable to define interfaces, add path to mac-vendor.txt to `ARP_ARGS` 10 | ```yaml 11 | arp_args: --macfile=/data/WatchYourLAN/mac-vendor.txt 12 | ``` 13 | 3. For interfaces defined in `ARP_STRS` add the same directly in the beginning of `ARP_STRS` string 14 | ```yaml 15 | arp_strs: 16 | - --macfile=/data/WatchYourLAN/mac-vendor.txt -gNx 10.144.0.1/24 -I eth0 17 | ``` 18 | 4. **WARNING!** To see an updated vendor, you'll have to delete host and wait for the next scan. -------------------------------------------------------------------------------- /frontend/src/functions/search.ts: -------------------------------------------------------------------------------- 1 | import { allHosts, bkpHosts, Host, setAllHosts } from "./exports"; 2 | 3 | export function searchFunc(s: string) { 4 | 5 | if (s != "") { 6 | 7 | const sl = s.toLowerCase(); 8 | let newArray:Host[] = []; 9 | 10 | for (let item of allHosts) { 11 | 12 | if (searchItem(item, sl)) { 13 | newArray.push(item); 14 | } 15 | } 16 | 17 | setAllHosts(newArray); 18 | } else { 19 | setAllHosts(bkpHosts()); 20 | } 21 | } 22 | 23 | function searchItem(item:Host, sl:string) { 24 | 25 | const name = item.Name.toLowerCase(); 26 | const hw = item.Hw.toLowerCase(); 27 | const mac = item.Mac.toLowerCase(); 28 | 29 | if ((name.includes(sl)) || (item.Iface.includes(sl)) || (item.IP.includes(sl)) || (mac.includes(sl)) || (hw.includes(sl)) || (item.Date.includes(sl))) { 30 | return true; 31 | } else { 32 | return false; 33 | } 34 | } -------------------------------------------------------------------------------- /frontend/src/functions/atstart.ts: -------------------------------------------------------------------------------- 1 | import { apiGetAllHosts } from "./api"; 2 | import { allHosts, setAllHosts, setBkpHosts, setIfaces } from "./exports"; 3 | import { filterAtStart, filterFunc } from "./filter"; 4 | import { sortAtStart } from "./sort"; 5 | 6 | export function runAtStart() { 7 | getHosts(); 8 | filterFunc("ID", 0); // reset filter 9 | 10 | setInterval(() => { 11 | getHosts(); 12 | }, 60000); // 60000 ms = 1 minute 13 | } 14 | 15 | export async function getHosts() { 16 | const hosts = await apiGetAllHosts(); 17 | 18 | if (hosts !== null && hosts.length > 0) { 19 | setAllHosts(hosts); 20 | setBkpHosts(hosts); 21 | 22 | listIfaces(); 23 | sortAtStart(); 24 | filterAtStart(); 25 | } 26 | } 27 | 28 | function listIfaces() { 29 | 30 | let ifaces:string[] = []; 31 | 32 | for (let host of allHosts) { 33 | if (!ifaces.includes(host.Iface)) { 34 | ifaces.push(host.Iface); 35 | } 36 | } 37 | 38 | setIfaces(ifaces); 39 | } -------------------------------------------------------------------------------- /backend/internal/routines/restart-scan.go: -------------------------------------------------------------------------------- 1 | package routines 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/aceberg/WatchYourLAN/internal/conf" 7 | ) 8 | 9 | var ( 10 | quitScan = make(chan bool) 11 | ) 12 | 13 | // ScanRestart - start or update routines 14 | func ScanRestart() { 15 | 16 | close(quitScan) 17 | 18 | slog.Info("Restarting scan routine") 19 | setLogLevel() 20 | 21 | quitScan = make(chan bool) 22 | go startScan(quitScan) // scan-routine.go 23 | } 24 | 25 | func setLogLevel() { 26 | var level slog.Level 27 | 28 | slog.Info("Log level: " + conf.AppConfig.LogLevel) 29 | 30 | switch conf.AppConfig.LogLevel { 31 | case "debug": 32 | level = slog.LevelDebug 33 | case "info": 34 | level = slog.LevelInfo 35 | case "warn": 36 | level = slog.LevelWarn 37 | case "error": 38 | level = slog.LevelError 39 | default: 40 | slog.Error("Invalid log level. Setting default level INFO") 41 | level = slog.LevelInfo 42 | } 43 | slog.SetLogLoggerLevel(level) 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/pages/Config.tsx: -------------------------------------------------------------------------------- 1 | import About from "../components/Config/About" 2 | import Basic from "../components/Config/Basic" 3 | import Donate from "../components/Config/Donate" 4 | import Influx from "../components/Config/Influx" 5 | import Prometheus from "../components/Config/Prometheus" 6 | import Scan from "../components/Config/Scan" 7 | 8 | function Config() { 9 | 10 | return ( 11 |
12 |
13 | 14 | 15 | 16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 |
24 | 25 | 26 | 27 |
28 | 29 |
30 |
31 | 32 |
33 |
34 |
35 | ) 36 | } 37 | 38 | export default Config -------------------------------------------------------------------------------- /frontend/src/pages/Body.tsx: -------------------------------------------------------------------------------- 1 | import { For, onMount } from "solid-js"; 2 | 3 | import { allHosts } from "../functions/exports"; 4 | 5 | import TableRow from "../components/Body/TableRow"; 6 | import TableHead from "../components/Body/TableHead"; 7 | import CardHead from "../components/Body/CardHead"; 8 | import { getHosts } from "../functions/atstart"; 9 | 10 | function Body() { 11 | 12 | onMount(() => { 13 | getHosts(); 14 | }); 15 | 16 | return ( 17 |
18 |
19 | 20 |
21 |
22 | 23 | 24 | 25 | {(host, index) => 26 | 27 | } 28 | 29 |
30 |
31 |
32 | ) 33 | } 34 | 35 | export default Body 36 | -------------------------------------------------------------------------------- /backend/internal/gdb/edit.go: -------------------------------------------------------------------------------- 1 | package gdb 2 | 3 | import ( 4 | "github.com/aceberg/WatchYourLAN/internal/check" 5 | "github.com/aceberg/WatchYourLAN/internal/models" 6 | ) 7 | 8 | // Update - update or create host 9 | func Update(table string, oneHost models.Host) { 10 | 11 | tab := db.Table(table) 12 | result := tab.Save(&oneHost) 13 | check.IfError(result.Error) 14 | } 15 | 16 | // Delete - delete host from DB 17 | func Delete(table string, id int) { 18 | 19 | tab := db.Table(table) 20 | result := tab.Delete(&models.Host{}, id) 21 | check.IfError(result.Error) 22 | } 23 | 24 | // DeleteOldHistory - delete a list of hosts from History 25 | func DeleteOldHistory(date string) int64 { 26 | 27 | tab := db.Table("history") 28 | result := tab.Where("\"DATE\" < ?", date).Delete(&models.Host{}) 29 | check.IfError(result.Error) 30 | 31 | return result.RowsAffected 32 | } 33 | 34 | // Clear - delete all hosts from table 35 | func Clear(table string) { 36 | 37 | tab := db.Table(table) 38 | result := tab.Where("1 = 1").Delete(&models.Host{}) 39 | check.IfError(result.Error) 40 | } 41 | -------------------------------------------------------------------------------- /backend/internal/notify/shout.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/nicholas-fedor/shoutrrr" 9 | 10 | "github.com/aceberg/WatchYourLAN/internal/conf" 11 | "github.com/aceberg/WatchYourLAN/internal/models" 12 | ) 13 | 14 | // Unknown - send message to log and shoutrrr 15 | func Unknown(host models.Host) { 16 | 17 | msg := fmt.Sprintf("Unknown host found. Name: '%s', IP: '%s', MAC: '%s', Hw: '%s', Iface: '%s'", host.DNS, host.IP, host.Mac, host.Hw, host.Iface) 18 | 19 | slog.Warn(msg) 20 | shout(msg) 21 | } 22 | 23 | // Test Shoutrrr notification 24 | func Test() { 25 | 26 | msg := "test notification" 27 | slog.Info("Sending " + msg) 28 | shout(msg) 29 | } 30 | 31 | // shout - send msg to Shoutrrr 32 | func shout(msg string) { 33 | 34 | hostname, _ := os.Hostname() 35 | wyl := "WatchYourLAN on '" + hostname + "': " 36 | 37 | if conf.AppConfig.ShoutURL != "" { 38 | err := shoutrrr.Send(conf.AppConfig.ShoutURL, wyl+msg) 39 | if err != nil { 40 | slog.Error("Notification failed (shoutrrr): ", "", err) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { lazy, onMount } from 'solid-js'; 2 | import { Router, Route } from "@solidjs/router"; 3 | import './App.css'; 4 | import { runAtStart } from './functions/atstart'; 5 | 6 | import Body from './pages/Body'; 7 | import Header from './components/Header'; 8 | 9 | function App() { 10 | 11 | onMount(() => { 12 | runAtStart(); 13 | }); 14 | 15 | const Config = lazy(() => import("./pages/Config")); 16 | const History = lazy(() => import("./pages/History")); 17 | const HostPage = lazy(() => import("./pages/HostPage")); 18 | 19 | return ( 20 | <> 21 |
22 |
23 |
24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 |
33 |
34 | 35 | ) 36 | } 37 | 38 | export default App 39 | -------------------------------------------------------------------------------- /frontend/src/functions/filter.ts: -------------------------------------------------------------------------------- 1 | import { allHosts, bkpHosts, Host, setAllHosts } from "./exports"; 2 | 3 | let oldFilter = 'ID'; 4 | 5 | export function filterAtStart() { 6 | const field = localStorage.getItem("filterField") as keyof Host; 7 | const value = localStorage.getItem("filterValue"); 8 | 9 | filterFunc(field, value); 10 | } 11 | 12 | export function filterFunc(field: keyof Host, value: any) { 13 | 14 | let addrsArray = allHosts; 15 | 16 | if (oldFilter == field) { 17 | addrsArray = bkpHosts(); 18 | } 19 | oldFilter = field; 20 | 21 | localStorage.setItem("filterField", field); 22 | localStorage.setItem("filterValue", value); 23 | 24 | switch (field) { 25 | case 'Iface': 26 | addrsArray = addrsArray.filter((item) => item.Iface == value); 27 | break; 28 | case 'Known': 29 | addrsArray = addrsArray.filter((item) => item.Known == value); 30 | break; 31 | case 'Now': 32 | addrsArray = addrsArray.filter((item) => item.Now == value); 33 | break; 34 | default: 35 | addrsArray = bkpHosts(); 36 | } 37 | 38 | setAllHosts(addrsArray); 39 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 aceberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 aceberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/pages/HostPage.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "@solidjs/router"; 2 | import { createSignal, onMount } from "solid-js"; 3 | 4 | import { apiGetHost } from "../functions/api"; 5 | 6 | import HostCard from "../components/HostPage/HostCard"; 7 | import Ping from "../components/HostPage/Ping"; 8 | import HistCard from "../components/HostPage/HistCard"; 9 | import { emptyHost, Host } from "../functions/exports"; 10 | 11 | function HostPage() { 12 | 13 | const [currentHost, setCurrentHost] = createSignal(emptyHost); 14 | 15 | onMount(async () => { 16 | const params = useParams(); 17 | const host = await apiGetHost(params.id); 18 | 19 | setCurrentHost(host); 20 | }); 21 | 22 | return ( 23 | <> 24 |
25 |
26 | 27 |
28 |
29 | 30 |
31 |
32 |
33 |
34 | 35 |
36 |
37 | 38 | ) 39 | } 40 | 41 | export default HostPage -------------------------------------------------------------------------------- /frontend/src/components/MacHistory.tsx: -------------------------------------------------------------------------------- 1 | import { For, onCleanup, onMount, Show } from "solid-js"; 2 | import { getHistoryForMac } from "../functions/history"; 3 | import { Host, show } from "../functions/exports"; 4 | import { createStore } from "solid-js/store"; 5 | 6 | function MacHistory(_props: any) { 7 | 8 | const [hist, setHist] = createStore([]); 9 | let interval: number; 10 | 11 | onMount(async () => { 12 | const newHistory = await getHistoryForMac(_props.mac, _props.date); 13 | setHist(newHistory); 14 | interval = setInterval(async () => { 15 | // console.log("Upd Hist", new Date()); 16 | const newHistory = await getHistoryForMac(_props.mac, _props.date); 17 | setHist(newHistory); 18 | }, 60000); // 60000 ms = 1 minute 19 | }); 20 | 21 | onCleanup(() => { 22 | clearInterval(interval); 23 | }); 24 | 25 | return ( 26 | {(h, index) => 27 | 30 | 32 | 33 | } 34 | ) 35 | } 36 | 37 | export default MacHistory 38 | -------------------------------------------------------------------------------- /backend/internal/check/file.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // Path - create path if not exists 9 | func Path(path string) bool { 10 | 11 | _, err := os.Stat(path) 12 | 13 | if path != "" && err != nil { 14 | 15 | dir := filepath.Dir(path) 16 | 17 | err = os.MkdirAll(dir, os.ModePerm) 18 | IfError(err) 19 | 20 | _, err = os.Create(path) 21 | IfError(err) 22 | 23 | return false 24 | } 25 | 26 | return true 27 | } 28 | 29 | // Exists - check is file exists 30 | func Exists(path string) bool { 31 | 32 | _, err := os.Stat(path) 33 | 34 | if path != "" && err != nil { 35 | 36 | return false 37 | } 38 | 39 | return true 40 | } 41 | 42 | // IsYaml - check if file got .yaml or .yml extension 43 | func IsYaml(path string) bool { 44 | 45 | if Exists(path) { 46 | ext := filepath.Ext(path) 47 | if ext == ".yaml" || ext == ".yml" { 48 | return true 49 | } 50 | } 51 | 52 | return false 53 | } 54 | 55 | // IsEmpty - check if file is empty 56 | func IsEmpty(path string) bool { 57 | 58 | if Exists(path) { 59 | stat, _ := os.Stat(path) 60 | size := stat.Size() 61 | if size > 0 { 62 | return false 63 | } 64 | } 65 | 66 | return true 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/components/HostPage/HistCard.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, onMount } from "solid-js"; 2 | import { setShow } from "../../functions/exports"; 3 | import MacHistory from "../MacHistory" 4 | 5 | function HistCard(_props: any) { 6 | 7 | const [today, setToday] = createSignal(''); 8 | 9 | onMount(() => { 10 | setShow(15000); 11 | setToday(new Date().toLocaleDateString("en-CA")); 12 | }); 13 | 14 | const handleDate = (date: string) => { 15 | setToday(""); 16 | setToday(date); 17 | }; 18 | 19 | return ( 20 |
21 |
22 |
23 | Host History for 24 | handleDate(e.currentTarget.value)} 29 | /> 30 |
31 |
32 |
33 | {_props.mac !== "" && today() !== "" 34 | ? 35 | : <>Loading... 36 | } 37 |
38 |
39 | ) 40 | } 41 | 42 | export default HistCard -------------------------------------------------------------------------------- /.github/workflows/dev-docker-io.yml: -------------------------------------------------------------------------------- 1 | name: Dev-to-docker 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | IMAGE_NAME: watchyourlan 8 | TAGS: dev 9 | 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Build and Push Docker Image to docker.io 20 | uses: mr-smithers-excellent/docker-build-push@v6 21 | with: 22 | image: ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }} 23 | tags: ${{ env.TAGS }} 24 | registry: docker.io 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_PASSWORD }} 27 | 28 | # - name: Login to GHCR 29 | # uses: docker/login-action@v3 30 | # with: 31 | # registry: ghcr.io 32 | # username: ${{ github.actor }} 33 | # password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | # - name: Build and push 36 | # uses: docker/build-push-action@v6 37 | # with: 38 | # context: . 39 | # platforms: linux/amd64 40 | # push: true 41 | # tags: | 42 | # ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.TAGS }} -------------------------------------------------------------------------------- /backend/internal/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Conf - app config 4 | type Conf struct { 5 | Host string 6 | Port string 7 | Theme string 8 | Color string 9 | DirPath string 10 | ConfPath string 11 | DBPath string 12 | NodePath string 13 | LogLevel string 14 | Ifaces string 15 | ArpArgs string 16 | ArpStrs []string 17 | Timeout int 18 | TrimHist int 19 | ShoutURL string 20 | Version string 21 | // PostgreSQL 22 | UseDB string 23 | PGConnect string 24 | // InfluxDB 25 | InfluxEnable bool 26 | InfluxAddr string 27 | InfluxToken string 28 | InfluxOrg string 29 | InfluxBucket string 30 | InfluxSkipTLS bool 31 | // Prometheus 32 | PrometheusEnable bool 33 | } 34 | 35 | // Host - one host 36 | type Host struct { 37 | ID int `gorm:"column:ID;primaryKey"` 38 | Name string `gorm:"column:NAME"` 39 | DNS string `gorm:"column:DNS"` 40 | Iface string `gorm:"column:IFACE"` 41 | IP string `gorm:"column:IP"` 42 | Mac string `gorm:"column:MAC"` 43 | Hw string `gorm:"column:HW"` 44 | Date string `gorm:"column:DATE"` 45 | Known int `gorm:"column:KNOWN"` 46 | Now int `gorm:"column:NOW"` 47 | } 48 | 49 | // Stat - status 50 | type Stat struct { 51 | Total int 52 | Online int 53 | Offline int 54 | Known int 55 | Unknown int 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/components/Config/Prometheus.tsx: -------------------------------------------------------------------------------- 1 | import { apiPath } from "../../functions/api" 2 | import { appConfig } from "../../functions/exports" 3 | 4 | function Prometheus() { 5 | 6 | return ( 7 |
8 |
Prometheus config
9 |
10 |
11 | 12 | 13 | 14 | 22 | 23 | 24 | 25 | 28 | 29 |
Enable 15 |
16 | {appConfig().PrometheusEnable 17 | ? 18 | : 19 | } 20 |
21 |
26 | /metrics 27 |
30 |
31 |
32 |
33 | ) 34 | } 35 | 36 | export default Prometheus -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | # node-bootstrap: # optional, local themes and icons 4 | # image: aceberg/node-bootstrap # dockerhub 5 | # # image: ghcr.io/aceberg/node-bootstrap # or github 6 | # restart: unless-stopped 7 | # ports: 8 | # - 8850:8850 9 | wyl: 10 | image: aceberg/watchyourlan # dockerhub 11 | # image: ghcr.io/aceberg/watchyourlan # or github 12 | network_mode: "host" 13 | restart: unless-stopped 14 | # uncomment those if you are using local node-bootstrap: 15 | # command: "-n http://YOUR_IP:8850" # put your server IP or DNS name here 16 | # depends_on: 17 | # - node-bootstrap 18 | volumes: 19 | - ~/.dockerdata/wyl:/data/WatchYourLAN 20 | environment: 21 | TZ: Asia/Novosibirsk # required: needs your TZ for correct time 22 | IFACES: "enp4s0 wlxf4ec3892dd51" # required: 1 or more interface 23 | # HOST: "0.0.0.0" # optional, default: 0.0.0.0 24 | # PORT: "8840" # optional, default: 8840 25 | # TIMEOUT: "120" # optional, time in seconds, default: 120 26 | # SHOUTRRR_URL: "" # optional, set url to notify 27 | # THEME: "sand" # optional 28 | # COLOR: "dark" # optional -------------------------------------------------------------------------------- /backend/internal/prometheus/prometheus.go: -------------------------------------------------------------------------------- 1 | package prometheus 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/prometheus/client_golang/prometheus" 9 | "github.com/prometheus/client_golang/prometheus/promauto" 10 | "github.com/prometheus/client_golang/prometheus/promhttp" 11 | 12 | "github.com/aceberg/WatchYourLAN/internal/conf" 13 | "github.com/aceberg/WatchYourLAN/internal/models" 14 | ) 15 | 16 | // Handler - display Prometheus metrics 17 | func Handler() func(c *gin.Context) { 18 | h := promhttp.Handler() 19 | return func(c *gin.Context) { 20 | if !conf.AppConfig.PrometheusEnable { 21 | c.AbortWithStatus(http.StatusNotFound) 22 | return 23 | } 24 | h.ServeHTTP(c.Writer, c.Request) 25 | } 26 | } 27 | 28 | var up = promauto.NewGaugeVec(prometheus.GaugeOpts{ 29 | Namespace: "watch_your_lan", 30 | Name: "up", 31 | Help: "Whether the host is up (1 for yes, 0 for no)", 32 | }, []string{"ip", "iface", "name", "mac", "known"}) 33 | 34 | // Add a Prometheus metric 35 | func Add(oneHist models.Host) { 36 | if oneHist.Name == "" { 37 | oneHist.Name = "unknown" 38 | } 39 | 40 | up.With(prometheus.Labels{ 41 | "ip": oneHist.IP, 42 | "iface": oneHist.Iface, 43 | "name": oneHist.Name, 44 | "mac": oneHist.Mac, 45 | "known": strconv.Itoa(oneHist.Known), 46 | }).Set(float64(oneHist.Now)) 47 | } 48 | -------------------------------------------------------------------------------- /backend/cmd/WatchYourLAN/main.go: -------------------------------------------------------------------------------- 1 | // @title WatchYourLAN API 2 | // @version 0.1 3 | // @description Lightweight network IP scanner written in Go 4 | // @contact.url https://github.com/aceberg/WatchYourLAN 5 | // @license.name MIT 6 | // @license.url https://opensource.org/licenses/MIT 7 | // @BasePath /api/ 8 | 9 | package main 10 | 11 | import ( 12 | "flag" 13 | // "net/http" 14 | 15 | // _ "net/http/pprof" 16 | 17 | // Import Swagger docs 18 | _ "github.com/aceberg/WatchYourLAN/docs" 19 | 20 | "github.com/aceberg/WatchYourLAN/internal/conf" 21 | "github.com/aceberg/WatchYourLAN/internal/gdb" 22 | "github.com/aceberg/WatchYourLAN/internal/routines" 23 | "github.com/aceberg/WatchYourLAN/internal/web" 24 | ) 25 | 26 | const dirPath = "/data/WatchYourLAN" 27 | const nodePath = "" 28 | 29 | func main() { 30 | dirPtr := flag.String("d", dirPath, "Path to config dir") 31 | nodePtr := flag.String("n", nodePath, "Path to node modules") 32 | flag.Parse() 33 | 34 | // pprof - memory leak detect 35 | // go tool pprof -alloc_space http://localhost:8085/debug/pprof/heap 36 | // (pprof) web 37 | // (pprof) list db.Select 38 | // 39 | // go func() { 40 | // http.ListenAndServe("localhost:8085", nil) 41 | // }() 42 | 43 | // Make AppConfig 44 | conf.Start(*dirPtr, *nodePtr) 45 | 46 | gdb.Start() 47 | 48 | routines.ScanRestart() 49 | routines.HistoryTrim() 50 | 51 | web.Gui() 52 | } 53 | -------------------------------------------------------------------------------- /backend/internal/gdb/select.go: -------------------------------------------------------------------------------- 1 | package gdb 2 | 3 | import ( 4 | "github.com/aceberg/WatchYourLAN/internal/check" 5 | "github.com/aceberg/WatchYourLAN/internal/models" 6 | ) 7 | 8 | // Select - get all hosts 9 | func Select(table string) (dbHosts []models.Host, ok bool) { 10 | 11 | tab := db.Table(table) 12 | err := tab.Find(&dbHosts).Error 13 | 14 | return dbHosts, !check.IfError(err) 15 | } 16 | 17 | // SelectByID - get host by ID 18 | func SelectByID(id int) (host models.Host) { 19 | 20 | tab := db.Table("now") 21 | tab.First(&host, id) 22 | 23 | return host 24 | } 25 | 26 | // SelectByMAC - get all hosts by MAC 27 | func SelectByMAC(table, mac string) (hosts []models.Host) { 28 | 29 | tab := db.Table(table) 30 | tab.Where("\"MAC\" = ?", mac).Find(&hosts) 31 | 32 | return hosts 33 | } 34 | 35 | // SelectByDate - get all hosts by MAC and DATE 36 | func SelectByDate(mac, date string) (hosts []models.Host) { 37 | 38 | tab := db.Table("history") 39 | tab. 40 | Where("\"MAC\" = ?", mac). 41 | Where("\"DATE\" LIKE ?", date+"%"). 42 | Find(&hosts) 43 | 44 | return hosts 45 | } 46 | 47 | // SelectLatest - get latest hosts by MAC 48 | func SelectLatest(mac string, number int) (hosts []models.Host) { 49 | 50 | tab := db.Table("history") 51 | tab. 52 | Where("\"MAC\" = ?", mac). 53 | Order("\"DATE\" DESC"). 54 | Limit(number). 55 | Find(&hosts) 56 | 57 | return hosts 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/new-dev-docker.yml: -------------------------------------------------------------------------------- 1 | name: New-Dev-Docker 2 | 3 | on: 4 | workflow_dispatch: 5 | # push: 6 | # branches: [ "main" ] 7 | # paths: 8 | # - 'Dockerfile' 9 | # - 'src/**' 10 | 11 | env: 12 | IMAGE_NAME: watchyourlan 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-22.04 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up QEMU 23 | uses: docker/setup-qemu-action@v3 24 | 25 | - name: Set up Docker Buildx 26 | id: buildx 27 | uses: docker/setup-buildx-action@v3 28 | 29 | - name: Login to GHCR 30 | uses: docker/login-action@v3 31 | with: 32 | registry: ghcr.io 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Login to Docker Hub 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKER_USERNAME }} 40 | password: ${{ secrets.DOCKER_PASSWORD }} 41 | 42 | - name: Build and push 43 | uses: docker/build-push-action@v6 44 | with: 45 | context: . 46 | platforms: linux/amd64,linux/arm/v7,linux/arm64 47 | push: true 48 | tags: | 49 | ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:dev 50 | ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:dev 51 | cache-from: type=gha 52 | cache-to: type=gha,mode=max 53 | -------------------------------------------------------------------------------- /frontend/src/functions/sort.ts: -------------------------------------------------------------------------------- 1 | import { bkpHosts, Host, setAllHosts } from "./exports"; 2 | 3 | let down = false; 4 | let oldField = ''; 5 | 6 | export function sortAtStart() { 7 | const field = localStorage.getItem("sortField") as keyof Host; 8 | down = JSON.parse(localStorage.getItem("sortDown") as string); 9 | down = !down; 10 | 11 | sortByAnyField(field); 12 | } 13 | 14 | export function sortByAnyField(field: keyof Host) { 15 | 16 | if (field != oldField) { 17 | oldField = field; 18 | down = !down; 19 | } else { 20 | oldField = ''; 21 | down = !down; 22 | } 23 | 24 | localStorage.setItem("sortDown", down.toString()); 25 | localStorage.setItem("sortField", field); 26 | 27 | let someArray = bkpHosts(); 28 | if (field == 'IP') { 29 | someArray.sort((a, b) => sortIP(a, b, down)); 30 | } else { 31 | someArray.sort((a, b) => byField(a, b, field, down)); 32 | } 33 | 34 | setAllHosts(someArray); 35 | } 36 | 37 | function byField(a:Host, b:Host, fieldName: keyof Host, down:boolean){ 38 | if (a[fieldName] > b[fieldName]) { 39 | return down ? 1 : -1; 40 | } else { 41 | return !down ? 1 : -1; 42 | } 43 | } 44 | 45 | function sortIP(a:Host, b:Host, down: boolean) { 46 | const num1 = numIP(a); 47 | const num2 = numIP(b); 48 | if (down) { 49 | return num1-num2; 50 | } else { 51 | return num2-num1; 52 | } 53 | } 54 | 55 | function numIP(a:Host) { 56 | return Number(a.IP.split(".").map((num) => (`000${num}`).slice(-3) ).join("")); 57 | } -------------------------------------------------------------------------------- /backend/internal/web/public/assets/History.js: -------------------------------------------------------------------------------- 1 | import{t as m,k as n,l as c,h as I,m as $,n as N,p as S,q as E,i as t,e as i,r as M,F as U,b as O,s as _,S as P}from"./index.js";import{M as j}from"./MacHistory.js";var k=m('');function q(l){const o=e=>{localStorage.setItem(l.name,e),n(+e),c()==0&&n(200)};return(()=>{var e=k();return e.$$input=r=>o(r.target.value),e})()}I(["input"]);var A=m('
'),D=m(" 28 | 29 | 30 | {(key) => 31 | 38 | } 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default TableHead -------------------------------------------------------------------------------- /backend/internal/conf/write.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/spf13/viper" 7 | 8 | "github.com/aceberg/WatchYourLAN/internal/check" 9 | "github.com/aceberg/WatchYourLAN/internal/models" 10 | ) 11 | 12 | // Write - write config to file 13 | func Write(config models.Conf) { 14 | 15 | slog.Info("Writing new config to " + config.ConfPath) 16 | 17 | viper.SetConfigFile(config.ConfPath) 18 | viper.SetConfigType("yaml") 19 | 20 | viper.Set("HOST", config.Host) 21 | viper.Set("PORT", config.Port) 22 | viper.Set("THEME", config.Theme) 23 | viper.Set("COLOR", config.Color) 24 | viper.Set("NODEPATH", config.NodePath) 25 | viper.Set("LOG_LEVEL", config.LogLevel) 26 | viper.Set("ARP_ARGS", config.ArpArgs) 27 | viper.Set("ARP_STRS", config.ArpStrs) 28 | viper.Set("ARP_STRS_JOINED", "") // Can be set only with ENV 29 | viper.Set("IFACES", config.Ifaces) 30 | viper.Set("TIMEOUT", config.Timeout) 31 | viper.Set("TRIM_HIST", config.TrimHist) 32 | viper.Set("SHOUTRRR_URL", config.ShoutURL) 33 | 34 | viper.Set("USE_DB", config.UseDB) 35 | viper.Set("PG_CONNECT", config.PGConnect) 36 | 37 | viper.Set("influx_enable", config.InfluxEnable) 38 | viper.Set("influx_skip_tls", config.InfluxSkipTLS) 39 | viper.Set("influx_addr", config.InfluxAddr) 40 | viper.Set("influx_token", config.InfluxToken) 41 | viper.Set("influx_org", config.InfluxOrg) 42 | viper.Set("influx_bucket", config.InfluxBucket) 43 | 44 | viper.Set("PROMETHEUS_ENABLE", config.PrometheusEnable) 45 | 46 | err := viper.WriteConfig() 47 | check.IfError(err) 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/components/Body/CardHead.tsx: -------------------------------------------------------------------------------- 1 | import { Show } from "solid-js"; 2 | import { editNames, selectedIDs, setEditNames } from "../../functions/exports"; 3 | import Filter from "../Filter"; 4 | import Search from "../Search"; 5 | import { getHosts } from "../../functions/atstart"; 6 | import { apiDelHost } from "../../functions/api"; 7 | 8 | function CardHead() { 9 | 10 | const handleEditNames = (toggle: boolean) => { 11 | if (!toggle) { 12 | getHosts(); 13 | } 14 | setEditNames(toggle); 15 | }; 16 | 17 | const handleDel = async () => { 18 | const ids = selectedIDs(); 19 | 20 | for (let id of ids) { 21 | await apiDelHost(id); 22 | } 23 | 24 | window.location.href = '/'; 25 | }; 26 | 27 | return ( 28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 |
36 | 37 | Edit} 40 | > 41 | 42 | 43 | 44 |
45 |
46 |
47 | ) 48 | } 49 | 50 | export default CardHead 51 | -------------------------------------------------------------------------------- /backend/internal/influx/influx.go: -------------------------------------------------------------------------------- 1 | package influx 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "log/slog" 8 | "strings" 9 | 10 | "github.com/influxdata/influxdb-client-go/v2" 11 | 12 | "github.com/aceberg/WatchYourLAN/internal/check" 13 | "github.com/aceberg/WatchYourLAN/internal/models" 14 | ) 15 | 16 | // Add - write data to InfluxDB2 17 | func Add(appConfig models.Conf, oneHist models.Host) { 18 | var ctx context.Context 19 | 20 | client := influxdb2.NewClientWithOptions(appConfig.InfluxAddr, appConfig.InfluxToken, 21 | influxdb2.DefaultOptions(). 22 | SetUseGZip(true). 23 | SetTLSConfig(&tls.Config{ 24 | InsecureSkipVerify: appConfig.InfluxSkipTLS, 25 | })) 26 | 27 | ctx = context.Background() 28 | ping, err := client.Ping(ctx) 29 | if ping { 30 | writeAPI := client.WriteAPIBlocking(appConfig.InfluxOrg, appConfig.InfluxBucket) 31 | 32 | // Escape special characters in strings 33 | oneHist.Name = strings.ReplaceAll(oneHist.Name, " ", "\\ ") 34 | oneHist.Name = strings.ReplaceAll(oneHist.Name, ",", "\\,") 35 | oneHist.Name = strings.ReplaceAll(oneHist.Name, "=", "\\=") 36 | if oneHist.Name == "" { 37 | oneHist.Name = "unknown" 38 | } 39 | 40 | line := fmt.Sprintf("WatchYourLAN,IP=%s,iface=%s,name=%s,mac=%s,known=%d state=%d", oneHist.IP, oneHist.Iface, oneHist.Name, oneHist.Mac, oneHist.Known, oneHist.Now) 41 | // slog.Debug("Writing to InfluxDB", "line", line) 42 | 43 | err = writeAPI.WriteRecord(context.Background(), line) 44 | check.IfError(err) 45 | } else { 46 | slog.Error("Can't connect to InfluxDB server") 47 | check.IfError(err) 48 | } 49 | 50 | client.Close() 51 | } 52 | -------------------------------------------------------------------------------- /backend/internal/api/api-network.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/linde12/gowol" 9 | 10 | "github.com/aceberg/WatchYourLAN/internal/check" 11 | "github.com/aceberg/WatchYourLAN/internal/portscan" 12 | ) 13 | 14 | // getPortState godoc 15 | // @Summary Check port state 16 | // @Description Check whether a given TCP port on an address is open or closed 17 | // @Tags network 18 | // @Produce json 19 | // @Param addr path string true "IP address or hostname" 20 | // @Param port path string true "Port number" 21 | // @Success 200 {boolean} bool "true if open, false if closed" 22 | // @Router /port/{addr}/{port} [get] 23 | func getPortState(c *gin.Context) { 24 | addr := c.Param("addr") 25 | port := c.Param("port") 26 | state := portscan.IsOpen(addr, port) 27 | c.IndentedJSON(http.StatusOK, state) 28 | } 29 | 30 | // sendWOL godoc 31 | // @Summary Send Wake-on-LAN packet 32 | // @Description Send a magic packet to wake up a host by its MAC address 33 | // @Tags network 34 | // @Produce json 35 | // @Param mac path string true "MAC address of the host" 36 | // @Success 200 {boolean} bool "true if sent successfully" 37 | // @Router /wol/{mac} [get] 38 | func sendWOL(c *gin.Context) { 39 | 40 | mac := c.Param("mac") 41 | 42 | packet, err := gowol.NewMagicPacket(mac) 43 | 44 | if !check.IfError(err) { 45 | err = packet.Send("255.255.255.255") 46 | 47 | slog.Info("Wake-on-LAN: " + mac) 48 | } 49 | 50 | c.IndentedJSON(http.StatusOK, !check.IfError(err)) 51 | } 52 | -------------------------------------------------------------------------------- /backend/internal/api/routes.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | swaggerFiles "github.com/swaggo/files" 7 | ginSwagger "github.com/swaggo/gin-swagger" 8 | ) 9 | 10 | // Routes - start API routes 11 | func Routes(router *gin.Engine) { 12 | 13 | r0 := router.Group("/api") 14 | { 15 | r0.GET("/all", getAllHosts) // api-hosts.go 16 | r0.GET("/edit/:id/:name/*known", editHost) // api-hosts.go 17 | r0.GET("/host/:id", getHost) // api-hosts.go 18 | r0.GET("/host/del/:id", delHost) // api-hosts.go 19 | r0.GET("/host/add/:mac", addHost) // api-hosts.go 20 | 21 | r0.GET("/config", getConfig) // api-system.go 22 | r0.GET("/notify_test", notifyTest) // api-system.go 23 | r0.GET("/status/*iface", getStatus) // api-system.go 24 | r0.GET("/version", getVersion) // api-system.go 25 | r0.GET("/rescan", triggerRescan) // api-system.go 26 | 27 | r0.GET("/history", getHistory) // api-history.go 28 | r0.GET("/history/:mac", getHistoryByMAC) // api-history.go 29 | r0.GET("/history/:mac/:date", getHistoryByDate) // api-history.go 30 | 31 | r0.GET("/port/:addr/:port", getPortState) // api-network.go 32 | r0.GET("/wol/:mac", sendWOL) // api-network.go 33 | 34 | r0.POST("/config/", saveConfigHandler) // config.go 35 | r0.POST("/config_settings/", saveSettingsHandler) // config.go 36 | r0.POST("/config_influx/", saveInfluxHandler) // config.go 37 | r0.POST("/config_prometheus/", savePrometheusHandler) // config.go 38 | } 39 | 40 | router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) 41 | } 42 | -------------------------------------------------------------------------------- /backend/.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: watchyourlan 3 | builds: 4 | - main: ./cmd/WatchYourLAN/ 5 | binary: watchyourlan 6 | id: default 7 | env: [CGO_ENABLED=0] 8 | goos: 9 | - linux 10 | goarch: 11 | - 386 12 | - amd64 13 | - arm 14 | - arm64 15 | goarm: 16 | - "5" 17 | - "6" 18 | - "7" 19 | 20 | nfpms: 21 | - id: systemd 22 | formats: 23 | - deb 24 | - rpm 25 | maintainer: aceberg 26 | description: Lightweight network IP scanner with web GUI 27 | homepage: https://github.com/aceberg/watchyourlan 28 | license: MIT 29 | section: utils 30 | dependencies: # Don't forget to edit! 31 | - arp-scan 32 | - tzdata 33 | contents: 34 | - src: ./configs/watchyourlan.service 35 | dst: /lib/systemd/system/watchyourlan.service 36 | scripts: 37 | postinstall: ./configs/postinstall.sh 38 | 39 | - id: alpine 40 | formats: 41 | - apk 42 | maintainer: aceberg 43 | description: Lightweight network IP scanner with web GUI 44 | homepage: https://github.com/aceberg/watchyourlan 45 | license: MIT 46 | section: utils 47 | dependencies: # Don't forget to edit! 48 | - arp-scan 49 | - tzdata 50 | contents: 51 | - src: ./configs/watchyourlan 52 | dst: /etc/init.d/watchyourlan 53 | 54 | archives: 55 | - files: 56 | - LICENSE 57 | - src: ./configs/watchyourlan.service 58 | dst: watchyourlan.service 59 | - src: ./configs/install.sh 60 | dst: install.sh 61 | wrap_in_directory: true 62 | format_overrides: 63 | - goos: windows 64 | format: zip 65 | 66 | checksum: 67 | name_template: "checksums.txt" 68 | -------------------------------------------------------------------------------- /frontend/src/pages/History.tsx: -------------------------------------------------------------------------------- 1 | import { createEffect, For, Show } from "solid-js" 2 | import Filter from "../components/Filter" 3 | import { allHosts, histUpdOnFilter, Host, setHistUpdOnFilter, setShow, show } from "../functions/exports" 4 | import MacHistory from "../components/MacHistory" 5 | import HistShow from "../components/HistShow" 6 | 7 | function History() { 8 | 9 | let hosts: Host[] = []; 10 | hosts.push(...allHosts); 11 | 12 | const showStr = localStorage.getItem("histShow") as string; 13 | setShow(+showStr); 14 | (show() === 0 || isNaN(show())) ? setShow(200) : ''; 15 | 16 | createEffect(() => { 17 | if (histUpdOnFilter()) { 18 | hosts = []; 19 | hosts.push(...allHosts); 20 | console.log("Upd on Filter"); 21 | setHistUpdOnFilter(false); 22 | } 23 | }); 24 | 25 | return ( 26 |
27 |
28 | 29 | 30 |
31 |
32 |
.
");function B(){let l=[];l.push(...$);const o=localStorage.getItem("histShow");return n(+o),(c()===0||isNaN(c()))&&n(200),N(()=>{S()&&(l=[],l.push(...$),console.log("Upd on Filter"),E(!1))}),(()=>{var e=A(),r=e.firstChild,g=r.nextSibling,w=g.firstChild,y=w.firstChild;return t(r,i(M,{}),null),t(r,i(q,{name:"histShow"}),null),t(y,i(P,{get when(){return!S()},get children(){return i(U,{each:l,children:(a,x)=>(()=>{var f=D(),d=f.firstChild,C=d.firstChild,u=d.nextSibling,h=u.firstChild,F=h.nextSibling,b=F.nextSibling,H=u.nextSibling;return t(d,()=>x()+1,C),t(h,()=>a.Name),t(b,()=>a.IP),t(H,i(j,{get mac(){return a.Mac},date:""})),O(s=>{var p="/host/"+a.ID,v="http://"+a.IP;return p!==s.e&&_(h,"href",s.e=p),v!==s.t&&_(b,"href",s.t=v),s},{e:void 0,t:void 0}),f})()})}})),e})()}export{B as default}; 2 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | ## API 2 | ```http 3 | GET /api/all 4 | ``` 5 | Returns all hosts in `json`. 6 | 7 | 8 | ```http 9 | GET /api/history 10 | ``` 11 | Returns all History. Not recommended, the output can be a lot. 12 | 13 | ```http 14 | GET /api/history/:mac/:date 15 | ``` 16 | Returns only history of a device with this `mac` filtered by `date`. `date` format can be anything from `2` to `2025-07` to `2025-07-26`. 17 | 18 | ```http 19 | GET /api/history/:mac?num=20 20 | ``` 21 | Returns only last 20 lines of history of a device with this `mac`. 22 | 23 | 24 | ```http 25 | GET /api/host/:id 26 | ``` 27 | Returns host with this `id` in `json`. 28 | 29 | 30 | ```http 31 | GET /api/port/:addr/:port 32 | ``` 33 | Gets state of one `port` of `addr`. Returns `true` if port is open or `false` otherwise. 34 |
35 | Request example 36 | 37 | ```bash 38 | curl http://0.0.0.0:8840/api/port/192.168.2.2/8844 39 | ``` 40 |

41 | 42 | 43 | ```http 44 | GET /api/edit/:id/:name/*known 45 | ``` 46 | Edit host with ID `id`. Can change `name`. `known` is optional, when set to `toggle` will change Known state. 47 | 48 | 49 | ```http 50 | GET /api/host/del/:id 51 | ``` 52 | Remove host with ID `id`. 53 | 54 | 55 | ```http 56 | GET /api/notify_test 57 | ``` 58 | Send test notification. 59 | 60 | 61 | ```http 62 | GET /api/status/*iface 63 | ``` 64 | Show status (Total number of hosts, online/offline, known/unknown). The `iface` parameter is optional and shows status for one interface only. For all interfaces just call `/api/status/`. -------------------------------------------------------------------------------- /frontend/src/components/Body/TableHead.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, For } from "solid-js"; 2 | import { Host } from "../../functions/exports"; 3 | import { sortByAnyField } from "../../functions/sort"; 4 | 5 | function TableHead() { 6 | 7 | const [sortField, setSortField] = createSignal(''); 8 | 9 | const showSort = () => { 10 | let field = localStorage.getItem("sortField") as string; 11 | field === "Mac" ? field = "MAC" : ''; 12 | field === "Hw" ? field = "Hardware" : ''; 13 | field === "Now" ? field = "On" : ''; 14 | setSortField(field); 15 | }; 16 | showSort(); 17 | 18 | const handleSort = (sortBy: string) => { 19 | setSortField(sortBy); 20 | sortBy === "MAC" ? sortBy = "Mac" : ''; 21 | sortBy === "Hardware" ? sortBy = "Hw" : ''; 22 | sortBy === "On" ? sortBy = "Now" : ''; 23 | sortByAnyField(sortBy as keyof Host); 24 | }; 25 | 26 | return ( 27 |
{key}
33 | 34 | 37 | {(host, index) => 38 | 39 | 40 | 44 | 47 | 48 | } 49 | 50 | 51 |
{index()+1}. 41 | {host.Name}

42 | {host.IP} 43 |
45 | 46 |
52 |
53 |
54 | ) 55 | } 56 | 57 | export default History 58 | -------------------------------------------------------------------------------- /backend/internal/web/webgui.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | "log/slog" 7 | "net/http" 8 | 9 | "github.com/aceberg/WatchYourLAN/internal/api" 10 | "github.com/aceberg/WatchYourLAN/internal/check" 11 | "github.com/aceberg/WatchYourLAN/internal/conf" 12 | "github.com/aceberg/WatchYourLAN/internal/prometheus" 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | // templFS - html templates 17 | // 18 | //go:embed templates/* 19 | var templFS embed.FS 20 | 21 | // pubFS - public folder 22 | // 23 | //go:embed public/* 24 | var pubFS embed.FS 25 | 26 | // Gui - start web server 27 | func Gui() { 28 | const ( 29 | colorCyan = "\033[36m" 30 | colorReset = "\033[0m" 31 | ) 32 | 33 | file, err := pubFS.ReadFile("public/version") 34 | check.IfError(err) 35 | conf.AppConfig.Version = string(file)[8:] 36 | 37 | address := conf.AppConfig.Host + ":" + conf.AppConfig.Port 38 | 39 | slog.Info(colorCyan + "\n=================================== " + 40 | "\n WatchYourLAN Version: " + conf.AppConfig.Version + 41 | "\n Config dir: " + conf.AppConfig.DirPath + 42 | "\n Default DB: " + conf.AppConfig.UseDB + 43 | "\n Log level: " + conf.AppConfig.LogLevel + 44 | "\n Web GUI: http://" + address + 45 | "\n=================================== " + colorReset) 46 | 47 | gin.SetMode(gin.ReleaseMode) 48 | router := gin.New() 49 | router.Use(gin.Recovery()) 50 | 51 | templ := template.Must(template.New("").ParseFS(templFS, "templates/*")) 52 | router.SetHTMLTemplate(templ) // templates 53 | 54 | router.StaticFS("/fs/", http.FS(pubFS)) // public 55 | 56 | router.GET("/", indexHandler) // index.go 57 | router.GET("/config", indexHandler) // index.go 58 | router.GET("/history", indexHandler) // index.go 59 | router.GET("/host/*any", indexHandler) // index.go 60 | router.GET("/metrics", prometheus.Handler()) 61 | 62 | api.Routes(router) 63 | 64 | err = router.Run(address) 65 | check.IfError(err) 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/main-docker-all.yml: -------------------------------------------------------------------------------- 1 | name: Main-Docker 2 | 3 | on: 4 | workflow_dispatch: 5 | # push: 6 | # branches: [ "main" ] 7 | # paths: 8 | # - 'Dockerfile' 9 | # - 'src/**' 10 | 11 | env: 12 | IMAGE_NAME: watchyourlan 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-22.04 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Get version tag from env file 23 | uses: c-py/action-dotenv-to-setenv@v5 24 | with: 25 | env-file: .version 26 | 27 | - name: Set up QEMU 28 | uses: docker/setup-qemu-action@v3 29 | 30 | - name: Set up Docker Buildx 31 | id: buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Login to GHCR 35 | uses: docker/login-action@v3 36 | with: 37 | registry: ghcr.io 38 | username: ${{ github.actor }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | - name: Login to Docker Hub 42 | uses: docker/login-action@v3 43 | with: 44 | username: ${{ secrets.DOCKER_USERNAME }} 45 | password: ${{ secrets.DOCKER_PASSWORD }} 46 | 47 | - name: Build and push 48 | uses: docker/build-push-action@v6 49 | with: 50 | context: . 51 | platforms: linux/amd64,linux/arm/v7,linux/arm64 52 | push: true 53 | tags: | 54 | ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:v2 55 | ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest 56 | ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} 57 | ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:v2 58 | ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest 59 | ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} 60 | cache-from: type=gha 61 | cache-to: type=gha,mode=max 62 | -------------------------------------------------------------------------------- /docker-compose-auth.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | wyl: 4 | image: aceberg/watchyourlan 5 | network_mode: "host" 6 | restart: unless-stopped 7 | volumes: 8 | - ~/.dockerdata/wyl:/data/WatchYourLAN 9 | environment: 10 | TZ: Asia/Novosibirsk # required: needs your TZ for correct time 11 | IFACES: "enp4s0 wlxf4ec3892dd51" # required: 1 or more interface 12 | HOST: "0.0.0.0" # optional, default: 0.0.0.0 13 | PORT: "8840" # optional, default: 8840 14 | TIMEOUT: "120" # optional, time in seconds, default: 120 15 | SHOUTRRR_URL: "" # optional, set url to notify 16 | THEME: "sand" # optional 17 | COLOR: "dark" # optional 18 | 19 | # WARNING! WYL needs 'host' network mode to work. So, WYL port will be exposed in this setup. You need to limit access to it with firewall or other measures 20 | 21 | forauth: 22 | image: aceberg/forauth 23 | restart: unless-stopped 24 | ports: 25 | - 8800:8800 # Proxy port 26 | - 8801:8801 # Config port 27 | volumes: 28 | - ~/.dockerdata/forauth:/data/ForAuth 29 | environment: 30 | TZ: Asia/Novosibirsk # required: needs your TZ for correct time 31 | FA_TARGET: "YOUR_IP:8840" # optional: path to wyl host:port 32 | FA_AUTH: "true" # optional: true - enabled, default: false 33 | FA_AUTH_EXPIRE: 7d # optional: expiration time, default: 7d 34 | FA_AUTH_PASSWORD: "$$2a$$10$$wGLUHXh2cRN1257uGg1s5eZvYgnjw8wB9vAcfcHqqqrxm5hvBqAzK" 35 | # WARNING! If password is set as environment variable, every '$' character must be escaped with another '$', like this '$$' 36 | # optional: password encrypted with bcrypt, how-to: https://github.com/aceberg/ForAuth/blob/main/docs/BCRYPT.md (In this example FA_AUTH_PASSWORD=pw) 37 | FA_AUTH_USER: user # optional: username 38 | -------------------------------------------------------------------------------- /frontend/src/components/Filter.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, For } from "solid-js"; 2 | import { Host, ifaces, setHistUpdOnFilter } from "../functions/exports"; 3 | import { filterFunc } from "../functions/filter"; 4 | 5 | 6 | function Filter() { 7 | type FilterEvent = Event & { 8 | currentTarget: HTMLSelectElement; 9 | target: HTMLSelectElement; 10 | }; 11 | 12 | const [selectValue, setSelectValue] = createSignal(""); 13 | 14 | const handleFilter = (field: keyof Host, event: FilterEvent) => { 15 | const value = event.target ? event.target.value : 0; 16 | filterFunc(field, value); 17 | setHistUpdOnFilter(true); 18 | }; 19 | 20 | const handleReset = () => { 21 | filterFunc("ID", 0); 22 | setSelectValue("something"); 23 | setSelectValue(""); 24 | setHistUpdOnFilter(true); 25 | }; 26 | 27 | return ( 28 |
29 |
30 | 36 | 41 | 46 | 47 |
48 |
49 | ) 50 | } 51 | 52 | export default Filter 53 | -------------------------------------------------------------------------------- /backend/internal/gdb/start.go: -------------------------------------------------------------------------------- 1 | package gdb 2 | 3 | import ( 4 | "log" 5 | "log/slog" 6 | "os" 7 | "time" 8 | 9 | sqlite "github.com/aceberg/gorm-sqlite" 10 | 11 | "gorm.io/driver/postgres" 12 | "gorm.io/gorm" 13 | "gorm.io/gorm/logger" 14 | "gorm.io/gorm/schema" 15 | 16 | "github.com/aceberg/WatchYourLAN/internal/check" 17 | "github.com/aceberg/WatchYourLAN/internal/conf" 18 | "github.com/aceberg/WatchYourLAN/internal/models" 19 | ) 20 | 21 | var db *gorm.DB 22 | var gormConf *gorm.Config 23 | 24 | // Start working with DB 25 | func Start() { 26 | var tab *gorm.DB 27 | var err error 28 | 29 | newLogger := logger.New( 30 | log.New(os.Stdout, "\r\n", log.LstdFlags), 31 | logger.Config{ 32 | SlowThreshold: 5 * time.Second, 33 | LogLevel: logger.Warn, 34 | IgnoreRecordNotFoundError: true, 35 | Colorful: true, 36 | }, 37 | ) 38 | gormConf = &gorm.Config{ 39 | Logger: newLogger, 40 | NamingStrategy: schema.NamingStrategy{ 41 | NoLowerCase: true, 42 | // So upper case Columns could work in both PostgreSQL and SQLite 43 | }, 44 | } 45 | 46 | Connect() 47 | 48 | // Migrate the schema 49 | tab = db.Table("now") 50 | err = tab.AutoMigrate(&models.Host{}) 51 | check.IfError(err) 52 | 53 | tab = db.Table("history") 54 | err = tab.AutoMigrate(&models.Host{}) 55 | check.IfError(err) 56 | } 57 | 58 | // Connect - choose DB and connect 59 | func Connect() { 60 | var err error 61 | var pgFail bool 62 | 63 | if conf.AppConfig.UseDB == "postgres" { 64 | db, err = gorm.Open(postgres.Open(conf.AppConfig.PGConnect), gormConf) 65 | 66 | if err != nil { 67 | pgFail = true 68 | 69 | slog.Error("PostgreSQL connection error:", "err", err) 70 | slog.Warn("Falling back to SQLite") 71 | } else { 72 | slog.Info("Connected to DB: PostgreSQL") 73 | } 74 | } 75 | 76 | if pgFail || conf.AppConfig.UseDB != "postgres" { 77 | 78 | db, err = gorm.Open(sqlite.Open(conf.AppConfig.DBPath), gormConf) 79 | 80 | if !check.IfError(err) { 81 | slog.Info("Connected to DB: SQLite") 82 | db.Exec("PRAGMA journal_mode = wal;") 83 | db.Exec("PRAGMA busy_timeout = 5000;") 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/functions/api.ts: -------------------------------------------------------------------------------- 1 | export const apiPath = 'http://0.0.0.0:8840'; 2 | 3 | export const apiGetAllHosts = async () => { 4 | const url = apiPath+'/api/all'; 5 | const hosts = await (await fetch(url)).json(); 6 | 7 | return hosts; 8 | }; 9 | 10 | export const apiGetConfig = async () => { 11 | 12 | const url = apiPath+'/api/config'; 13 | const res = await (await fetch(url)).json(); 14 | 15 | return res; 16 | }; 17 | 18 | export const apiGetVersion = async () => { 19 | 20 | const url = apiPath+'/api/version'; 21 | const res = await (await fetch(url)).json(); 22 | 23 | return res; 24 | }; 25 | 26 | export const apiTestNotify = async () => { 27 | 28 | const url = apiPath+'/api/notify_test'; 29 | await fetch(url); 30 | }; 31 | 32 | export const apiEditHost = async (id:number, name:string, known:string) => { 33 | 34 | const url = apiPath+'/api/edit/'+id+'/'+name+'/'+known; 35 | const res = await (await fetch(url)).json(); 36 | 37 | return res; 38 | }; 39 | 40 | export const apiGetHost = async (id:string) => { 41 | 42 | const url = apiPath+'/api/host/'+id; 43 | const res = await (await fetch(url)).json(); 44 | 45 | return res; 46 | }; 47 | 48 | export const apiDelHost = async (id:number) => { 49 | 50 | const url = apiPath+'/api/host/del/'+id; 51 | const res = await (await fetch(url)).json(); 52 | 53 | return res; 54 | }; 55 | 56 | export const apiPortScan = async (ip:string, port:number) => { 57 | 58 | const url = apiPath+'/api/port/'+ip+'/'+port; 59 | const res = await (await fetch(url)).json(); 60 | 61 | return res; 62 | }; 63 | 64 | export const apiGetHistory = async (mac:string) => { 65 | const url = apiPath+'/api/history/'+mac+'/?num=210'; 66 | const hosts = await (await fetch(url)).json(); 67 | 68 | return hosts; 69 | }; 70 | 71 | export const apiGetHistoryByDate = async (mac:string, date: string) => { 72 | const url = apiPath+'/api/history/'+mac+'/'+date; 73 | const hosts = await (await fetch(url)).json(); 74 | 75 | return hosts; 76 | }; 77 | 78 | export const apiWOL = async (mac:string) => { 79 | 80 | const url = apiPath+'/api/wol/'+mac; 81 | const res = await (await fetch(url)).json(); 82 | 83 | return res; 84 | }; -------------------------------------------------------------------------------- /backend/internal/api/api-history.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/aceberg/WatchYourLAN/internal/gdb" 10 | ) 11 | 12 | // getHistory godoc 13 | // @Summary Get full history 14 | // @Description Retrieve the complete history of all hosts. Not recommended, the output can be a lot 15 | // @Tags history 16 | // @Produce json 17 | // @Success 200 {array} models.Host 18 | // @Router /history [get] 19 | func getHistory(c *gin.Context) { 20 | hosts, _ := gdb.Select("history") 21 | c.IndentedJSON(http.StatusOK, hosts) 22 | } 23 | 24 | // getHistoryByMAC godoc 25 | // @Summary Get history by MAC 26 | // @Description Retrieve the latest history entries for a specific host by MAC address 27 | // @Tags history 28 | // @Produce json 29 | // @Param mac path string true "MAC address of the host" 30 | // @Param num query int true "Number of history entries to return" 31 | // @Success 200 {array} models.Host 32 | // @Router /history/{mac} [get] 33 | func getHistoryByMAC(c *gin.Context) { 34 | mac := c.Param("mac") 35 | numStr := c.Query("num") 36 | num, _ := strconv.Atoi(numStr) 37 | hosts := gdb.SelectLatest(mac, num) 38 | c.IndentedJSON(http.StatusOK, hosts) 39 | } 40 | 41 | // getHistoryByDate godoc 42 | // @Summary Get history by date 43 | // @Description Retrieve history for a specific host on a given date 44 | // @Description The date format is flexible and can be: 45 | // @Description - Year only: `2025` 46 | // @Description - Year + month: `2025-09` 47 | // @Description - Full date: `2025-09-06` 48 | // @Description - Full timestamp: `2025-09-06 00:58:26` 49 | // @Tags history 50 | // @Produce json 51 | // @Param mac path string true "MAC address of the host" 52 | // @Param date path string true "Date filter (supports YYYY, YYYY-MM, YYYY-MM-DD, YYYY-MM-DD HH:mm:ss)" 53 | // @Success 200 {array} models.Host 54 | // @Router /history/{mac}/{date} [get] 55 | func getHistoryByDate(c *gin.Context) { 56 | mac := c.Param("mac") 57 | date := c.Param("date") 58 | hosts := gdb.SelectByDate(mac, date) 59 | c.IndentedJSON(http.StatusOK, hosts) 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/functions/exports.ts: -------------------------------------------------------------------------------- 1 | import { createSignal } from "solid-js"; 2 | import { createStore } from "solid-js/store"; 3 | 4 | export interface Host { 5 | ID: number; 6 | Name: string; 7 | DNS: string; 8 | Iface: string; 9 | IP: string; 10 | Mac: string; 11 | Hw: string; 12 | Date: string; 13 | Known: number; 14 | Now: number; 15 | }; 16 | 17 | export interface Conf { 18 | Host: string; 19 | Port: string; 20 | Theme: string; 21 | Color: string; 22 | DirPath: string; 23 | Timeout: number; 24 | NodePath: string; 25 | LogLevel: string; 26 | Ifaces: string; 27 | ArpArgs: string; 28 | ArpStrs: string[]; 29 | TrimHist: number; 30 | ShoutURL: string; 31 | UseDB: string; 32 | PGConnect: string; 33 | // InfluxDB 34 | InfluxEnable: boolean; 35 | InfluxAddr: string; 36 | InfluxToken: string; 37 | InfluxOrg: string; 38 | InfluxBucket: string; 39 | InfluxSkipTLS: boolean; 40 | // Prometheus 41 | PrometheusEnable: boolean; 42 | }; 43 | 44 | export const emptyHost:Host = { 45 | ID: 0, 46 | Name: "", 47 | DNS: "", 48 | Iface: "", 49 | IP: "", 50 | Mac: "", 51 | Hw: "", 52 | Date: "", 53 | Known: 0, 54 | Now: 0, 55 | }; 56 | 57 | export const emptyConf:Conf = { 58 | Host: "", 59 | Port: "", 60 | Theme: "", 61 | Color: "", 62 | DirPath: "", 63 | Timeout: 120, 64 | NodePath: "", 65 | LogLevel: "", 66 | Ifaces: "", 67 | ArpArgs: "", 68 | ArpStrs: [], 69 | TrimHist: 48, 70 | ShoutURL: "", 71 | UseDB: "", 72 | PGConnect: "", 73 | InfluxEnable: false, 74 | InfluxAddr: "", 75 | InfluxToken: "", 76 | InfluxOrg: "", 77 | InfluxBucket: "", 78 | InfluxSkipTLS: false, 79 | PrometheusEnable: false, 80 | }; 81 | 82 | export const [allHosts, setAllHosts] = createStore([]); 83 | export const [bkpHosts, setBkpHosts] = createSignal([]); 84 | 85 | export const [ifaces, setIfaces] = createSignal([]); 86 | 87 | export const [appConfig, setAppConfig] = createSignal(emptyConf); 88 | 89 | export const [editNames, setEditNames] = createSignal(false); 90 | 91 | export const [show, setShow] = createSignal(200); 92 | 93 | export const [histUpdOnFilter, setHistUpdOnFilter] = createSignal(false); 94 | 95 | export const [selectedIDs, setSelectedIDs] = createSignal([]); -------------------------------------------------------------------------------- /backend/internal/arp/arpscan.go: -------------------------------------------------------------------------------- 1 | package arp 2 | 3 | import ( 4 | "log/slog" 5 | "os/exec" 6 | "strings" 7 | "time" 8 | 9 | "github.com/aceberg/WatchYourLAN/internal/check" 10 | "github.com/aceberg/WatchYourLAN/internal/models" 11 | ) 12 | 13 | var arpArgs string 14 | 15 | func scanIface(iface string) string { 16 | var cmd *exec.Cmd 17 | 18 | if arpArgs != "" { 19 | cmd = exec.Command("arp-scan", "-glNx", arpArgs, "-I", iface) 20 | } else { 21 | cmd = exec.Command("arp-scan", "-glNx", "-I", iface) 22 | } 23 | out, err := cmd.Output() 24 | slog.Debug(cmd.String()) 25 | 26 | if check.IfError(err) { 27 | return string("") 28 | } 29 | return string(out) 30 | } 31 | 32 | func scanStr(str string) string { 33 | 34 | args := strings.Split(str, " ") 35 | cmd := exec.Command("arp-scan", args...) 36 | 37 | out, err := cmd.Output() 38 | slog.Debug(cmd.String()) 39 | 40 | if check.IfError(err) { 41 | return string("") 42 | } 43 | return string(out) 44 | } 45 | 46 | func parseOutput(text, iface string) []models.Host { 47 | var foundHosts = []models.Host{} 48 | 49 | p := strings.Split(text, "\n") 50 | 51 | for _, host := range p { 52 | if host != "" { 53 | var oneHost models.Host 54 | p := strings.Split(host, " ") 55 | oneHost.Iface = iface 56 | oneHost.IP = p[0] 57 | oneHost.Mac = p[1] 58 | oneHost.Hw = p[2] 59 | oneHost.Date = time.Now().Format("2006-01-02 15:04:05") 60 | oneHost.Now = 1 61 | foundHosts = append(foundHosts, oneHost) 62 | } 63 | } 64 | 65 | return foundHosts 66 | } 67 | 68 | // Scan all interfaces 69 | func Scan(ifaces, args string, strs []string) []models.Host { 70 | var text string 71 | var p []string 72 | var foundHosts = []models.Host{} 73 | arpArgs = args 74 | 75 | if ifaces != "" { 76 | 77 | p = strings.Split(ifaces, " ") 78 | 79 | for _, iface := range p { 80 | slog.Debug("Scanning interface " + iface) 81 | text = scanIface(iface) 82 | slog.Debug("Found IPs: \n" + text) 83 | 84 | foundHosts = append(foundHosts, parseOutput(text, iface)...) 85 | } 86 | } 87 | 88 | for _, s := range strs { 89 | slog.Debug("Scanning string " + s) 90 | text = scanStr(s) 91 | slog.Debug("Found IPs: \n" + text) 92 | p = strings.Split(s, " ") 93 | 94 | foundHosts = append(foundHosts, parseOutput(text, p[len(p)-1])...) 95 | } 96 | 97 | return foundHosts 98 | } 99 | -------------------------------------------------------------------------------- /backend/internal/routines/scan-routine.go: -------------------------------------------------------------------------------- 1 | package routines 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/aceberg/WatchYourLAN/internal/arp" 7 | "github.com/aceberg/WatchYourLAN/internal/check" 8 | "github.com/aceberg/WatchYourLAN/internal/conf" 9 | "github.com/aceberg/WatchYourLAN/internal/gdb" 10 | "github.com/aceberg/WatchYourLAN/internal/influx" 11 | "github.com/aceberg/WatchYourLAN/internal/models" 12 | "github.com/aceberg/WatchYourLAN/internal/notify" 13 | "github.com/aceberg/WatchYourLAN/internal/prometheus" 14 | ) 15 | 16 | func startScan(quit chan bool) { 17 | var lastDate, nowDate, plusDate time.Time 18 | var foundHosts []models.Host 19 | 20 | for { 21 | select { 22 | case <-quit: 23 | return 24 | default: 25 | nowDate = time.Now() 26 | plusDate = lastDate.Add(time.Duration(conf.AppConfig.Timeout) * time.Second) 27 | 28 | if nowDate.After(plusDate) { 29 | 30 | foundHosts = arp.Scan(conf.AppConfig.Ifaces, conf.AppConfig.ArpArgs, conf.AppConfig.ArpStrs) 31 | 32 | // Make map of found hosts 33 | foundHostsMap := make(map[string]models.Host) 34 | for _, fHost := range foundHosts { 35 | foundHostsMap[fHost.Mac] = fHost 36 | } 37 | 38 | compareHosts(foundHostsMap) 39 | 40 | lastDate = time.Now() 41 | } 42 | 43 | time.Sleep(time.Duration(1) * time.Minute) 44 | } 45 | } 46 | } 47 | 48 | func compareHosts(foundHostsMap map[string]models.Host) { 49 | 50 | allHosts, ok := gdb.Select("now") 51 | if !ok { 52 | return 53 | } 54 | 55 | for _, aHost := range allHosts { 56 | 57 | fHost, exists := foundHostsMap[aHost.Mac] 58 | if exists { 59 | 60 | aHost.Iface = fHost.Iface 61 | aHost.IP = fHost.IP 62 | aHost.Date = fHost.Date 63 | aHost.Now = 1 64 | 65 | delete(foundHostsMap, aHost.Mac) 66 | 67 | } else { 68 | aHost.Now = 0 69 | } 70 | gdb.Update("now", aHost) 71 | 72 | aHost.ID = 0 73 | aHost.Date = time.Now().Format("2006-01-02 15:04:05") 74 | gdb.Update("history", aHost) 75 | 76 | if conf.AppConfig.InfluxEnable { 77 | influx.Add(conf.AppConfig, aHost) 78 | } 79 | if conf.AppConfig.PrometheusEnable { 80 | prometheus.Add(aHost) 81 | } 82 | } 83 | 84 | for _, fHost := range foundHostsMap { 85 | 86 | fHost.Name, fHost.DNS = check.DNS(fHost) 87 | notify.Unknown(fHost) // Log and Shoutrrr 88 | 89 | gdb.Update("now", fHost) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /docs/VLAN_ARP_SCAN.md: -------------------------------------------------------------------------------- 1 | ## Passing arguments to arp-scan 2 | 3 | ### 1. IFACES 4 | 5 | `IFACES` is a required variable for WYL to work. It can be set through `GUI`, config file or environment variables. 6 | `IFACES` is a list of network interfaces to scan, space separated. For example 7 | ```sh 8 | IFACES: "enp4s0 wlxf4ec3892dd51" 9 | ``` 10 | You can get a list of network interfaces by running `ip link show` or `netstat -i`. 11 | By default, the scan command will look like this: 12 | ```sh 13 | arp-scan -glNx -I $ONE_IFACE 14 | ``` 15 | 16 | ### 2. ARP_ARGS 17 | Setting `ARP_ARGS` is optional. It can be set through `GUI`, config file or environment variables. 18 | `ARP_ARGS` is additional arguments for `arp-scan`, that will be applied for **every** one of `IFACES`. For example: 19 | ```sh 20 | ARP_ARGS: "-r 1" 21 | ``` 22 | ```sh 23 | arp-scan -glNx -r 1 -I $ONE_IFACE 24 | ``` 25 | See `man arp-scan` for all arguments available. 26 | 27 | 28 | ### 3. ARP_STRS for VLANs, docker0 and other complicated scans 29 | If `ARP_STRS` is set, it will initiate a completely separate from `IFACES` scan. 30 | > [!WARNING] 31 | > `ARP_STRS` can be set only through `GUI` or config file. For environment (docker-compose) see `ARP_STRS_JOINED`. 32 | 33 | `ARP_STRS` is a list of strings. `arp-scan` will run for each of them: 34 | ```sh 35 | arp-scan $ONE_STRING 36 | ``` 37 | Every string must contain all information you need to pass to `arp-scan`. For example: 38 | ```sh 39 | arp-scan -gNx 10.0.107.0/24 -Q 107 -I eth0 40 | ``` 41 | Where `-Q` is a `vlan` id. **Warning:** the last element of string (`eth0` in this example) will be set as `Interface` for found hosts, so it is recommended to put interface at the end. 42 | 43 | 44 | Setting `ARP_STRS` from config file: 45 | ```yaml 46 | arp_strs: 47 | - -gNx 172.17.0.1/24 -I docker0 48 | - -glNx -I virbr0 49 | ``` 50 | From `GUI` put one string in `Arp Strings` input field, click `Save`, then another empty string will appear. 51 | 52 | ### 4. ARP_STRS_JOINED 53 | `ARP_STRS_JOINED` is a way to set `ARP_STRS` from ENV. It's a list of strings, comma separated, without spaces before or after comma. 54 | ```sh 55 | ARP_STRS_JOINED: "-gNx 172.17.0.1/24 -I docker0,-gNx 10.0.107.0/24 -Q 107 -I eth0" 56 | ``` 57 | 58 | ### 5. Examples 59 | vlan id 107 60 | ```sh 61 | ARP_STRS_JOINED: "-gNx 10.0.107.0/24 -Q 107 -I eth0" 62 | ``` 63 | docker0 64 | ```sh 65 | ARP_STRS_JOINED: "-gNx 172.17.0.1/24 -I docker0" 66 | ``` -------------------------------------------------------------------------------- /frontend/src/components/HostPage/Ping.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, For } from "solid-js"; 2 | import { apiPortScan } from "../../functions/api"; 3 | 4 | function Ping(_props: any) { 5 | 6 | let stop = false; 7 | 8 | const [beginStr, setBegin] = createSignal(""); 9 | const [endStr, setEnd] = createSignal(""); 10 | const [curPort, setCurPort] = createSignal(""); 11 | const [foundPorts, setFoundPorts] = createSignal([]); 12 | 13 | const handleScan = async () => { 14 | stop = false; 15 | 16 | let begin = Number(beginStr()); 17 | if (Number.isNaN(begin) || begin < 1 || begin > 65535) { 18 | begin = 1; 19 | } 20 | let end = Number(endStr()); 21 | if (Number.isNaN(end) || end < 1 || end > 65535) { 22 | end = 65535; 23 | } 24 | 25 | let portOpened:boolean; 26 | for (let i = begin ; i <= end; i++) { 27 | 28 | if (stop) { 29 | break; 30 | } 31 | setCurPort(i.toString()); 32 | portOpened = await apiPortScan(_props.IP, i); 33 | if (portOpened) { 34 | setFoundPorts([...foundPorts(), i]); 35 | } 36 | } 37 | }; 38 | 39 | const handleStop = () => { 40 | if (stop) { 41 | setBegin(curPort()); 42 | handleScan(); 43 | } else { 44 | stop = true; 45 | } 46 | } 47 | 48 | return ( 49 |
50 |
Port Scan
51 |
52 |
53 | setBegin(e.target.value)}> 55 | setEnd(e.target.value)}> 57 | 58 |
59 | {curPort() != "" 60 | ?
61 | 62 |
Scanning port: {curPort()}
63 |
64 | : <> 65 | } 66 |
67 | {(port) => 68 | {port} 69 | } 70 |
71 |
72 |
73 | ) 74 | } 75 | 76 | export default Ping -------------------------------------------------------------------------------- /backend/internal/conf/read.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/spf13/viper" 7 | 8 | "github.com/aceberg/WatchYourLAN/internal/check" 9 | "github.com/aceberg/WatchYourLAN/internal/models" 10 | ) 11 | 12 | func read(path string) (config models.Conf) { 13 | 14 | viper.SetDefault("HOST", "0.0.0.0") 15 | viper.SetDefault("PORT", "8840") 16 | viper.SetDefault("THEME", "sand") 17 | viper.SetDefault("COLOR", "dark") 18 | viper.SetDefault("NODEPATH", "") 19 | viper.SetDefault("LOG_LEVEL", "info") 20 | viper.SetDefault("ARP_ARGS", "") 21 | viper.SetDefault("ARP_STRS_JOINED", "") 22 | viper.SetDefault("IFACES", "") 23 | viper.SetDefault("TIMEOUT", 120) 24 | viper.SetDefault("TRIM_HIST", 48) 25 | viper.SetDefault("SHOUTRRR_URL", "") 26 | 27 | viper.SetDefault("USE_DB", "sqlite") 28 | viper.SetDefault("PG_CONNECT", "") 29 | 30 | viper.SetDefault("INFLUX_ENABLE", false) 31 | 32 | viper.SetDefault("PROMETHEUS_ENABLE", false) 33 | 34 | viper.SetConfigFile(path) 35 | viper.SetConfigType("yaml") 36 | err := viper.ReadInConfig() 37 | check.IfError(err) 38 | 39 | viper.AutomaticEnv() // Get ENVIRONMENT variables 40 | 41 | config.Host = viper.Get("HOST").(string) 42 | config.Port = viper.Get("PORT").(string) 43 | config.Theme = viper.Get("THEME").(string) 44 | config.Color = viper.Get("COLOR").(string) 45 | config.NodePath = viper.Get("NODEPATH").(string) 46 | config.LogLevel = viper.Get("LOG_LEVEL").(string) 47 | config.ArpArgs = viper.Get("ARP_ARGS").(string) 48 | config.ArpStrs = viper.GetStringSlice("ARP_STRS") 49 | config.Ifaces = viper.Get("IFACES").(string) 50 | config.Timeout = viper.GetInt("TIMEOUT") 51 | config.TrimHist = viper.GetInt("TRIM_HIST") 52 | config.ShoutURL = viper.Get("SHOUTRRR_URL").(string) 53 | 54 | config.UseDB = viper.Get("USE_DB").(string) 55 | config.PGConnect = viper.Get("PG_CONNECT").(string) 56 | 57 | config.InfluxEnable = viper.GetBool("INFLUX_ENABLE") 58 | config.InfluxSkipTLS = viper.GetBool("INFLUX_SKIP_TLS") 59 | config.InfluxAddr, _ = viper.Get("INFLUX_ADDR").(string) 60 | config.InfluxToken, _ = viper.Get("INFLUX_TOKEN").(string) 61 | config.InfluxOrg, _ = viper.Get("INFLUX_ORG").(string) 62 | config.InfluxBucket, _ = viper.Get("INFLUX_BUCKET").(string) 63 | 64 | config.PrometheusEnable = viper.GetBool("PROMETHEUS_ENABLE") 65 | 66 | joined := viper.Get("ARP_STRS_JOINED").(string) 67 | // slog.Info("ARP_STRS_JOINED: " + joined) 68 | 69 | if joined != "" { 70 | config.ArpStrs = strings.Split(joined, ",") 71 | } 72 | 73 | return config 74 | } 75 | -------------------------------------------------------------------------------- /backend/internal/api/config.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/aceberg/WatchYourLAN/internal/conf" 10 | "github.com/aceberg/WatchYourLAN/internal/gdb" 11 | "github.com/aceberg/WatchYourLAN/internal/routines" 12 | ) 13 | 14 | func saveConfigHandler(c *gin.Context) { 15 | 16 | conf.AppConfig.Host = c.PostForm("host") 17 | conf.AppConfig.Port = c.PostForm("port") 18 | conf.AppConfig.Theme = c.PostForm("theme") 19 | conf.AppConfig.Color = c.PostForm("color") 20 | conf.AppConfig.NodePath = c.PostForm("node") 21 | conf.AppConfig.ShoutURL = c.PostForm("shout") 22 | 23 | conf.Write(conf.AppConfig) 24 | 25 | c.Redirect(http.StatusFound, c.Request.Referer()) 26 | } 27 | 28 | func saveSettingsHandler(c *gin.Context) { 29 | 30 | conf.AppConfig.LogLevel = c.PostForm("log") 31 | conf.AppConfig.ArpArgs = c.PostForm("arpargs") 32 | conf.AppConfig.Ifaces = c.PostForm("ifaces") 33 | 34 | useDB := c.PostForm("usedb") 35 | pgConnect := c.PostForm("pgconnect") 36 | 37 | if useDB != conf.AppConfig.UseDB || pgConnect != conf.AppConfig.PGConnect { 38 | conf.AppConfig.UseDB = c.PostForm("usedb") 39 | conf.AppConfig.PGConnect = c.PostForm("pgconnect") 40 | gdb.Connect() 41 | } 42 | 43 | timeout := c.PostForm("timeout") 44 | trimHist := c.PostForm("trim") 45 | conf.AppConfig.Timeout, _ = strconv.Atoi(timeout) 46 | conf.AppConfig.TrimHist, _ = strconv.Atoi(trimHist) 47 | 48 | arpStrs := c.PostFormArray("arpstrs") 49 | conf.AppConfig.ArpStrs = []string{} 50 | for _, s := range arpStrs { 51 | if s != "" { 52 | conf.AppConfig.ArpStrs = append(conf.AppConfig.ArpStrs, s) 53 | } 54 | } 55 | 56 | conf.Write(conf.AppConfig) 57 | 58 | routines.ScanRestart() 59 | 60 | c.Redirect(http.StatusFound, c.Request.Referer()) 61 | } 62 | 63 | func saveInfluxHandler(c *gin.Context) { 64 | 65 | conf.AppConfig.InfluxAddr = c.PostForm("addr") 66 | conf.AppConfig.InfluxToken = c.PostForm("token") 67 | conf.AppConfig.InfluxOrg = c.PostForm("org") 68 | conf.AppConfig.InfluxBucket = c.PostForm("bucket") 69 | 70 | enable := c.PostForm("enable") 71 | skip := c.PostForm("skip") 72 | conf.AppConfig.InfluxEnable = enable == "on" 73 | conf.AppConfig.InfluxSkipTLS = skip == "on" 74 | 75 | conf.Write(conf.AppConfig) 76 | 77 | c.Redirect(http.StatusFound, c.Request.Referer()) 78 | } 79 | 80 | func savePrometheusHandler(c *gin.Context) { 81 | enable := c.PostForm("enable") 82 | 83 | conf.AppConfig.PrometheusEnable = enable == "on" 84 | 85 | conf.Write(conf.AppConfig) 86 | 87 | c.Redirect(http.StatusFound, c.Request.Referer()) 88 | } 89 | -------------------------------------------------------------------------------- /frontend/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from "solid-js"; 2 | import { appConfig, setAppConfig } from "../functions/exports"; 3 | import { apiGetConfig } from "../functions/api"; 4 | 5 | function Header() { 6 | 7 | const [themePath, setThemePath] = createSignal(''); 8 | const [iconsPath, setIconsPath] = createSignal(''); 9 | 10 | const setCurrentTheme = async () => { 11 | setAppConfig(await apiGetConfig()); 12 | 13 | const theme = appConfig().Theme?appConfig().Theme:"sand"; 14 | const color = appConfig().Color?appConfig().Color:"dark"; 15 | 16 | if (appConfig().NodePath == '') { 17 | setThemePath("https://cdn.jsdelivr.net/npm/aceberg-bootswatch-fork@v5.3.3-2/dist/"+theme+"/bootstrap.min.css"); 18 | setIconsPath("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css"); 19 | } else { 20 | setThemePath(appConfig().NodePath+"/node_modules/bootswatch/dist/"+theme+"/bootstrap.min.css"); 21 | setIconsPath(appConfig().NodePath+"/node_modules/bootstrap-icons/font/bootstrap-icons.css"); 22 | } 23 | 24 | document.documentElement.setAttribute("data-bs-theme", color); 25 | color === "dark" 26 | ? document.documentElement.style.setProperty('--transparent-light', '#ffffff15') 27 | : document.documentElement.style.setProperty('--transparent-light', '#00000015'); 28 | } 29 | setCurrentTheme(); 30 | 31 | return ( 32 | <> 33 | {/* icons */} 34 | {/* theme */} 35 | 58 | 59 | ) 60 | }; 61 | 62 | export default Header 63 | -------------------------------------------------------------------------------- /frontend/src/components/Config/Influx.tsx: -------------------------------------------------------------------------------- 1 | import { apiPath } from "../../functions/api" 2 | import { appConfig } from "../../functions/exports" 3 | 4 | function Influx() { 5 | 6 | return ( 7 |
8 |
InfluxDB2 config
9 |
10 |
11 | 12 | 13 | 14 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 49 | 50 | 51 | 52 | 53 | 54 |
Enable 15 |
16 | {appConfig().InfluxEnable 17 | ? 18 | : 19 | } 20 |
21 |
Address
Token
Org
Bucket
Skip TLS verify 42 |
43 | {appConfig().InfluxSkipTLS 44 | ? 45 | : 46 | } 47 |
48 |
55 |
56 |
57 |
58 | ) 59 | } 60 | 61 | export default Influx -------------------------------------------------------------------------------- /frontend/src/components/Body/TableRow.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, Show } from "solid-js"; 2 | import { editNames, selectedIDs, setSelectedIDs } from "../../functions/exports"; 3 | import { apiEditHost } from "../../functions/api"; 4 | 5 | import { debounce } from "@solid-primitives/scheduled"; 6 | 7 | function TableRow(_props: any) { 8 | 9 | const [name, setName] = createSignal(_props.host.Name); 10 | 11 | let now = ; 12 | if (_props.host.Now == 1) { 13 | now = ; 14 | }; 15 | 16 | let known:boolean; 17 | _props.host.Known === 1 ? known = true : known = false; 18 | 19 | const debouncedApi = debounce(async (val: string) => { 20 | await apiEditHost(_props.host.ID, val, ""); 21 | }, 300); 22 | 23 | const handleInput = async (n: string) => { 24 | setName(n); 25 | debouncedApi(n); 26 | }; 27 | const handleToggle = async () => { 28 | await apiEditHost(_props.host.ID, name(), "toggle"); 29 | }; 30 | 31 | const handleCheck = (checked: boolean) => { 32 | const id = _props.host.ID; 33 | setSelectedIDs(prev => { 34 | if (checked) { 35 | return prev.includes(id) ? prev : [...prev, id]; 36 | } else { 37 | return prev.filter(item => item !== id); 38 | } 39 | }); 40 | }; 41 | 42 | return ( 43 | 44 | {_props.index}. 45 | 46 | 50 | handleInput(e.target.value)}> 52 | 53 | 54 | {_props.host.Iface} 55 | {_props.host.IP} 56 | {_props.host.Mac} 57 | {_props.host.Hw.slice(0,12)+".."} 58 | {_props.host.Date} 59 | 60 |
61 | 63 |
64 | 65 | {now} 66 | 67 | 71 | 72 | } 73 | > 74 | handleCheck((e.target as HTMLInputElement).checked)} 79 | /> 80 | 81 | 82 | 83 | ) 84 | } 85 | 86 | export default TableRow 87 | -------------------------------------------------------------------------------- /frontend/src/components/Config/About.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, onMount } from "solid-js"; 2 | import { apiGetVersion } from "../../functions/api" 3 | 4 | function About() { 5 | 6 | const [version, setVersion] = createSignal(''); 7 | const [link, setLink] = createSignal(''); 8 | 9 | onMount(async () => { 10 | const v = await apiGetVersion(); 11 | setVersion(v); 12 | setLink("https://github.com/aceberg/WatchYourLAN/releases/tag/"+v); 13 | }); 14 | 15 | return ( 16 |
17 |
18 | About ({version()}) 19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |
Swagger API docs/swagger/index.html
Local node-bootstrap URLlocal themes and fonts (optional). If empty, the app will pull everything from cdn
Shoutrrr URLprovides notifications to Discord, Email, Gotify, Telegram and other services. Link to documentation
Interfacesone or more, space separated
Timeout (seconds)time between scans
Args for arp-scanpass your own arguments to arp-scan. Enable debug log level to see resulting command. (Example: -r 1). See docs for more
Arp Stringscan setup scans for vlans, docker0 and etcetera. See docs for more
Trim Historyremove history after (hours)
PG Connect URLaddress to connect to PostgreSQL DB. (Example: postgres://username:password@192.168.0.1:5432/dbname?sslmode=disable). Full list of URL parameters here
59 |
60 |
61 | ) 62 | } 63 | 64 | export default About -------------------------------------------------------------------------------- /backend/internal/api/api-system.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/aceberg/WatchYourLAN/internal/conf" 9 | "github.com/aceberg/WatchYourLAN/internal/gdb" 10 | "github.com/aceberg/WatchYourLAN/internal/models" 11 | "github.com/aceberg/WatchYourLAN/internal/notify" 12 | "github.com/aceberg/WatchYourLAN/internal/routines" 13 | ) 14 | 15 | // getVersion godoc 16 | // @Summary Get application version 17 | // @Description Returns the current running version of the application 18 | // @Tags system 19 | // @Produce json 20 | // @Success 200 {string} string 21 | // @Router /version [get] 22 | func getVersion(c *gin.Context) { 23 | c.IndentedJSON(http.StatusOK, conf.AppConfig.Version) 24 | } 25 | 26 | // triggerRescan godoc 27 | // @Summary Rescan all interfaces now 28 | // @Description Manually trigger rescan 29 | // @Tags system 30 | // @Produce json 31 | // @Success 200 {string} string "OK" 32 | // @Router /rescan [get] 33 | func triggerRescan(c *gin.Context) { 34 | routines.ScanRestart() 35 | c.Status(http.StatusOK) 36 | } 37 | 38 | // getConfig godoc 39 | // @Summary Get application configuration 40 | // @Description Returns the current configuration used by the app 41 | // @Tags system 42 | // @Produce json 43 | // @Success 200 {object} models.Conf 44 | // @Router /config [get] 45 | func getConfig(c *gin.Context) { 46 | c.IndentedJSON(http.StatusOK, conf.AppConfig) 47 | } 48 | 49 | // notifyTest godoc 50 | // @Summary Send test notification 51 | // @Description Trigger a test notification to verify notification settings 52 | // @Tags system 53 | // @Produce json 54 | // @Success 200 {string} string "OK" 55 | // @Router /notify_test [get] 56 | func notifyTest(c *gin.Context) { 57 | notify.Test() 58 | c.Status(http.StatusOK) 59 | } 60 | 61 | // getStatus godoc 62 | // @Summary Get network status 63 | // @Description Retrieve summary statistics of hosts, optionally filtered by interface 64 | // @Tags system 65 | // @Produce json 66 | // @Param iface path string false "Interface name (omit for all interfaces)" 67 | // @Success 200 {object} models.Stat 68 | // @Router /status/{iface} [get] 69 | func getStatus(c *gin.Context) { 70 | var status models.Stat 71 | var searchHosts []models.Host 72 | 73 | allHosts, _ := gdb.Select("now") 74 | 75 | iface := c.Param("iface") 76 | iface = iface[1:] 77 | 78 | if iface != "" && iface != "undefined" { 79 | for _, host := range allHosts { 80 | if iface == host.Iface { 81 | searchHosts = append(searchHosts, host) 82 | } 83 | } 84 | } else { 85 | searchHosts = allHosts 86 | } 87 | 88 | for _, host := range searchHosts { 89 | status.Total = status.Total + 1 90 | 91 | if host.Known > 0 { 92 | status.Known = status.Known + 1 93 | } else { 94 | status.Unknown = status.Unknown + 1 95 | } 96 | if host.Now > 0 { 97 | status.Online = status.Online + 1 98 | } else { 99 | status.Offline = status.Offline + 1 100 | } 101 | } 102 | 103 | c.IndentedJSON(http.StatusOK, status) 104 | } 105 | -------------------------------------------------------------------------------- /frontend/src/components/Config/Basic.tsx: -------------------------------------------------------------------------------- 1 | import { For, Show } from "solid-js"; 2 | import { apiPath, apiTestNotify } from "../../functions/api" 3 | import { appConfig } from "../../functions/exports" 4 | 5 | function Basic() { 6 | 7 | const themes = ["cerulean", "cosmo", "cyborg", "darkly", "emerald", "flatly", "grass", "grayscale", "journal", "litera", "lumen", "lux", "materia", "minty", "morph", "ocean", "pulse", "quartz", "sand", "sandstone", "simplex", "sketchy", "slate", "solar", "spacelab", "superhero", "united", "vapor", "wood", "yeti", "zephyr"]; 8 | 9 | const handleTestNotify = () => { 10 | apiTestNotify(); 11 | }; 12 | 13 | return ( 14 |
15 |
Basic config
16 |
17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 42 | 43 | 44 | 45 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
Host
Port
Theme 31 | 41 |
Color mode 46 | 58 |
Local node-bootstrap URL
Shoutrrr URL 67 | 68 |
77 |
78 |
79 |
80 | ) 81 | } 82 | 83 | export default Basic -------------------------------------------------------------------------------- /frontend/src/components/Config/Scan.tsx: -------------------------------------------------------------------------------- 1 | import { For, Show } from "solid-js" 2 | import { appConfig } from "../../functions/exports" 3 | import { apiPath } from "../../functions/api" 4 | 5 | function Scan() { 6 | 7 | return ( 8 |
9 |
Scan settings
10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 33 | 34 | 35 | 36 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 65 | 66 | 67 | 68 | 71 | 72 | 73 | 74 | 75 | 76 |
Interfaces
Timeout (seconds)
Args for arp-scan
Arp Strings 28 | {arpStr => 29 | 30 | } 31 | 32 |
Log level
Trim History (hours)
Use DB
PG Connect URL 69 | 70 |
*Pressing Save button will trigger rescan
77 |
78 |
79 |
80 | ) 81 | } 82 | 83 | export default Scan -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [v2.1.4] - 2025-09-10 5 | ### Added 6 | - Swagger API docs (`/swagger/index.html`) 7 | - Add host from API [#72](https://github.com/aceberg/WatchYourLAN/issues/72) 8 | - Trigger rescan from API or by pressing `Save` on `Config/Scan settings` [#74](https://github.com/aceberg/WatchYourLAN/issues/74) 9 | - Delete selected hosts [#195](https://github.com/aceberg/WatchYourLAN/issues/195) 10 | - Wake-on-LAN [#135](https://github.com/aceberg/WatchYourLAN/issues/135), [#196](https://github.com/aceberg/WatchYourLAN/issues/196) 11 | 12 | ## [v2.1.3] - 2025-07-26 13 | ### Fixed 14 | - Memory leak bug [#149](https://github.com/aceberg/WatchYourLAN/issues/149) 15 | - Duplicated devices bug [#187](https://github.com/aceberg/WatchYourLAN/issues/187) [#198](https://github.com/aceberg/WatchYourLAN/issues/198) 16 | 17 | ### Changed 18 | - **DEPRECATED:** `HIST_IN_DB` config option. Now history is always stored in `DB` 19 | - Upd to `go 1.24.5` 20 | - Moved `DB` handling to `GORM` 21 | - Moved to maintained `Shoutrrr`: [github.com/nicholas-fedor/shoutrrr](https://github.com/nicholas-fedor/shoutrrr) ([#197](https://github.com/aceberg/WatchYourLAN/issues/197)) 22 | 23 | ## [v2.1.2] - 2025-03-30 24 | ### Fixed 25 | - Edit names bug 26 | - History page full rerenders replaced with only rerendering updated data 27 | - Select options reset 28 | 29 | ## [v2.1.1] - 2025-03-26 30 | ### Fixed 31 | - Filter bug in Chrome 32 | 33 | ## [v2.1.0] - 2025-03-25 34 | ### Added 35 | - Rewrited GUI in `SolidJS` and `TypeScript` 36 | - Prometheus integration [#181](https://github.com/aceberg/WatchYourLAN/pull/181) 37 | - Optimized Docker build [#180](https://github.com/aceberg/WatchYourLAN/pull/180) 38 | 39 | ### Fixed 40 | - Vite: file names 41 | - Node Path bug 42 | 43 | ## [v2.0.4] - 2024-10-21 44 | ### Added 45 | - Notification test [#147](https://github.com/aceberg/WatchYourLAN/issues/147) 46 | - API status [#148](https://github.com/aceberg/WatchYourLAN/issues/148) 47 | 48 | ### Fixed 49 | - [#101](https://github.com/aceberg/WatchYourLAN/issues/101) 50 | - The same problem for Theme, Color mode, Log level 51 | - Sort bug in Chrome [#140](https://github.com/aceberg/WatchYourLAN/issues/140) 52 | 53 | ## [v2.0.3] - 2024-09-17 54 | ### Fixed 55 | - `ARP_STRS_JOINED` should be empty in config file 56 | - Optimized History Trim 57 | 58 | ## [v2.0.2] - 2024-09-07 59 | ### Added 60 | - Remember Refresh setting in browser [#123](https://github.com/aceberg/WatchYourLAN/issues/123) 61 | 62 | ### Fixed 63 | - Error when `IFACES` are empty 64 | - Sticky sort bug fix 65 | - Bug [#124](https://github.com/aceberg/WatchYourLAN/issues/124) 66 | - Bug [#128](https://github.com/aceberg/WatchYourLAN/issues/128) 67 | 68 | 69 | ## [v2.0.1] - 2024-09-02 70 | ### Added 71 | - `Vlans` and `docker0` support [#47](https://github.com/aceberg/WatchYourLAN/issues/47). Thanks [thehijacker](https://github.com/thehijacker)! 72 | - Remember `sort` field 73 | - `InfluxDB` error handling 74 | 75 | ### Fixed 76 | - Bug [#103](https://github.com/aceberg/WatchYourLAN/issues/103) 77 | - Bug [#104](https://github.com/aceberg/WatchYourLAN/issues/104). Thanks [Steve Clement](https://github.com/SteveClement)! 78 | 79 | ## [v2.0.0] - 2024-08-30 80 | ### Added 81 | - API 82 | - Arguments for `arp-scan` option 83 | - `InfluxDB` export 84 | - `PostgreSQL` or `SQLite` DB options 85 | - Names from DNS 86 | 87 | ### Changed 88 | - Better UI with JS 89 | - Switched to `gin` web framework 90 | - Reworked DB schema and config variables 91 | 92 | -------------------------------------------------------------------------------- /frontend/src/components/HostPage/HostCard.tsx: -------------------------------------------------------------------------------- 1 | import { apiDelHost, apiEditHost, apiWOL } from "../../functions/api"; 2 | 3 | import { debounce } from "@solid-primitives/scheduled"; 4 | 5 | function HostCard(_props: any) { 6 | 7 | let name:string = ""; 8 | 9 | const debouncedApi = debounce(async (val: string) => { 10 | await apiEditHost(_props.host.ID, val, ""); 11 | }, 300); 12 | 13 | const handleInput = async (n: string) => { 14 | 15 | name = n; 16 | debouncedApi(n); 17 | }; 18 | 19 | const handleToggle = async () => { 20 | 21 | if (name == "") { 22 | name = _props.host.Name; 23 | } 24 | 25 | await apiEditHost(_props.host.ID, name, 'toggle'); 26 | }; 27 | 28 | const handleDel = async () => { 29 | 30 | await apiDelHost(_props.host.ID); 31 | window.location.href = '/'; 32 | }; 33 | 34 | const handleWOL = async () => { 35 | 36 | await apiWOL(_props.host.Mac); 37 | }; 38 | 39 | return ( 40 |
41 |
Host
42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 95 | 96 | 97 | 98 | 105 | 106 | 107 |
ID{_props.host.ID}
Name 52 | handleInput(e.target.value)}> 54 |
DNS name{_props.host.DNS}
Iface{_props.host.Iface}
IP 67 | {_props.host.IP} 68 |
MAC{_props.host.Mac}
Hardware{_props.host.Hw}
Date{_props.host.Date}
Known 85 |
86 | 93 |
94 |
Online{_props.host.Now == 1 99 | ? 100 | : 101 | } 102 |     103 | 104 |
108 | 109 |
110 |
111 | ) 112 | } 113 | 114 | export default HostCard -------------------------------------------------------------------------------- /backend/internal/api/api-hosts.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/aceberg/WatchYourLAN/internal/check" 10 | "github.com/aceberg/WatchYourLAN/internal/gdb" 11 | "github.com/aceberg/WatchYourLAN/internal/models" 12 | ) 13 | 14 | // getAllHosts godoc 15 | // @Summary Get all hosts 16 | // @Description Retrieve all hosts from the database 17 | // @Tags hosts 18 | // @Produce json 19 | // @Success 200 {array} models.Host 20 | // @Router /all [get] 21 | func getAllHosts(c *gin.Context) { 22 | allHosts, _ := gdb.Select("now") 23 | c.IndentedJSON(http.StatusOK, allHosts) 24 | } 25 | 26 | // getHost godoc 27 | // @Summary Get host by ID 28 | // @Description Retrieve detailed information about a host by its unique ID 29 | // @Tags hosts 30 | // @Produce json 31 | // @Param id path string true "Host ID" 32 | // @Success 200 {object} models.Host 33 | // @Router /host/{id} [get] 34 | func getHost(c *gin.Context) { 35 | idStr := c.Param("id") 36 | host := getHostByID(idStr) // functions.go 37 | _, host.DNS = check.DNS(host) 38 | c.IndentedJSON(http.StatusOK, host) 39 | } 40 | 41 | // delHost godoc 42 | // @Summary Delete host 43 | // @Description Remove a host from the database by its unique ID 44 | // @Tags hosts 45 | // @Produce json 46 | // @Param id path string true "Host ID" 47 | // @Success 200 {string} string "OK" 48 | // @Router /host/del/{id} [get] 49 | func delHost(c *gin.Context) { 50 | idStr := c.Param("id") 51 | host := getHostByID(idStr) // functions.go 52 | gdb.Delete("now", host.ID) 53 | slog.Info("Deleting from DB", "host", host) 54 | c.IndentedJSON(http.StatusOK, "OK") 55 | } 56 | 57 | // addHost godoc 58 | // @Summary Add host manually 59 | // @Description Add host by MAC, with optional Name, IP, Hardware 60 | // @Description Returns `models.Host` with this MAC form DB, either just added or existing 61 | // @Tags hosts 62 | // @Produce json 63 | // @Param mac path string true "Host MAC" 64 | // @Param name query string false "Name" 65 | // @Param ip query string false "IP" 66 | // @Param hw query string false "Hardware" 67 | // @Success 200 {object} models.Host 68 | // @Router /host/add/{mac} [get] 69 | func addHost(c *gin.Context) { 70 | 71 | mac := c.Param("mac") 72 | hosts := gdb.SelectByMAC("now", mac) 73 | 74 | if len(hosts) > 0 { 75 | slog.Warn("Host with this MAC already exists", "host", hosts[0]) 76 | } else { 77 | var host models.Host 78 | 79 | host.Mac = mac 80 | host.Name = c.Query("name") 81 | host.IP = c.Query("ip") 82 | host.Hw = c.Query("hw") 83 | 84 | gdb.Update("now", host) 85 | hosts = gdb.SelectByMAC("now", mac) 86 | 87 | slog.Info("Added host to DB", "host", hosts[0]) 88 | } 89 | 90 | c.IndentedJSON(http.StatusOK, hosts[0]) 91 | } 92 | 93 | // editHost godoc 94 | // @Summary Edit host 95 | // @Description Update a host's name and optionally toggle its "known" status 96 | // @Tags hosts 97 | // @Produce json 98 | // @Param id path string true "Host ID" 99 | // @Param name path string true "New name for the host" 100 | // @Param known path string false "Pass 'toggle' to flip the known/unknown status" 101 | // @Success 200 {string} string "OK" 102 | // @Router /edit/{id}/{name}/{known} [get] 103 | func editHost(c *gin.Context) { 104 | 105 | idStr := c.Param("id") 106 | name := c.Param("name") 107 | toggleKnown := c.Param("known") 108 | 109 | host := getHostByID(idStr) // functions.go 110 | 111 | host.Name = name 112 | 113 | if toggleKnown == "/toggle" { 114 | host.Known = 1 - host.Known 115 | } 116 | 117 | gdb.Update("now", host) 118 | 119 | c.IndentedJSON(http.StatusOK, "OK") 120 | } 121 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aceberg/WatchYourLAN 2 | 3 | go 1.25.1 4 | 5 | require ( 6 | github.com/aceberg/gorm-sqlite v1.6.0 7 | github.com/gin-gonic/gin v1.10.1 8 | github.com/influxdata/influxdb-client-go/v2 v2.14.0 9 | github.com/linde12/gowol v0.0.0-20180926075039-797e4d01634c 10 | github.com/nicholas-fedor/shoutrrr v0.8.18 11 | github.com/prometheus/client_golang v1.23.1 12 | github.com/spf13/viper v1.20.1 13 | github.com/swaggo/files v1.0.1 14 | github.com/swaggo/gin-swagger v1.6.1 15 | github.com/swaggo/swag v1.16.6 16 | gorm.io/driver/postgres v1.6.0 17 | gorm.io/gorm v1.30.3 18 | ) 19 | 20 | require ( 21 | github.com/KyleBanks/depth v1.2.1 // indirect 22 | github.com/PuerkitoBio/purell v1.1.1 // indirect 23 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect 24 | github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect 25 | github.com/beorn7/perks v1.0.1 // indirect 26 | github.com/bytedance/sonic v1.11.6 // indirect 27 | github.com/bytedance/sonic/loader v0.1.1 // indirect 28 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 29 | github.com/cloudwego/base64x v0.1.4 // indirect 30 | github.com/cloudwego/iasm v0.2.0 // indirect 31 | github.com/dustin/go-humanize v1.0.1 // indirect 32 | github.com/fatih/color v1.18.0 // indirect 33 | github.com/fsnotify/fsnotify v1.9.0 // indirect 34 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 35 | github.com/gin-contrib/sse v0.1.0 // indirect 36 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 37 | github.com/go-openapi/jsonreference v0.19.6 // indirect 38 | github.com/go-openapi/spec v0.20.4 // indirect 39 | github.com/go-openapi/swag v0.19.15 // indirect 40 | github.com/go-playground/locales v0.14.1 // indirect 41 | github.com/go-playground/universal-translator v0.18.1 // indirect 42 | github.com/go-playground/validator/v10 v10.20.0 // indirect 43 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 44 | github.com/goccy/go-json v0.10.2 // indirect 45 | github.com/google/uuid v1.6.0 // indirect 46 | github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect 47 | github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect 48 | github.com/jackc/pgpassfile v1.0.0 // indirect 49 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 50 | github.com/jackc/pgx/v5 v5.6.0 // indirect 51 | github.com/jackc/puddle/v2 v2.2.2 // indirect 52 | github.com/jinzhu/inflection v1.0.0 // indirect 53 | github.com/jinzhu/now v1.1.5 // indirect 54 | github.com/josharian/intern v1.0.0 // indirect 55 | github.com/json-iterator/go v1.1.12 // indirect 56 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 57 | github.com/leodido/go-urn v1.4.0 // indirect 58 | github.com/mailru/easyjson v0.7.7 // indirect 59 | github.com/mattn/go-colorable v0.1.14 // indirect 60 | github.com/mattn/go-isatty v0.0.20 // indirect 61 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 62 | github.com/modern-go/reflect2 v1.0.2 // indirect 63 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 64 | github.com/ncruces/go-strftime v0.1.9 // indirect 65 | github.com/oapi-codegen/runtime v1.0.0 // indirect 66 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 67 | github.com/prometheus/client_model v0.6.2 // indirect 68 | github.com/prometheus/common v0.66.0 // indirect 69 | github.com/prometheus/procfs v0.16.1 // indirect 70 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 71 | github.com/sagikazarmark/locafero v0.10.0 // indirect 72 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 73 | github.com/spf13/afero v1.14.0 // indirect 74 | github.com/spf13/cast v1.9.2 // indirect 75 | github.com/spf13/pflag v1.0.9 // indirect 76 | github.com/subosito/gotenv v1.6.0 // indirect 77 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 78 | github.com/ugorji/go/codec v1.2.12 // indirect 79 | golang.org/x/arch v0.8.0 // indirect 80 | golang.org/x/crypto v0.41.0 // indirect 81 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 82 | golang.org/x/mod v0.27.0 // indirect 83 | golang.org/x/net v0.43.0 // indirect 84 | golang.org/x/sync v0.16.0 // indirect 85 | golang.org/x/sys v0.35.0 // indirect 86 | golang.org/x/text v0.28.0 // indirect 87 | golang.org/x/tools v0.36.0 // indirect 88 | google.golang.org/protobuf v1.36.8 // indirect 89 | gopkg.in/yaml.v2 v2.4.0 // indirect 90 | gopkg.in/yaml.v3 v3.0.1 // indirect 91 | modernc.org/libc v1.65.10 // indirect 92 | modernc.org/mathutil v1.7.1 // indirect 93 | modernc.org/memory v1.11.0 // indirect 94 | modernc.org/sqlite v1.38.0 // indirect 95 | ) 96 | -------------------------------------------------------------------------------- /backend/internal/web/public/assets/HostPage.js: -------------------------------------------------------------------------------- 1 | import{u as rt,t as u,i as e,j as I,b as p,s as K,v as B,w as st,x as dt,h as N,c as m,e as C,F as ct,y as ot,o as z,k as $t,z as ut,A as bt,B as ht}from"./index.js";import{M as ft}from"./MacHistory.js";var gt=u('
Host
ID
Name
DNS name
Iface
IP
MAC
Hardware
Date
Known
Online   
'),xt=u('
Scanning port: '),pt=u("");function Ct(t){let l=!1;const[i,n]=m(""),[d,_]=m(""),[h,a]=m(""),[f,v]=m([]),o=async()=>{l=!1;let r=Number(i());(Number.isNaN(r)||r<1||r>65535)&&(r=1);let $=Number(d());(Number.isNaN($)||$<1||$>65535)&&($=65535);let g;for(let s=r;s<=$&&!l;s++)a(s.toString()),g=await ot(t.IP,s),g&&v([...f(),s])},D=()=>{l?(n(h()),o()):l=!0};return(()=>{var r=St(),$=r.firstChild,g=$.nextSibling,s=g.firstChild,y=s.firstChild,w=y.nextSibling,k=w.nextSibling,S=s.nextSibling;return y.$$input=c=>n(c.target.value),w.$$input=c=>_(c.target.value),k.$$click=o,e(g,(()=>{var c=I(()=>h()!="");return()=>c()?(()=>{var b=xt(),x=b.firstChild,P=x.nextSibling;return P.firstChild,x.$$click=D,e(P,h,null),b})():[]})(),S),e(S,C(ct,{get each(){return f()},children:c=>(()=>{var b=pt();return e(b,c),p(()=>K(b,"href","http://"+t.IP+":"+c)),b})()})),r})()}N(["input","click"]);var yt=u('
Host History for
');function wt(t){const[l,i]=m("");z(()=>{$t(15e3),i(new Date().toLocaleDateString("en-CA"))});const n=d=>{i(""),i(d)};return(()=>{var d=yt(),_=d.firstChild,h=_.firstChild,a=h.firstChild,f=a.nextSibling,v=_.nextSibling;return f.$$input=o=>n(o.currentTarget.value),e(v,(()=>{var o=I(()=>t.mac!==""&&l()!=="");return()=>o()?C(ft,{get mac(){return t.mac},get date(){return l()}}):"Loading..."})()),p(()=>f.value=l()),d})()}N(["input"]);var kt=u("
"),Pt=u('
');function It(){const[t,l]=m(ut);return z(async()=>{const i=bt(),n=await ht(i.id);l(n)}),[(()=>{var i=kt(),n=i.firstChild,d=n.nextSibling;return e(n,C(vt,{get host(){return t()}})),e(d,C(Ct,{get IP(){return t().IP}})),i})(),(()=>{var i=Pt(),n=i.firstChild;return e(n,C(wt,{get mac(){return t().Mac}})),i})()]}export{It as default}; 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | WatchYourLAN

4 |
5 | 6 | [![Docker](https://github.com/aceberg/WatchYourLAN/actions/workflows/main-docker-all.yml/badge.svg)](https://github.com/aceberg/WatchYourLAN/actions/workflows/main-docker-all.yml) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/aceberg/WatchYourLAN)](https://goreportcard.com/report/github.com/aceberg/WatchYourLAN) 8 | [![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/aceberg/watchyourlan)](https://hub.docker.com/r/aceberg/watchyourlan) 9 | [![GitHub Discussions](https://img.shields.io/github/discussions/aceberg/WatchYourLAN)](https://github.com/aceberg/WatchYourLAN/discussions) 10 | 11 | aceberg%2FWatchYourLAN | Trendshift 12 | 13 | Lightweight network IP scanner with web GUI. Features: 14 | - Send notification when new host is found 15 | - Monitor hosts online/offline history 16 | - Keep a list of all hosts in the network 17 | - Send data to `InfluxDB2` or `Prometheus` to make a `Grafana` dashboard 18 | 19 | > [!IMPORTANT] 20 | > Please, consider making a [donation](https://github.com/aceberg#donate). Even $10 will make a difference to me. 21 | 22 | ![Screenshot_1](https://raw.githubusercontent.com/aceberg/WatchYourLAN/main/assets/Screenshot_1.png) 23 | 24 | ## More screenshots 25 | 26 |
27 | Expand 28 | 29 | ![Screenshot_5](https://raw.githubusercontent.com/aceberg/WatchYourLAN/main/assets/Screenshot_5.png) 30 | ![Screenshot_2](https://raw.githubusercontent.com/aceberg/WatchYourLAN/main/assets/Screenshot_2.png) 31 | ![Screenshot_3](https://raw.githubusercontent.com/aceberg/WatchYourLAN/main/assets/Screenshot_3.png) 32 | ![Screenshot_4](https://raw.githubusercontent.com/aceberg/WatchYourLAN/main/assets/Screenshot_4.png) 33 |
34 | 35 | ## Quick start 36 | 37 |
38 | Expand 39 | 40 | Replace `$YOURTIMEZONE` with correct time zone and `$YOURIFACE` with network interface you want to scan. Network mode must be `host`. Set `$DOCKERDATAPATH` for container to save data: 41 | 42 | ```sh 43 | docker run --name wyl \ 44 | -e "IFACES=$YOURIFACE" \ 45 | -e "TZ=$YOURTIMEZONE" \ 46 | --network="host" \ 47 | -v $DOCKERDATAPATH/wyl:/data/WatchYourLAN \ 48 | aceberg/watchyourlan 49 | ``` 50 | Web GUI should be at http://localhost:8840 51 | 52 |
53 | 54 | ## Auth 55 | 56 |
57 | Expand 58 | 59 | **WatchYourLAN** does not have built-in auth option. But you can use it with SSO tools like Authelia, or my simple auth app [ForAuth](https://github.com/aceberg/ForAuth). 60 | Here is an example [docker-compose-auth.yml](https://github.com/aceberg/WatchYourLAN/blob/main/docker-compose-auth.yml). 61 | 62 | > :warning: **WARNING!** 63 | > Please, don't forget that WYL needs `host` network mode to work. So, WYL port will be exposed in this setup. You need to limit access to it with firewall or other measures. 64 | 65 |
66 | 67 | ## Install on Linux 68 | 69 |
70 | Expand 71 | 72 | All binary packages can be found in [latest](https://github.com/aceberg/WatchYourLAN/releases/latest) release. There are `.deb`, `.rpm`, `.apk` (Alpine Linux) and `.tar.gz` files. 73 | 74 | Supported architectures: `amd64`, `i386`, `arm_v5`, `arm_v6`, `arm_v7`, `arm64`. 75 | Dependencies: `arp-scan`, `tzdata`. 76 | 77 | For `amd64` there is a `deb` repo [available](https://github.com/aceberg/ppa) 78 | 79 |
80 | 81 | ## Config 82 |
83 | Expand 84 | 85 | Configuration can be done through config file, GUI or environment variables. Variable names is `config_v2.yaml` file are the same, but in lowcase. 86 | 87 | ### Basic config 88 | | Variable | Description | Default | 89 | | -------- | ----------- | ------- | 90 | | TZ | Set your timezone for correct time | | 91 | | HOST | Listen address | 0.0.0.0 | 92 | | PORT | Port for web GUI | 8840 | 93 | | THEME | Any theme name from https://bootswatch.com in lowcase or [additional](https://github.com/aceberg/aceberg-bootswatch-fork) | sand | 94 | | COLOR | Background color: light or dark | dark | 95 | | NODEPATH | Path to local node modules | | 96 | | SHOUTRRR_URL | WatchYourLAN uses [Shoutrrr](https://github.com/nicholas-fedor/shoutrrr) to send notifications. It is already integrated, just needs a correct URL. Examples for Discord, Email, Gotify, Matrix, Ntfy, Pushover, Slack, Telegram, Generic Webhook and etc are [here](https://nicholas-fedor.github.io/shoutrrr/) | | 97 | 98 | ### Scan settings 99 | | Variable | Description | Default | 100 | | -------- | ----------- | ------- | 101 | | IFACES | Interfaces to scan. Could be one or more, separated by space. See [docs/VLAN_ARP_SCAN.md](https://github.com/aceberg/WatchYourLAN/blob/main/docs/VLAN_ARP_SCAN.md). | | 102 | | TIMEOUT | Time between scans (seconds) | 120 | 103 | | ARP_ARGS | Arguments for `arp-scan`. Enable `debug` log level to see resulting command. (Example: `-r 1`). See [docs/VLAN_ARP_SCAN.md](https://github.com/aceberg/WatchYourLAN/blob/main/docs/VLAN_ARP_SCAN.md). | | 104 | | ARP_STRS ARP_STRS_JOINED | See [docs/VLAN_ARP_SCAN.md](https://github.com/aceberg/WatchYourLAN/blob/main/docs/VLAN_ARP_SCAN.md). | | 105 | | LOG_LEVEL | Log level: `debug`, `info`, `warn` or `error` | info | 106 | | TRIM_HIST | Remove history after (hours) | 48 | 107 | | HIST_IN_DB | DEPRECATED since 2.1.3. Now History is always stored in DB. Use TRIM_HIST to reduce DB size | | 108 | | USE_DB | Either `sqlite` or `postgres` | sqlite | 109 | | PG_CONNECT | Address to connect to PostgreSQL. (Example: `postgres://username:password@192.168.0.1:5432/dbname?sslmode=disable`). Full list of URL parameters [here](https://pkg.go.dev/github.com/lib/pq#hdr-Connection_String_Parameters) | | 110 | 111 | ### InfluxDB2 config 112 | This config matches Grafana's config for InfluxDB data source 113 | 114 | | Variable | Description | Default | Example | 115 | | -------- | ----------- | ------- | ------- | 116 | | INFLUX_ENABLE | Enable export to InfluxDB2 | false | true | 117 | | INFLUX_SKIP_TLS | Skip TLS Verify | false | true | 118 | | INFLUX_ADDR | Address:port of InfluxDB2 server | | https://192.168.2.3:8086/ | 119 | | INFLUX_BUCKET | InfluxDB2 bucket | | test | 120 | | INFLUX_ORG | InfluxDB2 org | | home | 121 | | INFLUX_TOKEN | Secret token, generated by InfluxDB2 | | | 122 | 123 | ### Prometheus config 124 | This config configures the Prometheus data source 125 | 126 | | Variable | Description | Default | Example | 127 | | -------- | ----------- | ------- | ------- | 128 | | PROMETHEUS_ENABLE | Enable the Prometheus `/metrics` endpoint | false | true | 129 | 130 |
131 | 132 | ## Config file 133 | 134 |
135 | Expand 136 | 137 | Config file name is `config_v2.yaml`. Example: 138 | 139 | ```yaml 140 | arp_args: "" 141 | color: dark 142 | host: 0.0.0.0 143 | ifaces: enp4s0 144 | influx_addr: "" 145 | influx_bucket: "" 146 | influx_enable: false 147 | influx_org: "" 148 | influx_skip_tls: false 149 | influx_token: "" 150 | log_level: info 151 | nodepath: "" 152 | pg_connect: "" 153 | port: "8840" 154 | prometheus_enable: false 155 | shoutrrr_url: "gotify://192.168.0.1:8083/AwQqpAae.rrl5Ob/?title=Unknown host detected&DisableTLS=yes" 156 | theme: sand 157 | timeout: 60 158 | trim_hist: 48 159 | use_db: sqlite 160 | ``` 161 | 162 |
163 | 164 | ## Options 165 | 166 |
167 | Expand 168 | 169 | | Key | Description | Default | 170 | | -------- | ----------- | ------- | 171 | | -d | Path to config dir | /data/WatchYourLAN | 172 | | -n | Path to node modules (see below) | | 173 | 174 |
175 | 176 | ## Local network only 177 |
178 | Expand 179 | 180 | By default, this app pulls themes, icons and fonts from the internet. But, in some cases, it may be useful to have an independent from global network setup. I created a separate [image](https://github.com/aceberg/my-dockerfiles/tree/main/node-bootstrap) with all necessary modules and fonts. 181 | Run with Docker: 182 | ```sh 183 | docker run --name node-bootstrap \ 184 | -p 8850:8850 \ 185 | aceberg/node-bootstrap 186 | ``` 187 | ```sh 188 | docker run --name wyl \ 189 | -e "IFACES=$YOURIFACE" \ 190 | -e "TZ=$YOURTIMEZONE" \ 191 | --network="host" \ 192 | -v $DOCKERDATAPATH/wyl:/data/WatchYourLAN \ 193 | aceberg/watchyourlan -n "http://$YOUR_IP:8850" 194 | ``` 195 | Or use [docker-compose](docker-compose.yml) 196 | 197 |
198 | 199 | ## API & Integrations 200 | 201 |
202 | Expand 203 | 204 | ### API 205 | Moved to [docs/API.md](https://github.com/aceberg/WatchYourLAN/blob/main/docs/API.md) 206 | 207 | ### Integrations 208 | - [ArchLinux (AUR)](https://aur.archlinux.org/packages/watch-your-lan) by `gilcu3` 209 | - [Python API client](https://github.com/drwahl/py-watchyourlanclient) by [drwahl](https://github.com/drwahl) 210 | - [Umbrel](https://apps.umbrel.com/app/watch-your-lan) by [Jasper](https://github.com/ceramicwhite) 211 | - [YunoHost](https://apps.yunohost.org/app/watchyourlan) 212 |
213 | 214 | ## Thanks 215 |
216 | Expand 217 | 218 | - All go packages listed in [dependencies](https://github.com/aceberg/WatchYourLAN/network/dependencies) 219 | - Favicon and logo: [Access point icons created by Freepik - Flaticon](https://www.flaticon.com/free-icons/access-point) 220 | - [Bootstrap](https://getbootstrap.com/) 221 | - Themes: [Free themes for Bootstrap](https://bootswatch.com) 222 | 223 |
224 | -------------------------------------------------------------------------------- /backend/internal/web/public/assets/Config.js: -------------------------------------------------------------------------------- 1 | import{c as J,o as K,a as X,t,i as r,b,s as H,d as W,e as c,S as V,f as e,F as M,g as Z,h as tt,j as Q}from"./index.js";var et=t('
About ()
Swagger API docs/swagger/index.html
Local node-bootstrap URLlocal themes and fonts (optional). If empty, the app will pull everything from cdn
Shoutrrr URLprovides notifications to Discord, Email, Gotify, Telegram and other services. Link to documentation
Interfacesone or more, space separated
Timeout (seconds)time between scans
Args for arp-scanpass your own arguments to arp-scan. Enable debug log level to see resulting command. (Example: -r 1). See docs for more
Arp Stringscan setup scans for vlans, docker0 and etcetera. See docs for more
Trim Historyremove history after (hours)
PG Connect URLaddress to connect to PostgreSQL DB. (Example: postgres://username:password@192.168.0.1:5432/dbname?sslmode=disable). Full list of URL parameters here');function rt(){const[i,d]=J(""),[s,a]=J("");return K(async()=>{const l=await X();d(l),a("https://github.com/aceberg/WatchYourLAN/releases/tag/"+l)}),(()=>{var l=et(),n=l.firstChild,o=n.firstChild,p=o.nextSibling;return r(p,i),b(()=>H(p,"href",s())),l})()}var lt=t("