├── .github ├── FUNDING.yml └── workflows │ ├── binary-release.yml │ ├── dev-docker-io.yml │ ├── main-docker-all.yml │ ├── new-dev-docker.yml │ └── readme-docker.yml ├── .gitignore ├── .goreleaser.yaml ├── .version ├── CHANGELOG.md ├── Dockerfile ├── FAQ.md ├── LICENSE ├── Makefile ├── README.md ├── assets ├── Screenshot 2024-08-29 at 01-41-12 WatchYourLAN.png ├── Screenshot 2024-08-29 at 11-17-59 WatchYourLAN.png ├── Screenshot_1.png ├── Screenshot_2.png ├── Screenshot_3.png ├── Screenshot_4.png ├── Screenshot_5.png ├── Screenshot_Gotify.png ├── Screenshot_v0.6.png └── logo.png ├── cmd └── WatchYourLAN │ └── main.go ├── configs ├── install.sh ├── postinstall.sh ├── watchyourlan └── watchyourlan.service ├── docker-compose-auth.yml ├── docker-compose.yml ├── docs ├── API.md └── VLAN_ARP_SCAN.md ├── frontend ├── .gitignore ├── Makefile ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App.css │ ├── App.tsx │ ├── components │ │ ├── Body │ │ │ ├── CardHead.tsx │ │ │ ├── TableHead.tsx │ │ │ └── TableRow.tsx │ │ ├── Config │ │ │ ├── About.tsx │ │ │ ├── Basic.tsx │ │ │ ├── Influx.tsx │ │ │ ├── Prometheus.tsx │ │ │ └── Scan.tsx │ │ ├── Filter.tsx │ │ ├── Header.tsx │ │ ├── HistShow.tsx │ │ ├── HostPage │ │ │ ├── HistCard.tsx │ │ │ ├── HostCard.tsx │ │ │ └── Ping.tsx │ │ ├── MacHistory.tsx │ │ └── Search.tsx │ ├── functions │ │ ├── api.ts │ │ ├── atstart.ts │ │ ├── exports.ts │ │ ├── filter.ts │ │ ├── history.ts │ │ ├── search.ts │ │ └── sort.ts │ ├── index.tsx │ ├── pages │ │ ├── Body.tsx │ │ ├── Config.tsx │ │ ├── History.tsx │ │ └── HostPage.tsx │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── go.mod ├── go.sum └── internal ├── arp └── arpscan.go ├── check ├── error.go └── file.go ├── conf └── getconfig.go ├── db ├── choose_db.go ├── connect.go ├── edit.go ├── quote_str.go └── select-exec.go ├── influx └── influx.go ├── models └── models.go ├── notify └── shout.go ├── portscan └── scan.go ├── prometheus └── prometheus.go └── web ├── api.go ├── config.go ├── const-var.go ├── functions.go ├── index.go ├── public ├── assets │ ├── Config.js │ ├── HistShow.js │ ├── History.js │ ├── HostPage.js │ ├── index.css │ └── index.js ├── favicon.png └── version ├── routines-upd.go ├── scan-routine.go ├── templates └── index.html ├── trim-history.go └── webgui.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: ['https://boosty.to/aceberg/donate', 'https://github.com/aceberg#donate'] 4 | -------------------------------------------------------------------------------- /.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-latest 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: '~> v2' 25 | args: release --clean 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.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 }} -------------------------------------------------------------------------------- /.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-latest 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 | -------------------------------------------------------------------------------- /.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-latest 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore local data 2 | data/ 3 | tmp/ 4 | 5 | -------------------------------------------------------------------------------- /.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 | - README.md 58 | - CHANGELOG.md 59 | - src: ./configs/watchyourlan.service 60 | dst: watchyourlan.service 61 | - src: ./configs/install.sh 62 | dst: install.sh 63 | wrap_in_directory: true 64 | format_overrides: 65 | - goos: windows 66 | format: zip 67 | 68 | checksum: 69 | name_template: "checksums.txt" 70 | -------------------------------------------------------------------------------- /.version: -------------------------------------------------------------------------------- 1 | internal/web/public/version -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [v2.1.2] - 2025-03-30 5 | ### Fixed 6 | - Edit names bug 7 | - History page full rerenders replaced with only rerendering updated data 8 | - Select options reset 9 | 10 | ## [v2.1.1] - 2025-03-26 11 | ### Fixed 12 | - Filter bug in Chrome 13 | 14 | ## [v2.1.0] - 2025-03-25 15 | ### Added 16 | - Rewrited GUI in `SolidJS` and `TypeScript` 17 | - Prometheus integration [#181](https://github.com/aceberg/WatchYourLAN/pull/181) 18 | - Optimized Docker build [#180](https://github.com/aceberg/WatchYourLAN/pull/180) 19 | 20 | ### Fixed 21 | - Vite: file names 22 | - Node Path bug 23 | 24 | ## [v2.0.4] - 2024-10-21 25 | ### Added 26 | - Notification test [#147](https://github.com/aceberg/WatchYourLAN/issues/147) 27 | - API status [#148](https://github.com/aceberg/WatchYourLAN/issues/148) 28 | 29 | ### Fixed 30 | - [#101](https://github.com/aceberg/WatchYourLAN/issues/101) 31 | - The same problem for Theme, Color mode, Log level 32 | - Sort bug in Chrome [#140](https://github.com/aceberg/WatchYourLAN/issues/140) 33 | 34 | ## [v2.0.3] - 2024-09-17 35 | ### Fixed 36 | - `ARP_STRS_JOINED` should be empty in config file 37 | - Optimized History Trim 38 | 39 | ## [v2.0.2] - 2024-09-07 40 | ### Added 41 | - Remember Refresh setting in browser [#123](https://github.com/aceberg/WatchYourLAN/issues/123) 42 | 43 | ### Fixed 44 | - Error when `IFACES` are empty 45 | - Sticky sort bug fix 46 | - Bug [#124](https://github.com/aceberg/WatchYourLAN/issues/124) 47 | - Bug [#128](https://github.com/aceberg/WatchYourLAN/issues/128) 48 | 49 | 50 | ## [v2.0.1] - 2024-09-02 51 | ### Added 52 | - `Vlans` and `docker0` support [#47](https://github.com/aceberg/WatchYourLAN/issues/47). Thanks [thehijacker](https://github.com/thehijacker)! 53 | - Remember `sort` field 54 | - `InfluxDB` error handling 55 | 56 | ### Fixed 57 | - Bug [#103](https://github.com/aceberg/WatchYourLAN/issues/103) 58 | - Bug [#104](https://github.com/aceberg/WatchYourLAN/issues/104). Thanks [Steve Clement](https://github.com/SteveClement)! 59 | 60 | ## [v2.0.0] - 2024-08-30 61 | ### Added 62 | - API 63 | - Arguments for `arp-scan` option 64 | - `InfluxDB` export 65 | - `PostgreSQL` or `SQLite` DB options 66 | - Names from DNS 67 | 68 | ### Changed 69 | - Better UI with JS 70 | - Switched to `gin` web framework 71 | - Reworked DB schema and config variables 72 | 73 | -------------------------------------------------------------------------------- /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 go.mod go.sum ./ 10 | RUN go mod download 11 | 12 | COPY . . 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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 . #-c /data/config #-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 -------------------------------------------------------------------------------- /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 | 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/containrrr/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://containrrr.dev/shoutrrr/v0.8/services/gotify/) | | 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 | Store History in DB - if `false`, the History will be stored only in memory and will be lost on app restart. Though, it will keep the app DB smaller (InfluxDB or Prometheus is recommended for long-term History storage) | false | 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 | hist_in_db: false 143 | host: 0.0.0.0 144 | ifaces: enp4s0 145 | influx_addr: "" 146 | influx_bucket: "" 147 | influx_enable: false 148 | influx_org: "" 149 | influx_skip_tls: false 150 | influx_token: "" 151 | log_level: info 152 | nodepath: "" 153 | pg_connect: "" 154 | port: "8840" 155 | prometheus_enable: false 156 | shoutrrr_url: "gotify://192.168.0.1:8083/AwQqpAae.rrl5Ob/?title=Unknown host detected&DisableTLS=yes" 157 | theme: sand 158 | timeout: 60 159 | trim_hist: 48 160 | use_db: sqlite 161 | ``` 162 | 163 |
164 | 165 | ## Options 166 | 167 |
168 | Expand 169 | 170 | | Key | Description | Default | 171 | | -------- | ----------- | ------- | 172 | | -d | Path to config dir | /data/WatchYourLAN | 173 | | -n | Path to node modules (see below) | | 174 | 175 |
176 | 177 | ## Local network only 178 |
179 | Expand 180 | 181 | 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. 182 | Run with Docker: 183 | ```sh 184 | docker run --name node-bootstrap \ 185 | -p 8850:8850 \ 186 | aceberg/node-bootstrap 187 | ``` 188 | ```sh 189 | docker run --name wyl \ 190 | -e "IFACES=$YOURIFACE" \ 191 | -e "TZ=$YOURTIMEZONE" \ 192 | --network="host" \ 193 | -v $DOCKERDATAPATH/wyl:/data/WatchYourLAN \ 194 | aceberg/watchyourlan -n "http://$YOUR_IP:8850" 195 | ``` 196 | Or use [docker-compose](docker-compose.yml) 197 | 198 |
199 | 200 | ## API & Integrations 201 | 202 |
203 | Expand 204 | 205 | ### API 206 | Moved to [docs/API.md](https://github.com/aceberg/WatchYourLAN/blob/main/docs/API.md) 207 | 208 | ### Integrations 209 | - [ArchLinux (AUR)](https://aur.archlinux.org/packages/watch-your-lan) by `gilcu3` 210 | - [Python API client](https://github.com/drwahl/py-watchyourlanclient) by [drwahl](https://github.com/drwahl) 211 | - [Umbrel](https://apps.umbrel.com/app/watch-your-lan) by [Jasper](https://github.com/ceramicwhite) 212 | - [YunoHost](https://apps.yunohost.org/app/watchyourlan) 213 |
214 | 215 | ## Thanks 216 |
217 | Expand 218 | 219 | - All go packages listed in [dependencies](https://github.com/aceberg/WatchYourLAN/network/dependencies) 220 | - Favicon and logo: [Access point icons created by Freepik - Flaticon](https://www.flaticon.com/free-icons/access-point) 221 | - [Bootstrap](https://getbootstrap.com/) 222 | - Themes: [Free themes for Bootstrap](https://bootswatch.com) 223 | 224 |
225 | -------------------------------------------------------------------------------- /assets/Screenshot 2024-08-29 at 01-41-12 WatchYourLAN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/2957a516134b1527fda9970cdd907de59b855db9/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/2957a516134b1527fda9970cdd907de59b855db9/assets/Screenshot 2024-08-29 at 11-17-59 WatchYourLAN.png -------------------------------------------------------------------------------- /assets/Screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/2957a516134b1527fda9970cdd907de59b855db9/assets/Screenshot_1.png -------------------------------------------------------------------------------- /assets/Screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/2957a516134b1527fda9970cdd907de59b855db9/assets/Screenshot_2.png -------------------------------------------------------------------------------- /assets/Screenshot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/2957a516134b1527fda9970cdd907de59b855db9/assets/Screenshot_3.png -------------------------------------------------------------------------------- /assets/Screenshot_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/2957a516134b1527fda9970cdd907de59b855db9/assets/Screenshot_4.png -------------------------------------------------------------------------------- /assets/Screenshot_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/2957a516134b1527fda9970cdd907de59b855db9/assets/Screenshot_5.png -------------------------------------------------------------------------------- /assets/Screenshot_Gotify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/2957a516134b1527fda9970cdd907de59b855db9/assets/Screenshot_Gotify.png -------------------------------------------------------------------------------- /assets/Screenshot_v0.6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/2957a516134b1527fda9970cdd907de59b855db9/assets/Screenshot_v0.6.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aceberg/WatchYourLAN/2957a516134b1527fda9970cdd907de59b855db9/assets/logo.png -------------------------------------------------------------------------------- /cmd/WatchYourLAN/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | // "net/http" 6 | 7 | // _ "net/http/pprof" 8 | 9 | "github.com/aceberg/WatchYourLAN/internal/web" 10 | ) 11 | 12 | const dirPath = "/data/WatchYourLAN" 13 | const nodePath = "" 14 | 15 | func main() { 16 | dirPtr := flag.String("d", dirPath, "Path to config dir") 17 | nodePtr := flag.String("n", nodePath, "Path to node modules") 18 | flag.Parse() 19 | 20 | // pprof - memory leak detect 21 | // go tool pprof -alloc_space http://localhost:8085/debug/pprof/heap 22 | // (pprof) web 23 | // (pprof) list db.Select 24 | // 25 | // go func() { 26 | // http.ListenAndServe("localhost:8085", nil) 27 | // }() 28 | 29 | web.Gui(*dirPtr, *nodePtr) // webgui.go 30 | } 31 | -------------------------------------------------------------------------------- /configs/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cp watchyourlan /usr/bin/ 4 | cp watchyourlan.service /lib/systemd/system/ -------------------------------------------------------------------------------- /configs/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | systemctl daemon-reload -------------------------------------------------------------------------------- /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" -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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/*mac 10 | ``` 11 | Returns all History. If `mac` is not empty, returns only history of a device with this `mac`. 12 | 13 | 14 | ```http 15 | GET /api/host/:id 16 | ``` 17 | Returns host with this `id` in `json`. 18 | 19 | 20 | ```http 21 | GET /api/port/:addr/:port 22 | ``` 23 | Gets state of one `port` of `addr`. Returns `true` if port is open or `false` otherwise. 24 |
25 | Request example 26 | 27 | ```bash 28 | curl http://0.0.0.0:8840/api/port/192.168.2.2/8844 29 | ``` 30 |

31 | 32 | 33 | ```http 34 | GET /api/edit/:id/:name/*known 35 | ``` 36 | Edit host with ID `id`. Can change `name`. `known` is optional, when set to `toggle` will change Known state. 37 | 38 | 39 | ```http 40 | GET /api/host/del/:id 41 | ``` 42 | Remove host with ID `id`. 43 | 44 | 45 | ```http 46 | GET /api/notify_test 47 | ``` 48 | Send test notification. 49 | 50 | 51 | ```http 52 | GET /api/status/*iface 53 | ``` 54 | 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/`. -------------------------------------------------------------------------------- /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/.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 ../internal/web/public/assets/* && \ 7 | cp -r dist/assets ../internal/web/public 8 | 9 | replace: 10 | cd ../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/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 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | WatchYourLAN 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "watchyourlan", 3 | "private": true, 4 | "version": "2.1.2", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@solidjs/router": "^0.15.3", 13 | "solid-js": "^1.9.5" 14 | }, 15 | "devDependencies": { 16 | "typescript": "~5.7.2", 17 | "vite": "^6.2.0", 18 | "vite-plugin-solid": "^2.11.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /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/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/components/Body/CardHead.tsx: -------------------------------------------------------------------------------- 1 | import { Show } from "solid-js"; 2 | import { editNames, setEditNames } from "../../functions/exports"; 3 | import Filter from "../Filter"; 4 | import Search from "../Search"; 5 | import { getHosts } from "../../functions/atstart"; 6 | 7 | function CardHead() { 8 | 9 | const handleEditNames = (toggle: boolean) => { 10 | if (!toggle) { 11 | getHosts(); 12 | } 13 | setEditNames(toggle); 14 | }; 15 | 16 | return ( 17 |
18 |
19 |
20 | 21 |
22 |
23 |
24 |
25 | 26 | Edit names} 29 | > 30 | 31 | 32 |
33 |
34 |
35 | ) 36 | } 37 | 38 | export default CardHead 39 | -------------------------------------------------------------------------------- /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 | 28 | 29 | 30 | {(key) => 31 | {key} 38 | } 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default TableHead -------------------------------------------------------------------------------- /frontend/src/components/Body/TableRow.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, Show } from "solid-js"; 2 | import { editNames } from "../../functions/exports"; 3 | import { apiEditHost } from "../../functions/api"; 4 | 5 | function TableRow(_props: any) { 6 | 7 | const [name, setName] = createSignal(_props.host.Name); 8 | 9 | let now = ; 10 | if (_props.host.Now == 1) { 11 | now = ; 12 | }; 13 | 14 | let known:boolean; 15 | _props.host.Known === 1 ? known = true : known = false; 16 | 17 | const handleInput = async (n: string) => { 18 | setName(n); 19 | await apiEditHost(_props.host.ID, name(), ''); 20 | }; 21 | const handleToggle = async () => { 22 | await apiEditHost(_props.host.ID, name(), 'toggle'); 23 | }; 24 | 25 | return ( 26 | 27 | {_props.index}. 28 | 29 | 33 | handleInput(e.target.value)}> 35 | 36 | 37 | {_props.host.Iface} 38 | {_props.host.IP} 39 | {_props.host.Mac} 40 | {_props.host.Hw.slice(0,12)+".."} 41 | {_props.host.Date} 42 | 43 |
44 | 46 |
47 | 48 | {now} 49 | 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | } 57 | 58 | export default TableRow 59 | -------------------------------------------------------------------------------- /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 |

● After changing Host or Port the app must be restarted

22 |

Shoutrrr URL provides notifications to Discord, Email, Gotify, Telegram and other services. Link to documentation

23 |

Interfaces - one or more, space separated

24 |

Timeout (seconds) - time between scans

25 |

Args for arp-scan - pass your own arguments to arp-scan. Enable debug log level to see resulting command. (Example: -r 1). See docs for more.

26 |

Arp Strings - can setup scans for vlans, docker0 and etcetera. See docs for more.

27 |

Trim History - remove history after (hours)

28 |

Store History in DB - if off, the History will be stored only in memory and will be lost on app restart. Though, it will keep the app DB smaller and InfluxDB is recommended for long term History storage

29 |

PG Connect URL - address to connect to PostgreSQL DB. (Example: postgres://username:password@192.168.0.1:5432/dbname?sslmode=disable). Full list of URL parameters here

30 |

● If you find this app useful, please, donate

31 |

● Commission you own app (Golang, React/SolidJS). Contact here

32 |
33 |
34 | ) 35 | } 36 | 37 | export default About -------------------------------------------------------------------------------- /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/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/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 -------------------------------------------------------------------------------- /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 | 61 | 62 | 63 | 64 | 76 | 77 | 78 | 79 | 82 | 83 | 84 | 85 | 86 | 87 |
Interfaces
Timeout (seconds)
Args for arp-scan
Arp Strings 28 | {arpStr => 29 | 30 | } 31 | 32 |
Log level
Trim History (hours)
Store History in DB 54 |
55 | {appConfig().HistInDB 56 | ? 57 | : 58 | } 59 |
60 |
Use DB
PG Connect URL 80 | 81 |
88 |
89 |
90 |
91 | ) 92 | } 93 | 94 | export default Scan -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /frontend/src/components/HostPage/HistCard.tsx: -------------------------------------------------------------------------------- 1 | import { setShow, show } from "../../functions/exports"; 2 | import HistShow from "../HistShow" 3 | import MacHistory from "../MacHistory" 4 | 5 | function HistCard(_props: any) { 6 | 7 | const showStr = localStorage.getItem("hostShow") as string; 8 | setShow(+showStr); 9 | (show() === 0 || isNaN(show())) ? setShow(500) : ''; 10 | 11 | return ( 12 |
13 |
14 |
Host History
15 | 16 |
17 |
18 | {_props.mac !== "" 19 | ? 20 | : <>Loading... 21 | } 22 |
23 |
24 | ) 25 | } 26 | 27 | export default HistCard -------------------------------------------------------------------------------- /frontend/src/components/HostPage/HostCard.tsx: -------------------------------------------------------------------------------- 1 | import { apiDelHost, apiEditHost } from "../../functions/api"; 2 | import { getHosts } from "../../functions/atstart"; 3 | 4 | function HostCard(_props: any) { 5 | 6 | let name:string = ""; 7 | 8 | const handleInput = async (n: string) => { 9 | 10 | name = n; 11 | await apiEditHost(_props.host.ID, name, ''); 12 | getHosts(); 13 | }; 14 | 15 | const handleToggle = async () => { 16 | 17 | if (name == "") { 18 | name = _props.host.Name; 19 | } 20 | 21 | await apiEditHost(_props.host.ID, name, 'toggle'); 22 | getHosts(); 23 | }; 24 | 25 | const handleDel = async () => { 26 | 27 | await apiDelHost(_props.host.ID); 28 | window.location.href = '/'; 29 | }; 30 | 31 | return ( 32 |
33 |
Host
34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 87 | 88 | 89 | 90 | 94 | 95 | 96 |
ID{_props.host.ID}
Name 44 | handleInput(e.target.value)}> 46 |
DNS name{_props.host.DNS}
Iface{_props.host.Iface}
IP 59 | {_props.host.IP} 60 |
MAC{_props.host.Mac}
Hardware{_props.host.Hw}
Date{_props.host.Date}
Known 77 |
78 | 85 |
86 |
Online{_props.host.Now == 1 91 | ? 92 | : 93 | }
97 | 98 |
99 |
100 | ) 101 | } 102 | 103 | export default HostCard -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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); 13 | setHist(newHistory); 14 | interval = setInterval(async () => { 15 | // console.log("Upd Hist", new Date()); 16 | const newHistory = await getHistoryForMac(_props.mac); 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 | -------------------------------------------------------------------------------- /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/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; 66 | const hosts = await (await fetch(url)).json(); 67 | 68 | return hosts; 69 | }; -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | HistInDB: boolean; 31 | ShoutURL: string; 32 | UseDB: string; 33 | PGConnect: string; 34 | // InfluxDB 35 | InfluxEnable: boolean; 36 | InfluxAddr: string; 37 | InfluxToken: string; 38 | InfluxOrg: string; 39 | InfluxBucket: string; 40 | InfluxSkipTLS: boolean; 41 | // Prometheus 42 | PrometheusEnable: boolean; 43 | }; 44 | 45 | export const emptyHost:Host = { 46 | ID: 0, 47 | Name: "", 48 | DNS: "", 49 | Iface: "", 50 | IP: "", 51 | Mac: "", 52 | Hw: "", 53 | Date: "", 54 | Known: 0, 55 | Now: 0, 56 | }; 57 | 58 | export const emptyConf:Conf = { 59 | Host: "", 60 | Port: "", 61 | Theme: "", 62 | Color: "", 63 | DirPath: "", 64 | Timeout: 120, 65 | NodePath: "", 66 | LogLevel: "", 67 | Ifaces: "", 68 | ArpArgs: "", 69 | ArpStrs: [], 70 | TrimHist: 48, 71 | HistInDB: false, 72 | ShoutURL: "", 73 | UseDB: "", 74 | PGConnect: "", 75 | InfluxEnable: false, 76 | InfluxAddr: "", 77 | InfluxToken: "", 78 | InfluxOrg: "", 79 | InfluxBucket: "", 80 | InfluxSkipTLS: false, 81 | PrometheusEnable: false, 82 | }; 83 | 84 | export const [allHosts, setAllHosts] = createStore([]); 85 | export const [bkpHosts, setBkpHosts] = createSignal([]); 86 | 87 | export const [ifaces, setIfaces] = createSignal([]); 88 | 89 | export const [appConfig, setAppConfig] = createSignal(emptyConf); 90 | 91 | export const [editNames, setEditNames] = createSignal(false); 92 | 93 | export const [show, setShow] = createSignal(200); 94 | 95 | export const [histUpdOnFilter, setHistUpdOnFilter] = createSignal(false); -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /frontend/src/functions/history.ts: -------------------------------------------------------------------------------- 1 | import { apiGetHistory } from "./api"; 2 | import { Host } from "./exports"; 3 | 4 | export async function getHistoryForMac(mac: string) { 5 | let h:Host[] = []; 6 | h = await apiGetHistory(mac); 7 | if (h != null) { 8 | h.sort((a:Host, b:Host) => (a.Date < b.Date ? 1 : -1)); 9 | return h; 10 | } 11 | return []; 12 | } -------------------------------------------------------------------------------- /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/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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/src/pages/Body.tsx: -------------------------------------------------------------------------------- 1 | import { For } 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 | 9 | function Body() { 10 | 11 | return ( 12 |
13 |
14 | 15 |
16 |
17 | 18 | 19 | 20 | {(host, index) => 21 | 22 | } 23 | 24 |
25 |
26 |
27 | ) 28 | } 29 | 30 | export default Body 31 | -------------------------------------------------------------------------------- /frontend/src/pages/Config.tsx: -------------------------------------------------------------------------------- 1 | import About from "../components/Config/About" 2 | import Basic from "../components/Config/Basic" 3 | import Influx from "../components/Config/Influx" 4 | import Prometheus from "../components/Config/Prometheus" 5 | import Scan from "../components/Config/Scan" 6 | 7 | function Config() { 8 | 9 | return ( 10 |
11 |
12 | 13 |
14 | 15 |
16 |
17 |
18 | 19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 | ) 28 | } 29 | 30 | export default Config -------------------------------------------------------------------------------- /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 | 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 | -------------------------------------------------------------------------------- /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/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /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/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | } 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aceberg/WatchYourLAN 2 | 3 | go 1.23.6 4 | 5 | require ( 6 | github.com/containrrr/shoutrrr v0.8.0 7 | github.com/gin-gonic/gin v1.10.0 8 | github.com/influxdata/influxdb-client-go/v2 v2.14.0 9 | github.com/jmoiron/sqlx v1.4.0 10 | github.com/lib/pq v1.10.9 11 | github.com/prometheus/client_golang v1.21.1 12 | github.com/spf13/viper v1.20.0 13 | modernc.org/sqlite v1.36.2 14 | ) 15 | 16 | require ( 17 | github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect 18 | github.com/beorn7/perks v1.0.1 // indirect 19 | github.com/bytedance/sonic v1.11.6 // indirect 20 | github.com/bytedance/sonic/loader v0.1.1 // indirect 21 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 22 | github.com/cloudwego/base64x v0.1.4 // indirect 23 | github.com/cloudwego/iasm v0.2.0 // indirect 24 | github.com/dustin/go-humanize v1.0.1 // indirect 25 | github.com/fatih/color v1.15.0 // indirect 26 | github.com/fsnotify/fsnotify v1.8.0 // indirect 27 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 28 | github.com/gin-contrib/sse v0.1.0 // indirect 29 | github.com/go-playground/locales v0.14.1 // indirect 30 | github.com/go-playground/universal-translator v0.18.1 // indirect 31 | github.com/go-playground/validator/v10 v10.20.0 // indirect 32 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 33 | github.com/goccy/go-json v0.10.2 // indirect 34 | github.com/google/uuid v1.6.0 // indirect 35 | github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect 36 | github.com/json-iterator/go v1.1.12 // indirect 37 | github.com/klauspost/compress v1.17.11 // indirect 38 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 39 | github.com/leodido/go-urn v1.4.0 // indirect 40 | github.com/mattn/go-colorable v0.1.13 // indirect 41 | github.com/mattn/go-isatty v0.0.20 // indirect 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 43 | github.com/modern-go/reflect2 v1.0.2 // indirect 44 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 45 | github.com/ncruces/go-strftime v0.1.9 // indirect 46 | github.com/oapi-codegen/runtime v1.0.0 // indirect 47 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 48 | github.com/prometheus/client_model v0.6.1 // indirect 49 | github.com/prometheus/common v0.62.0 // indirect 50 | github.com/prometheus/procfs v0.15.1 // indirect 51 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 52 | github.com/sagikazarmark/locafero v0.7.0 // indirect 53 | github.com/sourcegraph/conc v0.3.0 // indirect 54 | github.com/spf13/afero v1.12.0 // indirect 55 | github.com/spf13/cast v1.7.1 // indirect 56 | github.com/spf13/pflag v1.0.6 // indirect 57 | github.com/subosito/gotenv v1.6.0 // indirect 58 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 59 | github.com/ugorji/go/codec v1.2.12 // indirect 60 | go.uber.org/atomic v1.9.0 // indirect 61 | go.uber.org/multierr v1.9.0 // indirect 62 | golang.org/x/arch v0.8.0 // indirect 63 | golang.org/x/crypto v0.32.0 // indirect 64 | golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect 65 | golang.org/x/net v0.33.0 // indirect 66 | golang.org/x/sys v0.31.0 // indirect 67 | golang.org/x/text v0.21.0 // indirect 68 | google.golang.org/protobuf v1.36.1 // indirect 69 | gopkg.in/yaml.v3 v3.0.1 // indirect 70 | modernc.org/libc v1.61.13 // indirect 71 | modernc.org/mathutil v1.7.1 // indirect 72 | modernc.org/memory v1.8.2 // indirect 73 | ) 74 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= 4 | github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= 5 | github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= 6 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 7 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 8 | github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= 9 | github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= 10 | github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= 11 | github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= 12 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 13 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 14 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 15 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 16 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 17 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 18 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 19 | github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= 20 | github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= 21 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 23 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 25 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 26 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 27 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 28 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 29 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 30 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 31 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 32 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 33 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 34 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 35 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 36 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 37 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 38 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 39 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 40 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 41 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 42 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 43 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 44 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 45 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 46 | github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= 47 | github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 48 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 49 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 50 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 51 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 52 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 53 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 54 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 55 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 56 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 57 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 58 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 59 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 60 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 61 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 62 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 63 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 64 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 65 | github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= 66 | github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= 67 | github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= 68 | github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= 69 | github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= 70 | github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= 71 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 72 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 73 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 74 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 75 | github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= 76 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 77 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 78 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 79 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 80 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 81 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 82 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 83 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 84 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 85 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 86 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 87 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 88 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 89 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 90 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 91 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 92 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 93 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 94 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 95 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 96 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 97 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 98 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 99 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 100 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 101 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 102 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 103 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 104 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 105 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 106 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 107 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 108 | github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= 109 | github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= 110 | github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= 111 | github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= 112 | github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= 113 | github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 114 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 115 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 116 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 117 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 118 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 119 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 120 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 121 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 122 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 123 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 124 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 125 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 126 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 127 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 128 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 129 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 130 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 131 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 132 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 133 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 134 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 135 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 136 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 137 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 138 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 139 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 140 | github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY= 141 | github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 142 | github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= 143 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 144 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 145 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 146 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 147 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 148 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 149 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 150 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 151 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 152 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 153 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 154 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 155 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 156 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 157 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 158 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 159 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 160 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 161 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 162 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 163 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 164 | golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= 165 | golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 166 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 167 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 168 | golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= 169 | golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= 170 | golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= 171 | golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 172 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 173 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 174 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 175 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 176 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 177 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 178 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 179 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 180 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 181 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 182 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 183 | golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= 184 | golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= 185 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 186 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 187 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 188 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 189 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 190 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 191 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 192 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 193 | modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= 194 | modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 195 | modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= 196 | modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= 197 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 198 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 199 | modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= 200 | modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 201 | modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= 202 | modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= 203 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 204 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 205 | modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= 206 | modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= 207 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 208 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 209 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 210 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 211 | modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA= 212 | modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws= 213 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 214 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 215 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 216 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 217 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 218 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 219 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /internal/conf/getconfig.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "log/slog" 5 | "strings" 6 | 7 | "github.com/spf13/viper" 8 | 9 | "github.com/aceberg/WatchYourLAN/internal/check" 10 | "github.com/aceberg/WatchYourLAN/internal/models" 11 | ) 12 | 13 | // Get - get app config 14 | func Get(path string) (config models.Conf) { 15 | 16 | viper.SetDefault("HOST", "0.0.0.0") 17 | viper.SetDefault("PORT", "8840") 18 | viper.SetDefault("THEME", "sand") 19 | viper.SetDefault("COLOR", "dark") 20 | viper.SetDefault("NODEPATH", "") 21 | viper.SetDefault("LOG_LEVEL", "info") 22 | viper.SetDefault("ARP_ARGS", "") 23 | viper.SetDefault("ARP_STRS_JOINED", "") 24 | viper.SetDefault("IFACES", "") 25 | viper.SetDefault("TIMEOUT", 120) 26 | viper.SetDefault("TRIM_HIST", 48) 27 | viper.SetDefault("HIST_IN_DB", false) 28 | viper.SetDefault("SHOUTRRR_URL", "") 29 | 30 | viper.SetDefault("USE_DB", "sqlite") 31 | viper.SetDefault("PG_CONNECT", "") 32 | 33 | viper.SetDefault("INFLUX_ENABLE", false) 34 | 35 | viper.SetDefault("PROMETHEUS_ENABLE", false) 36 | 37 | viper.SetConfigFile(path) 38 | viper.SetConfigType("yaml") 39 | err := viper.ReadInConfig() 40 | check.IfError(err) 41 | 42 | viper.AutomaticEnv() // Get ENVIRONMENT variables 43 | 44 | config.Host = viper.Get("HOST").(string) 45 | config.Port = viper.Get("PORT").(string) 46 | config.Theme = viper.Get("THEME").(string) 47 | config.Color = viper.Get("COLOR").(string) 48 | config.NodePath = viper.Get("NODEPATH").(string) 49 | config.LogLevel = viper.Get("LOG_LEVEL").(string) 50 | config.ArpArgs = viper.Get("ARP_ARGS").(string) 51 | config.ArpStrs = viper.GetStringSlice("ARP_STRS") 52 | config.Ifaces = viper.Get("IFACES").(string) 53 | config.Timeout = viper.GetInt("TIMEOUT") 54 | config.TrimHist = viper.GetInt("TRIM_HIST") 55 | config.HistInDB = viper.GetBool("HIST_IN_DB") 56 | config.ShoutURL = viper.Get("SHOUTRRR_URL").(string) 57 | 58 | config.UseDB = viper.Get("USE_DB").(string) 59 | config.PGConnect = viper.Get("PG_CONNECT").(string) 60 | 61 | config.InfluxEnable = viper.GetBool("INFLUX_ENABLE") 62 | config.InfluxSkipTLS = viper.GetBool("INFLUX_SKIP_TLS") 63 | config.InfluxAddr, _ = viper.Get("INFLUX_ADDR").(string) 64 | config.InfluxToken, _ = viper.Get("INFLUX_TOKEN").(string) 65 | config.InfluxOrg, _ = viper.Get("INFLUX_ORG").(string) 66 | config.InfluxBucket, _ = viper.Get("INFLUX_BUCKET").(string) 67 | 68 | config.PrometheusEnable = viper.GetBool("PROMETHEUS_ENABLE") 69 | 70 | joined := viper.Get("ARP_STRS_JOINED").(string) 71 | slog.Info("ARP_STRS_JOINED: " + joined) 72 | 73 | if joined != "" { 74 | config.ArpStrs = strings.Split(joined, ",") 75 | } 76 | 77 | return config 78 | } 79 | 80 | // Write - write config to file 81 | func Write(config models.Conf) { 82 | 83 | viper.SetConfigFile(config.ConfPath) 84 | viper.SetConfigType("yaml") 85 | 86 | viper.Set("HOST", config.Host) 87 | viper.Set("PORT", config.Port) 88 | viper.Set("THEME", config.Theme) 89 | viper.Set("COLOR", config.Color) 90 | viper.Set("NODEPATH", config.NodePath) 91 | viper.Set("LOG_LEVEL", config.LogLevel) 92 | viper.Set("ARP_ARGS", config.ArpArgs) 93 | viper.Set("ARP_STRS", config.ArpStrs) 94 | viper.Set("ARP_STRS_JOINED", "") // Can be set only with ENV 95 | viper.Set("IFACES", config.Ifaces) 96 | viper.Set("TIMEOUT", config.Timeout) 97 | viper.Set("TRIM_HIST", config.TrimHist) 98 | viper.Set("HIST_IN_DB", config.HistInDB) 99 | viper.Set("SHOUTRRR_URL", config.ShoutURL) 100 | 101 | viper.Set("USE_DB", config.UseDB) 102 | viper.Set("PG_CONNECT", config.PGConnect) 103 | 104 | viper.Set("influx_enable", config.InfluxEnable) 105 | viper.Set("influx_skip_tls", config.InfluxSkipTLS) 106 | viper.Set("influx_addr", config.InfluxAddr) 107 | viper.Set("influx_token", config.InfluxToken) 108 | viper.Set("influx_org", config.InfluxOrg) 109 | viper.Set("influx_bucket", config.InfluxBucket) 110 | 111 | viper.Set("PROMETHEUS_ENABLE", config.PrometheusEnable) 112 | 113 | err := viper.WriteConfig() 114 | check.IfError(err) 115 | } 116 | -------------------------------------------------------------------------------- /internal/db/choose_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/aceberg/WatchYourLAN/internal/models" 7 | ) 8 | 9 | // Data to connect to DB 10 | type Data struct { 11 | Use string 12 | Path string 13 | SQLitePath string 14 | PGConnect string 15 | PrimaryKey string 16 | } 17 | 18 | var currentDB Data 19 | 20 | func setCurrentDB() { 21 | 22 | if currentDB.Use == "postgres" && currentDB.PGConnect != "" { 23 | currentDB.Path = currentDB.PGConnect 24 | currentDB.PrimaryKey = "BIGSERIAL PRIMARY KEY" 25 | } else { 26 | currentDB.Use = "sqlite" 27 | currentDB.Path = currentDB.SQLitePath 28 | currentDB.PrimaryKey = "INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE" 29 | } 30 | 31 | slog.Info("Using DB", "type", currentDB.Use) 32 | } 33 | 34 | // SetCurrent - set paths and which DB to use 35 | func SetCurrent(config models.Conf) { 36 | 37 | currentDB.Use = config.UseDB 38 | currentDB.SQLitePath = config.DBPath 39 | currentDB.PGConnect = config.PGConnect 40 | 41 | setCurrentDB() 42 | } 43 | -------------------------------------------------------------------------------- /internal/db/connect.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/jmoiron/sqlx" 7 | 8 | // import postgres 9 | _ "github.com/lib/pq" 10 | 11 | // Import sqlite module 12 | _ "modernc.org/sqlite" 13 | 14 | "github.com/aceberg/WatchYourLAN/internal/check" 15 | ) 16 | 17 | func connectDB() (*sqlx.DB, bool) { 18 | var ok bool 19 | 20 | db, err := sqlx.Open(currentDB.Use, currentDB.Path) 21 | check.IfError(err) 22 | 23 | err = db.Ping() 24 | if check.IfError(err) && currentDB.Use == "postgres" { 25 | slog.Warn("PostgreSQL connection error. Falling back to SQLite.") 26 | currentDB.Use = "sqlite" 27 | setCurrentDB() 28 | Create() 29 | } 30 | 31 | if err == nil { 32 | ok = true 33 | } 34 | 35 | return db, ok 36 | } 37 | -------------------------------------------------------------------------------- /internal/db/edit.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/aceberg/WatchYourLAN/internal/models" 7 | ) 8 | 9 | // Create - create DB if not exists 10 | func Create() { 11 | 12 | sqlStatement := `CREATE TABLE IF NOT EXISTS "now" ( 13 | "ID" ` + currentDB.PrimaryKey + `, 14 | "NAME" TEXT NOT NULL, 15 | "DNS" TEXT NOT NULL, 16 | "IFACE" TEXT, 17 | "IP" TEXT, 18 | "MAC" TEXT, 19 | "HW" TEXT, 20 | "DATE" TEXT, 21 | "KNOWN" INTEGER DEFAULT 0, 22 | "NOW" INTEGER DEFAULT 0 23 | );` 24 | dbExec(sqlStatement) 25 | 26 | sqlStatement = `CREATE TABLE IF NOT EXISTS "history" ( 27 | "ID" ` + currentDB.PrimaryKey + `, 28 | "NAME" TEXT NOT NULL, 29 | "DNS" TEXT NOT NULL, 30 | "IFACE" TEXT, 31 | "IP" TEXT, 32 | "MAC" TEXT, 33 | "HW" TEXT, 34 | "DATE" TEXT, 35 | "KNOWN" INTEGER DEFAULT 0, 36 | "NOW" INTEGER DEFAULT 0 37 | );` 38 | dbExec(sqlStatement) 39 | } 40 | 41 | // Insert - insert host into table 42 | func Insert(table string, oneHost models.Host) { 43 | oneHost.Name = quoteStr(oneHost.Name) 44 | oneHost.Hw = quoteStr(oneHost.Hw) 45 | sqlStatement := `INSERT INTO %s ("NAME", "DNS", "IFACE", "IP", "MAC", "HW", "DATE", "KNOWN", "NOW") VALUES ('%s','%s','%s','%s','%s','%s','%s','%d','%d');` 46 | sqlStatement = fmt.Sprintf(sqlStatement, table, oneHost.Name, oneHost.DNS, oneHost.Iface, oneHost.IP, oneHost.Mac, oneHost.Hw, oneHost.Date, oneHost.Known, oneHost.Now) 47 | 48 | dbExec(sqlStatement) 49 | } 50 | 51 | // Update - update host 52 | func Update(table string, oneHost models.Host) { 53 | oneHost.Name = quoteStr(oneHost.Name) 54 | oneHost.Hw = quoteStr(oneHost.Hw) 55 | sqlStatement := `UPDATE %s set 56 | "NAME" = '%s', "DNS" = '%s', "IFACE" = '%s', "IP" = '%s', "MAC" = '%s', "HW" = '%s', "DATE" = '%s', "KNOWN" = '%d', "NOW" = '%d' 57 | WHERE "ID" = '%d';` 58 | sqlStatement = fmt.Sprintf(sqlStatement, table, oneHost.Name, oneHost.DNS, oneHost.Iface, oneHost.IP, oneHost.Mac, oneHost.Hw, oneHost.Date, oneHost.Known, oneHost.Now, oneHost.ID) 59 | 60 | dbExec(sqlStatement) 61 | } 62 | 63 | // Delete - delete host from DB 64 | func Delete(table string, id int) { 65 | sqlStatement := `DELETE FROM %s WHERE "ID"='%d';` 66 | sqlStatement = fmt.Sprintf(sqlStatement, table, id) 67 | dbExec(sqlStatement) 68 | } 69 | 70 | // DeleteList - delete a list of hosts from History 71 | func DeleteList(ids []int) { 72 | if len(ids) > 0 { 73 | idString := "" 74 | 75 | for _, id := range ids { 76 | idString = idString + fmt.Sprintf("%d, ", id) 77 | } 78 | idString = idString[:len(idString)-2] 79 | 80 | sqlStatement := `DELETE FROM history WHERE "ID" IN (%s);` 81 | sqlStatement = fmt.Sprintf(sqlStatement, idString) 82 | 83 | dbExec(sqlStatement) 84 | } 85 | } 86 | 87 | // Clear - delete all hosts from table 88 | func Clear(table string) { 89 | sqlStatement := `DELETE FROM %s;` 90 | sqlStatement = fmt.Sprintf(sqlStatement, table) 91 | dbExec(sqlStatement) 92 | } 93 | -------------------------------------------------------------------------------- /internal/db/quote_str.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "strings" 4 | 5 | func quoteStr(str string) string { 6 | return strings.ReplaceAll(str, "'", "''") 7 | } 8 | -------------------------------------------------------------------------------- /internal/db/select-exec.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | // "log/slog" 5 | "sync" 6 | 7 | "github.com/aceberg/WatchYourLAN/internal/check" 8 | "github.com/aceberg/WatchYourLAN/internal/models" 9 | ) 10 | 11 | var mu sync.Mutex 12 | 13 | func dbExec(sqlStatement string) { 14 | 15 | db, ok := connectDB() 16 | defer db.Close() 17 | 18 | if ok { 19 | mu.Lock() 20 | _, err := db.Exec(sqlStatement) 21 | mu.Unlock() 22 | check.IfError(err) 23 | } 24 | } 25 | 26 | // Select - get all hosts 27 | func Select(table string) (dbHosts []models.Host) { 28 | 29 | sqlStatement := `SELECT * FROM ` + table + ` ORDER BY "DATE" DESC` 30 | 31 | db, ok := connectDB() 32 | defer db.Close() 33 | 34 | if ok { 35 | mu.Lock() 36 | err := db.Select(&dbHosts, sqlStatement) 37 | mu.Unlock() 38 | check.IfError(err) 39 | } 40 | 41 | return dbHosts 42 | } 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | HistInDB bool 20 | ShoutURL 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 `db:"ID"` 38 | Name string `db:"NAME"` 39 | DNS string `db:"DNS"` 40 | Iface string `db:"IFACE"` 41 | IP string `db:"IP"` 42 | Mac string `db:"MAC"` 43 | Hw string `db:"HW"` 44 | Date string `db:"DATE"` 45 | Known int `db:"KNOWN"` 46 | Now int `db:"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 | -------------------------------------------------------------------------------- /internal/notify/shout.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "github.com/containrrr/shoutrrr" 5 | "log/slog" 6 | ) 7 | 8 | // Shout - send message with shoutrrr 9 | func Shout(message string, url string) { 10 | if url != "" { 11 | err := shoutrrr.Send(url, message) 12 | if err != nil { 13 | slog.Error("Notification failed (shoutrrr): ", "", err) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /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 | if err != nil { 17 | return false 18 | } 19 | 20 | if conn != nil { 21 | conn.Close() 22 | return true 23 | } 24 | 25 | return false 26 | } 27 | -------------------------------------------------------------------------------- /internal/prometheus/prometheus.go: -------------------------------------------------------------------------------- 1 | package prometheus 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/aceberg/WatchYourLAN/internal/models" 8 | "github.com/gin-gonic/gin" 9 | "github.com/prometheus/client_golang/prometheus" 10 | "github.com/prometheus/client_golang/prometheus/promauto" 11 | "github.com/prometheus/client_golang/prometheus/promhttp" 12 | ) 13 | 14 | // Handler - display Prometheus metrics 15 | func Handler(appConfig *models.Conf) func(c *gin.Context) { 16 | h := promhttp.Handler() 17 | return func(c *gin.Context) { 18 | if !appConfig.PrometheusEnable { 19 | c.AbortWithStatus(http.StatusNotFound) 20 | return 21 | } 22 | h.ServeHTTP(c.Writer, c.Request) 23 | } 24 | } 25 | 26 | var up = promauto.NewGaugeVec(prometheus.GaugeOpts{ 27 | Namespace: "watch_your_lan", 28 | Name: "up", 29 | Help: "Whether the host is up (1 for yes, 0 for no)", 30 | }, []string{"ip", "iface", "name", "mac", "known"}) 31 | 32 | // Add a Prometheus metric 33 | func Add(oneHist models.Host) { 34 | if oneHist.Name == "" { 35 | oneHist.Name = "unknown" 36 | } 37 | 38 | up.With(prometheus.Labels{ 39 | "ip": oneHist.IP, 40 | "iface": oneHist.Iface, 41 | "name": oneHist.Name, 42 | "mac": oneHist.Mac, 43 | "known": strconv.Itoa(oneHist.Known), 44 | }).Set(float64(oneHist.Now)) 45 | } 46 | -------------------------------------------------------------------------------- /internal/web/api.go: -------------------------------------------------------------------------------- 1 | package web 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/db" 11 | "github.com/aceberg/WatchYourLAN/internal/models" 12 | "github.com/aceberg/WatchYourLAN/internal/notify" 13 | "github.com/aceberg/WatchYourLAN/internal/portscan" 14 | ) 15 | 16 | func apiAll(c *gin.Context) { 17 | 18 | allHosts = db.Select("now") 19 | 20 | c.IndentedJSON(http.StatusOK, allHosts) 21 | } 22 | 23 | func apiVersion(c *gin.Context) { 24 | 25 | file, err := pubFS.ReadFile("public/version") 26 | check.IfError(err) 27 | version := string(file)[8:] 28 | 29 | c.IndentedJSON(http.StatusOK, version) 30 | } 31 | 32 | func apiGetConfig(c *gin.Context) { 33 | 34 | c.IndentedJSON(http.StatusOK, appConfig) 35 | } 36 | 37 | func apiHistory(c *gin.Context) { 38 | var hosts []models.Host 39 | 40 | if appConfig.HistInDB { 41 | histHosts = db.Select("history") 42 | } 43 | 44 | mac := c.Param("mac") 45 | 46 | if mac != "/" { 47 | mac = mac[1:] 48 | hosts = getHostsByMAC(mac, histHosts) 49 | } else { 50 | hosts = histHosts 51 | } 52 | 53 | c.IndentedJSON(http.StatusOK, hosts) 54 | } 55 | 56 | func apiHost(c *gin.Context) { 57 | 58 | idStr := c.Param("id") 59 | 60 | host := getHostByID(idStr, allHosts) // functions.go 61 | _, host.DNS = updateDNS(host) 62 | 63 | c.IndentedJSON(http.StatusOK, host) 64 | } 65 | 66 | func apiHostDel(c *gin.Context) { 67 | 68 | idStr := c.Param("id") 69 | host := getHostByID(idStr, allHosts) // functions.go 70 | db.Delete("now", host.ID) 71 | allHosts = db.Select("now") 72 | 73 | slog.Info("Deleting from DB", "host", host) 74 | 75 | c.IndentedJSON(http.StatusOK, "OK") 76 | } 77 | 78 | func apiPort(c *gin.Context) { 79 | 80 | addr := c.Param("addr") 81 | port := c.Param("port") 82 | state := portscan.IsOpen(addr, port) 83 | 84 | c.IndentedJSON(http.StatusOK, state) 85 | } 86 | 87 | func apiEdit(c *gin.Context) { 88 | 89 | idStr := c.Param("id") 90 | name := c.Param("name") 91 | toggleKnown := c.Param("known") 92 | 93 | host := getHostByID(idStr, allHosts) // functions.go 94 | if host.Date != "" { 95 | host.Name = name 96 | 97 | if toggleKnown == "/toggle" { 98 | host.Known = 1 - host.Known 99 | } 100 | // log.Println("EDIT: ", host) 101 | 102 | db.Update("now", host) 103 | allHosts = db.Select("now") 104 | } 105 | 106 | c.IndentedJSON(http.StatusOK, "OK") 107 | } 108 | 109 | func apiNotifyTest(c *gin.Context) { 110 | 111 | msg := "WatchYourLAN: test notification" 112 | notify.Shout(msg, appConfig.ShoutURL) 113 | 114 | c.Status(http.StatusOK) 115 | } 116 | 117 | func apiStatus(c *gin.Context) { 118 | var status models.Stat 119 | var searchHosts []models.Host 120 | 121 | iface := c.Param("iface") 122 | iface = iface[1:] 123 | 124 | if iface != "" { 125 | for _, host := range allHosts { 126 | if iface == host.Iface { 127 | searchHosts = append(searchHosts, host) 128 | } 129 | } 130 | } else { 131 | searchHosts = allHosts 132 | } 133 | 134 | for _, host := range searchHosts { 135 | status.Total = status.Total + 1 136 | 137 | if host.Known > 0 { 138 | status.Known = status.Known + 1 139 | } else { 140 | status.Unknown = status.Unknown + 1 141 | } 142 | if host.Now > 0 { 143 | status.Online = status.Online + 1 144 | } else { 145 | status.Offline = status.Offline + 1 146 | } 147 | } 148 | 149 | c.IndentedJSON(http.StatusOK, status) 150 | } 151 | -------------------------------------------------------------------------------- /internal/web/config.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/aceberg/WatchYourLAN/internal/conf" 11 | ) 12 | 13 | func saveConfigHandler(c *gin.Context) { 14 | 15 | appConfig.Host = c.PostForm("host") 16 | appConfig.Port = c.PostForm("port") 17 | appConfig.Theme = c.PostForm("theme") 18 | appConfig.Color = c.PostForm("color") 19 | appConfig.NodePath = c.PostForm("node") 20 | appConfig.ShoutURL = c.PostForm("shout") 21 | 22 | conf.Write(appConfig) 23 | 24 | slog.Info("Writing new config to " + appConfig.ConfPath) 25 | 26 | c.Redirect(http.StatusFound, c.Request.Referer()) 27 | } 28 | 29 | func saveSettingsHandler(c *gin.Context) { 30 | 31 | appConfig.LogLevel = c.PostForm("log") 32 | appConfig.ArpArgs = c.PostForm("arpargs") 33 | appConfig.Ifaces = c.PostForm("ifaces") 34 | 35 | appConfig.UseDB = c.PostForm("usedb") 36 | appConfig.PGConnect = c.PostForm("pgconnect") 37 | 38 | timeout := c.PostForm("timeout") 39 | trimHist := c.PostForm("trim") 40 | appConfig.Timeout, _ = strconv.Atoi(timeout) 41 | appConfig.TrimHist, _ = strconv.Atoi(trimHist) 42 | 43 | histdb := c.PostForm("histdb") 44 | if histdb == "on" { 45 | appConfig.HistInDB = true 46 | } else { 47 | appConfig.HistInDB = false 48 | } 49 | 50 | arpStrs := c.PostFormArray("arpstrs") 51 | appConfig.ArpStrs = []string{} 52 | for _, s := range arpStrs { 53 | if s != "" { 54 | appConfig.ArpStrs = append(appConfig.ArpStrs, s) 55 | } 56 | } 57 | 58 | conf.Write(appConfig) 59 | 60 | slog.Debug("ARP_STRS", "", appConfig.ArpArgs) 61 | slog.Info("Writing new config to " + appConfig.ConfPath) 62 | 63 | updateRoutines() // routines-upd.go 64 | 65 | c.Redirect(http.StatusFound, c.Request.Referer()) 66 | } 67 | 68 | func saveInfluxHandler(c *gin.Context) { 69 | 70 | appConfig.InfluxAddr = c.PostForm("addr") 71 | appConfig.InfluxToken = c.PostForm("token") 72 | appConfig.InfluxOrg = c.PostForm("org") 73 | appConfig.InfluxBucket = c.PostForm("bucket") 74 | enable := c.PostForm("enable") 75 | skip := c.PostForm("skip") 76 | 77 | if enable == "on" { 78 | appConfig.InfluxEnable = true 79 | } else { 80 | appConfig.InfluxEnable = false 81 | } 82 | if skip == "on" { 83 | appConfig.InfluxSkipTLS = true 84 | } else { 85 | appConfig.InfluxSkipTLS = false 86 | } 87 | 88 | conf.Write(appConfig) 89 | 90 | slog.Info("Writing new config to " + appConfig.ConfPath) 91 | 92 | c.Redirect(http.StatusFound, c.Request.Referer()) 93 | } 94 | 95 | func savePrometheusHandler(c *gin.Context) { 96 | enable := c.PostForm("enable") 97 | 98 | appConfig.PrometheusEnable = enable == "on" 99 | 100 | conf.Write(appConfig) 101 | 102 | slog.Info("Writing new config to " + appConfig.ConfPath) 103 | 104 | c.Redirect(http.StatusFound, c.Request.Referer()) 105 | } 106 | -------------------------------------------------------------------------------- /internal/web/const-var.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | 6 | "github.com/aceberg/WatchYourLAN/internal/models" 7 | ) 8 | 9 | var ( 10 | // appConfig - config for Web Gui 11 | appConfig models.Conf 12 | 13 | allHosts []models.Host 14 | histHosts []models.Host 15 | 16 | quitScan chan bool 17 | ) 18 | 19 | // templFS - html templates 20 | // 21 | //go:embed templates/* 22 | var templFS embed.FS 23 | 24 | // pubFS - public folder 25 | // 26 | //go:embed public/* 27 | var pubFS embed.FS 28 | -------------------------------------------------------------------------------- /internal/web/functions.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/aceberg/WatchYourLAN/internal/models" 9 | ) 10 | 11 | func getHostByID(idStr string, hosts []models.Host) (oneHost models.Host) { 12 | 13 | id, _ := strconv.Atoi(idStr) 14 | 15 | for _, host := range hosts { 16 | if host.ID == id { 17 | oneHost = host 18 | break 19 | } 20 | } 21 | 22 | return oneHost 23 | } 24 | 25 | func updateDNS(host models.Host) (name, dns string) { 26 | 27 | dnsNames, _ := net.LookupAddr(host.IP) 28 | 29 | if len(dnsNames) > 0 { 30 | name = dnsNames[0] 31 | dns = strings.Join(dnsNames, " ") 32 | } 33 | 34 | return name, dns 35 | } 36 | 37 | func getHostsByMAC(mac string, hosts []models.Host) (foundHosts []models.Host) { 38 | 39 | for _, host := range hosts { 40 | if host.Mac == mac { 41 | 42 | foundHosts = append(foundHosts, host) 43 | } 44 | } 45 | 46 | return foundHosts 47 | } 48 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /internal/web/public/assets/Config.js: -------------------------------------------------------------------------------- 1 | import{c as Q,o as et,a as lt,t,i as l,b as c,s as G,d as Y,e as d,S as F,f as e,F as J,g as rt,h as it,j as W}from"./index.js";var st=t('
About ()

● After changing Host or Port the app must be restarted

Shoutrrr URL provides notifications to Discord, Email, Gotify, Telegram and other services. Link to documentation

Interfaces - one or more, space separated

Timeout (seconds) - time between scans

Args for arp-scan - pass your own arguments to arp-scan. Enable debug log level to see resulting command. (Example: -r 1). See docs for more.

Arp Strings - can setup scans for vlans, docker0 and etcetera. See docs for more.

Trim History - remove history after (hours)

Store History in DB - if off, the History will be stored only in memory and will be lost on app restart. Though, it will keep the app DB smaller and InfluxDB is recommended for long term History storage

PG Connect URL - address to connect to PostgreSQL DB. (Example: postgres://username:password@192.168.0.1:5432/dbname?sslmode=disable). Full list of URL parameters here

● If you find this app useful, please, donate

● Commission you own app (Golang, React/SolidJS). Contact here');function nt(){const[i,n]=Q(""),[a,s]=Q("");return et(async()=>{const r=await lt();n(r),s("https://github.com/aceberg/WatchYourLAN/releases/tag/"+r)}),(()=>{var r=st(),o=r.firstChild,p=o.firstChild,b=p.nextSibling;return l(b,i),c(()=>G(b,"href",a())),r})()}var at=t("

Host
Port
Theme
Color mode
Local node-bootstrap URL
Shoutrrr URL
'),ct=t("