Compare commits

...

143 Commits

Author SHA1 Message Date
30df042f17
fix drone build
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-02 16:17:13 +00:00
c4bd22a323
tweak to allow use of CIDR network masks for proxy
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
2023-10-04 14:44:02 +01:00
5f9400b9d4
also build docker image
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-25 09:48:02 +01:00
3601b99ef1 update build
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-25 09:22:19 +01:00
955d85d2ba minio has moved to fr-par1 2023-09-25 09:22:19 +01:00
d633be2908 Fix go vet warning 2023-09-25 09:22:19 +01:00
8d9ecb85f0 Add static resource cache-control headers 2023-09-25 09:22:19 +01:00
679edd1be4 add burble.dn42 logo 2023-09-25 09:22:19 +01:00
b5204d87e6 burble.dn42 customisations 2023-09-25 09:22:19 +01:00
Lan Tian
8457b18d46
release: v1.3.2.2 2023-09-09 01:44:16 -07:00
Lan Tian
f8f64b03a6
general: add tag for release Docker images 2023-09-09 01:42:38 -07:00
Lan Tian
cc818c1cc0
release: v1.3.2.1 2023-09-09 01:33:24 -07:00
Lan Tian
6224b43808
general: update GitHub actions 2023-09-09 01:32:56 -07:00
Lan Tian
17e0b14243
general: update GitHub actions 2023-09-09 01:29:49 -07:00
Lan Tian
b4c1bed9ba
release: v1.3.2 2023-09-09 01:23:14 -07:00
Lan Tian
abb32abff3
general: add unit test for docker images 2023-09-08 18:45:58 -07:00
Lan Tian
b368c75aa3
frontend: fix whois client cannot get default whois port 2023-09-08 18:38:23 -07:00
Lan Tian
09405cdb38
frontend: also print whois client output on error 2023-09-08 18:22:31 -07:00
Lan Tian
f999d47d9f
frontend: force enable whois client regex parser on alpine/musl 2023-09-07 19:14:04 -07:00
Lan Tian
005dfb1435
frontend: make docker image whois client try to use config file 2023-09-07 00:51:56 -07:00
Lan Tian
4bd7a6bb95
general: also release docker image to GitHub container registry 2023-09-06 21:06:10 -07:00
Lan Tian
462d76a2d0
general: reenable docker multiarch build 2023-09-06 21:02:32 -07:00
Lan Tian
58f217578c
readme: add note about development version of docker image 2023-09-06 20:59:27 -07:00
Lan Tian
0e95727de1
general: reorganize GitHub Actions workflows and readd unit test 2023-09-06 20:55:45 -07:00
Lan Tian
a48f1c8040
general: move Docker image build to GitHub Actions 2023-09-06 20:48:14 -07:00
Lan Tian
81acde3a37
frontend: add whois client for more complex whois lookup 2023-09-06 20:35:30 -07:00
Lan Tian
7c0fe0d512
proxy: update traceroute version in Docker image 2023-09-06 20:33:40 -07:00
dependabot[bot]
a5f4452d02
build(deps): bump github.com/jarcoal/httpmock in /frontend (#82)
Bumps [github.com/jarcoal/httpmock](https://github.com/jarcoal/httpmock) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/jarcoal/httpmock/releases)
- [Commits](https://github.com/jarcoal/httpmock/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: github.com/jarcoal/httpmock
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-25 00:44:30 -07:00
Lan Tian
b237185ef7
release: v1.3.1 2023-06-18 20:14:41 -07:00
towalink
e949646790
Properly escape URL path (#81) 2023-06-10 15:14:10 -07:00
dependabot[bot]
bb479d22ae
build(deps): bump github.com/spf13/viper from 1.15.0 to 1.16.0 in /proxy (#79)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/viper
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-02 00:58:12 -07:00
dependabot[bot]
d40f41b4d5
build(deps): bump github.com/spf13/viper in /frontend (#80)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/viper
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-02 00:58:06 -07:00
Lan Tian
cdc34704b5 release: v1.3.0 2023-05-14 12:37:46 -07:00
Lan Tian
db58bd3354
frontend: add a test case for lookup DNS -> WHOIS fallback 2023-05-06 00:26:40 -07:00
Lan Tian
a0246ccee2
general: add unit tests for >80% coverage
Includes a few minor fixes:
- frontend: support setting port for WHOIS server
- proxy: fix handling of very long lines
- proxy: refactor IP allowlist logic, parse allow IP list at startup
2023-05-06 00:23:28 -07:00
James Lu
ccd14af0c8
settings: treat empty environment variables as set (#77)
This allows disabling specific options like dns_interface or whois via environment variables.

ref: https://github.com/spf13/viper#working-with-environment-variables
2023-05-05 21:36:38 -07:00
Lan Tian
594ca80f50
frontend: fix whois lookup & only show bgpmap nexthop info on the first hop 2023-05-05 20:20:12 -07:00
Lan Tian
5625058e71
frontend: use ASN as bgpmap node identifier (instead of resolved whois result) 2023-05-05 19:52:30 -07:00
Lan Tian
7efa3237a9
frontend: refactor bgpmap code to fix #75 2023-05-05 01:58:05 -07:00
Lan Tian
7b0c8c0556
general: bump go version in go.mod 2023-01-26 22:01:47 -08:00
dependabot[bot]
ffd9165062
build(deps): bump github.com/spf13/viper in /frontend (#73)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.14.0 to 1.15.0.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.14.0...v1.15.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/viper
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-23 10:50:42 -08:00
dependabot[bot]
24fd5203e8
build(deps): bump github.com/spf13/viper from 1.14.0 to 1.15.0 in /proxy (#74)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.14.0 to 1.15.0.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.14.0...v1.15.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/viper
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-23 10:50:30 -08:00
Lan Tian
49a05767c1
ci: bump version for go-release-action 2023-01-08 01:16:23 -06:00
Lan Tian
e7010f75f8
release: v1.2.0 2023-01-06 23:05:05 -06:00
Yuhui Xu
dba2af7634
proxy: fix description for --traceroute_flags (#70) 2022-12-27 15:38:41 -06:00
Yuhui Xu
049775319b
proxy: autodetect traceroute args on startup (#69) 2022-12-25 15:41:29 -06:00
Lan Tian
47c66b125c
release: v1.1.1 2022-12-18 16:26:47 -06:00
Yuhui Xu
9e17b116f1
frontend: refactor bgpmap and fix node colors (#67)
* frontend: refactor bgpmap and fix node colors

* frontend: alternative way to test bgpmap
2022-12-07 16:30:19 -06:00
Lan Tian
335ad40634
release: v1.1.0 2022-11-27 17:22:21 -06:00
dependabot[bot]
6ec0f2e7a6
build(deps): bump github.com/spf13/viper from 1.13.0 to 1.14.0 in /proxy (#65)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.13.0...v1.14.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/viper
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-07 20:26:44 -06:00
dependabot[bot]
4b73cf0fcb
build(deps): bump github.com/spf13/viper in /frontend (#64)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.13.0...v1.14.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/viper
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-07 20:26:37 -06:00
Klara Modin
3b1d001543
frontend: sortable tables in summary (#61)
Adapted from https://stackoverflow.com/a/57080195.
Additions:
1. sortTable accepts a secondary column and sorting direction. The 'Name'
   column (number 0) is always used for the secondary
2. use classes 'ascSorted' and 'descSorted' to toggle between ascending and
   descending order
3. in the table functions, use .innerHTML instead of .innnerText so the
   link to detailed protocol information does not get lost. Also
   preserve .classList
2022-09-18 15:58:20 -05:00
dependabot[bot]
675cb26ed1
build(deps): bump github.com/spf13/viper from 1.12.0 to 1.13.0 in /proxy (#63)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.12.0 to 1.13.0.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.12.0...v1.13.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/viper
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-14 12:22:03 -05:00
dependabot[bot]
556d3e50d3
build(deps): bump github.com/spf13/viper in /frontend (#62)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.12.0 to 1.13.0.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.12.0...v1.13.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/viper
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-14 12:21:56 -05:00
Lan Tian
06796f546e
general: remove failing docker image build tasks for PRs 2022-08-25 11:42:41 -05:00
Potat0
d029d6684c
Fix the wrong order of examples (#60) 2022-08-17 11:44:35 -05:00
日下部 詩
5ce0f55f35
1. support local whois. 2 add some useful bird command (#59) 2022-08-11 22:34:39 -04:00
Lan Tian
890ab51b07
release: v1.0.0 2022-08-05 22:36:43 -04:00
Lan Tian
8e4a35cc8c
general: fix GOARCH for ARM 32 bit 2022-08-05 22:33:33 -04:00
Lan Tian
97f3c6088f
general: add GitHub actions for releasing binaries 2022-08-05 22:29:30 -04:00
Yuhui Xu
982326a678
frontend: fix XSS (#57) (#58) 2022-08-05 21:59:18 -04:00
Tristan Heaven
4b3980f6bd
Fix navbar_brand_url config (#56) 2022-07-28 09:54:39 -04:00
Yuhui Xu
6f6b2bd283
general: support reading config files (#55) 2022-07-08 23:13:10 -04:00
Yuhui Xu
892a7bee22
frontend: support listening on unix socket (#54) 2022-07-08 21:14:11 -04:00
Nicolas Lorin
348295b9aa
frontend: add the abilities to customized timeout time (#51)
* main.go: add timeout setting

* lgproxy.go: use timeout setting when querying server

* README.md: add new timeout setting
2022-02-08 02:29:05 -06:00
Kioubit
950c018b18
Confirm that bird access was restricted (#49)
Co-authored-by: Kioubit <kioubit@localhost.invalid>
2022-01-18 03:03:09 -06:00
herver
26efeb4996
Add name filter feature (#48)
This uses a RE2 regexp to hide protocols which name matches the expression
2022-01-18 03:01:57 -06:00
Lan Tian
5a5dfbc93f
general: add docker hub links to readme 2022-01-09 01:51:36 -06:00
Lan Tian
f60a292129
general: build docker images with correct arch label 2022-01-09 01:45:00 -06:00
James Lu
e7f6026854
Add BIRDLG_TRACEROUTE_RAW option to leave traceroute output in the default format (#47) 2022-01-09 00:14:31 -06:00
Lan Tian
a4e0f4c193
frontend: skip network related tests when unavailable
Fix #46
2022-01-09 00:10:14 -06:00
日下部 詩
af5b653326
BIRDLG_BGPMAP_INFO 選項 (#44)
* BIRDLG_BGPMAP_INFO

update the paramater

description fix for bgpmap_test

singleline and multiline

* Static file instead of jsdelivr; favicon.ico

Co-authored-by: testscript <testscript@example.com>
2021-12-20 03:35:43 -06:00
日下部 詩
58847759b3
一些小改動 (#42)
* 1. remove ":" at the start of port assignement. 2. use BIRDLG_PROXY_PORT at proxy. 3. add custom URL to brand

* goto / if only one server

* add BIRDLG_TRACEROUTE_BIN
2021-11-09 12:27:02 -06:00
Yuhui Xu
6481e7cc8d
frontend: fix uninitialized buffer (#41) 2021-09-26 13:26:07 -05:00
James Lu
2166d73b3d
frontend: add filtering by protocol type to summary tables (#40)
* frontend: add option to filter by protocol type

Closes #33.

* frontend: use case insensitive comparisons for protocol filter
2021-09-07 15:17:16 -05:00
Yuhui Xu
a64d839e2c
frontend: limit fetched response size to 64KB (#39) 2021-09-02 20:21:28 -05:00
James Lu
1a3c618522
frontend: BGPmap improvements (#36)
* bgpmap: Compact nexthop info into an edge label

* bgpmap: parse and show non-BGP routes

* bgpmap: Misc tweaks

- Show the protocol name instead of the ASN in edge labels
- Correctly draw only the primary path if there are multiple routes to the first neighbour ASN in a path
- Use a smaller font size for edge labels

* bgpmap_test: update to match new changes

* bgpmap: Split route info on all (non-empty) rta_dest_names values
2021-08-31 09:44:14 -05:00
Yuhui Xu
fbd190628c
general: add go 1.16 requirement to readme [skip ci] (#37) 2021-08-30 23:46:14 -05:00
Yuhui Xu
823b639245
frontend: also filter whois privacy redacted lines (#34) 2021-08-28 22:02:03 -05:00
Yuhui Xu
b0c0e5442d
frontend: set lgproxy request timeout to 120s (#31)
lgproxy traceroutecan be really slow if dns resolve doesnt work well.
2021-08-04 00:30:38 +08:00
Yuhui Xu
4e4ce89418
frontend: set timeout longer for lgproxy requests (#30) 2021-08-03 11:46:05 +08:00
Yuhui Xu
234aadadd9
frontend: specify timeout for requests (#29) 2021-08-02 18:46:43 +08:00
Lan Tian
bee26f421c frontend: resolve asn in dns/whois/fail order & fix tests 2021-07-31 17:11:20 +08:00
Yuhui Xu
2e0cb131ca
Merge pull request #27 from miegl/master
frontend: optional ASN resolution using whois
2021-07-31 17:02:25 +08:00
Josef Miegl
4c248c638a frontend: optional asn resolution using whois 2021-07-30 16:54:41 +02:00
Yuhui Xu
3550362a4d
Merge pull request #26 from sesa-me/master
Refactor to use go:embed
2021-07-14 22:13:36 +08:00
256a80646f
- Refactor file embedding to use Go 1.16 embed functionality.
- Remove references to previous bindata packages from build scripts and docs
2021-07-13 11:54:35 +01:00
Yuhui Xu
03c42eb1e8
Merge pull request #25 from outloudvi/patch-1
doc: add --net-specific-mode
2021-07-03 22:20:56 +08:00
Outvi V
aea85e774c
doc: add --net-specific-mode 2021-07-03 12:33:49 +08:00
Yuhui Xu
80d9351a58
Merge pull request #24 from xddxdd/lantian-dev
frontend: allow long lines if result is short
2021-06-21 01:01:37 +08:00
Lan Tian
5e0bc081e6
frontend: allow long lines if result is short 2021-06-21 00:57:26 +08:00
Yuhui Xu
4d53d1f095
Merge pull request #23 from xddxdd/lantian-dev
frontend: change behavior of whois shorten mode
2021-06-21 00:49:37 +08:00
Lan Tian
5883015294
frontend: change behavior of whois shorten mode 2021-06-21 00:44:44 +08:00
Yuhui Xu
80e66a7a81
Merge pull request #22 from xddxdd/lantian-dev
frontend: add generic whois shorten mode
2021-06-20 02:24:49 +08:00
Lan Tian
41329da7cb
frontend: add generic whois shorten mode 2021-06-20 02:20:18 +08:00
Yuhui Xu
8e56705205
Merge pull request #21 from xddxdd/lantian-dev
frontend: fix typo
2021-06-19 16:42:01 +08:00
Lan Tian
6a8b3a0e55
frontend: fix typo 2021-06-19 16:36:18 +08:00
Yuhui Xu
83ab403706
Merge pull request #20 from xddxdd/lantian-dev
frontend: limit telegram commands by bot name
2021-06-19 16:27:17 +08:00
Lan Tian
7c7814cc7b
frontend: limit telegram commands by bot name 2021-06-19 16:23:43 +08:00
Yuhui Xu
8598060cc0
Merge pull request #19 from xddxdd/lantian-dev
Add instruction for create Dockerfile
2021-05-21 21:11:00 +08:00
Lan Tian
bda06ddd5e
Add instruction for create Dockerfile 2021-05-21 21:09:50 +08:00
Yuhui Xu
f404072ab8
Merge pull request #18 from xddxdd/dependabot/add-v2-config-file
Upgrade to GitHub-native Dependabot
2021-05-01 23:21:14 +08:00
dependabot-preview[bot]
fa827502cf
Upgrade to GitHub-native Dependabot 2021-04-29 22:35:16 +00:00
Yuhui Xu
794125a96f
Merge pull request #17 from towalink/master
Allow specifying display names for servers
2021-04-18 19:37:22 +08:00
Henri
de9d9101b1 Fix test initialization so that tests succeed after previous commit 2021-04-14 09:17:44 +02:00
Henri
056ef3769e Allow specifying display names for servers 2021-04-13 21:58:50 +02:00
towalink
974e809deb
Merge pull request #1 from xddxdd/master
Merge pull request #15 from towalink/master
2021-04-10 08:36:39 +02:00
Yuhui Xu
6e19b5ae64
Merge pull request #15 from towalink/master
Increase consistency of path escaping;  support IPv6 addresses
2021-04-10 10:25:45 +08:00
Henri
874089117b Increase consistency of path escaping and support IPv6 addresses instead of hostnames 2021-04-06 21:43:58 +02:00
Yuhui Xu
f77a8a28fe
Merge pull request #14 from xddxdd/lantian-dev
Fix link in README
2021-04-01 22:57:56 +08:00
Lan Tian
9e8a845658
general: fix link in README 2021-04-01 22:56:13 +08:00
Yuhui Xu
fd3e7b8379
Merge pull request #13 from xddxdd/lantian-dev
Add build notes from #11
2021-04-01 22:54:41 +08:00
Lan Tian
8765189deb
general: add build notes from #11 2021-04-01 22:47:08 +08:00
Yuhui Xu
492942cce1
Merge pull request #12 from AluisioASG/domainless
frontend: make domain optional
2021-04-01 22:43:13 +08:00
Aluísio Augusto Silva Gonçalves
f81a5308ae
frontend: make domain optional
Making the domain optional allows usage of bare hostnames for
the servers (e.g. when they're statically configured) and even
IP addresses if someone is so inclined (although presentation
might suffer in this case).
2021-03-31 16:44:44 -03:00
Lan Tian
5b5a09ccbd
frontend: clamp telegram api response to 4096 chars 2021-03-31 22:43:46 +08:00
Lan Tian
dc4d7e6532
general: update makefiles for project 2021-03-15 00:49:13 +08:00
Lan Tian
28a7d2a53f
general: fix incorrect name for pushed docker images 2021-03-06 21:41:44 +08:00
Lan Tian
4413f1032f
general: try to fix multiarch build 2021-03-06 21:21:42 +08:00
Lan Tian
007b66e036
Revert "general: remove build for all arch but amd64"
This reverts commit 1c3d9ec5948826fac1a6f87656291e24b7662fff.
2021-03-06 20:52:04 +08:00
Lan Tian
3f612d2e76
proxy: fix plain traceroute not executed 2021-02-28 23:09:11 +08:00
Lan Tian
f49f8bac5e
proxy: support arbitraty traceroute arguments 2021-02-27 16:42:42 +08:00
Lan Tian
f6ddc5761b
frontend: remove unnecessary URL escapes 2021-02-27 15:24:21 +08:00
Lan Tian
1c3d9ec594
general: remove build for all arch but amd64 2021-01-18 23:13:25 +08:00
Lan Tian
6cc0c617b4
frontend: add API for server list 2021-01-17 16:16:41 +08:00
Lan Tian
e2cc580da3
frontend: add CORS header to API 2021-01-17 12:45:20 +08:00
Lan Tian
472cec74b0
general: update README.md 2021-01-17 12:37:22 +08:00
Lan Tian
da2c3d9aed
frontend: add API 2021-01-17 12:35:29 +08:00
Lan Tian
aa76bc3de7
ci: try fix test error 2021-01-17 02:39:17 +08:00
Lan Tian
a984095282
frontend: add tests against XSS 2021-01-17 02:21:23 +08:00
Lan Tian
1baf325149
frontend: move redirect logic to HTML 2021-01-17 01:33:14 +08:00
Lan Tian
72946e1113
frontend: filter output to prevent XSS 2021-01-17 01:14:49 +08:00
Lan Tian
90e5012840
proxy: filter input to prevent XSS 2021-01-15 01:22:39 +08:00
Lan Tian
8d5eb56199
Fix building Docker image for arm and i386 2021-01-15 01:00:54 +08:00
Lan Tian
8d0618fed9
Update CircleCI config 2021-01-15 00:41:38 +08:00
Yuhui Xu
f8ea511d44
Merge pull request #8 from sesa-me/burble.dn42-templates
Add static file bundling and HTML templating
2021-01-14 23:01:43 +08:00
Yuhui Xu
b99eb60c30
Merge pull request #9 from xddxdd/circleci-project-setup
Circleci project setup
2021-01-14 00:02:18 +08:00
Yuhui Xu
9f934ca53c Updated config.yml 2021-01-13 23:58:44 +08:00
Yuhui Xu
ee7cc1675b Add .circleci/config.yml 2021-01-13 23:37:32 +08:00
f4b6955343
Add utility functions for filtering results and rename templates 2021-01-12 10:21:03 +00:00
78ce724171
Fix bindata build step and parameterize docker build 2021-01-12 10:21:02 +00:00
6179c688be
- Use bindata to package static file content in to the frontend binary
- Add golang templates to move HTML rendering out of the go code where possible
- Add an endpoint for serving static files
- Add URL escaping for servers and targets
2021-01-11 15:00:05 +00:00
Lan Tian
8d0e210572
Fix #7 2021-01-11 22:24:15 +08:00
79 changed files with 8204 additions and 812 deletions

55
.drone.yml Normal file
View File

@ -0,0 +1,55 @@
---
kind: pipeline
type: docker
name: default
steps:
- name: build frontend
image: golang
environment:
CGO_ENABLED: 0
commands:
- cd frontend
- go vet
- go build
- name: build proxy
image: golang
environment:
CGO_ENABLED: 0
commands:
- cd proxy
- go vet
- go build
- name: upload artifacts
image: git.burble.dn42/burble.dn42/drone-gitea-pkg-plugin:latest
settings:
token:
from_secret: TOKEN
version: RELEASE
artifact: proxy
filename: proxy/proxy
package: bird-lg-go
owner: burble.dn42
- name: docker
image: plugins/docker
settings:
registry: git.burble.dn42
repo: git.burble.dn42/burble.dn42/bird-lg-go
dockerfile: frontend/Dockerfile
context: frontend/
tags: frontend
username: burble
password:
from_secret: TOKEN
storage_driver: vfs
---
kind: secret
name: TOKEN
get:
path: burble.dn42/kv/data/drone/git.burble.dn42
name: artifact-token

16
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: gomod
directory: "/frontend"
schedule:
interval: daily
time: "08:00"
timezone: Asia/Shanghai
open-pull-requests-limit: 10
- package-ecosystem: gomod
directory: "/proxy"
schedule:
interval: daily
time: "08:00"
timezone: Asia/Shanghai
open-pull-requests-limit: 10

108
.github/workflows/develop.yaml vendored Normal file
View File

@ -0,0 +1,108 @@
on:
push:
branches:
- '**'
pull_request:
branches:
- 'master'
jobs:
go-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Golang
uses: actions/setup-go@v4
- name: Run frontend unit test
run: |
export GO111MODULE=on
cd frontend
go get -v -t -d ./...
go test -v ./...
cd ..
- name: Run proxy unit test
run: |
export GO111MODULE=on
cd proxy
go get -v -t -d ./...
go test -v ./...
cd ..
docker-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Test whois binary in frontend image
run: |
docker build -t local/frontend frontend/
docker run --rm --net host --entrypoint whois local/frontend github.com || exit 1
docker run --rm --net host --entrypoint whois local/frontend -h whois.ripe.net github.com || exit 1
docker run --rm --net host --entrypoint whois local/frontend -h whois.ripe.net:43 github.com || exit 1
- name: Test traceroute binary in proxy image
run: |
docker build -t local/proxy proxy/
docker run --rm --net host --entrypoint traceroute local/proxy 127.0.0.1 || exit 1
docker run --rm --net host --entrypoint traceroute local/proxy ::1 || exit 1
docker-develop:
runs-on: ubuntu-latest
needs:
- go-test
- docker-test
if: github.event_name != 'pull_request'
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build frontend docker image
uses: docker/build-push-action@v4
with:
context: '{{defaultContext}}:frontend'
platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7
push: true
tags: |
xddxdd/bird-lg-go:develop
xddxdd/bird-lg-go:develop-${{ github.sha }}
ghcr.io/xddxdd/bird-lg-go:frontend-develop
ghcr.io/xddxdd/bird-lg-go:frontend-develop-${{ github.sha }}
- name: Build proxy docker image
uses: docker/build-push-action@v4
with:
context: '{{defaultContext}}:proxy'
platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7
push: true
tags: |
xddxdd/bird-lgproxy-go:develop
xddxdd/bird-lgproxy-go:develop-${{ github.sha }}
ghcr.io/xddxdd/bird-lg-go:proxy-develop
ghcr.io/xddxdd/bird-lg-go:proxy-develop-${{ github.sha }}

87
.github/workflows/release.yaml vendored Normal file
View File

@ -0,0 +1,87 @@
on:
release:
types: [created]
jobs:
go-release:
name: Release Go Binary
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
goos: [linux, windows, darwin]
goarch: ["386", amd64, "arm", arm64]
exclude:
- goarch: "386"
goos: darwin
- goarch: "arm"
goos: darwin
- goarch: "arm"
goos: windows
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Release frontend
uses: wangyoucao577/go-release-action@v1.40
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
project_path: "./frontend"
binary_name: "bird-lg-go"
- name: Release proxy
uses: wangyoucao577/go-release-action@v1.40
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
project_path: "./proxy"
binary_name: "bird-lgproxy-go"
docker-release:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build frontend docker image
uses: docker/build-push-action@v4
with:
context: '{{defaultContext}}:frontend'
platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7
push: true
tags: |
xddxdd/bird-lg-go:latest
xddxdd/bird-lg-go:${{ github.event.release.tag_name }}
ghcr.io/xddxdd/bird-lg-go:frontend
ghcr.io/xddxdd/bird-lg-go:frontend-${{ github.event.release.tag_name }}
- name: Build proxy docker image
uses: docker/build-push-action@v4
with:
context: '{{defaultContext}}:proxy'
platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7
push: true
tags: |
xddxdd/bird-lgproxy-go:latest
xddxdd/bird-lgproxy-go:${{ github.event.release.tag_name }}
ghcr.io/xddxdd/bird-lg-go:proxy
ghcr.io/xddxdd/bird-lg-go:proxy-${{ github.event.release.tag_name }}

9
.gitignore vendored
View File

@ -16,4 +16,11 @@
.DS_Store
frontend/frontend
proxy/proxy
proxy/proxy
# don't include generated bindata file
frontend/bindata.go
# don't include generated Dockerfiles
frontend/Dockerfile.*
proxy/Dockerfile.*

View File

@ -1,45 +0,0 @@
language: minimal
os: linux
dist: focal
services:
- docker
env:
- PROGRAM=frontend IMAGE_NAME=bird-lg-go IMAGE_ARCH=i386
- PROGRAM=frontend IMAGE_NAME=bird-lg-go IMAGE_ARCH=amd64
- PROGRAM=frontend IMAGE_NAME=bird-lg-go IMAGE_ARCH=arm32v7
- PROGRAM=frontend IMAGE_NAME=bird-lg-go IMAGE_ARCH=arm64v8
- PROGRAM=frontend IMAGE_NAME=bird-lg-go IMAGE_ARCH=ppc64le
- PROGRAM=frontend IMAGE_NAME=bird-lg-go IMAGE_ARCH=s390x
- PROGRAM=proxy IMAGE_NAME=bird-lgproxy-go IMAGE_ARCH=i386
- PROGRAM=proxy IMAGE_NAME=bird-lgproxy-go IMAGE_ARCH=amd64
- PROGRAM=proxy IMAGE_NAME=bird-lgproxy-go IMAGE_ARCH=arm32v7
- PROGRAM=proxy IMAGE_NAME=bird-lgproxy-go IMAGE_ARCH=arm64v8
- PROGRAM=proxy IMAGE_NAME=bird-lgproxy-go IMAGE_ARCH=ppc64le
- PROGRAM=proxy IMAGE_NAME=bird-lgproxy-go IMAGE_ARCH=s390x
addons:
apt:
update: true
packages:
- qemu-user-static
- binfmt-support
install:
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
script:
- |
# Build image
docker build \
-t $DOCKER_USERNAME/$IMAGE_NAME:$IMAGE_ARCH \
-f $PROGRAM/Dockerfile.$IMAGE_ARCH \
$PROGRAM
# Tag image :{arch} and :{arch}-build{build number}
docker tag $DOCKER_USERNAME/$IMAGE_NAME:$IMAGE_ARCH $DOCKER_USERNAME/$IMAGE_NAME:$IMAGE_ARCH-build$TRAVIS_BUILD_NUMBER
if [ "$IMAGE_ARCH" = "amd64" ]; then
# Tag as latest for amd64 images
docker tag $DOCKER_USERNAME/$IMAGE_NAME:$IMAGE_ARCH $DOCKER_USERNAME/$IMAGE_NAME:latest
docker tag $DOCKER_USERNAME/$IMAGE_NAME:$IMAGE_ARCH $DOCKER_USERNAME/$IMAGE_NAME:build$TRAVIS_BUILD_NUMBER
fi
- docker push $DOCKER_USERNAME/$IMAGE_NAME

13
Makefile Normal file
View File

@ -0,0 +1,13 @@
frontend:
$(MAKE) -C frontend all
proxy:
$(MAKE) -C proxy all
.DEFAULT_GOAL := all
.PHONY: all frontend proxy
all: frontend proxy
install:
install -m 755 frontend/frontend /usr/local/bin/bird-lg-go
install -m 755 proxy/proxy /usr/local/bin/bird-lgproxy-go

221
README.md
View File

@ -1,12 +1,45 @@
Bird-lg-go
==========
# Bird-lg-go
[![Build Status](https://ci.burble.dn42/api/badges/burble.dn42/bird-lg-go/status.svg?ref=refs/heads/burble.dn42)](https://ci.burble.dn42/burble.dn42/bird-lg-go)
An alternative implementation for [bird-lg](https://github.com/sileht/bird-lg) written in Go. Both frontend and backend (proxy) are implemented, and can work with either the original Python implementation or the Go implementation.
> The code on master branch no longer support BIRDv1. Branch "bird1" is the last version that supports BIRDv1.
Frontend
--------
## Table of Contents
- [Bird-lg-go](#bird-lg-go)
- [Table of Contents](#table-of-contents)
- [Build Instructions](#build-instructions)
- [Build Docker Images](#build-docker-images)
- [Frontend](#frontend)
- [Proxy](#proxy)
- [Advanced Features](#advanced-features)
- [Display names](#display-names)
- [IP addresses](#ip-addresses)
- [API](#api)
- [Telegram Bot Webhook](#telegram-bot-webhook)
- [Credits](#credits)
- [License](#license)
## Build Instructions
You need to have **Go 1.16 or newer** installed on your machine.
Run `make` to build binaries for both the frontend and the proxy.
Optionally run `make install` to install them to `/usr/local/bin` (`bird-lg-go` and `bird-lgproxy-go`).
### Build Docker Images
Use the Dockerfiles in `frontend` and `proxy` directory.
Ready-to-use images are available at:
- Frontend: <https://hub.docker.com/r/xddxdd/bird-lg-go>
- Proxy: <https://hub.docker.com/r/xddxdd/bird-lgproxy-go>
## Frontend
The frontend directory contains the code for the web frontend, where users see BGP states, do traceroutes and whois, etc. It's a replacement for "lg.py" in original bird-lg project.
@ -18,40 +51,61 @@ Features implemented:
- Work with both Python proxy (lgproxy.py) and Go proxy (proxy dir of this project)
- Visualize AS paths as picture (bgpmap feature)
Usage: all configuration is done via commandline parameters or environment variables, no config file.
Configuration can be set in:
| Parameter | Environment Variable | Description |
| --------- | -------------------- | ----------- |
| --servers | BIRDLG_SERVERS | server name prefixes, separated by comma |
| --domain | BIRDLG_DOMAIN | server name domain suffixes |
| --listen | BIRDLG_LISTEN | address bird-lg is listening on (default ":5000") |
| --proxy-port | BIRDLG_PROXY_PORT | port bird-lgproxy is running on (default 8000) |
| --whois | BIRDLG_WHOIS | whois server for queries (default "whois.verisign-grs.com") |
| --dns-interface | BIRDLG_DNS_INTERFACE | dns zone to query ASN information (default "asn.cymru.com") |
| --title-brand | BIRDLG_TITLE_BRAND | prefix of page titles in browser tabs (default "Bird-lg Go") |
| --navbar-brand | BIRDLG_NAVBAR_BRAND | brand to show in the navigation bar (default "Bird-lg Go") |
- `bird-lg.[json/yaml/etc]` in current directory
- `/etc/bird-lg/bird-lg.[json/yaml/etc]`
- Commandline parameter
- Environment variables
Configuration is handled by [viper](https://github.com/spf13/viper), any config format supported by it can be used.
| Config Key | Parameter | Environment Variable | Description |
| ---------- | --------- | -------------------- | ----------- |
| servers | --servers | BIRDLG_SERVERS | server name prefixes, separated by comma |
| domain | --domain | BIRDLG_DOMAIN | server name domain suffixes |
| listen | --listen | BIRDLG_LISTEN | address bird-lg is listening on (default "5000") |
| proxy_port | --proxy-port | BIRDLG_PROXY_PORT | port bird-lgproxy is running on (default 8000) |
| whois | --whois | BIRDLG_WHOIS | whois server for queries (default "whois.verisign-grs.com"). Start with "/" to spacify local whois binary("/usr/local/whois"). |
| dns_interface | --dns-interface | BIRDLG_DNS_INTERFACE | dns zone to query ASN information (default "asn.cymru.com") |
| bgpmap_info | --bgpmap-info | BIRDLG_BGPMAP_INFO | the infos displayed in bgpmap, separated by comma, start with `:` means allow multiline (default "asn,as-name,ASName,descr") |
| title_brand | --title-brand | BIRDLG_TITLE_BRAND | prefix of page titles in browser tabs (default "Bird-lg Go") |
| navbar_brand | --navbar-brand | BIRDLG_NAVBAR_BRAND | brand to show in the navigation bar (default "Bird-lg Go") |
| navbar_brand_url | --navbar-brand-url | BIRDLG_NAVBAR_BRAND_URL | the url of the brand to show in the navigation bar (default "/") |
| navbar_all_servers | --navbar-all-servers | BIRDLG_NAVBAR_ALL_SERVERS | the text of "All servers" button in the navigation bar (default "ALL Servers") |
| navbar_all_url | --navbar-all-url | BIRDLG_NAVBAR_ALL_URL | the URL of "All servers" button (default "all") |
| net_specific_mode | --net-specific-mode | BIRDLG_NET_SPECIFIC_MODE | apply network-specific changes for some networks, use "dn42" for BIRD in dn42 network |
| protocol_filter | --protocol-filter | BIRDLG_PROTOCOL_FILTER | protocol types to show in summary tables (comma separated list); defaults to all if not set |
| name_filter | --name-filter | BIRDLG_NAME_FILTER | protocol names to hide in summary tables (RE2 syntax); defaults to none if not set |
| timeout | --time-out | BIRDLG_TIMEOUT | time before request timed out, in seconds; defaults to 120 if not set |
### Examples
Example: the following command starts the frontend with 2 BIRD nodes, with domain name "gigsgigscloud.dn42.lantian.pub" and "hostdare.dn42.lantian.pub", and proxies are running on port 8000 on both nodes.
./frontend --servers=gigsgigscloud,hostdare --domain=dn42.lantian.pub --proxy-port=8000
```bash
./frontend --servers=gigsgigscloud,hostdare --domain=dn42.lantian.pub --proxy-port=8000
```
Example: the following docker-compose.yml entry does the same as above, but by starting a Docker container:
services:
bird-lg:
image: xddxdd/bird-lg-go
container_name: bird-lg
restart: always
environment:
- BIRDLG_SERVERS=gigsgigscloud,hostdare
- BIRDLG_DOMAIN=dn42.lantian.pub
ports:
- "5000:5000"
```yaml
services:
bird-lg:
# Use xddxdd/bird-lg-go:develop for the latest build from master branch
image: xddxdd/bird-lg-go:latest
container_name: bird-lg
restart: always
environment:
- BIRDLG_SERVERS=gigsgigscloud,hostdare
- BIRDLG_DOMAIN=dn42.lantian.pub
ports:
- "5000:5000"
```
Demo: https://lg.lantian.pub
Demo: <https://lg.lantian.pub>
Proxy
-----
## Proxy
The proxy directory contains the code for the "proxy" for bird commands and traceroutes. It's a replacement for "lgproxy.py" in original bird-lg project.
@ -62,43 +116,112 @@ Features implemented:
- Executing traceroute command on Linux, FreeBSD and OpenBSD
- Source IP restriction
Usage: all configuration is done via commandline parameters or environment variables, no config file.
Configuration can be set in:
| Parameter | Environment Variable | Description |
| --------- | -------------------- | ----------- |
| --allowed | ALLOWED_IPS | IPs allowed to access this proxy, separated by commas. Don't set to allow all IPs. (default "") |
| --bird | BIRD_SOCKET | socket file for bird, set either in parameter or environment variable BIRD_SOCKET (default "/var/run/bird/bird.ctl") |
| --listen | BIRDLG_LISTEN | listen address, set either in parameter or environment variable BIRDLG_LISTEN (default ":8000") |
- `bird-lgproxy.[json/yaml/etc]` in current directory
- `/etc/bird-lg/bird-lgproxy.[json/yaml/etc]`
- Commandline parameter
- Environment variables
Configuration is handled by [viper](https://github.com/spf13/viper), any config format supported by it can be used.
| Config Key | Parameter | Environment Variable | Description |
| ---------- | --------- | -------------------- | ----------- |
| allowed_ips | --allowed | ALLOWED_IPS | IPs or networks allowed to access this proxy, separated by commas. Don't set to allow all IPs. (default "") |
| bird_socket | --bird | BIRD_SOCKET | socket file for bird, set either in parameter or environment variable BIRD_SOCKET (default "/var/run/bird/bird.ctl") |
| listen | --listen | BIRDLG_PROXY_PORT | listen address, set either in parameter or environment variable BIRDLG_PROXY_PORT(default "8000") |
| traceroute_bin | --traceroute_bin | BIRDLG_TRACEROUTE_BIN | traceroute binary file, set either in parameter or environment variable BIRDLG_TRACEROUTE_BIN |
| traceroute_flags | --traceroute_flags | BIRDLG_TRACEROUTE_FLAGS | traceroute flags, supports multiple flags separated with space. |
| traceroute_raw | --traceroute_raw | BIRDLG_TRACEROUTE_RAW | whether to display traceroute outputs raw (default false) |
### Traceroute Binary Autodetection
If `traceroute_bin` or `traceroute_flags` is not set, then on startup, the proxy will try to `traceroute 127.0.0.1` with different traceroute binaries and arguments, in order to use the most optimized setting available, while maintaining compatibility with multiple variants of traceroute binaries.
Traceroute binaries will be autodetected in the following order:
1. If `traceroute_bin` is set:
1. `[traceroute_bin] -q1 -N32 -w1 127.0.0.1` (Corresponds to Traceroute on Debian)
2. `[traceroute_bin] -q1 -w1 127.0.0.1` (Corresponds to Traceroute on FreeBSD)
3. `[traceroute_bin] 127.0.0.1` (Corresponds to Busybox Traceroute)
2. `mtr -w -c1 -Z1 -G1 -b 127.0.0.1` (MTR)
3. `traceroute -q1 -N32 -w1 127.0.0.1` (Corresponds to Traceroute on Debian)
4. `traceroute -q1 -w1 127.0.0.1` (Corresponds to Traceroute on FreeBSD)
5. `traceroute 127.0.0.1` (Corresponds to Busybox Traceroute)
### Examples
Example: start proxy with default configuration, should work "out of the box" on Debian 9 with BIRDv1:
./proxy
```bash
./proxy
```
Example: start proxy with custom bird socket location:
./proxy --bird /run/bird.ctl
```bash
./proxy --bird /run/bird.ctl
```
Example: the following docker-compose.yml entry does the same as above, but by starting a Docker container:
bird-lgproxy:
image: xddxdd/bird-lgproxy-go
container_name: bird-lgproxy
restart: always
volumes:
- "/run/bird.ctl:/var/run/bird/bird.ctl"
ports:
- "192.168.0.1:8000:8000"
```yaml
services:
bird-lgproxy:
# Use xddxdd/bird-lgproxy-go:develop for the latest build from master branch
image: xddxdd/bird-lgproxy-go:latest
container_name: bird-lgproxy
restart: always
volumes:
- "/run/bird.ctl:/var/run/bird/bird.ctl"
ports:
- "192.168.0.1:8000:8000"
```
You can use source IP restriction to increase security. You should also bind the proxy to a specific interface and use an external firewall/iptables for added security.
Credits
-------
## Advanced Features
### Display names
The server parameter is composed of server name prefixes, separated by comma. It also supports an extended syntax: It allows to define display names for the user interface that are different from the actual server names.
For instance, the two servers from the basic example can be displayed as "Gigs" and "Hostdare" using the following syntax (as known from email addresses):
```bash
./frontend --servers="Gigs<gigsgigscloud>,Hostdare<hostdare>" --domain=dn42.lantian.pub
```
### IP addresses
You may also specify IP addresses as server names when no domain is specified. IPv6 link local addresses are supported, too.
For example:
```bash
./frontend --servers="Prod<prod.mydomain.local>,Test1<fd88:dead:beef::1>,Test2<fe80::c%wg0>" --domain=
```
These three servers are displayed as "Prod", "Test1" and "Test2" in the user interface.
### API
The frontend provides an API for running BIRD/traceroute/whois queries.
See [API docs](docs/API.md) for detailed information.
### Telegram Bot Webhook
The frontend can act as a Telegram Bot webhook endpoint, to add BGP route/traceroute/whois lookup functionality to your tech group.
See [Telegram docs](docs/Telegram.md) for detailed information.
## Credits
- Everyone who contributed to this project (see Contributors section on the right)
- Mehdi Abaakouk for creating [the original bird-lg project](https://github.com/sileht/bird-lg)
- [Bootstrap](https://getbootstrap.com/) as web UI framework
License
-------
## License
GPL 3.0

1
RELEASE Normal file
View File

@ -0,0 +1 @@
v1.3.2.2

202
docs/API.md Normal file
View File

@ -0,0 +1,202 @@
# Bird-lg-go API documentation
The frontend provides an API for running BIRD/traceroute/whois queries.
API Endpoint: `https://your.frontend.com/api/` (the last slash must not be omitted!)
Requests are sent as POSTS with JSON bodies.
## Table of Contents
* [Bird-lg-go API documentation](#bird-lg-go-api-documentation)
* [Table of Contents](#table-of-contents)
* [Request fields](#request-fields)
* [Example request of type bird](#example-request-of-type-bird)
* [Example request of type server_list](#example-request-of-type-server_list)
* [Response fields (when type is summary)](#response-fields-when-type-is-summary)
* [Fields for apiSummaryResultPair](#fields-for-apisummaryresultpair)
* [Fields for SummaryRowData](#fields-for-summaryrowdata)
* [Example response](#example-response)
* [Response fields (when type is bird, traceroute, whois or server_list)](#response-fields-when-type-is-bird-traceroute-whois-or-server_list)
* [Fields for apiGenericResultPair](#fields-for-apigenericresultpair)
* [Example response of type bird](#example-response-of-type-bird)
* [Example response of type server_list](#example-response-of-type-server_list)
Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc)
## Request fields
| Name | Type | Value |
| ---- | ---- | -------- |
| `servers` | array of `string` | List of servers to be queried |
| `type` | `string` | Can be `summary`, `bird`, `traceroute`, `whois` or `server_list` |
| `args` | `string` | Arguments to be passed, see below |
Argument examples for each type:
- `summary`: `args` is ignored. Recommended to set to empty string.
- `bird`: `args` is the command to be passed to bird, e.g. `show route for 8.8.8.8`
- `traceroute`: `args` is the traceroute target, e.g. `8.8.8.8` or `google.com`
- `whois`: `args` is the whois target, e.g. `8.8.8.8` or `google.com`
- `server_list`: `args` is ignored. In addition, `servers` is also ignored.
### Example request of type `bird`
```json
{
"servers": [
"alpha"
],
"type": "bird",
"args": "show route for 8.8.8.8"
}
```
### Example request of type `server_list`
```json
{
"servers": [],
"type": "server_list",
"args": ""
}
```
## Response fields (when `type` is `summary`)
| Name | Type | Value |
| ---- | ---- | -------- |
| `error` | `string` | Error message when something is wrong. Empty when everything is good |
| `result` | array of `apiSummaryResultPair` | See below |
### Fields for `apiSummaryResultPair`
| Name | Type | Value |
| ---- | ---- | -------- |
| `server` | `string` | Name of the server |
| `data` | array of `SummaryRowData` | Summaries of the server, see below |
### Fields for `SummaryRowData`
All fields below is 1:1 correspondent to the output of `birdc show protocols`.
| Name | Type |
| ---- | ---- |
| `name` | `string` |
| `proto` | `string` |
| `table` | `string` |
| `state` | `string` |
| `since` | `string` |
| `info` | `string` |
### Example response
Request:
```json
{
"servers": [
"alpha"
],
"type": "summary",
"args": ""
}
```
Response:
```json
{
"error": "",
"result": [
{
"server": "alpha",
"data": [
{
"name": "bgp1",
"proto": "BGP",
"table": "---",
"state": "start",
"since": "2021-01-15 22:40:01",
"info": "Active Socket: Operation timed out"
},
{
"name": "bgp2",
"proto": "BGP",
"table": "---",
"state": "start",
"since": "2021-01-03 08:15:48",
"info": "Established"
}
]
}
]
}
```
## Response fields (when `type` is `bird`, `traceroute`, `whois` or `server_list`)
| Name | Type | Value |
| ---- | ---- | -------- |
| `error` | `string` | Error message, empty when everything is good |
| `result` | array of `apiGenericResultPair` | See below |
### Fields for `apiGenericResultPair`
| Name | Type | Value |
| ---- | ---- | -------- |
| `server` | `string` | Name of the server; is empty when type is `whois` |
| `data` | `string` | Result from the server; is empty when type is `server_list` |
### Example response of type `bird`
Request:
```json
{
"servers": [
"alpha"
],
"type": "bird",
"args": "show status"
}
```
Response:
```json
{
"error": "",
"result": [
{
"server": "alpha",
"data": "BIRD v2.0.7-137-g61dae32b\nRouter ID is 1.2.3.4\nCurrent server time is 2021-01-17 04:21:14.792\nLast reboot on 2021-01-03 08:15:48.494\nLast reconfiguration on 2021-01-17 00:49:10.573\nDaemon is up and running\n"
}
]
}
```
### Example response of type `server_list`
Request:
```json
{
"servers": [],
"type": "server_list",
"args": ""
}
```
Response:
```json
{
"error": "",
"result": [
{
"server": "gigsgigscloud",
"data": ""
}
]
}
```

22
docs/Telegram.md Normal file
View File

@ -0,0 +1,22 @@
# Telegram Bot Webhook
The frontend can act as a Telegram Bot webhook endpoint, to add BGP route/traceroute/whois lookup functionality to your tech group.
There is no configuration necessary on the frontend, just start it up normally.
Set your Telegram Bot webhook URL to `https://your.frontend.com/telegram/alpha+beta+gamma`, where `alpha+beta+gamma` is the list of servers to be queried on Telegram commands, separated by `+`.
You may omit `alpha+beta+gamma` to use all your servers, but it is not recommended when you have lots of servers, or the message would be too long and hard to read.
## Example of setting the webhook
```bash
curl "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook?url=https://your.frontend.com:5000/telegram/alpha+beta+gamma"
```
## Supported commands
- `path`: Show bird's ASN path to target IP
- `route`: Show bird's preferred route to target IP
- `trace`: Traceroute to target IP/domain
- `whois`: Whois query

33
frontend/Dockerfile Normal file
View File

@ -0,0 +1,33 @@
FROM golang AS step_0
ENV CGO_ENABLED=0 GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -ldflags "-w -s" -o /frontend
################################################################################
FROM alpine:edge AS step_1
WORKDIR /root
RUN apk add --no-cache build-base pkgconf perl gettext \
libidn2-dev libidn2-static libunistring-dev libunistring-static gnu-libiconv-dev
RUN wget https://github.com/rfc1036/whois/archive/refs/tags/v5.5.18.tar.gz \
-O whois-5.5.18.tar.gz
RUN tar xvf whois-5.5.18.tar.gz \
&& cd whois-5.5.18 \
&& sed -i "s/#if defined _POSIX_C_SOURCE && _POSIX_C_SOURCE >= 200112L/#if 1/g" config.h \
&& make whois -j4 \
LDFLAGS="-static" CONFIG_FILE="/etc/whois.conf" PKG_CONFIG="pkg-config --static" HAVE_ICONV=1 \
&& strip /root/whois-5.5.18/whois
################################################################################
FROM scratch AS step_2
ENV PATH=/
ENV BIRDLG_WHOIS=/whois
COPY --from=step_0 /frontend /
COPY --from=step_1 /root/whois-5.5.18/whois /
COPY --from=step_1 /etc/services /etc/services
ENTRYPOINT ["/frontend"]

View File

@ -1,9 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /frontend
FROM scratch AS step_1
COPY --from=step_0 /frontend /
ENTRYPOINT ["/frontend"]

View File

@ -1,9 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=arm GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /frontend
FROM scratch AS step_1
COPY --from=step_0 /frontend /
ENTRYPOINT ["/frontend"]

View File

@ -1,9 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /frontend
FROM scratch AS step_1
COPY --from=step_0 /frontend /
ENTRYPOINT ["/frontend"]

View File

@ -1,9 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=386 GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /frontend
FROM scratch AS step_1
COPY --from=step_0 /frontend /
ENTRYPOINT ["/frontend"]

View File

@ -1,9 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=ppc64le GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /frontend
FROM scratch AS step_1
COPY --from=step_0 /frontend /
ENTRYPOINT ["/frontend"]

View File

@ -1,9 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=s390x GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /frontend
FROM scratch AS step_1
COPY --from=step_0 /frontend /
ENTRYPOINT ["/frontend"]

3
frontend/Makefile Normal file
View File

@ -0,0 +1,3 @@
.PHONY: all
all:
go build -ldflags "-w -s" -o frontend

130
frontend/api.go Normal file
View File

@ -0,0 +1,130 @@
package main
import (
"encoding/json"
"errors"
"net/http"
)
type apiRequest struct {
Servers []string `json:"servers"`
Type string `json:"type"`
Args string `json:"args"`
}
type apiGenericResultPair struct {
Server string `json:"server"`
Data string `json:"data"`
}
type apiSummaryResultPair struct {
Server string `json:"server"`
Data []SummaryRowData `json:"data"`
}
type apiResponse struct {
Error string `json:"error"`
Result []interface{} `json:"result"`
}
var apiHandlerMap = map[string](func(request apiRequest) apiResponse){
"summary": apiSummaryHandler,
"bird": apiGenericHandlerFactory("bird"),
"traceroute": apiGenericHandlerFactory("traceroute"),
"whois": apiWhoisHandler,
"server_list": apiServerListHandler,
}
func apiGenericHandlerFactory(endpoint string) func(request apiRequest) apiResponse {
return func(request apiRequest) apiResponse {
results := batchRequest(request.Servers, endpoint, request.Args)
var response apiResponse
for i, result := range results {
response.Result = append(response.Result, &apiGenericResultPair{
Server: request.Servers[i],
Data: result,
})
}
return response
}
}
func apiServerListHandler(request apiRequest) apiResponse {
var response apiResponse
for _, server := range setting.servers {
response.Result = append(response.Result, apiGenericResultPair{
Server: server,
})
}
return response
}
func apiSummaryHandler(request apiRequest) apiResponse {
results := batchRequest(request.Servers, "bird", "show protocols")
var response apiResponse
for i, result := range results {
parsedSummary, err := summaryParse(result, request.Servers[i])
if err != nil {
return apiResponse{
Error: err.Error(),
}
}
response.Result = append(response.Result, &apiSummaryResultPair{
Server: request.Servers[i],
Data: parsedSummary.Rows,
})
}
return response
}
func apiWhoisHandler(request apiRequest) apiResponse {
return apiResponse{
Error: "",
Result: []interface{}{
apiGenericResultPair{
Server: "",
Data: whois(request.Args),
},
},
}
}
func apiErrorHandler(err error) apiResponse {
return apiResponse{
Error: err.Error(),
}
}
func apiHandler(w http.ResponseWriter, r *http.Request) {
var request apiRequest
var response apiResponse
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
response = apiResponse{
Error: err.Error(),
}
} else {
handler := apiHandlerMap[request.Type]
if handler == nil {
response = apiErrorHandler(errors.New("invalid request type"))
} else {
response = handler(request)
}
}
w.Header().Add("Content-Type", "application/json")
w.Header().Add("Access-Control-Allow-Origin", "*")
bytes, err := json.Marshal(response)
if err != nil {
println(err.Error())
return
}
w.Write(bytes)
}

207
frontend/api_test.go Normal file
View File

@ -0,0 +1,207 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/jarcoal/httpmock"
"github.com/magiconair/properties/assert"
)
func TestApiServerListHandler(t *testing.T) {
setting.servers = []string{"alpha", "beta", "gamma"}
response := apiServerListHandler(apiRequest{})
assert.Equal(t, len(response.Result), 3)
assert.Equal(t, response.Result[0].(apiGenericResultPair).Server, "alpha")
assert.Equal(t, response.Result[1].(apiGenericResultPair).Server, "beta")
assert.Equal(t, response.Result[2].(apiGenericResultPair).Server, "gamma")
}
func TestApiGenericHandlerFactory(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, BirdSummaryData)
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show protocols"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
request := apiRequest{
Servers: setting.servers,
Type: "bird",
Args: "show protocols",
}
handler := apiGenericHandlerFactory("bird")
response := handler(request)
assert.Equal(t, response.Error, "")
result := response.Result[0].(*apiGenericResultPair)
assert.Equal(t, result.Server, "alpha")
assert.Equal(t, result.Data, BirdSummaryData)
}
func TestApiSummaryHandler(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, BirdSummaryData)
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show protocols"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
request := apiRequest{
Servers: setting.servers,
Type: "summary",
Args: "",
}
response := apiSummaryHandler(request)
assert.Equal(t, response.Error, "")
summary := response.Result[0].(*apiSummaryResultPair)
assert.Equal(t, summary.Server, "alpha")
// Protocol list will be sorted
assert.Equal(t, summary.Data[1].Name, "device1")
assert.Equal(t, summary.Data[1].Proto, "Device")
assert.Equal(t, summary.Data[1].Table, "---")
assert.Equal(t, summary.Data[1].State, "up")
assert.Equal(t, summary.Data[1].Since, "2021-08-27")
assert.Equal(t, summary.Data[1].Info, "")
}
func TestApiSummaryHandlerError(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock backend error")
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show protocols"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
request := apiRequest{
Servers: setting.servers,
Type: "summary",
Args: "",
}
response := apiSummaryHandler(request)
assert.Equal(t, response.Error, "Mock backend error")
}
func TestApiWhoisHandler(t *testing.T) {
expectedData := "Mock Data"
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: expectedData,
}
server.Listen()
go server.Run()
defer server.Close()
setting.whoisServer = server.server.Addr().String()
request := apiRequest{
Servers: []string{},
Type: "",
Args: "AS6939",
}
response := apiWhoisHandler(request)
assert.Equal(t, response.Error, "")
whoisResult := response.Result[0].(apiGenericResultPair)
assert.Equal(t, whoisResult.Server, "")
assert.Equal(t, whoisResult.Data, expectedData)
}
func TestApiErrorHandler(t *testing.T) {
err := errors.New("Mock Error")
response := apiErrorHandler(err)
assert.Equal(t, response.Error, "Mock Error")
}
func TestApiHandler(t *testing.T) {
setting.servers = []string{"alpha", "beta", "gamma"}
request := apiRequest{
Servers: []string{},
Type: "server_list",
Args: "",
}
requestJson, err := json.Marshal(request)
if err != nil {
t.Error(err)
}
r := httptest.NewRequest(http.MethodGet, "/api", bytes.NewReader(requestJson))
w := httptest.NewRecorder()
apiHandler(w, r)
var response apiResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Error(err)
}
assert.Equal(t, len(response.Result), 3)
// Hard to unmarshal JSON into apiGenericResultPair objects, won't check here
}
func TestApiHandlerBadJSON(t *testing.T) {
setting.servers = []string{"alpha", "beta", "gamma"}
r := httptest.NewRequest(http.MethodGet, "/api", strings.NewReader("{bad json}"))
w := httptest.NewRecorder()
apiHandler(w, r)
var response apiResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Error(err)
}
assert.Equal(t, len(response.Result), 0)
}
func TestApiHandlerInvalidType(t *testing.T) {
setting.servers = []string{"alpha", "beta", "gamma"}
request := apiRequest{
Servers: setting.servers,
Type: "invalid_type",
Args: "",
}
requestJson, err := json.Marshal(request)
if err != nil {
t.Error(err)
}
r := httptest.NewRequest(http.MethodGet, "/api", bytes.NewReader(requestJson))
w := httptest.NewRecorder()
apiHandler(w, r)
var response apiResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Error(err)
}
assert.Equal(t, len(response.Result), 0)
}

83
frontend/asn_cache.go Normal file
View File

@ -0,0 +1,83 @@
package main
import (
"fmt"
"net"
"strings"
)
type ASNCache map[string]string
func (cache ASNCache) _lookup(asn string) string {
// Try to get ASN representation using DNS
if setting.dnsInterface != "" {
records, err := net.LookupTXT(fmt.Sprintf("AS%s.%s", asn, setting.dnsInterface))
if err == nil {
result := strings.Join(records, " ")
if resultSplit := strings.Split(result, " | "); len(resultSplit) > 1 {
result = strings.Join(resultSplit[1:], "\n")
}
return fmt.Sprintf("AS%s\n%s", asn, result)
}
}
// Try to get ASN representation using WHOIS
if setting.whoisServer != "" {
if setting.bgpmapInfo == "" {
setting.bgpmapInfo = "asn,as-name,ASName,descr"
}
records := whois(fmt.Sprintf("AS%s", asn))
if records != "" {
recordsSplit := strings.Split(records, "\n")
var result []string
for _, title := range strings.Split(setting.bgpmapInfo, ",") {
if title == "asn" {
result = append(result, "AS"+asn)
}
}
for _, title := range strings.Split(setting.bgpmapInfo, ",") {
allow_multiline := false
if title[0] == ':' && len(title) >= 2 {
title = title[1:]
allow_multiline = true
}
for _, line := range recordsSplit {
if len(line) == 0 || line[0] == '%' || !strings.Contains(line, ":") {
continue
}
linearr := strings.SplitN(line, ":", 2)
line_title := linearr[0]
content := strings.TrimSpace(linearr[1])
if line_title != title {
continue
}
result = append(result, content)
if !allow_multiline {
break
}
}
}
if len(result) > 0 {
return strings.Join(result, "\n")
}
}
}
return ""
}
func (cache ASNCache) Lookup(asn string) string {
cachedValue, cacheOk := cache[asn]
if cacheOk {
return cachedValue
}
result := cache._lookup(asn)
if len(result) == 0 {
result = fmt.Sprintf("AS%s", asn)
}
cache[asn] = result
return result
}

View File

@ -0,0 +1,52 @@
package main
import (
"strings"
"testing"
"github.com/magiconair/properties/assert"
)
func TestGetASNRepresentationDNS(t *testing.T) {
checkNetwork(t)
setting.dnsInterface = "asn.cymru.com"
setting.whoisServer = ""
cache := make(ASNCache)
result := cache.Lookup("6939")
if !strings.Contains(result, "HURRICANE") {
t.Errorf("Lookup AS6939 failed, got %s", result)
}
}
func TestGetASNRepresentationDNSFallback(t *testing.T) {
checkNetwork(t)
setting.dnsInterface = "invalid.example.com"
setting.whoisServer = "whois.arin.net"
cache := make(ASNCache)
result := cache.Lookup("6939")
if !strings.Contains(result, "HURRICANE") {
t.Errorf("Lookup AS6939 failed, got %s", result)
}
}
func TestGetASNRepresentationWhois(t *testing.T) {
checkNetwork(t)
setting.dnsInterface = ""
setting.whoisServer = "whois.arin.net"
cache := make(ASNCache)
result := cache.Lookup("6939")
if !strings.Contains(result, "HURRICANE") {
t.Errorf("Lookup AS6939 failed, got %s", result)
}
}
func TestGetASNRepresentationFallback(t *testing.T) {
setting.dnsInterface = ""
setting.whoisServer = ""
cache := make(ASNCache)
result := cache.Lookup("6939")
assert.Equal(t, result, "AS6939")
}

BIN
frontend/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,73 @@
// adapted from https://stackoverflow.com/a/57080195
document.querySelectorAll('table.sortable')
.forEach((table)=> {
table.querySelectorAll('th')
.forEach((element, columnNo) => {
element.addEventListener('click', event => {
if(element.classList.contains('ascSorted')) {
dir = -1;
element.classList.remove('ascSorted');
element.classList.add('descSorted');
element.innerText = element.innerText.slice(0,-2) + " ↓";
} else if(element.classList.contains('descSorted')) {
dir = 1;
element.classList.remove('descSorted');
element.classList.add('ascSorted');
element.innerText = element.innerText.slice(0,-2) + " ↑";
} else {
dir = 1;
element.classList.add('ascSorted');
element.innerText += " ↑";
}
sortTable(table, columnNo, 0, dir, 1);
});
});
});
function sortTable(table, priCol, secCol, priDir, secDir) {
const tableBody = table.querySelector('tbody');
const tableData = table2data(tableBody);
tableData.sort((a, b) => {
if(a[priCol] === b[priCol]) {
if(a[secCol] > b[secCol]) {
return secDir;
} else {
return -secDir;
}
} else if(a[priCol] > b[priCol]) {
return priDir;
} else {
return -priDir;
}
});
data2table(tableBody, tableData);
}
function table2data(tableBody) {
const tableData = [];
tableBody.querySelectorAll('tr')
.forEach(row => {
const rowData = [];
row.querySelectorAll('td')
.forEach(cell => {
rowData.push(cell.innerHTML);
});
rowData.classList = row.classList.toString();
tableData.push(rowData);
});
return tableData;
}
function data2table(tableBody, tableData) {
tableBody.querySelectorAll('tr')
.forEach((row, i) => {
const rowData = tableData[i];
row.classList = rowData.classList;
row.querySelectorAll('td')
.forEach((cell, j) => {
cell.innerHTML = rowData[j];
});
tableData.push(rowData);
});
}

View File

@ -0,0 +1,16 @@
<h2><span class="badge badge-info mr-3">BGPmap</span>{{ html .Target }}</h2>
<div id="bgpmap">
</div>
<script src="/static/jsdelivr/npm/viz.js@2.1.2/viz.min.js" crossorigin="anonymous"></script>
<script src="/static/jsdelivr/npm/viz.js@2.1.2/lite.render.js" crossorigin="anonymous"></script>
<script>
var viz = new Viz();
viz.renderSVGElement(atob({{ .Result }}))
.then(element => {
document.getElementById("bgpmap").appendChild(element);
})
.catch(error => {
document.getElementById("bgpmap").innerHTML = "<pre>"+error+"</pre>"
});
</script>

View File

@ -0,0 +1,2 @@
<h2 class="mb-3"><span class="badge badge-info mr-3">{{ html .ServerName }}</span>{{ html .Target }}</h2>
{{ .Result }}

View File

@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="renderer" content="webkit">
<title>{{ html .Title }}</title>
<link rel="stylesheet" href="/static/jsdelivr/npm/bootstrap@4.5.1/dist/css/bootstrap.min.css" integrity="sha256-VoFZSlmyTXsegReQCNmbXrS4hBBUl/cexZvPmPWoJsY=" crossorigin="anonymous">
<meta name="robots" content="noindex, nofollow">
<style>
.navwrap { flex-wrap: wrap; font-size: 0.9rem; }
.logo {
width: 180px;
height: 32px;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="/"><img class="logo" src="/static/burble-dn42-64-white.png"></a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
{{ $option := .URLOption }}
{{ $server := .URLServer }}
{{ $target := .URLCommand }}
{{ if .IsWhois }}
{{ $option = "summary" }}
{{ $server = .AllServersURL }}
{{ $target = "" }}
{{ end }}
<ul class="navbar-nav mr-auto">
<li class="nav-item">
{{ if eq .AllServersURLCustom "all" }}
<a class="nav-link{{ if .AllServersLinkActive }} active{{ end }}"
href="/{{ $option }}/{{ .AllServersURL }}/{{ $target }}"> {{ .AllServerTitle }} </a>
{{ else }}
<a class="nav-link active"
href="{{ .AllServersURLCustom }}"> {{ .AllServerTitle }} </a>
{{ end }}
</li>
{{ $length := len .Servers }}
{{ range $k, $v := .Servers }}
<li class="nav-item">
{{ if gt $length 1 }}
<a class="nav-link{{ if eq $server $v }} active{{ end }}"
href="/{{ $option }}/{{ $v }}/{{ $target }}">{{ html (index $.ServersDisplay $k) }}</a>
{{ else }}
<a class="nav-link{{ if eq $server $v }} active{{ end }}"
href="/">{{ html (index $.ServersDisplay $k) }}</a>
{{ end }}
</li>
{{ end }}
</ul>
{{ if .IsWhois }}
{{ $target = .WhoisTarget }}
{{ end }}
<form name="goto" class="form-inline" action="javascript:goto();">
<div class="input-group">
<select name="action" class="form-control">
{{ range $k, $v := .Options }}
<option value="{{ html $k }}"{{ if eq $k $.URLOption }} selected{{end}}>{{ html $v }}</option>
{{ end }}
</select>
<input name="server" class="d-none" value="{{ html ($server | pathescape) }}">
<input name="target" class="form-control" placeholder="Target" aria-label="Target" value="{{ html $target }}">
<div class="input-group-append">
<button class="btn btn-outline-success" type="submit">&raquo;</button>
</div>
</div>
</form>
</div>
</nav>
<div class="container mt-4">
{{ .Content }}
</div>
<script src="/static/jsdelivr/npm/jquery@3.5.1/dist/jquery.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script src="/static/jsdelivr/npm/bootstrap@4.5.1/dist/js/bootstrap.min.js" integrity="sha256-0IiaoZCI++9oAAvmCb5Y0r93XkuhvJpRalZLffQXLok=" crossorigin="anonymous"></script>
<script src="/static/sortTable.js"></script>
<script>
function goto() {
let action = $('[name="action"]').val();
let server = $('[name="server"]').val();
let target = $('[name="target"]').val();
let url = "";
if (action == "whois") {
url = "/" + action + "/" + target;
} else if (action == "summary") {
url = "/" + action + "/" + server + "/";
} else {
url = "/" + action + "/" + server + "/" + target;
}
window.location.href = url;
}
</script>
</body>
</html>

View File

@ -0,0 +1,22 @@
{{ $ServerName := urlquery .ServerName }}
<table class="table table-striped table-bordered table-sm sortable">
<thead>
{{ range .Header }}
{{ if ne . "Table" }}
<th scope="col">{{ html . }}</th>
{{ end }}
{{ end }}
</thead>
<tbody>
{{ range .Rows }}
<tr>
<td><a href="/detail/{{ $ServerName }}/{{ urlquery .Name }}">{{ html .Name }}</a></td>
<td>{{ html .Proto }}</td>
<td><span class="badge badge-{{ .MappedState }}">{{ html .State }}</span></td>
<td>{{ html .Since }}</td>
<td>{{ html .Info }}</td>
</tr>
{{ end }}
</tbody>
</table>

View File

@ -0,0 +1,2 @@
<h2><span class="badge badge-info mr-3">whois<span>{{ html .Target }}</h2>
{{ .Result }}

View File

@ -1,121 +1,121 @@
package main
import (
"fmt"
"net"
"regexp"
"strings"
)
func getASNRepresentation(asn string) string {
records, err := net.LookupTXT(fmt.Sprintf("AS%s.%s", asn, setting.dnsInterface))
if err != nil {
// DNS query failed, only use ASN as output
return fmt.Sprintf("AS%s", asn)
}
// The protocol name for each route (e.g. "ibgp_sea02") is encoded in the form:
//
// unicast [ibgp_sea02 2021-08-27 from fd86:bad:11b7:1::1] * (100/1015) [i]
var protocolNameRe = regexp.MustCompile(`\[(.*?) .*\]`)
result := strings.Join(records, " ")
if resultSplit := strings.Split(result, " | "); len(resultSplit) > 1 {
result = strings.Join(resultSplit[1:], "\\n")
// Try to split the output into one chunk for each route.
// Possible values are defined at https://gitlab.nic.cz/labs/bird/-/blob/v2.0.8/nest/rt-attr.c#L81-87
var routeSplitRe = regexp.MustCompile("(unicast|blackhole|unreachable|prohibited)")
var routeViaRe = regexp.MustCompile(`(?m)^\t(via .*?)$`)
var routeASPathRe = regexp.MustCompile(`(?m)^\tBGP\.as_path: (.*?)$`)
func makeEdgeAttrs(preferred bool) RouteAttrs {
result := RouteAttrs{
"fontsize": "12.0",
}
return fmt.Sprintf("AS%s\\n%s", asn, result)
if preferred {
result["color"] = "red"
}
return result
}
func birdRouteToGraphviz(servers []string, responses []string, target string) string {
graph := make(map[string]string)
// Helper to add an edge
addEdge := func(src string, dest string, attr string) {
key := "\"" + src + "\" -> \"" + dest + "\""
_, present := graph[key]
// Do not remove edge's attributes if it's already present
if present && len(attr) == 0 {
return
}
graph[key] = attr
}
// Helper to set attribute for a point in graph
addPoint := func(name string, attr string) {
key := "\"" + name + "\""
_, present := graph[key]
// Do not remove point's attributes if it's already present
if present && len(attr) == 0 {
return
}
graph[key] = attr
func makePointAttrs(preferred bool) RouteAttrs {
result := RouteAttrs{}
if preferred {
result["color"] = "red"
}
return result
}
func birdRouteToGraph(servers []string, responses []string, target string) RouteGraph {
graph := makeRouteGraph()
graph.AddPoint(target, false, RouteAttrs{"color": "red", "shape": "diamond"})
addPoint("Target: "+target, "[color=red,shape=diamond]")
for serverID, server := range servers {
response := responses[serverID]
if len(response) == 0 {
continue
}
addPoint(server, "[color=blue,shape=box]")
// This is the best split point I can find for bird2
routes := strings.Split(response, "\tvia ")
routeFound := false
for routeIndex, route := range routes {
var routeNexthop string
var routeASPath string
var routePreferred bool = routeIndex > 0 && strings.Contains(routes[routeIndex-1], "*")
// Have to look at previous slice to determine if route is preferred, due to bad split point selection
graph.AddPoint(server, false, RouteAttrs{"color": "blue", "shape": "box"})
routes := routeSplitRe.Split(response, -1)
for _, routeParameter := range strings.Split(route, "\n") {
if strings.HasPrefix(routeParameter, "\tBGP.next_hop: ") {
routeNexthop = strings.TrimPrefix(routeParameter, "\tBGP.next_hop: ")
} else if strings.HasPrefix(routeParameter, "\tBGP.as_path: ") {
routeASPath = strings.TrimPrefix(routeParameter, "\tBGP.as_path: ")
}
}
if len(routeASPath) == 0 {
// Either this is not a BGP route, or the information is incomplete
for routeIndex, route := range routes {
if routeIndex == 0 {
continue
}
// Connect each node on AS path
paths := strings.Split(strings.TrimSpace(routeASPath), " ")
var via string
var paths []string
var routePreferred bool = strings.Contains(route, "*")
// Track non-BGP routes in the output by their protocol name, but draw them altogether in one line
// so that there are no conflicts in the edge label
var protocolName string
for pathIndex := range paths {
paths[pathIndex] = strings.TrimPrefix(paths[pathIndex], "(")
paths[pathIndex] = strings.TrimSuffix(paths[pathIndex], ")")
if match := routeViaRe.FindStringSubmatch(route); len(match) >= 2 {
via = strings.TrimSpace(match[1])
}
// First step starting from originating server
if len(paths) > 0 {
if len(routeNexthop) > 0 {
// Edge from originating server to nexthop
addEdge(server, "Nexthop:\\n"+routeNexthop, (map[bool]string{true: "[color=red]"})[routePreferred])
// and from nexthop to AS
addEdge("Nexthop:\\n"+routeNexthop, getASNRepresentation(paths[0]), (map[bool]string{true: "[color=red]"})[routePreferred])
addPoint("Nexthop:\\n"+routeNexthop, "[shape=diamond]")
routeFound = true
if match := routeASPathRe.FindStringSubmatch(route); len(match) >= 2 {
pathString := strings.TrimSpace(match[1])
if len(pathString) > 0 {
paths = strings.Split(strings.TrimSpace(match[1]), " ")
for i := range paths {
paths[i] = strings.TrimPrefix(paths[i], "(")
paths[i] = strings.TrimSuffix(paths[i], ")")
}
}
}
if match := protocolNameRe.FindStringSubmatch(route); len(match) >= 2 {
protocolName = strings.TrimSpace(match[1])
if routePreferred {
protocolName = protocolName + "*"
}
}
if len(paths) == 0 {
graph.AddEdge(server, target, strings.TrimSpace(protocolName+"\n"+via), makeEdgeAttrs(routePreferred))
continue
}
// Edges between AS
for i := range paths {
var src string
var label string
// Only show nexthop information on the first hop
if i == 0 {
src = server
label = strings.TrimSpace(protocolName + "\n" + via)
} else {
// Edge from originating server to AS
addEdge(server, getASNRepresentation(paths[0]), (map[bool]string{true: "[color=red]"})[routePreferred])
routeFound = true
src = paths[i-1]
label = ""
}
dst := paths[i]
graph.AddEdge(src, dst, label, makeEdgeAttrs(routePreferred))
// Only set color for next step, origin color is set to blue above
graph.AddPoint(dst, true, makePointAttrs(routePreferred))
}
// Following steps, edges between AS
for pathIndex := range paths {
if pathIndex == 0 {
continue
}
addEdge(getASNRepresentation(paths[pathIndex-1]), getASNRepresentation(paths[pathIndex]), (map[bool]string{true: "[color=red]"})[routePreferred])
}
// Last AS to destination
addEdge(getASNRepresentation(paths[len(paths)-1]), "Target: "+target, (map[bool]string{true: "[color=red]"})[routePreferred])
}
if !routeFound {
// Cannot find a path starting from this server
addEdge(server, "Target: "+target, "[color=gray,label=\"?\"]")
src := paths[len(paths)-1]
graph.AddEdge(src, target, "", makeEdgeAttrs(routePreferred))
}
}
// Combine all graphviz commands
var result string
for edge, attr := range graph {
result += edge + " " + attr + ";\n"
}
return "digraph {\n" + result + "}\n"
return graph
}
func birdRouteToGraphviz(servers []string, responses []string, targetName string) string {
graph := birdRouteToGraph(servers, responses, targetName)
return graph.ToGraphviz()
}

173
frontend/bgpmap_graph.go Normal file
View File

@ -0,0 +1,173 @@
package main
import (
"encoding/json"
"fmt"
"strings"
)
type RouteAttrs map[string]string
type RoutePoint struct {
performLookup bool
attrs RouteAttrs
}
type RouteEdgeKey struct {
src string
dest string
}
type RouteEdgeValue struct {
label []string
attrs RouteAttrs
}
type RouteGraph struct {
points map[string]RoutePoint
edges map[RouteEdgeKey]RouteEdgeValue
}
func makeRouteGraph() RouteGraph {
return RouteGraph{
points: make(map[string]RoutePoint),
edges: make(map[RouteEdgeKey]RouteEdgeValue),
}
}
func makeRoutePoint() RoutePoint {
return RoutePoint{
performLookup: false,
attrs: make(RouteAttrs),
}
}
func makeRouteEdgeValue() RouteEdgeValue {
return RouteEdgeValue{
label: []string{},
attrs: make(RouteAttrs),
}
}
func (graph *RouteGraph) attrsToString(attrs RouteAttrs) string {
if len(attrs) == 0 {
return ""
}
result := ""
isFirst := true
for k, v := range attrs {
if isFirst {
isFirst = false
} else {
result += ","
}
result += graph.escape(k) + "=" + graph.escape(v) + ""
}
return "[" + result + "]"
}
func (graph *RouteGraph) escape(s string) string {
result, err := json.Marshal(s)
if err != nil {
return err.Error()
} else {
return string(result)
}
}
func (graph *RouteGraph) AddEdge(src string, dest string, label string, attrs RouteAttrs) {
// Add edges with same src/dest separately, multiple edges with same src/dest could exist
edge := RouteEdgeKey{
src: src,
dest: dest,
}
newValue, exists := graph.edges[edge]
if !exists {
newValue = makeRouteEdgeValue()
}
if len(label) != 0 {
newValue.label = append(newValue.label, label)
}
for k, v := range attrs {
newValue.attrs[k] = v
}
graph.edges[edge] = newValue
}
func (graph *RouteGraph) AddPoint(name string, performLookup bool, attrs RouteAttrs) {
newValue, exists := graph.points[name]
if !exists {
newValue = makeRoutePoint()
}
newValue.performLookup = performLookup
for k, v := range attrs {
newValue.attrs[k] = v
}
graph.points[name] = newValue
}
func (graph *RouteGraph) GetEdge(src string, dest string) *RouteEdgeValue {
key := RouteEdgeKey{
src: src,
dest: dest,
}
value, ok := graph.edges[key]
if ok {
return &value
} else {
return nil
}
}
func (graph *RouteGraph) GetPoint(name string) *RoutePoint {
value, ok := graph.points[name]
if ok {
return &value
} else {
return nil
}
}
func (graph *RouteGraph) ToGraphviz() string {
var result string
asnCache := make(ASNCache)
for name, value := range graph.points {
var representation string
if value.performLookup {
representation = asnCache.Lookup(name)
} else {
representation = name
}
attrsCopy := value.attrs
if attrsCopy == nil {
attrsCopy = make(RouteAttrs)
}
attrsCopy["label"] = representation
result += fmt.Sprintf("%s %s;\n", graph.escape(name), graph.attrsToString(value.attrs))
}
for key, value := range graph.edges {
attrsCopy := value.attrs
if attrsCopy == nil {
attrsCopy = make(RouteAttrs)
}
if len(value.label) > 0 {
attrsCopy["label"] = strings.Join(value.label, "\n")
}
result += fmt.Sprintf("%s -> %s %s;\n", graph.escape(key.src), graph.escape(key.dest), graph.attrsToString(attrsCopy))
}
return "digraph {\n" + result + "}\n"
}

84
frontend/bgpmap_test.go Normal file
View File

@ -0,0 +1,84 @@
package main
import (
"io/ioutil"
"path"
"runtime"
"strings"
"testing"
)
func readDataFile(t *testing.T, filename string) string {
_, sourceName, _, _ := runtime.Caller(0)
projectRoot := path.Join(path.Dir(sourceName), "..")
dir := path.Join(projectRoot, filename)
data, err := ioutil.ReadFile(dir)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func TestBirdRouteToGraphvizXSS(t *testing.T) {
setting.dnsInterface = ""
// Don't change formatting of the following strings!
fakeResult := `<script>alert("evil!")</script>`
result := birdRouteToGraphviz([]string{
"alpha",
}, []string{
fakeResult,
}, fakeResult)
if strings.Contains(result, "<script>") {
t.Errorf("XSS injection succeeded: %s", result)
}
}
func TestBirdRouteToGraph(t *testing.T) {
setting.dnsInterface = ""
input := readDataFile(t, "frontend/test_data/bgpmap_case1.txt")
result := birdRouteToGraph([]string{"node"}, []string{input}, "target")
// Source node must exist
if result.GetPoint("node") == nil {
t.Error("Result doesn't contain point node")
}
// Last hop must exist
if result.GetPoint("4242423914") == nil {
t.Error("Result doesn't contain point 4242423914")
}
// Destination must exist
if result.GetPoint("target") == nil {
t.Error("Result doesn't contain point target")
}
// Verify that a few paths exist
if result.GetEdge("node", "4242423914") == nil {
t.Error("Result doesn't contain edge from node to 4242423914")
}
if result.GetEdge("node", "4242422688") == nil {
t.Error("Result doesn't contain edge from node to 4242422688")
}
if result.GetEdge("4242422688", "4242423914") == nil {
t.Error("Result doesn't contain edge from 4242422688 to 4242423914")
}
if result.GetEdge("4242423914", "target") == nil {
t.Error("Result doesn't contain edge from 4242423914 to target")
}
}
func TestBirdRouteToGraphviz(t *testing.T) {
setting.dnsInterface = ""
input := readDataFile(t, "frontend/test_data/bgpmap_case1.txt")
result := birdRouteToGraphviz([]string{"node"}, []string{input}, "target")
if !strings.Contains(result, "digraph {") {
t.Error("Response is not Graphviz data")
}
}

View File

@ -49,3 +49,53 @@ func dn42WhoisFilter(whois string) string {
return commandResult
}
}
/* experimental, behavior may change */
func shortenWhoisFilter(whois string) string {
commandResult := ""
commandResultLonger := ""
lines := 0
linesLonger := 0
skippedLines := 0
skippedLinesLonger := 0
for _, s := range strings.Split(whois, "\n") {
s = strings.TrimSpace(s)
shouldSkip := false
shouldSkip = shouldSkip || len(s) == 0
shouldSkip = shouldSkip || len(s) > 0 && s[0] == '#'
shouldSkip = shouldSkip || strings.Contains(strings.ToUpper(s), "REDACTED")
if shouldSkip {
skippedLinesLonger++
continue
}
commandResultLonger += s + "\n"
linesLonger++
shouldSkip = shouldSkip || len(s) > 80
shouldSkip = shouldSkip || !strings.Contains(s, ":")
shouldSkip = shouldSkip || strings.Index(s, ":") > 20
if shouldSkip {
skippedLines++
continue
}
commandResult += s + "\n"
lines++
}
if lines < 5 {
commandResult = commandResultLonger
skippedLines = skippedLinesLonger
}
if skippedLines > 0 {
return commandResult + fmt.Sprintf("\n%d line(s) skipped.\n", skippedLines)
} else {
return commandResult
}
}

106
frontend/dn42_test.go Normal file
View File

@ -0,0 +1,106 @@
package main
import (
"testing"
)
func TestDN42WhoisFilter(t *testing.T) {
input := "name: Testing\ndescr: Description"
result := dn42WhoisFilter(input)
expectedResult := `name: Testing
1 line(s) skipped.
`
if result != expectedResult {
t.Errorf("Output doesn't match expected: %s", result)
}
}
func TestDN42WhoisFilterUnneeded(t *testing.T) {
input := "name: Testing\nwhatever: Description"
result := dn42WhoisFilter(input)
if result != input+"\n" {
t.Errorf("Output doesn't match expected: %s", result)
}
}
func TestShortenWhoisFilterShorterMode(t *testing.T) {
input := `
Information line that will be removed
# Comment that will be removed
Name: Redacted for privacy
Descr: This is a vvvvvvvvvvvvvvvvvvvvvvveeeeeeeeeeeeeeeeeeeerrrrrrrrrrrrrrrrrrrrrrrryyyyyyyyyyyyyyyyyyy long line that will be skipped.
Looooooooooooooooooooooong key: this line will be skipped.
Preserved1: this line isn't removed.
Preserved2: this line isn't removed.
Preserved3: this line isn't removed.
Preserved4: this line isn't removed.
Preserved5: this line isn't removed.
`
result := shortenWhoisFilter(input)
expectedResult := `Preserved1: this line isn't removed.
Preserved2: this line isn't removed.
Preserved3: this line isn't removed.
Preserved4: this line isn't removed.
Preserved5: this line isn't removed.
3 line(s) skipped.
`
if result != expectedResult {
t.Errorf("Output doesn't match expected: %s", result)
}
}
func TestShortenWhoisFilterLongerMode(t *testing.T) {
input := `
Information line that will be removed
# Comment that will be removed
Name: Redacted for privacy
Descr: This is a vvvvvvvvvvvvvvvvvvvvvvveeeeeeeeeeeeeeeeeeeerrrrrrrrrrrrrrrrrrrrrrrryyyyyyyyyyyyyyyyyyy long line that will be skipped.
Looooooooooooooooooooooong key: this line will be skipped.
Preserved1: this line isn't removed.
`
result := shortenWhoisFilter(input)
expectedResult := `Information line that will be removed
Descr: This is a vvvvvvvvvvvvvvvvvvvvvvveeeeeeeeeeeeeeeeeeeerrrrrrrrrrrrrrrrrrrrrrrryyyyyyyyyyyyyyyyyyy long line that will be skipped.
Looooooooooooooooooooooong key: this line will be skipped.
Preserved1: this line isn't removed.
7 line(s) skipped.
`
if result != expectedResult {
t.Errorf("Output doesn't match expected: %s", result)
}
}
func TestShortenWhoisFilterSkipNothing(t *testing.T) {
input := `Preserved1: this line isn't removed.
Preserved2: this line isn't removed.
Preserved3: this line isn't removed.
Preserved4: this line isn't removed.
Preserved5: this line isn't removed.
`
result := shortenWhoisFilter(input)
if result != input {
t.Errorf("Output doesn't match expected: %s", result)
}
}

View File

@ -1,5 +1,29 @@
module github.com/xddxdd/bird-lg-go/frontend
go 1.15
go 1.17
require github.com/gorilla/handlers v1.5.1
require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/handlers v1.5.1
github.com/jarcoal/httpmock v1.3.1
github.com/magiconair/properties v1.8.7
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
)
require (
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,12 @@
package main
import (
"io/ioutil"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type channelData struct {
@ -35,15 +37,30 @@ func batchRequest(servers []string, endpoint string, command string) []string {
}(i)
} else {
// Compose URL and send the request
url := "http://" + server + "." + setting.domain + ":" + strconv.Itoa(setting.proxyPort) + "/" + url.PathEscape(endpoint) + "?q=" + url.QueryEscape(command)
hostname := server
hostname = url.PathEscape(hostname)
if strings.Contains(hostname, ":") {
hostname = "[" + hostname + "]"
}
if setting.domain != "" {
hostname += "." + setting.domain
}
url := "http://" + hostname + ":" + strconv.Itoa(setting.proxyPort) + "/" + url.PathEscape(endpoint) + "?q=" + url.QueryEscape(command)
go func(url string, i int) {
response, err := http.Get(url)
client := http.Client{Timeout: time.Duration(setting.timeOut) * time.Second}
response, err := client.Get(url)
if err != nil {
ch <- channelData{i, "request failed: " + err.Error() + "\n"}
return
}
text, _ := ioutil.ReadAll(response.Body)
ch <- channelData{i, string(text)}
buf := make([]byte, 65536)
n, err := io.ReadFull(response.Body, buf)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
ch <- channelData{i, "request failed: " + err.Error()}
} else {
ch <- channelData{i, string(buf[:n])}
}
}(url, i)
}
}

163
frontend/lgproxy_test.go Normal file
View File

@ -0,0 +1,163 @@
package main
import (
"errors"
"strings"
"testing"
"github.com/jarcoal/httpmock"
)
func TestBatchRequestIPv4(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock Result")
httpmock.RegisterResponder("GET", "http://1.1.1.1:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://2.2.2.2:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://3.3.3.3:8000/mock?q=cmd", httpResponse)
setting.servers = []string{
"1.1.1.1",
"2.2.2.2",
"3.3.3.3",
}
setting.domain = ""
setting.proxyPort = 8000
response := batchRequest(setting.servers, "mock", "cmd")
if len(response) != 3 {
t.Error("Did not get response of all three mock servers")
}
for i := 0; i < len(response); i++ {
if response[i] != "Mock Result" {
t.Error("HTTP response mismatch")
}
}
}
func TestBatchRequestIPv6(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock Result")
httpmock.RegisterResponder("GET", "http://[2001:db8::1]:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://[2001:db8::2]:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://[2001:db8::3]:8000/mock?q=cmd", httpResponse)
setting.servers = []string{
"2001:db8::1",
"2001:db8::2",
"2001:db8::3",
}
setting.domain = ""
setting.proxyPort = 8000
response := batchRequest(setting.servers, "mock", "cmd")
if len(response) != 3 {
t.Error("Did not get response of all three mock servers")
}
for i := 0; i < len(response); i++ {
if response[i] != "Mock Result" {
t.Error("HTTP response mismatch")
}
}
}
func TestBatchRequestEmptyResponse(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "")
httpmock.RegisterResponder("GET", "http://alpha:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://beta:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://gamma:8000/mock?q=cmd", httpResponse)
setting.servers = []string{
"alpha",
"beta",
"gamma",
}
setting.domain = ""
setting.proxyPort = 8000
response := batchRequest(setting.servers, "mock", "cmd")
if len(response) != 3 {
t.Error("Did not get response of all three mock servers")
}
for i := 0; i < len(response); i++ {
if !strings.Contains(response[i], "node returned empty response") {
t.Error("Did not produce error for empty response")
}
}
}
func TestBatchRequestDomainSuffix(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock Result")
httpmock.RegisterResponder("GET", "http://alpha.suffix:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://beta.suffix:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://gamma.suffix:8000/mock?q=cmd", httpResponse)
setting.servers = []string{
"alpha",
"beta",
"gamma",
}
setting.domain = "suffix"
setting.proxyPort = 8000
response := batchRequest(setting.servers, "mock", "cmd")
if len(response) != 3 {
t.Error("Did not get response of all three mock servers")
}
for i := 0; i < len(response); i++ {
if response[i] != "Mock Result" {
t.Error("HTTP response mismatch")
}
}
}
func TestBatchRequestHTTPError(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpError := httpmock.NewErrorResponder(errors.New("Oops!"))
httpmock.RegisterResponder("GET", "http://alpha:8000/mock?q=cmd", httpError)
httpmock.RegisterResponder("GET", "http://beta:8000/mock?q=cmd", httpError)
httpmock.RegisterResponder("GET", "http://gamma:8000/mock?q=cmd", httpError)
setting.servers = []string{
"alpha",
"beta",
"gamma",
}
setting.domain = ""
setting.proxyPort = 8000
response := batchRequest(setting.servers, "mock", "cmd")
if len(response) != 3 {
t.Error("Did not get response of all three mock servers")
}
for i := 0; i < len(response); i++ {
if !strings.Contains(response[i], "request failed") {
t.Error("Did not produce HTTP error")
}
}
}
func TestBatchRequestInvalidServer(t *testing.T) {
setting.servers = []string{}
setting.domain = ""
setting.proxyPort = 8000
response := batchRequest([]string{"invalid"}, "mock", "cmd")
if len(response) != 1 {
t.Error("Did not get response of all mock servers")
}
if !strings.Contains(response[0], "invalid server") {
t.Error("Did not produce invalid server error")
}
}

View File

@ -1,14 +1,14 @@
package main
import (
"flag"
"net"
"os"
"strconv"
"strings"
)
type settingType struct {
servers []string
serversDisplay []string
domain string
proxyPort int
whoisServer string
@ -17,81 +17,40 @@ type settingType struct {
netSpecificMode string
titleBrand string
navBarBrand string
navBarBrandURL string
navBarAllServer string
navBarAllURL string
bgpmapInfo string
telegramBotName string
protocolFilter []string
nameFilter string
timeOut int
}
var setting settingType
func main() {
var settingDefault = settingType{
servers: []string{""},
proxyPort: 8000,
whoisServer: "whois.verisign-grs.com",
listen: ":5000",
dnsInterface: "asn.cymru.com",
titleBrand: "Bird-lg Go",
navBarBrand: "Bird-lg Go",
}
parseSettings()
ImportTemplates()
if env := os.Getenv("BIRDLG_SERVERS"); env != "" {
settingDefault.servers = strings.Split(env, ",")
}
if env := os.Getenv("BIRDLG_DOMAIN"); env != "" {
settingDefault.domain = env
}
if env := os.Getenv("BIRDLG_PROXY_PORT"); env != "" {
var err error
if settingDefault.proxyPort, err = strconv.Atoi(env); err != nil {
panic(err)
var l net.Listener
var err error
if strings.HasPrefix(setting.listen, "/") {
// Delete existing socket file, ignore errors (will fail later anyway)
os.Remove(setting.listen)
l, err = net.Listen("unix", setting.listen)
} else {
listenAddr := setting.listen
if !strings.Contains(listenAddr, ":") {
listenAddr = ":" + listenAddr
}
}
if env := os.Getenv("BIRDLG_WHOIS"); env != "" {
settingDefault.whoisServer = env
}
if env := os.Getenv("BIRDLG_LISTEN"); env != "" {
settingDefault.listen = env
}
if env := os.Getenv("BIRDLG_DNS_INTERFACE"); env != "" {
settingDefault.dnsInterface = env
}
if env := os.Getenv("BIRDLG_NET_SPECIFIC_MODE"); env != "" {
settingDefault.netSpecificMode = env
}
if env := os.Getenv("BIRDLG_TITLE_BRAND"); env != "" {
settingDefault.titleBrand = env
settingDefault.navBarBrand = env
}
if env := os.Getenv("BIRDLG_NAVBAR_BRAND"); env != "" {
settingDefault.navBarBrand = env
l, err = net.Listen("tcp", listenAddr)
}
serversPtr := flag.String("servers", strings.Join(settingDefault.servers, ","), "server name prefixes, separated by comma")
domainPtr := flag.String("domain", settingDefault.domain, "server name domain suffixes")
proxyPortPtr := flag.Int("proxy-port", settingDefault.proxyPort, "port bird-lgproxy is running on")
whoisPtr := flag.String("whois", settingDefault.whoisServer, "whois server for queries")
listenPtr := flag.String("listen", settingDefault.listen, "address bird-lg is listening on")
dnsInterfacePtr := flag.String("dns-interface", settingDefault.dnsInterface, "dns zone to query ASN information")
netSpecificModePtr := flag.String("net-specific-mode", settingDefault.netSpecificMode, "network specific operation mode, [(none)|dn42]")
titleBrandPtr := flag.String("title-brand", settingDefault.titleBrand, "prefix of page titles in browser tabs")
navBarBrandPtr := flag.String("navbar-brand", settingDefault.navBarBrand, "brand to show in the navigation bar")
flag.Parse()
if *serversPtr == "" {
panic("no server set")
} else if *domainPtr == "" {
panic("no base domain set")
if err != nil {
panic(err)
}
setting = settingType{
strings.Split(*serversPtr, ","),
*domainPtr,
*proxyPortPtr,
*whoisPtr,
*listenPtr,
*dnsInterfacePtr,
strings.ToLower(*netSpecificModePtr),
*titleBrandPtr,
*navBarBrandPtr,
}
webServerStart()
webServerStart(l)
}

30
frontend/network_test.go Normal file
View File

@ -0,0 +1,30 @@
package main
import (
"net"
"testing"
"time"
)
const (
NETWORK_UNKNOWN = 0
NETWORK_DOWN = 1
NETWORK_UP = 2
)
var networkState int = NETWORK_UNKNOWN
func checkNetwork(t *testing.T) {
if networkState == NETWORK_UNKNOWN {
conn, err := net.DialTimeout("tcp", "8.8.8.8:53", 1*time.Second)
if err != nil {
networkState = NETWORK_DOWN
} else {
networkState = NETWORK_UP
conn.Close()
}
}
if networkState == NETWORK_DOWN {
t.Skipf("Test skipped for network error")
}
}

View File

@ -1,13 +1,51 @@
package main
import (
"bytes"
"errors"
"fmt"
"html/template"
"net/http"
"regexp"
"sort"
"strings"
)
func renderTemplate(w http.ResponseWriter, r *http.Request, title string, content string) {
// static options map
var optionsMap = map[string]string{
"summary": "show protocols",
"detail": "show protocols all ...",
"route_from_protocol": "show route protocol ...",
"route_from_protocol_all": "show route protocol ... all",
"route_from_protocol_all_primary": "show route protocol ... all primary",
"route_filtered_from_protocol": "show route filtered protocol ...",
"route_filtered_from_protocol_all": "show route filtered protocol ... all",
"route_from_origin": "show route where bgp_path.last = ...",
"route_from_origin_all": "show route where bgp_path.last = ... all",
"route_from_origin_all_primary": "show route where bgp_path.last = ... all primary",
"route": "show route for ...",
"route_all": "show route for ... all",
"route_bgpmap": "show route for ... (bgpmap)",
"route_where": "show route where net ~ [ ... ]",
"route_where_all": "show route where net ~ [ ... ] all",
"route_where_bgpmap": "show route where net ~ [ ... ] (bgpmap)",
"route_generic": "show route ...",
"generic": "show ...",
"whois": "whois ...",
"traceroute": "traceroute ...",
}
// pre-compiled regexp and constant statemap for summary rendering
var splitSummaryLine = regexp.MustCompile(`(\w+)(\s+)(\w+)(\s+)([\w-]+)(\s+)(\w+)(\s+)([0-9\-\. :]+)(.*)`)
var summaryStateMap = map[string]string{
"up": "success",
"down": "secondary",
"start": "danger",
"passive": "info",
}
// render the page template
func renderPageTemplate(w http.ResponseWriter, r *http.Request, title string, content template.HTML) {
path := r.URL.Path[1:]
split := strings.SplitN(path, "/", 3)
@ -24,43 +62,40 @@ func renderTemplate(w http.ResponseWriter, r *http.Request, title string, conten
split = strings.SplitN(path, "/", 3)
var args tmplArguments
args.Options = map[string]string{
"summary": "show protocols",
"detail": "show protocols all",
"route": "show route for ...",
"route_all": "show route for ... all",
"route_bgpmap": "show route for ... (bgpmap)",
"route_where": "show route where net ~ [ ... ]",
"route_where_all": "show route where net ~ [ ... ] all",
"route_where_bgpmap": "show route where net ~ [ ... ] (bgpmap)",
"route_generic": "show route ...",
"generic": "show ...",
"whois": "whois ...",
"traceroute": "traceroute ...",
args := TemplatePage{
Options: optionsMap,
Servers: setting.servers,
ServersDisplay: setting.serversDisplay,
AllServersLinkActive: strings.EqualFold(split[1], strings.Join(setting.servers, "+")),
AllServersURL: strings.Join(setting.servers, "+"),
AllServerTitle: setting.navBarAllServer,
AllServersURLCustom: setting.navBarAllURL,
IsWhois: isWhois,
WhoisTarget: whoisTarget,
URLOption: strings.ToLower(split[0]),
URLServer: strings.ToLower(split[1]),
URLCommand: split[2],
Title: setting.titleBrand + title,
Brand: setting.navBarBrand,
BrandURL: setting.navBarBrandURL,
Content: content,
}
args.Servers = setting.servers
args.AllServersLinkActive = strings.ToLower(split[1]) == strings.ToLower(strings.Join(setting.servers, "+"))
args.AllServersURL = strings.Join(setting.servers, "+")
args.IsWhois = isWhois
args.WhoisTarget = whoisTarget
args.URLOption = strings.ToLower(split[0])
args.URLServer = strings.ToLower(split[1])
args.URLCommand = split[2]
tmpl := TemplateLibrary["page"]
err := tmpl.Execute(w, args)
if err != nil {
fmt.Println("Error rendering page:", err.Error())
}
args.Title = setting.titleBrand + title
args.Brand = setting.navBarBrand
args.Content = content
tmpl.Execute(w, args)
}
// Write the given text to http response, and add whois links for
// ASNs and IP addresses
func smartFormatter(s string) string {
func smartFormatter(s string) template.HTML {
var result string
result += "<pre>"
s = template.HTMLEscapeString(s)
for _, line := range strings.Split(s, "\n") {
var lineFormatted string
if strings.HasPrefix(strings.TrimSpace(line), "BGP.as_path:") || strings.HasPrefix(strings.TrimSpace(line), "Neighbor AS:") || strings.HasPrefix(strings.TrimSpace(line), "Local AS:") {
@ -74,90 +109,112 @@ func smartFormatter(s string) string {
result += lineFormatted + "\n"
}
result += "</pre>"
return result
return template.HTML(result)
}
type summaryTableArguments struct {
Headers []string
Lines [][]string
// Parse bird show protocols result
func summaryParse(data string, serverName string) (TemplateSummary, error) {
args := TemplateSummary{
ServerName: serverName,
Raw: data,
}
lines := strings.Split(strings.TrimSpace(data), "\n")
if len(lines) <= 1 {
// Likely backend returned an error message
return args, errors.New(strings.TrimSpace(data))
}
// extract the table header
for _, col := range strings.Split(lines[0], " ") {
colTrimmed := strings.TrimSpace(col)
if len(colTrimmed) == 0 {
continue
}
args.Header = append(args.Header, col)
}
// Build regexp for nameFilter
nameFilterRegexp := regexp.MustCompile(setting.nameFilter)
// sort the remaining rows
rows := lines[1:]
sort.Strings(rows)
// parse each line
for _, line := range rows {
// Ignore empty lines
line = strings.TrimSpace(line)
if len(line) == 0 {
continue
}
// Parse a total of 6 columns from bird summary
lineSplitted := splitSummaryLine.FindStringSubmatch(line)
if lineSplitted == nil {
continue
}
var row SummaryRowData
if len(lineSplitted) >= 2 {
row.Name = strings.TrimSpace(lineSplitted[1])
if setting.nameFilter != "" && nameFilterRegexp.MatchString(row.Name) {
continue
}
}
if len(lineSplitted) >= 4 {
row.Proto = strings.TrimSpace(lineSplitted[3])
// Filter away unwanted protocol types, if setting.protocolFilter is non-empty
found := false
for _, protocol := range setting.protocolFilter {
if strings.EqualFold(row.Proto, protocol) {
found = true
break
}
}
if len(setting.protocolFilter) > 0 && !found {
continue
}
}
if len(lineSplitted) >= 6 {
row.Table = strings.TrimSpace(lineSplitted[5])
}
if len(lineSplitted) >= 8 {
row.State = strings.TrimSpace(lineSplitted[7])
row.MappedState = summaryStateMap[row.State]
}
if len(lineSplitted) >= 10 {
row.Since = strings.TrimSpace(lineSplitted[9])
}
if len(lineSplitted) >= 11 {
row.Info = strings.TrimSpace(lineSplitted[10])
}
// add to the result
args.Rows = append(args.Rows, row)
}
return args, nil
}
// Output a table for the summary page
func summaryTable(data string, serverName string) string {
var result string
func summaryTable(data string, serverName string) template.HTML {
result, err := summaryParse(data, serverName)
// Sort the table, excluding title row
stringsSplitted := strings.Split(strings.TrimSpace(data), "\n")
if len(stringsSplitted) <= 1 {
// Likely backend returned an error message
result = "<pre>" + strings.TrimSpace(data) + "</pre>"
} else {
// Draw the table head
result += `<table class="table table-striped table-bordered table-sm">`
result += `<thead>`
for _, col := range strings.Split(stringsSplitted[0], " ") {
colTrimmed := strings.TrimSpace(col)
if len(colTrimmed) == 0 {
continue
}
result += `<th scope="col">` + colTrimmed + `</th>`
}
result += `</thead><tbody>`
stringsWithoutTitle := stringsSplitted[1:]
sort.Strings(stringsWithoutTitle)
for _, line := range stringsWithoutTitle {
// Ignore empty lines
line = strings.TrimSpace(line)
if len(line) == 0 {
continue
}
// Parse a total of 6 columns from bird summary
lineSplitted := regexp.MustCompile(`(\w+)(\s+)(\w+)(\s+)([\w-]+)(\s+)(\w+)(\s+)([0-9\-\. :]+)(.*)`).FindStringSubmatch(line)
if lineSplitted == nil {
continue
}
var row [6]string
if len(lineSplitted) >= 2 {
row[0] = strings.TrimSpace(lineSplitted[1])
}
if len(lineSplitted) >= 4 {
row[1] = strings.TrimSpace(lineSplitted[3])
}
if len(lineSplitted) >= 6 {
row[2] = strings.TrimSpace(lineSplitted[5])
}
if len(lineSplitted) >= 8 {
row[3] = strings.TrimSpace(lineSplitted[7])
}
if len(lineSplitted) >= 10 {
row[4] = strings.TrimSpace(lineSplitted[9])
}
if len(lineSplitted) >= 11 {
row[5] = strings.TrimSpace(lineSplitted[10])
}
// Draw the row in red if the link isn't up
result += `<tr class="` + (map[string]string{
"up": "table-success",
"down": "table-secondary",
"start": "table-danger",
"passive": "table-info",
})[row[3]] + `">`
// Add link to detail for first column
result += `<td><a href="/detail/` + serverName + `/` + row[0] + `">` + row[0] + `</a></td>`
// Draw the other cells
for i := 1; i < 6; i++ {
result += "<td>" + row[i] + "</td>"
}
result += "</tr>"
}
result += "</tbody></table>"
result += "<!--" + data + "-->"
if err != nil {
return template.HTML("<pre>" + template.HTMLEscapeString(err.Error()) + "</pre>")
}
return result
// render the summary template
tmpl := TemplateLibrary["summary"]
var buffer bytes.Buffer
err = tmpl.Execute(&buffer, result)
if err != nil {
fmt.Println("Error rendering summary:", err.Error())
}
return template.HTML(buffer.String())
}

158
frontend/render_test.go Normal file
View File

@ -0,0 +1,158 @@
package main
import (
"html/template"
"io/ioutil"
"net/http/httptest"
"strings"
"testing"
)
const BirdSummaryData = `BIRD 2.0.8 ready.
Name Proto Table State Since Info
static1 Static master4 up 2021-08-27
static2 Static master6 up 2021-08-27
device1 Device --- up 2021-08-27
kernel1 Kernel master6 up 2021-08-27
kernel2 Kernel master4 up 2021-08-27
direct1 Direct --- up 2021-08-27
int_babel Babel --- up 2021-08-27
`
func initSettings() {
setting.servers = []string{"alpha"}
setting.serversDisplay = []string{"alpha"}
setting.titleBrand = "Bird-lg Go"
setting.navBarBrand = "Bird-lg Go"
ImportTemplates()
}
func TestRenderPageTemplate(t *testing.T) {
initSettings()
title := "Test Title"
content := "Test Content"
r := httptest.NewRequest("GET", "/route/alpha/192.168.0.1/", nil)
w := httptest.NewRecorder()
renderPageTemplate(w, r, title, template.HTML(content))
resultBytes, _ := ioutil.ReadAll(w.Result().Body)
result := string(resultBytes)
if !strings.Contains(result, title) {
t.Error("Title not found in output")
}
if !strings.Contains(result, content) {
t.Error("Content not found in output")
}
}
func TestRenderPageTemplateXSS(t *testing.T) {
initSettings()
evil := "<script>alert('evil');</script>"
r := httptest.NewRequest("GET", "/whois/"+evil, nil)
w := httptest.NewRecorder()
// renderPageTemplate doesn't escape content, filter is done beforehand
renderPageTemplate(w, r, evil, "Test Content")
resultBytes, _ := ioutil.ReadAll(w.Result().Body)
result := string(resultBytes)
if strings.Contains(result, evil) {
t.Errorf("XSS injection succeeded: %s", result)
}
}
// https://github.com/xddxdd/bird-lg-go/issues/57
func TestRenderPageTemplateXSS_2(t *testing.T) {
initSettings()
evil := "<script>alert('evil');</script>"
r := httptest.NewRequest("GET", "/generic/dummy_server/"+evil, nil)
w := httptest.NewRecorder()
// renderPageTemplate doesn't escape content, filter is done beforehand
renderPageTemplate(w, r, evil, "Test Content")
resultBytes, _ := ioutil.ReadAll(w.Result().Body)
result := string(resultBytes)
if strings.Contains(result, evil) {
t.Errorf("XSS injection succeeded: %s", result)
}
}
func TestSmartFormatterXSS(t *testing.T) {
evil := "<script>alert('evil');</script>"
result := string(smartFormatter(evil))
if strings.Contains(result, evil) {
t.Errorf("XSS injection succeeded: %s", result)
}
}
func TestSummaryTableXSS(t *testing.T) {
evil := "<script>alert('evil');</script>"
evilData := `Name Proto Table State Since Info
` + evil + ` ` + evil + ` --- up 2021-01-04 17:21:44 ` + evil
result := string(summaryTable(evilData, evil))
if strings.Contains(result, evil) {
t.Errorf("XSS injection succeeded: %s", result)
}
}
func TestSummaryTableProtocolFilter(t *testing.T) {
initSettings()
setting.protocolFilter = []string{"Static", "Direct", "Babel"}
result := string(summaryTable(BirdSummaryData, "testserver"))
expectedInclude := []string{"static1", "static2", "int_babel", "direct1"}
expectedExclude := []string{"device1", "kernel1", "kernel2"}
for _, item := range expectedInclude {
if !strings.Contains(result, item) {
t.Errorf("Did not find expected %s in summary table output", result)
}
}
for _, item := range expectedExclude {
if strings.Contains(result, item) {
t.Errorf("Found unexpected %s in summary table output", result)
}
}
t.Cleanup(func() {
setting.protocolFilter = []string{}
})
}
func TestSummaryTableNameFilter(t *testing.T) {
initSettings()
setting.nameFilter = "^static"
result := string(summaryTable(BirdSummaryData, "testserver"))
expectedInclude := []string{"device1", "kernel1", "kernel2", "direct1", "int_babel"}
expectedExclude := []string{"static1", "static2"}
for _, item := range expectedInclude {
if !strings.Contains(result, item) {
t.Errorf("Did not find expected %s in summary table output", result)
}
}
for _, item := range expectedExclude {
if strings.Contains(result, item) {
t.Errorf("Found unexpected %s in summary table output", result)
}
}
t.Cleanup(func() {
setting.nameFilter = ""
})
}

144
frontend/settings.go Normal file
View File

@ -0,0 +1,144 @@
package main
import (
"fmt"
"strings"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
type viperSettingType struct {
Servers string `mapstructure:"servers"`
Domain string `mapstructure:"domain"`
ProxyPort int `mapstructure:"proxy_port"`
WhoisServer string `mapstructure:"whois"`
Listen string `mapstructure:"listen"`
DNSInterface string `mapstructure:"dns_interface"`
NetSpecificMode string `mapstructure:"net_specific_mode"`
TitleBrand string `mapstructure:"title_brand"`
NavBarBrand string `mapstructure:"navbar_brand"`
NavBarBrandURL string `mapstructure:"navbar_brand_url"`
NavBarAllServer string `mapstructure:"navbar_all_servers"`
NavBarAllURL string `mapstructure:"navbar_all_url"`
BgpmapInfo string `mapstructure:"bgpmap_info"`
TelegramBotName string `mapstructure:"telegram_bot_name"`
ProtocolFilter string `mapstructure:"protocol_filter"`
NameFilter string `mapstructure:"name_filter"`
TimeOut int `mapstructure:"timeout"`
}
// Parse settings with viper, and convert to legacy setting format
func parseSettings() {
viper.AddConfigPath(".")
viper.AddConfigPath("/etc/bird-lg")
viper.SetConfigName("bird-lg")
viper.AllowEmptyEnv(true)
viper.AutomaticEnv()
viper.SetEnvPrefix("birdlg")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
pflag.String("servers", "", "server name prefixes, separated by comma")
viper.BindPFlag("servers", pflag.Lookup("servers"))
pflag.String("domain", "", "server name domain suffixes")
viper.BindPFlag("domain", pflag.Lookup("domain"))
pflag.Int("proxy-port", 8000, "port bird-lgproxy is running on")
viper.BindPFlag("proxy_port", pflag.Lookup("proxy-port"))
pflag.String("whois", "whois.verisign-grs.com", "whois server for queries")
viper.BindPFlag("whois", pflag.Lookup("whois"))
pflag.String("listen", "5000", "address or unix socket bird-lg is listening on")
viper.BindPFlag("listen", pflag.Lookup("listen"))
pflag.String("dns-interface", "asn.cymru.com", "dns zone to query ASN information")
viper.BindPFlag("dns_interface", pflag.Lookup("dns-interface"))
pflag.String("net-specific-mode", "", "network specific operation mode, [(none)|dn42]")
viper.BindPFlag("net_specific-mode", pflag.Lookup("net-specific-mode"))
pflag.String("title-brand", "Bird-lg Go", "prefix of page titles in browser tabs")
viper.BindPFlag("title_brand", pflag.Lookup("title-brand"))
pflag.String("navbar-brand", "", "brand to show in the navigation bar")
viper.BindPFlag("navbar_brand", pflag.Lookup("navbar-brand"))
pflag.String("navbar-brand-url", "/", "the url of the brand to show in the navigation bar")
viper.BindPFlag("navbar_brand_url", pflag.Lookup("navbar-brand-url"))
pflag.String("navbar-all-servers", "All Servers", "the text of \"All servers\" button in the navigation bar")
viper.BindPFlag("navbar_all_servers", pflag.Lookup("navbar-all-servers"))
pflag.String("navbar-all-url", "all", "the URL of \"All servers\" button")
viper.BindPFlag("navbar_all_url", pflag.Lookup("navbar-all-url"))
pflag.String("bgpmap-info", "asn,as-name,ASName,descr", "the infos displayed in bgpmap, separated by comma, start with \":\" means allow multiline")
viper.BindPFlag("bgpmap_info", pflag.Lookup("bgpmap-info"))
pflag.String("telegram-bot-name", "", "telegram bot name (used to filter @bot commands)")
viper.BindPFlag("telegram_bot_name", pflag.Lookup("telegram-bot-name"))
pflag.String("protocol-filter", "",
"protocol types to show in summary tables (comma separated list); defaults to all if not set")
viper.BindPFlag("protocol_filter", pflag.Lookup("protocol-filter"))
pflag.String("name-filter", "", "protocol name regex to hide in summary tables (RE2 syntax); defaults to none if not set")
viper.BindPFlag("name_filter", pflag.Lookup("name-filter"))
pflag.Int("time-out", 120, "time before request timed out, in seconds; defaults to 120 if not set")
viper.BindPFlag("timeout", pflag.Lookup("time-out"))
pflag.Parse()
if err := viper.ReadInConfig(); err != nil {
println("Warning on reading config: " + err.Error())
}
viperSettings := viperSettingType{}
if err := viper.Unmarshal(&viperSettings); err != nil {
panic(err)
}
setting.servers = strings.Split(viperSettings.Servers, ",")
setting.serversDisplay = strings.Split(viperSettings.Servers, ",")
// Split server names of the form "DisplayName<Hostname>"
for i, server := range setting.servers {
pos := strings.Index(server, "<")
if pos != -1 {
setting.serversDisplay[i] = server[0:pos]
setting.servers[i] = server[pos+1 : len(server)-1]
}
}
setting.domain = viperSettings.Domain
setting.proxyPort = viperSettings.ProxyPort
setting.whoisServer = viperSettings.WhoisServer
setting.listen = viperSettings.Listen
setting.dnsInterface = viperSettings.DNSInterface
setting.netSpecificMode = viperSettings.NetSpecificMode
setting.titleBrand = viperSettings.TitleBrand
setting.navBarBrand = viperSettings.NavBarBrand
if setting.navBarBrand == "" {
setting.navBarBrand = setting.titleBrand
}
setting.navBarBrandURL = viperSettings.NavBarBrandURL
setting.navBarAllServer = viperSettings.NavBarAllServer
setting.navBarAllURL = viperSettings.NavBarAllURL
setting.bgpmapInfo = viperSettings.BgpmapInfo
setting.telegramBotName = viperSettings.TelegramBotName
if viperSettings.ProtocolFilter != "" {
setting.protocolFilter = strings.Split(viperSettings.ProtocolFilter, ",")
} else {
setting.protocolFilter = []string{}
}
setting.nameFilter = viperSettings.NameFilter
setting.timeOut = viperSettings.TimeOut
fmt.Printf("%#v\n", setting)
}

View File

@ -0,0 +1,8 @@
package main
import "testing"
func TestParseSettings(t *testing.T) {
parseSettings()
// Good as long as it doesn't panic
}

View File

@ -31,7 +31,8 @@ type tgWebhookResponse struct {
func telegramIsCommand(message string, command string) bool {
b := false
b = b || strings.HasPrefix(message, "/"+command+"@")
b = b || strings.HasPrefix(message, "/"+command+"@"+setting.telegramBotName+" ")
b = b || message == "/"+command+"@"+setting.telegramBotName
b = b || strings.HasPrefix(message, "/"+command+" ")
b = b || message == "/"+command
return b
@ -104,7 +105,7 @@ func webHandlerTelegramBot(w http.ResponseWriter, r *http.Request) {
})
} else if telegramIsCommand(request.Message.Text, "whois") {
if setting.netSpecificMode == "dn42" {
if setting.netSpecificMode == "dn42" || setting.netSpecificMode == "dn42_generic" {
targetNumber, err := strconv.ParseUint(target, 10, 64)
if err == nil {
if targetNumber < 10000 {
@ -118,6 +119,8 @@ func webHandlerTelegramBot(w http.ResponseWriter, r *http.Request) {
tempResult := whois(target)
if setting.netSpecificMode == "dn42" {
commandResult = dn42WhoisFilter(tempResult)
} else if setting.netSpecificMode == "dn42_shorten" || setting.netSpecificMode == "shorten" {
commandResult = shortenWhoisFilter(tempResult)
} else {
commandResult = tempResult
}
@ -138,6 +141,10 @@ func webHandlerTelegramBot(w http.ResponseWriter, r *http.Request) {
commandResult = "empty result"
}
if len(commandResult) > 4096 {
commandResult = commandResult[0:4096]
}
// Create a JSON response
w.Header().Add("Content-Type", "application/json")
response := &tgWebhookResponse{

View File

@ -0,0 +1,367 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/jarcoal/httpmock"
"github.com/magiconair/properties/assert"
)
func doTestTelegramIsCommand(t *testing.T, message string, command string, expected bool) {
result := telegramIsCommand(message, command)
assert.Equal(t, result, expected)
}
func mockTelegramCall(t *testing.T, msg string, raw bool) string {
return mockTelegramEndpointCall(t, "/telegram/", msg, raw)
}
func mockTelegramEndpointCall(t *testing.T, endpoint string, msg string, raw bool) string {
request := tgWebhookRequest{
Message: tgMessage{
MessageID: 123,
Chat: tgChat{
ID: 456,
},
Text: msg,
},
}
requestJson, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
requestBody := bytes.NewReader(requestJson)
r := httptest.NewRequest(http.MethodGet, endpoint, requestBody)
w := httptest.NewRecorder()
webHandlerTelegramBot(w, r)
if raw {
return w.Body.String()
} else {
var response tgWebhookResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Error(err)
}
assert.Equal(t, response.ChatID, request.Message.Chat.ID)
assert.Equal(t, response.ReplyToMessageID, request.Message.MessageID)
return response.Text
}
}
func TestTelegramIsCommand(t *testing.T) {
setting.telegramBotName = "test_bot"
// Recognize command
doTestTelegramIsCommand(t, "/trace", "trace", true)
doTestTelegramIsCommand(t, "/trace", "trace1234", false)
doTestTelegramIsCommand(t, "/trace", "tra", false)
doTestTelegramIsCommand(t, "/trace", "abcdefg", false)
// Recognize command with parameters
doTestTelegramIsCommand(t, "/trace google.com", "trace", true)
doTestTelegramIsCommand(t, "/trace google.com", "trace1234", false)
doTestTelegramIsCommand(t, "/trace google.com", "tra", false)
doTestTelegramIsCommand(t, "/trace google.com", "abcdefg", false)
// Recognize command with bot name
doTestTelegramIsCommand(t, "/trace@test_bot", "trace", true)
doTestTelegramIsCommand(t, "/trace@test_bot", "trace1234", false)
doTestTelegramIsCommand(t, "/trace@test_bot", "tra", false)
doTestTelegramIsCommand(t, "/trace@test_bot", "abcdefg", false)
doTestTelegramIsCommand(t, "/trace@test_bot_123", "trace", false)
doTestTelegramIsCommand(t, "/trace@test_", "trace", false)
// Recognize command with bot name and parameters
doTestTelegramIsCommand(t, "/trace@test_bot google.com", "trace", true)
doTestTelegramIsCommand(t, "/trace@test_bot google.com", "trace1234", false)
doTestTelegramIsCommand(t, "/trace@test_bot google.com", "tra", false)
doTestTelegramIsCommand(t, "/trace@test_bot google.com", "abcdefg", false)
doTestTelegramIsCommand(t, "/trace@test_bot_123 google.com", "trace", false)
doTestTelegramIsCommand(t, "/trace@test google.com", "trace", false)
}
func TestTelegramBatchRequestFormatSingleServer(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock")
httpmock.RegisterResponder("GET", "http://alpha:8000/mock?q=cmd", httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
result := telegramBatchRequestFormat(setting.servers, "mock", "cmd", telegramDefaultPostProcess)
expected := "Mock\n\n"
assert.Equal(t, result, expected)
}
func TestTelegramBatchRequestFormatMultipleServers(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock")
httpmock.RegisterResponder("GET", "http://alpha:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://beta:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://gamma:8000/mock?q=cmd", httpResponse)
setting.servers = []string{
"alpha",
"beta",
"gamma",
}
setting.domain = ""
setting.proxyPort = 8000
result := telegramBatchRequestFormat(setting.servers, "mock", "cmd", telegramDefaultPostProcess)
expected := "alpha\nMock\n\nbeta\nMock\n\ngamma\nMock\n\n"
assert.Equal(t, result, expected)
}
func TestWebHandlerTelegramBotBadJSON(t *testing.T) {
requestBody := strings.NewReader("{bad json}")
r := httptest.NewRequest(http.MethodGet, "/telegram/", requestBody)
w := httptest.NewRecorder()
webHandlerTelegramBot(w, r)
response := w.Body.String()
assert.Equal(t, response, "")
}
func TestWebHandlerTelegramBotTrace(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock Response")
httpmock.RegisterResponder("GET", "http://alpha:8000/traceroute?q=1.1.1.1", httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
response := mockTelegramCall(t, "/trace 1.1.1.1", false)
assert.Equal(t, response, "```\nMock Response\n```")
}
func TestWebHandlerTelegramBotTraceWithServerList(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock Response")
httpmock.RegisterResponder("GET", "http://alpha:8000/traceroute?q=1.1.1.1", httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
response := mockTelegramEndpointCall(t, "/telegram/alpha", "/trace 1.1.1.1", false)
assert.Equal(t, response, "```\nMock Response\n```")
}
func TestWebHandlerTelegramBotRoute(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock Response")
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show route for 1.1.1.1 primary"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
response := mockTelegramCall(t, "/route 1.1.1.1", false)
assert.Equal(t, response, "```\nMock Response\n```")
}
func TestWebHandlerTelegramBotPath(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, `
BGP.as_path: 123 456
`)
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show route for 1.1.1.1 all primary"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
response := mockTelegramCall(t, "/path 1.1.1.1", false)
assert.Equal(t, response, "```\n123 456\n```")
}
func TestWebHandlerTelegramBotPathMissing(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "No path in this response")
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show route for 1.1.1.1 all primary"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
response := mockTelegramCall(t, "/path 1.1.1.1", false)
assert.Equal(t, response, "```\nempty result\n```")
}
func TestWebHandlerTelegramBotWhois(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: AS6939Response,
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = ""
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois AS6939", false)
assert.Equal(t, response, "```"+server.response+"```")
}
func TestWebHandlerTelegramBotWhoisDN42Mode(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS4242422547",
response: `
Query for AS4242422547
`,
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = "dn42"
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois 2547", false)
assert.Equal(t, response, "```"+server.response+"```")
}
func TestWebHandlerTelegramBotWhoisDN42ModeFullASN(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS4242422547",
response: `
Query for AS4242422547
`,
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = "dn42"
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois 4242422547", false)
assert.Equal(t, response, "```"+server.response+"```")
}
func TestWebHandlerTelegramBotWhoisShortenMode(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: `
Information line that will be removed
# Comment that will be removed
Name: Redacted for privacy
Descr: This is a vvvvvvvvvvvvvvvvvvvvvvveeeeeeeeeeeeeeeeeeeerrrrrrrrrrrrrrrrrrrrrrrryyyyyyyyyyyyyyyyyyy long line that will be skipped.
Looooooooooooooooooooooong key: this line will be skipped.
Preserved1: this line isn't removed.
Preserved2: this line isn't removed.
Preserved3: this line isn't removed.
Preserved4: this line isn't removed.
Preserved5: this line isn't removed.
`,
}
expectedResult := `Preserved1: this line isn't removed.
Preserved2: this line isn't removed.
Preserved3: this line isn't removed.
Preserved4: this line isn't removed.
Preserved5: this line isn't removed.
3 line(s) skipped.`
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = "shorten"
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois AS6939", false)
assert.Equal(t, response, "```\n"+expectedResult+"\n```")
}
func TestWebHandlerTelegramBotHelp(t *testing.T) {
response := mockTelegramCall(t, "/help", false)
if !strings.Contains(response, "/trace") {
t.Error("Did not get help message")
}
}
func TestWebHandlerTelegramBotUnknownCommand(t *testing.T) {
response := mockTelegramCall(t, "/nonexistent", true)
assert.Equal(t, response, "")
}
func TestWebHandlerTelegramBotNotCommand(t *testing.T) {
response := mockTelegramCall(t, "random chat message", true)
assert.Equal(t, response, "")
}
func TestWebHandlerTelegramBotEmptyResponse(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: "",
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = ""
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois AS6939", false)
assert.Equal(t, response, "```\nempty result\n```")
}
func TestWebHandlerTelegramBotTruncateLongResponse(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: strings.Repeat("A", 65536),
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = ""
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois AS6939", false)
assert.Equal(t, response, "```\n"+strings.Repeat("A", 4096)+"\n```")
}

View File

@ -1,17 +1,32 @@
package main
import (
"text/template"
"embed"
"html/template"
"net/url"
"strings"
)
type tmplArguments struct {
// import templates and other assets
//go:embed assets
var assets embed.FS
const TEMPLATE_PATH = "assets/templates/"
// template argument structures
// page
type TemplatePage struct {
// Global options
Options map[string]string
Servers []string
Options map[string]string
Servers []string
ServersDisplay []string
// Parameters related to current request
AllServersLinkActive bool
AllServerTitle string
AllServersURL string
AllServersURLCustom string
// Whois specific handling (for its unique URL)
IsWhois bool
@ -22,78 +37,104 @@ type tmplArguments struct {
URLCommand string
// Generated content to be displayed
Title string
Brand string
Content string
Title string
Brand string
BrandURL string
Content template.HTML
}
var tmpl = template.Must(template.New("tmpl").Parse(`
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="renderer" content="webkit">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.1/dist/css/bootstrap.min.css" integrity="sha256-VoFZSlmyTXsegReQCNmbXrS4hBBUl/cexZvPmPWoJsY=" crossorigin="anonymous">
<meta name="robots" content="noindex, nofollow">
</head>
<body>
// summary
type SummaryRowData struct {
Name string `json:"name"`
Proto string `json:"proto"`
Table string `json:"table"`
State string `json:"state"`
MappedState string `json:"-"`
Since string `json:"since"`
Info string `json:"info"`
}
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/">{{ .Brand }}</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
// utility functions to allow filtering of results in the template
<div class="collapse navbar-collapse" id="navbarSupportedContent">
{{ $option := .URLOption }}
{{ $server := .URLServer }}
{{ $target := .URLCommand }}
{{ if .IsWhois }}
{{ $option = "summary" }}
{{ $server = .AllServersURL }}
{{ $target = "" }}
{{ end }}
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link{{ if .AllServersLinkActive }} active{{ end }}"
href="/{{ $option }}/{{ .AllServersURL }}/{{ $target }}"> All Servers </a>
</li>
{{ range $k, $v := .Servers }}
<li class="nav-item">
<a class="nav-link{{ if eq $server $v }} active{{ end }}"
href="/{{ $option }}/{{ $v }}/{{ $target }}">{{ $v }}</a>
</li>
{{ end }}
</ul>
{{ if .IsWhois }}
{{ $target = .WhoisTarget }}
{{ end }}
<form class="form-inline" action="/redir" method="GET">
<div class="input-group">
<select name="action" class="form-control">
{{ range $k, $v := .Options }}
<option value="{{ $k }}"{{ if eq $k $.URLOption }} selected{{end}}>{{ $v }}</option>
{{ end }}
</select>
<input name="server" class="d-none" value="{{ $server }}">
<input name="target" class="form-control" placeholder="Target" aria-label="Target" value="{{ $target }}">
<div class="input-group-append">
<button class="btn btn-outline-success" type="submit">&raquo;</button>
</div>
</div>
</form>
</div>
</nav>
func (r SummaryRowData) NameHasPrefix(prefix string) bool {
return strings.HasPrefix(r.Name, prefix)
}
<div class="container">
{{ .Content }}
</div>
func (r SummaryRowData) NameContains(prefix string) bool {
return strings.Contains(r.Name, prefix)
}
<script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.1/dist/js/bootstrap.min.js" integrity="sha256-0IiaoZCI++9oAAvmCb5Y0r93XkuhvJpRalZLffQXLok=" crossorigin="anonymous"></script>
</body>
</html>
`))
type TemplateSummary struct {
ServerName string
Raw string
Header []string
Rows []SummaryRowData
}
// whois
type TemplateWhois struct {
Target string
Result template.HTML
}
// bgpmap
type TemplateBGPmap struct {
Servers []string
Target string
Result string
}
// bird
type TemplateBird struct {
ServerName string
Target string
Result template.HTML
}
// global variable to hold the templates
var TemplateLibrary map[string]*template.Template
// list of required templates
var requiredTemplates = [...]string{
"page",
"summary",
"whois",
"bgpmap",
"bird",
}
// define functions to be made available in templates
var funcMap = template.FuncMap{
"pathescape": url.PathEscape,
}
// import templates from embedded assets
func ImportTemplates() {
// create a new (blank) initial template
TemplateLibrary = make(map[string]*template.Template)
// for each template that is needed
for _, tmpl := range requiredTemplates {
// extract the template definition from the embedded assets
def, err := assets.ReadFile(TEMPLATE_PATH + tmpl + ".tpl")
if err != nil {
panic("Unable to read template (" + TEMPLATE_PATH + tmpl + ": " + err.Error())
}
// and add it to the template library
template, err := template.New(tmpl).Funcs(funcMap).Parse(string(def))
if err != nil {
panic("Unable to parse template (" + TEMPLATE_PATH + tmpl + ": " + err.Error())
}
// store in the library
TemplateLibrary[tmpl] = template
}
}

25
frontend/template_test.go Normal file
View File

@ -0,0 +1,25 @@
package main
import (
"testing"
"github.com/magiconair/properties/assert"
)
func TestSummaryRowDataNameHasPrefix(t *testing.T) {
data := SummaryRowData{
Name: "mock",
}
assert.Equal(t, data.NameHasPrefix("m"), true)
assert.Equal(t, data.NameHasPrefix("n"), false)
}
func TestSummaryRowDataNameContains(t *testing.T) {
data := SummaryRowData{
Name: "mock",
}
assert.Equal(t, data.NameContains("oc"), true)
assert.Equal(t, data.NameContains("no"), false)
}

View File

@ -0,0 +1,151 @@
Table master4:
172.20.0.53/32 unicast [ibgp_sjc2 2023-04-29 from fd86:bad:11b7:22::1] * (100/38) [AS4242423914i]
via 169.254.108.122 on igp-sjc2
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242423914
BGP.next_hop: 172.20.229.122
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,1) (64511,24) (64511,34)
BGP.large_community: (4242421080, 101, 44) (4242421080, 103, 122) (4242421080, 104, 1)
unicast [miaotony_2688 2023-04-29 from fe80::2688] (100) [AS4242423914i]
via 172.23.6.6 on dn42las-miaoton
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242422688 4242423914
BGP.next_hop: 172.23.6.6
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,3) (64511,24) (64511,34)
BGP.large_community: (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)
unicast [imlonghao_1888 2023-04-17] (100) [AS4242423914i]
via fe80::1888 on dn42-imlonghao
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242421888 4242423914
BGP.next_hop: :: fe80::1888
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,1) (64511,24) (64511,34)
BGP.large_community: (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)
unicast [ciplc_3021 2023-04-29 from fe80::943e] (100) [AS4242423914i]
via 172.23.33.161 on dn42-ciplc
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242423021 4242423914
BGP.next_hop: 172.23.33.161
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,1) (64511,24) (64511,34)
BGP.large_community: (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)
unicast [iedon_2189 2023-04-29 from fe80::2189:ef] (100) [AS4242423914i]
via 172.23.91.114 on dn42-iedon
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242422189 4242423914
BGP.next_hop: 172.23.91.114
BGP.med: 65
BGP.local_pref: 100
BGP.community: (64511,24) (64511,33) (64511,3)
BGP.large_community: (4242422189, 1, 4) (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)
unicast [prevarinite_2475 2023-04-19] (100) [AS4242423914i]
via fe80::7072:6576:6172:1 on dn42-prevarinit
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242422475 4242423192 4242423914
BGP.next_hop: :: fe80::7072:6576:6172:1
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,1) (64511,24) (64511,34)
BGP.large_community: (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)
unicast [lare_3035 2023-04-29] (100) [AS4242423914i]
via fe80::3035:132 on dn42-lare
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242423035 4242423914
BGP.next_hop: :: fe80::3035:132
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,3) (64511,34) (64511,24)
BGP.large_community: (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)
unicast [hinata_3724 2023-04-29 from fe80::3724] (100) [AS4242423914i]
via 172.23.215.228 on dn42las-hinata
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242423724 4201271111 4242423914
BGP.next_hop: 172.23.215.228
BGP.med: 70
BGP.local_pref: 100
BGP.community: (64511,22) (64511,1) (64511,34)
BGP.large_community: (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)
unicast [liki4_0927 2023-04-21] (100) [AS4242423914i]
via fe80::927 on dn42-liki4
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242420927 4242421888 4242423914
BGP.next_hop: :: fe80::927
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,2) (64511,24) (64511,34)
BGP.large_community: (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)
unicast [eastbound_2633 2023-04-29 from fe80::2633] (100) [AS4242423914i]
via 172.23.250.42 on dn42las-eastbnd
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242422633 4242423914
BGP.next_hop: 172.23.250.42
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,24) (64511,34) (64511,3)
BGP.large_community: (4242422633, 101, 44) (4242422633, 103, 36) (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)
unicast [yura_2464 2023-04-29] (100) [AS4242423914i]
via fe80::2464 on dn42las-yura
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242422464 4242423914
BGP.next_hop: :: fe80::2464
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,1) (64511,24) (64511,34)
BGP.large_community: (4242422464, 2, 4242423914) (4242422464, 64511, 44) (4242422464, 64511, 1840) (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)
unicast [ibgp_fra 2023-04-29 from fd86:bad:11b7:117::1] (100/186) [AS4242423914i]
via 169.254.108.113 on igp-chi
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242423914
BGP.next_hop: 172.20.229.117
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,1) (64511,24) (64511,34)
BGP.large_community: (4242421080, 101, 41) (4242421080, 103, 117) (4242421080, 104, 3)
unicast [ibgp_sgp 2023-04-29 from fd86:bad:11b7:239::1] (100/200) [AS4242423914i]
via 169.254.108.39 on igp-sgp
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242423914
BGP.next_hop: 172.22.108.39
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,4) (64511,24) (64511,34)
BGP.large_community: (4242421080, 101, 51) (4242421080, 103, 39) (4242421080, 104, 4)
unicast [ibgp_ymq 2023-04-30 from fd86:bad:11b7:23::1] (100/105) [AS4242423914i]
via 169.254.108.113 on igp-chi
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242423914
BGP.next_hop: 172.20.229.123
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,3) (64511,24) (64511,34)
BGP.large_community: (4242421080, 101, 42) (4242421080, 103, 123) (4242421080, 104, 2)
unicast [cola_3391 18:41:16.608 from fe80::3391] (100) [AS4242423914i]
via 172.22.96.65 on dn42-cola
Type: BGP univ
BGP.origin: IGP
BGP.as_path: 4242423391 4242420604 4242423914
BGP.next_hop: 172.22.96.65
BGP.med: 50
BGP.local_pref: 100
BGP.community: (64511,4) (64511,34) (64511,24)
BGP.large_community: (4242420604, 2, 50) (4242420604, 501, 4242423914) (4242420604, 502, 44) (4242420604, 504, 4) (4242421080, 104, 1) (4242421080, 101, 44) (4242421080, 103, 126)

View File

@ -1,49 +1,99 @@
package main
import (
"bytes"
"encoding/base64"
"fmt"
"html"
"html/template"
"io/fs"
"net"
"net/http"
"net/url"
"os"
"strings"
"github.com/gorilla/handlers"
)
func webHandlerWhois(w http.ResponseWriter, r *http.Request) {
var target string = r.URL.Path[len("/whois/"):]
var primitiveMap = map[string]string{
"summary": "show protocols",
"detail": "show protocols all %s",
"route_from_protocol": "show route protocol %s",
"route_from_protocol_all": "show route protocol %s all",
"route_from_protocol_primary": "show route protocol %s primary",
"route_from_protocol_all_primary": "show route protocol %s all primary",
"route_filtered_from_protocol": "show route filtered protocol %s",
"route_filtered_from_protocol_all": "show route filtered protocol %s all",
"route_from_origin": "show route where bgp_path.last = %s",
"route_from_origin_all": "show route where bgp_path.last = %s all",
"route_from_origin_primary": "show route where bgp_path.last = %s primary",
"route_from_origin_all_primary": "show route where bgp_path.last = %s all primary",
"route": "show route for %s",
"route_all": "show route for %s all",
"route_where": "show route where net ~ [ %s ]",
"route_where_all": "show route where net ~ [ %s ] all",
"route_generic": "show route %s",
"generic": "show %s",
"whois": "%s",
"traceroute": "%s",
}
renderTemplate(
// serve up a generic error
func serverError(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("500 Internal Server Error"))
}
// WHOIS pages
func webHandlerWhois(w http.ResponseWriter, r *http.Request) {
target, err := url.PathUnescape(r.URL.Path[len("/whois/"):])
if err != nil {
serverError(w, r)
return
}
// render the whois template
args := TemplateWhois{
Target: target,
Result: smartFormatter(whois(target)),
}
tmpl := TemplateLibrary["whois"]
var buffer bytes.Buffer
err = tmpl.Execute(&buffer, args)
if err != nil {
fmt.Println("Error rendering whois template:", err.Error())
}
renderPageTemplate(
w, r,
" - whois "+html.EscapeString(target),
"<h2>whois "+html.EscapeString(target)+"</h2>"+smartFormatter(whois(target)),
template.HTML(buffer.String()),
)
}
// serve up results from bird
func webBackendCommunicator(endpoint string, command string) func(w http.ResponseWriter, r *http.Request) {
backendCommandPrimitive, commandPresent := (map[string]string{
"summary": "show protocols",
"detail": "show protocols all %s",
"route": "show route for %s",
"route_all": "show route for %s all",
"route_where": "show route where net ~ [ %s ]",
"route_where_all": "show route where net ~ [ %s ] all",
"route_generic": "show route %s",
"generic": "show %s",
"traceroute": "%s",
})[command]
backendCommandPrimitive, commandPresent := primitiveMap[command]
if !commandPresent {
panic("invalid command: " + command)
}
return func(w http.ResponseWriter, r *http.Request) {
split := strings.SplitN(r.URL.Path[1:], "/", 4)
split := strings.SplitN(r.URL.Path[1:], "/", 3)
var urlCommands string
if len(split) >= 3 {
urlCommands = split[2]
}
if (command == "generic") && !(urlCommands == "memory" || urlCommands == "status") {
renderPageTemplate(w, r, " - "+html.EscapeString(endpoint+" show "+urlCommands),
template.HTML("<h2>'show "+urlCommands+"' not supported</h2>"))
return
}
var backendCommand string
if strings.Contains(backendCommandPrimitive, "%") {
backendCommand = fmt.Sprintf(backendCommandPrimitive, urlCommands)
@ -52,26 +102,53 @@ func webBackendCommunicator(endpoint string, command string) func(w http.Respons
}
backendCommand = strings.TrimSpace(backendCommand)
var servers []string = strings.Split(split[1], "+")
servers := strings.Split(split[1], "+")
var responses []string = batchRequest(servers, endpoint, backendCommand)
var result string
var content string
for i, response := range responses {
result += "<h2>" + html.EscapeString(servers[i]) + ": " + html.EscapeString(backendCommand) + "</h2>"
var result template.HTML
if (endpoint == "bird") && backendCommand == "show protocols" && len(response) > 4 && strings.ToLower(response[0:4]) == "name" {
result += summaryTable(response, servers[i])
result = summaryTable(response, servers[i])
} else {
result += smartFormatter(response)
result = smartFormatter(response)
}
serverDisplay := servers[i]
for k, v := range setting.servers {
if servers[i] == v {
serverDisplay = setting.serversDisplay[k]
break
}
}
// render the bird result template
args := TemplateBird{
ServerName: serverDisplay,
Target: backendCommand,
Result: result,
}
tmpl := TemplateLibrary["bird"]
var buffer bytes.Buffer
err := tmpl.Execute(&buffer, args)
if err != nil {
fmt.Println("Error rendering bird template:", err.Error())
}
content += buffer.String()
}
renderTemplate(
renderPageTemplate(
w, r,
" - "+html.EscapeString(endpoint+" "+backendCommand),
result,
" - "+endpoint+" "+backendCommand,
template.HTML(content),
)
}
}
// bgpmap result
func webHandlerBGPMap(endpoint string, command string) func(w http.ResponseWriter, r *http.Request) {
backendCommandPrimitive, commandPresent := (map[string]string{
"route_bgpmap": "show route for %s all",
@ -95,53 +172,68 @@ func webHandlerBGPMap(endpoint string, command string) func(w http.ResponseWrite
var servers []string = strings.Split(split[1], "+")
var responses []string = batchRequest(servers, endpoint, backendCommand)
renderTemplate(
// encode result with base64 to prevent xss
result := birdRouteToGraphviz(servers, responses, urlCommands)
result = base64.StdEncoding.EncodeToString([]byte(result))
// render the bgpmap result template
args := TemplateBGPmap{
Servers: servers,
Target: backendCommand,
Result: result,
}
tmpl := TemplateLibrary["bgpmap"]
var buffer bytes.Buffer
err := tmpl.Execute(&buffer, args)
if err != nil {
fmt.Println("Error rendering bgpmap template:", err.Error())
}
renderPageTemplate(
w, r,
" - "+html.EscapeString(endpoint+" "+backendCommand),
`
<script src="https://cdn.jsdelivr.net/npm/viz.js@2.1.2/viz.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/viz.js@2.1.2/lite.render.js" crossorigin="anonymous"></script>
<script>
var viz = new Viz();
viz.renderSVGElement(`+"`"+birdRouteToGraphviz(servers, responses, urlCommands)+"`"+`)
.then(element => {
document.body.appendChild(element);
})
.catch(error => {
document.body.innerHTML = "<pre>"+error+"</pre>"
});
</script>`,
template.HTML(buffer.String()),
)
}
}
func webHandlerNavbarFormRedirect(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
if query.Get("action") == "whois" {
http.Redirect(w, r, "/"+query.Get("action")+"/"+query.Get("target"), 302)
} else if query.Get("action") == "summary" {
http.Redirect(w, r, "/"+query.Get("action")+"/"+query.Get("server")+"/", 302)
} else {
http.Redirect(w, r, "/"+query.Get("action")+"/"+query.Get("server")+"/"+query.Get("target"), 302)
}
}
// set up routing paths and start webserver
func webServerStart(l net.Listener) {
func webHandlerRobotsTxt(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("User-agent: *\nDisallow: /\n"))
}
func webHandler404(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404 not found\n"))
}
func webServerStart() {
// Start HTTP server
// redirect main page to all server summary
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/summary/"+strings.Join(setting.servers, "+"), 302)
http.Redirect(w, r, "/summary/"+url.PathEscape(strings.Join(setting.servers, "+")), 302)
})
// serve static pages using embedded assets from template.go
subfs, err := fs.Sub(assets, "assets")
if err != nil {
panic("Webserver fs.sub failed: " + err.Error())
}
fs := http.FileServer(http.FS(subfs))
http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
fs.ServeHTTP(w, r)
})
http.Handle("/robots.txt", fs)
http.Handle("/favicon.ico", fs)
// backend routes
http.HandleFunc("/summary/", webBackendCommunicator("bird", "summary"))
http.HandleFunc("/detail/", webBackendCommunicator("bird", "detail"))
http.HandleFunc("/route_filtered_from_protocol/", webBackendCommunicator("bird", "route_filtered_from_protocol"))
http.HandleFunc("/route_filtered_from_protocol_all/", webBackendCommunicator("bird", "route_filtered_from_protocol_all"))
http.HandleFunc("/route_from_protocol/", webBackendCommunicator("bird", "route_from_protocol"))
http.HandleFunc("/route_from_protocol_all/", webBackendCommunicator("bird", "route_from_protocol_all"))
http.HandleFunc("/route_from_protocol_primary/", webBackendCommunicator("bird", "route_from_protocol_primary"))
http.HandleFunc("/route_from_protocol_all_primary/", webBackendCommunicator("bird", "route_from_protocol_all_primary"))
http.HandleFunc("/route_from_origin/", webBackendCommunicator("bird", "route_from_origin"))
http.HandleFunc("/route_from_origin_all/", webBackendCommunicator("bird", "route_from_origin_all"))
http.HandleFunc("/route_from_origin_primary/", webBackendCommunicator("bird", "route_from_origin_primary"))
http.HandleFunc("/route_from_origin_all_primary/", webBackendCommunicator("bird", "route_from_origin_all_primary"))
http.HandleFunc("/route/", webBackendCommunicator("bird", "route"))
http.HandleFunc("/route_all/", webBackendCommunicator("bird", "route_all"))
http.HandleFunc("/route_bgpmap/", webHandlerBGPMap("bird", "route_bgpmap"))
@ -152,9 +244,9 @@ func webServerStart() {
http.HandleFunc("/generic/", webBackendCommunicator("bird", "generic"))
http.HandleFunc("/traceroute/", webBackendCommunicator("traceroute", "traceroute"))
http.HandleFunc("/whois/", webHandlerWhois)
http.HandleFunc("/redir", webHandlerNavbarFormRedirect)
http.HandleFunc("/telegram/", webHandlerTelegramBot)
http.HandleFunc("/robots.txt", webHandlerRobotsTxt)
http.HandleFunc("/favicon.ico", webHandler404)
http.ListenAndServe(setting.listen, handlers.LoggingHandler(os.Stdout, http.DefaultServeMux))
// http.HandleFunc("/api/", apiHandler)
// http.HandleFunc("/telegram/", webHandlerTelegramBot)
// Start HTTP server
http.Serve(l, handlers.LoggingHandler(os.Stdout, http.DefaultServeMux))
}

View File

@ -0,0 +1,89 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/jarcoal/httpmock"
"github.com/magiconair/properties/assert"
)
func TestServerError(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/error", nil)
w := httptest.NewRecorder()
serverError(w, r)
assert.Equal(t, w.Code, http.StatusInternalServerError)
}
func TestWebHandlerWhois(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: AS6939Response,
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = ""
setting.whoisServer = server.server.Addr().String()
r := httptest.NewRequest(http.MethodGet, "/whois/AS6939", nil)
w := httptest.NewRecorder()
webHandlerWhois(w, r)
assert.Equal(t, w.Code, http.StatusOK)
if !strings.Contains(w.Body.String(), "HURRICANE") {
t.Error("Body does not contain whois result")
}
}
func TestWebBackendCommunicator(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
input := readDataFile(t, "frontend/test_data/bgpmap_case1.txt")
httpResponse := httpmock.NewStringResponder(200, input)
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show route for 1.1.1.1 all"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
setting.dnsInterface = ""
setting.whoisServer = ""
r := httptest.NewRequest(http.MethodGet, "/route_bgpmap/alpha/1.1.1.1", nil)
w := httptest.NewRecorder()
handler := webBackendCommunicator("bird", "route_all")
handler(w, r)
assert.Equal(t, w.Code, http.StatusOK)
}
func TestWebHandlerBGPMap(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
input := readDataFile(t, "frontend/test_data/bgpmap_case1.txt")
httpResponse := httpmock.NewStringResponder(200, input)
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show route for 1.1.1.1 all"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
setting.dnsInterface = ""
setting.whoisServer = ""
r := httptest.NewRequest(http.MethodGet, "/route_bgpmap/alpha/1.1.1.1", nil)
w := httptest.NewRecorder()
handler := webHandlerBGPMap("bird", "route_bgpmap")
handler(w, r)
assert.Equal(t, w.Code, http.StatusOK)
}

View File

@ -1,22 +1,59 @@
package main
import (
"io/ioutil"
"io"
"net"
"os/exec"
"strings"
"time"
"github.com/google/shlex"
)
// Send a whois request
func whois(s string) string {
conn, err := net.Dial("tcp", setting.whoisServer+":43")
if err != nil {
return err.Error()
if setting.whoisServer == "" {
return ""
}
defer conn.Close()
conn.Write([]byte(s + "\r\n"))
result, err := ioutil.ReadAll(conn)
if err != nil {
return err.Error()
if strings.HasPrefix(setting.whoisServer, "/") {
args, err := shlex.Split(setting.whoisServer)
if err != nil {
return err.Error()
}
args = append(args, s)
cmd := exec.Command(args[0], args[1:]...)
output, err := cmd.CombinedOutput()
if len(output) > 65535 {
output = output[:65535]
}
if err != nil {
return err.Error() + "\n" + string(output)
} else {
return string(output)
}
} else {
buf := make([]byte, 65536)
whoisServer := setting.whoisServer
if !strings.Contains(whoisServer, ":") {
whoisServer = whoisServer + ":43"
}
conn, err := net.DialTimeout("tcp", whoisServer, 5*time.Second)
if err != nil {
return err.Error()
}
defer conn.Close()
conn.Write([]byte(s + "\r\n"))
n, err := io.ReadFull(conn, buf)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return err.Error() + "\n" + string(buf[:n])
}
return string(buf[:n])
}
return string(result)
}

128
frontend/whois_test.go Normal file
View File

@ -0,0 +1,128 @@
package main
import (
"bufio"
"net"
"strings"
"testing"
)
type WhoisServer struct {
t *testing.T
expectedQuery string
response string
server net.Listener
}
const AS6939Response = `
ASNumber: 6939
ASName: HURRICANE
ASHandle: AS6939
RegDate: 1996-06-28
Updated: 2003-11-04
Ref: https://rdap.arin.net/registry/autnum/6939
`
func (s *WhoisServer) Listen() {
var err error
s.server, err = net.Listen("tcp", "127.0.0.1:0")
if err != nil {
s.t.Error(err)
}
}
func (s *WhoisServer) Run() {
for {
conn, err := s.server.Accept()
if err != nil {
break
}
if conn == nil {
break
}
reader := bufio.NewReader(conn)
query, err := reader.ReadBytes('\n')
if err != nil {
break
}
if strings.TrimSpace(string(query)) != s.expectedQuery {
s.t.Errorf("Query %s doesn't match expectation %s", string(query), s.expectedQuery)
}
conn.Write([]byte(s.response))
conn.Close()
}
}
func (s *WhoisServer) Close() {
if s.server == nil {
return
}
s.server.Close()
}
func TestWhois(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: AS6939Response,
}
server.Listen()
go server.Run()
defer server.Close()
setting.whoisServer = server.server.Addr().String()
result := whois("AS6939")
if !strings.Contains(result, "HURRICANE") {
t.Errorf("Whois AS6939 failed, got %s", result)
}
}
func TestWhoisWithoutServer(t *testing.T) {
setting.whoisServer = ""
result := whois("AS6939")
if result != "" {
t.Errorf("Whois AS6939 without server produced output, got %s", result)
}
}
func TestWhoisConnectionError(t *testing.T) {
setting.whoisServer = "127.0.0.1:0"
result := whois("AS6939")
if !strings.Contains(result, "connect: connection refused") {
t.Errorf("Whois AS6939 without server produced output, got %s", result)
}
}
func TestWhoisHostProcess(t *testing.T) {
setting.whoisServer = "/bin/sh -c \"echo Mock Result\""
result := whois("AS6939")
if result != "Mock Result\n" {
t.Errorf("Whois didn't produce expected result, got %s", result)
}
}
func TestWhoisHostProcessMalformedCommand(t *testing.T) {
setting.whoisServer = "/bin/sh -c \"mock"
result := whois("AS6939")
if result != "EOF found when expecting closing quote" {
t.Errorf("Whois didn't produce expected result, got %s", result)
}
}
func TestWhoisHostProcessError(t *testing.T) {
setting.whoisServer = "/nonexistent"
result := whois("AS6939")
if !strings.Contains(result, "no such file or directory") {
t.Errorf("Whois didn't produce expected result, got %s", result)
}
}
func TestWhoisHostProcessVeryLong(t *testing.T) {
setting.whoisServer = "/bin/sh -c \"for i in $(seq 1 131072); do printf 'A'; done\""
result := whois("AS6939")
if len(result) != 65535 {
t.Errorf("Whois result incorrectly truncated, actual len %d", len(result))
}
}

28
proxy/Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM golang AS step_0
ENV CGO_ENABLED=0 GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -ldflags "-w -s" -o /proxy
################################################################################
FROM alpine:edge AS step_1
WORKDIR /root
RUN apk add --no-cache build-base linux-headers
RUN wget https://sourceforge.net/projects/traceroute/files/traceroute/traceroute-2.1.3/traceroute-2.1.3.tar.gz/download \
-O traceroute-2.1.3.tar.gz
RUN tar xvf traceroute-2.1.3.tar.gz \
&& cd traceroute-2.1.3 \
&& make -j4 LDFLAGS="-static" \
&& strip /root/traceroute-2.1.3/traceroute/traceroute
################################################################################
FROM scratch AS step_2
ENV PATH=/
COPY --from=step_0 /proxy /
COPY --from=step_1 /root/traceroute-2.1.3/traceroute/traceroute /
ENTRYPOINT ["/proxy"]

View File

@ -1,23 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /proxy
FROM amd64/debian AS step_1
ENV TARGET_ARCH=x86_64
WORKDIR /root
RUN apt-get -qq update && DEBIAN_FRONTEND=noninteractive apt-get -qq install -y \
build-essential musl-dev musl-tools tar wget git
RUN git clone https://github.com/sabotage-linux/kernel-headers.git
RUN wget https://sourceforge.net/projects/traceroute/files/traceroute/traceroute-2.1.0/traceroute-2.1.0.tar.gz/download \
-O traceroute-2.1.0.tar.gz
RUN tar xvf traceroute-2.1.0.tar.gz \
&& cd traceroute-2.1.0 \
&& make -j4 CC=musl-gcc CFLAGS="-I/root/kernel-headers/${TARGET_ARCH}/include" LDFLAGS="-static"
FROM scratch AS step_2
ENV PATH=/
COPY --from=step_0 /proxy /
COPY --from=step_1 /root/traceroute-2.1.0/traceroute/traceroute /
ENTRYPOINT ["/proxy"]

View File

@ -1,23 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=arm GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /proxy
FROM arm32v7/debian AS step_1
ENV TARGET_ARCH=arm
WORKDIR /root
RUN apt-get -qq update && DEBIAN_FRONTEND=noninteractive apt-get -qq install -y \
build-essential musl-dev musl-tools tar wget git
RUN git clone https://github.com/sabotage-linux/kernel-headers.git
RUN wget https://sourceforge.net/projects/traceroute/files/traceroute/traceroute-2.1.0/traceroute-2.1.0.tar.gz/download \
-O traceroute-2.1.0.tar.gz
RUN tar xvf traceroute-2.1.0.tar.gz \
&& cd traceroute-2.1.0 \
&& make -j4 CC=musl-gcc CFLAGS="-I/root/kernel-headers/${TARGET_ARCH}/include" LDFLAGS="-static"
FROM scratch AS step_2
ENV PATH=/
COPY --from=step_0 /proxy /
COPY --from=step_1 /root/traceroute-2.1.0/traceroute/traceroute /
ENTRYPOINT ["/proxy"]

View File

@ -1,23 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /proxy
FROM arm64v8/debian AS step_1
ENV TARGET_ARCH=arm64
WORKDIR /root
RUN apt-get -qq update && DEBIAN_FRONTEND=noninteractive apt-get -qq install -y \
build-essential musl-dev musl-tools tar wget git
RUN git clone https://github.com/sabotage-linux/kernel-headers.git
RUN wget https://sourceforge.net/projects/traceroute/files/traceroute/traceroute-2.1.0/traceroute-2.1.0.tar.gz/download \
-O traceroute-2.1.0.tar.gz
RUN tar xvf traceroute-2.1.0.tar.gz \
&& cd traceroute-2.1.0 \
&& make -j4 CC=musl-gcc CFLAGS="-I/root/kernel-headers/${TARGET_ARCH}/include" LDFLAGS="-static"
FROM scratch AS step_2
ENV PATH=/
COPY --from=step_0 /proxy /
COPY --from=step_1 /root/traceroute-2.1.0/traceroute/traceroute /
ENTRYPOINT ["/proxy"]

View File

@ -1,23 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=386 GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /proxy
FROM i386/debian AS step_1
ENV TARGET_ARCH=x86
WORKDIR /root
RUN apt-get -qq update && DEBIAN_FRONTEND=noninteractive apt-get -qq install -y \
build-essential musl-dev musl-tools tar wget git
RUN git clone https://github.com/sabotage-linux/kernel-headers.git
RUN wget https://sourceforge.net/projects/traceroute/files/traceroute/traceroute-2.1.0/traceroute-2.1.0.tar.gz/download \
-O traceroute-2.1.0.tar.gz
RUN tar xvf traceroute-2.1.0.tar.gz \
&& cd traceroute-2.1.0 \
&& make -j4 CC=musl-gcc CFLAGS="-I/root/kernel-headers/${TARGET_ARCH}/include" LDFLAGS="-static"
FROM scratch AS step_2
ENV PATH=/
COPY --from=step_0 /proxy /
COPY --from=step_1 /root/traceroute-2.1.0/traceroute/traceroute /
ENTRYPOINT ["/proxy"]

View File

@ -1,23 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=ppc64le GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /proxy
FROM ppc64le/debian AS step_1
ENV TARGET_ARCH=ppc64le
WORKDIR /root
RUN apt-get -qq update && DEBIAN_FRONTEND=noninteractive apt-get -qq install -y \
build-essential musl-dev musl-tools tar wget git
RUN git clone https://github.com/sabotage-linux/kernel-headers.git
RUN wget https://sourceforge.net/projects/traceroute/files/traceroute/traceroute-2.1.0/traceroute-2.1.0.tar.gz/download \
-O traceroute-2.1.0.tar.gz
RUN tar xvf traceroute-2.1.0.tar.gz \
&& cd traceroute-2.1.0 \
&& make -j4 CC=musl-gcc CFLAGS="-I/root/kernel-headers/${TARGET_ARCH}/include" LDFLAGS="-static"
FROM scratch AS step_2
ENV PATH=/
COPY --from=step_0 /proxy /
COPY --from=step_1 /root/traceroute-2.1.0/traceroute/traceroute /
ENTRYPOINT ["/proxy"]

View File

@ -1,23 +0,0 @@
FROM golang:buster AS step_0
ENV CGO_ENABLED=0 GOOS=linux GOARCH=s390x GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -o /proxy
FROM s390x/debian AS step_1
ENV TARGET_ARCH=s390
WORKDIR /root
RUN apt-get -qq update && DEBIAN_FRONTEND=noninteractive apt-get -qq install -y \
build-essential musl-dev musl-tools tar wget git
RUN git clone https://github.com/sabotage-linux/kernel-headers.git
RUN wget https://sourceforge.net/projects/traceroute/files/traceroute/traceroute-2.1.0/traceroute-2.1.0.tar.gz/download \
-O traceroute-2.1.0.tar.gz
RUN tar xvf traceroute-2.1.0.tar.gz \
&& cd traceroute-2.1.0 \
&& make -j4 CC=musl-gcc CFLAGS="-I/root/kernel-headers/${TARGET_ARCH}/include" LDFLAGS="-static"
FROM scratch AS step_2
ENV PATH=/
COPY --from=step_0 /proxy /
COPY --from=step_1 /root/traceroute-2.1.0/traceroute/traceroute /
ENTRYPOINT ["/proxy"]

3
proxy/Makefile Normal file
View File

@ -0,0 +1,3 @@
.PHONY: all
all: $(shell find . -name \*.go -type f)
go build -ldflags "-w -s" -o proxy

View File

@ -1,24 +1,30 @@
package main
import (
"bytes"
"io"
"net"
"net/http"
"strings"
)
const MAX_LINE_SIZE = 1024
// Read a line from bird socket, removing preceding status number, output it.
// Returns if there are more lines.
func birdReadln(bird io.Reader, w io.Writer) bool {
// Read from socket byte by byte, until reaching newline character
c := make([]byte, 1024, 1024)
c := make([]byte, MAX_LINE_SIZE)
pos := 0
for {
if pos >= 1024 {
// Leave one byte for newline character
if pos >= MAX_LINE_SIZE-1 {
break
}
_, err := bird.Read(c[pos : pos+1])
if err != nil {
panic(err)
w.Write([]byte(err.Error()))
return false
}
if c[pos] == byte('\n') {
break
@ -27,6 +33,7 @@ func birdReadln(bird io.Reader, w io.Writer) bool {
}
c = c[:pos+1]
c[pos] = '\n'
// print(string(c[:]))
// Remove preceding status number, different situations
@ -59,13 +66,21 @@ func birdHandler(httpW http.ResponseWriter, httpR *http.Request) {
// Initialize BIRDv4 socket
bird, err := net.Dial("unix", setting.birdSocket)
if err != nil {
panic(err)
httpW.WriteHeader(http.StatusInternalServerError)
httpW.Write([]byte(err.Error()))
return
}
defer bird.Close()
birdReadln(bird, nil)
birdWriteln(bird, "restrict")
birdReadln(bird, nil)
var restrictedConfirmation bytes.Buffer
birdReadln(bird, &restrictedConfirmation)
if !strings.Contains(restrictedConfirmation.String(), "Access restricted") {
httpW.WriteHeader(http.StatusInternalServerError)
httpW.Write([]byte("could not verify that bird access was restricted"))
return
}
birdWriteln(bird, query)
for birdReadln(bird, httpW) {
}

213
proxy/bird_test.go Normal file
View File

@ -0,0 +1,213 @@
package main
import (
"bufio"
"bytes"
"io/ioutil"
"net"
"net/http"
"net/http/httptest"
"net/url"
"path"
"strings"
"testing"
"github.com/magiconair/properties/assert"
)
type BirdServer struct {
t *testing.T
expectedQuery string
response string
server net.Listener
socket string
injectError string
}
func (s *BirdServer) initSocket() {
tmpDir, err := ioutil.TempDir("", "bird-lgproxy-go-mock")
if err != nil {
s.t.Fatal(err)
}
s.socket = path.Join(tmpDir, "mock.socket")
}
func (s *BirdServer) Listen() {
s.initSocket()
var err error
s.server, err = net.Listen("unix", s.socket)
if err != nil {
s.t.Error(err)
}
}
func (s *BirdServer) Run() {
for {
conn, err := s.server.Accept()
if err != nil {
break
}
if conn == nil {
break
}
reader := bufio.NewReader(conn)
conn.Write([]byte("1234 Hello from mock bird\n"))
query, err := reader.ReadBytes('\n')
if err != nil {
break
}
if strings.TrimSpace(string(query)) != "restrict" {
s.t.Errorf("Did not restrict bird permissions")
}
if s.injectError == "restriction" {
conn.Write([]byte("1234 Restriction is disabled!\n"))
} else {
conn.Write([]byte("1234 Access restricted\n"))
}
query, err = reader.ReadBytes('\n')
if err != nil {
break
}
if strings.TrimSpace(string(query)) != s.expectedQuery {
s.t.Errorf("Query %s doesn't match expectation %s", string(query), s.expectedQuery)
}
responseList := strings.Split(s.response, "\n")
for i := range responseList {
if i == len(responseList)-1 {
if s.injectError == "eof" {
conn.Write([]byte("0000 " + responseList[i]))
} else {
conn.Write([]byte("0000 " + responseList[i] + "\n"))
}
} else {
conn.Write([]byte("1234 " + responseList[i] + "\n"))
}
}
conn.Close()
}
}
func (s *BirdServer) Close() {
if s.server == nil {
return
}
s.server.Close()
}
func TestBirdReadln(t *testing.T) {
input := strings.NewReader("1234 Bird Message\n")
var output bytes.Buffer
birdReadln(input, &output)
assert.Equal(t, output.String(), "Bird Message\n")
}
func TestBirdReadlnNoPrefix(t *testing.T) {
input := strings.NewReader(" Message without prefix\n")
var output bytes.Buffer
birdReadln(input, &output)
assert.Equal(t, output.String(), "Message without prefix\n")
}
func TestBirdReadlnVeryLongLine(t *testing.T) {
input := strings.NewReader(strings.Repeat("A", 4096))
var output bytes.Buffer
birdReadln(input, &output)
assert.Equal(t, output.String(), strings.Repeat("A", 1022)+"\n")
}
func TestBirdWriteln(t *testing.T) {
var output bytes.Buffer
birdWriteln(&output, "Test command")
assert.Equal(t, output.String(), "Test command\n")
}
func TestBirdHandlerWithoutQuery(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/bird", nil)
w := httptest.NewRecorder()
birdHandler(w, r)
}
func TestBirdHandlerWithQuery(t *testing.T) {
server := BirdServer{
t: t,
expectedQuery: "show protocols",
response: "Mock Response\nSecond Line",
injectError: "",
}
server.Listen()
go server.Run()
defer server.Close()
setting.birdSocket = server.socket
r := httptest.NewRequest(http.MethodGet, "/bird?q="+url.QueryEscape(server.expectedQuery), nil)
w := httptest.NewRecorder()
birdHandler(w, r)
assert.Equal(t, w.Code, http.StatusOK)
assert.Equal(t, w.Body.String(), server.response+"\n")
}
func TestBirdHandlerWithBadSocket(t *testing.T) {
setting.birdSocket = "/nonexistent.sock"
r := httptest.NewRequest(http.MethodGet, "/bird?q="+url.QueryEscape("mock"), nil)
w := httptest.NewRecorder()
birdHandler(w, r)
assert.Equal(t, w.Code, http.StatusInternalServerError)
}
func TestBirdHandlerWithoutRestriction(t *testing.T) {
server := BirdServer{
t: t,
expectedQuery: "show protocols",
response: "Mock Response",
injectError: "restriction",
}
server.Listen()
go server.Run()
defer server.Close()
setting.birdSocket = server.socket
r := httptest.NewRequest(http.MethodGet, "/bird?q="+url.QueryEscape("mock"), nil)
w := httptest.NewRecorder()
birdHandler(w, r)
assert.Equal(t, w.Code, http.StatusInternalServerError)
}
func TestBirdHandlerEOF(t *testing.T) {
server := BirdServer{
t: t,
expectedQuery: "show protocols",
response: "Mock Response\nSecond Line",
injectError: "eof",
}
server.Listen()
go server.Run()
defer server.Close()
setting.birdSocket = server.socket
r := httptest.NewRequest(http.MethodGet, "/bird?q="+url.QueryEscape("show protocols"), nil)
w := httptest.NewRecorder()
birdHandler(w, r)
assert.Equal(t, w.Code, http.StatusOK)
assert.Equal(t, w.Body.String(), "Mock Response\nEOF")
}

View File

@ -1,5 +1,28 @@
module github.com/xddxdd/bird-lg-go/proxy
go 1.15
go 1.17
require github.com/gorilla/handlers v1.5.1
require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/handlers v1.5.1
github.com/magiconair/properties v1.8.7
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
)
require (
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
package main
import (
"flag"
"fmt"
"net"
"net/http"
"os"
"strings"
@ -20,71 +21,81 @@ func invalidHandler(httpW http.ResponseWriter, httpR *http.Request) {
httpW.Write([]byte("Invalid Request\n"))
}
// Access handler, check to see if client IP in allowed IPs, continue if it is, send to invalidHandler if not
func hasAccess(remoteAddr string) bool {
// setting.allowedNets will always have at least one element because of how it's defined
if len(setting.allowedNets) == 0 {
return true
}
if !strings.Contains(remoteAddr, ":") {
return false
}
// Remove port from IP and remove brackets that are around IPv6 addresses
remoteAddr = remoteAddr[0:strings.LastIndex(remoteAddr, ":")]
remoteAddr = strings.Trim(remoteAddr, "[]")
ipObject := net.ParseIP(remoteAddr)
if ipObject == nil {
return false
}
for _, net := range setting.allowedNets {
if net.Contains(ipObject) {
return true
}
}
return false
}
// Access handler, check to see if client IP in allowed nets, continue if it is, send to invalidHandler if not
func accessHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(httpW http.ResponseWriter, httpR *http.Request) {
// setting.allowedIPs will always have at least one element because of how it's defined
if setting.allowedIPs[0] == "" {
if hasAccess(httpR.RemoteAddr) {
next.ServeHTTP(httpW, httpR)
return
} else {
invalidHandler(httpW, httpR)
}
IPPort := httpR.RemoteAddr
// Remove port from IP and remove brackets that are around IPv6 addresses
requestIp := IPPort[0:strings.LastIndex(IPPort, ":")]
requestIp = strings.Replace(requestIp, "[", "", -1)
requestIp = strings.Replace(requestIp, "]", "", -1)
for _, allowedIP := range setting.allowedIPs {
if requestIp == allowedIP {
next.ServeHTTP(httpW, httpR)
return
}
}
invalidHandler(httpW, httpR)
return
})
}
type settingType struct {
birdSocket string
listen string
allowedIPs []string
birdSocket string
listen string
allowedNets []*net.IPNet
tr_bin string
tr_flags []string
tr_raw bool
}
var setting settingType
// Wrapper of tracer
func main() {
// Prepare default socket paths, use environment variable if possible
var settingDefault = settingType{
"/var/run/bird/bird.ctl",
":8000",
[]string{""},
parseSettings()
tracerouteAutodetect()
fmt.Printf("Listening on %s...\n", setting.listen)
var l net.Listener
var err error
if strings.HasPrefix(setting.listen, "/") {
// Delete existing socket file, ignore errors (will fail later anyway)
os.Remove(setting.listen)
l, err = net.Listen("unix", setting.listen)
} else {
listenAddr := setting.listen
if !strings.Contains(listenAddr, ":") {
listenAddr = ":" + listenAddr
}
l, err = net.Listen("tcp", listenAddr)
}
if birdSocketEnv := os.Getenv("BIRD_SOCKET"); birdSocketEnv != "" {
settingDefault.birdSocket = birdSocketEnv
if err != nil {
panic(err)
}
if listenEnv := os.Getenv("BIRDLG_LISTEN"); listenEnv != "" {
settingDefault.listen = listenEnv
}
if AllowedIPsEnv := os.Getenv("ALLOWED_IPS"); AllowedIPsEnv != "" {
settingDefault.allowedIPs = strings.Split(AllowedIPsEnv, ",")
}
// Allow parameters to override environment variables
birdParam := flag.String("bird", settingDefault.birdSocket, "socket file for bird, set either in parameter or environment variable BIRD_SOCKET")
listenParam := flag.String("listen", settingDefault.listen, "listen address, set either in parameter or environment variable BIRDLG_LISTEN")
AllowedIPsParam := flag.String("allowed", strings.Join(settingDefault.allowedIPs, ","), "IPs allowed to access this proxy, separated by commas. Don't set to allow all IPs.")
flag.Parse()
setting.birdSocket = *birdParam
setting.listen = *listenParam
setting.allowedIPs = strings.Split(*AllowedIPsParam, ",")
// Start HTTP server
http.HandleFunc("/", invalidHandler)
@ -92,5 +103,5 @@ func main() {
http.HandleFunc("/bird6", birdHandler)
http.HandleFunc("/traceroute", tracerouteHandler)
http.HandleFunc("/traceroute6", tracerouteHandler)
http.ListenAndServe(*listenParam, handlers.LoggingHandler(os.Stdout, accessHandler(http.DefaultServeMux)))
http.Serve(l, handlers.LoggingHandler(os.Stdout, accessHandler(http.DefaultServeMux)))
}

99
proxy/main_test.go Normal file
View File

@ -0,0 +1,99 @@
package main
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/magiconair/properties/assert"
)
func TestHasAccessNotConfigured(t *testing.T) {
setting.allowedNets = []*net.IPNet{}
assert.Equal(t, hasAccess("whatever"), true)
}
func TestHasAccessAllowIPv4(t *testing.T) {
_, netip, _ := net.ParseCIDR("1.2.3.4/32")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("1.2.3.4:4321"), true)
}
func TestHasAccessAllowIPv4Net(t *testing.T) {
_, netip, _ := net.ParseCIDR("1.2.3.0/24")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("1.2.3.4:4321"), true)
}
func TestHasAccessDenyIPv4(t *testing.T) {
_, netip, _ := net.ParseCIDR("4.3.2.1/32")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("1.2.3.4:4321"), false)
}
func TestHasAccessAllowIPv6(t *testing.T) {
_, netip, _ := net.ParseCIDR("2001:db8::1/128")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("[2001:db8::1]:4321"), true)
}
func TestHasAccessAllowIPv6Net(t *testing.T) {
_, netip, _ := net.ParseCIDR("2001:db8::/64")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("[2001:db8::1]:4321"), true)
}
func TestHasAccessAllowIPv6DifferentForm(t *testing.T) {
_, netip, _ := net.ParseCIDR("2001:db8::1/128")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("[2001:db8::1]:4321"), true)
}
func TestHasAccessDenyIPv6(t *testing.T) {
_, netip, _ := net.ParseCIDR("2001:db8::2/128")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("[2001:db8::1]:4321"), false)
}
func TestHasAccessBadClientIP(t *testing.T) {
_, netip, _ := net.ParseCIDR("1.2.3.4/32")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("not an IP"), false)
}
func TestHasAccessBadClientIPPort(t *testing.T) {
_, netip, _ := net.ParseCIDR("1.2.3.4/32")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("not an IP:not a port"), false)
}
func TestAccessHandlerAllow(t *testing.T) {
baseHandler := http.NotFoundHandler()
wrappedHandler := accessHandler(baseHandler)
r := httptest.NewRequest(http.MethodGet, "/mock", nil)
r.RemoteAddr = "1.2.3.4:4321"
w := httptest.NewRecorder()
_, netip, _ := net.ParseCIDR("1.2.3.4/32")
setting.allowedNets = []*net.IPNet{netip}
wrappedHandler.ServeHTTP(w, r)
assert.Equal(t, w.Code, http.StatusNotFound)
}
func TestAccessHandlerDeny(t *testing.T) {
baseHandler := http.NotFoundHandler()
wrappedHandler := accessHandler(baseHandler)
r := httptest.NewRequest(http.MethodGet, "/mock", nil)
r.RemoteAddr = "1.2.3.4:4321"
w := httptest.NewRecorder()
_, netip, _ := net.ParseCIDR("4.3.2.1/32")
setting.allowedNets = []*net.IPNet{netip}
wrappedHandler.ServeHTTP(w, r)
assert.Equal(t, w.Code, http.StatusInternalServerError)
}

106
proxy/settings.go Normal file
View File

@ -0,0 +1,106 @@
package main
import (
"fmt"
"net"
"strings"
"github.com/google/shlex"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
type viperSettingType struct {
BirdSocket string `mapstructure:"bird_socket"`
Listen string `mapstructure:"listen"`
AllowedNets string `mapstructure:"allowed_ips"`
TracerouteBin string `mapstructure:"traceroute_bin"`
TracerouteFlags string `mapstructure:"traceroute_flags"`
TracerouteRaw bool `mapstructure:"traceroute_raw"`
}
// Parse settings with viper, and convert to legacy setting format
func parseSettings() {
viper.AddConfigPath(".")
viper.AddConfigPath("/etc/bird-lg")
viper.SetConfigName("bird-lgproxy")
viper.AllowEmptyEnv(true)
viper.AutomaticEnv()
viper.SetEnvPrefix("birdlg")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
// Legacy environment variables without prefixes
viper.BindEnv("bird_socket", "BIRD_SOCKET")
viper.BindEnv("listen", "BIRDLG_LISTEN", "BIRDLG_PROXY_PORT")
viper.BindEnv("allowed_ips", "ALLOWED_IPS")
pflag.String("bird", "/var/run/bird/bird.ctl", "socket file for bird, set either in parameter or environment variable BIRD_SOCKET")
viper.BindPFlag("bird_socket", pflag.Lookup("bird"))
pflag.String("listen", "8000", "listen address, set either in parameter or environment variable BIRDLG_PROXY_PORT")
viper.BindPFlag("listen", pflag.Lookup("listen"))
pflag.String("allowed", "", "IPs or networks allowed to access this proxy, separated by commas. Don't set to allow all IPs.")
viper.BindPFlag("allowed_ips", pflag.Lookup("allowed"))
pflag.String("traceroute_bin", "", "traceroute binary file, set either in parameter or environment variable BIRDLG_TRACEROUTE_BIN")
viper.BindPFlag("traceroute_bin", pflag.Lookup("traceroute_bin"))
pflag.String("traceroute_flags", "", "traceroute flags, supports multiple flags separated with space.")
viper.BindPFlag("traceroute_flags", pflag.Lookup("traceroute_flags"))
pflag.Bool("traceroute_raw", false, "whether to display traceroute outputs raw; set via parameter or environment variable BIRDLG_TRACEROUTE_RAW")
viper.BindPFlag("traceroute_raw", pflag.Lookup("traceroute_raw"))
pflag.Parse()
if err := viper.ReadInConfig(); err != nil {
println("Warning on reading config: " + err.Error())
}
viperSettings := viperSettingType{}
if err := viper.Unmarshal(&viperSettings); err != nil {
panic(err)
}
setting.birdSocket = viperSettings.BirdSocket
setting.listen = viperSettings.Listen
if viperSettings.AllowedNets != "" {
for _, arg := range strings.Split(viperSettings.AllowedNets, ",") {
// if argument is an IP address, convert to CIDR by adding a suitable mask
if !strings.Contains(arg, "/") {
if strings.Contains(arg, ":") {
// IPv6 address with /128 mask
arg += "/128"
} else {
// IPv4 address with /32 mask
arg += "/32"
}
}
// parse the network
_, netip, err := net.ParseCIDR(arg)
if err != nil {
fmt.Printf("Failed to parse CIDR %s: %s\n", arg, err.Error())
continue
}
setting.allowedNets = append(setting.allowedNets, netip)
}
} else {
setting.allowedNets = []*net.IPNet{}
}
var err error
setting.tr_bin = viperSettings.TracerouteBin
setting.tr_flags, err = shlex.Split(viperSettings.TracerouteFlags)
if err != nil {
panic(err)
}
setting.tr_raw = viperSettings.TracerouteRaw
fmt.Printf("%#v\n", setting)
}

8
proxy/settings_test.go Normal file
View File

@ -0,0 +1,8 @@
package main
import "testing"
func TestParseSettings(t *testing.T) {
parseSettings()
// Good as long as it doesn't panic
}

View File

@ -5,76 +5,125 @@ import (
"net/http"
"os/exec"
"regexp"
"runtime"
"strconv"
"strings"
"github.com/google/shlex"
)
func tracerouteTryExecute(cmd []string, args [][]string) ([]byte, string) {
var output []byte
var errString = ""
for i := range cmd {
var err error
var cmdCombined = cmd[i] + " " + strings.Join(args[i], " ")
func tracerouteArgsToString(cmd string, args []string, target []string) string {
var cmdCombined = append([]string{cmd}, args...)
cmdCombined = append(cmdCombined, target...)
return strings.Join(cmdCombined, " ")
}
instance := exec.Command(cmd[i], args[i]...)
output, err = instance.CombinedOutput()
if err == nil {
return output, ""
}
errString += fmt.Sprintf("+ (Try %d) %s\n%s\n\n", (i + 1), cmdCombined, output)
func tracerouteTryExecute(cmd string, args []string, target []string) ([]byte, error) {
instance := exec.Command(cmd, append(args, target...)...)
output, err := instance.CombinedOutput()
if err == nil {
return output, nil
}
return nil, errString
return output, err
}
func tracerouteDetect(cmd string, args []string) bool {
target := []string{"127.0.0.1"}
success := false
if result, err := tracerouteTryExecute(cmd, args, target); err == nil {
setting.tr_bin = cmd
setting.tr_flags = args
success = true
fmt.Printf("Traceroute autodetect success: %s\n", tracerouteArgsToString(cmd, args, target))
} else {
fmt.Printf("Traceroute autodetect fail, continuing: %s (%s)\n%s", tracerouteArgsToString(cmd, args, target), err.Error(), result)
}
return success
}
func tracerouteAutodetect() {
if setting.tr_bin != "" && setting.tr_flags != nil {
return
}
// Traceroute (custom binary)
if setting.tr_bin != "" {
if tracerouteDetect(setting.tr_bin, []string{"-q1", "-N32", "-w1"}) {
return
}
if tracerouteDetect(setting.tr_bin, []string{"-q1", "-w1"}) {
return
}
if tracerouteDetect(setting.tr_bin, []string{}) {
return
}
}
// MTR
if tracerouteDetect("mtr", []string{"-w", "-c1", "-Z1", "-G1", "-b"}) {
return
}
// Traceroute
if tracerouteDetect("traceroute", []string{"-q1", "-N32", "-w1"}) {
return
}
if tracerouteDetect("traceroute", []string{"-q1", "-w1"}) {
return
}
if tracerouteDetect("traceroute", []string{}) {
return
}
// Unsupported
setting.tr_bin = ""
setting.tr_flags = nil
println("Traceroute autodetect failed! Traceroute will be disabled")
}
func tracerouteHandler(httpW http.ResponseWriter, httpR *http.Request) {
query := string(httpR.URL.Query().Get("q"))
query = strings.TrimSpace(query)
if query == "" {
invalidHandler(httpW, httpR)
} else {
args, err := shlex.Split(query)
if err != nil {
httpW.WriteHeader(http.StatusInternalServerError)
httpW.Write([]byte(fmt.Sprintf("failed to parse args: %s\n", err.Error())))
return
}
var result []byte
var errString string
skippedCounter := 0
if runtime.GOOS == "freebsd" || runtime.GOOS == "netbsd" || runtime.GOOS == "openbsd" {
result, errString = tracerouteTryExecute(
[]string{
"traceroute",
},
[][]string{
{"-q1", "-w1", query},
},
)
} else if runtime.GOOS == "linux" {
result, errString = tracerouteTryExecute(
[]string{
"traceroute",
"traceroute",
},
[][]string{
{"-q1", "-N32", "-w1", query},
{"-q1", "-w1", query},
},
)
} else {
if setting.tr_bin == "" {
httpW.WriteHeader(http.StatusInternalServerError)
httpW.Write([]byte("traceroute not supported on this node.\n"))
return
}
if errString != "" {
result, err = tracerouteTryExecute(setting.tr_bin, setting.tr_flags, args)
if err != nil {
httpW.WriteHeader(http.StatusInternalServerError)
httpW.Write([]byte(errString))
httpW.Write([]byte(fmt.Sprintf("Error executing traceroute: %s\n\n", err.Error())))
}
if result != nil {
errString = string(result)
errString = regexp.MustCompile(`(?m)^\s*(\d*)\s*\*\n`).ReplaceAllStringFunc(errString, func(w string) string {
skippedCounter++
return ""
})
httpW.Write([]byte(strings.TrimSpace(errString)))
if skippedCounter > 0 {
httpW.Write([]byte("\n\n" + strconv.Itoa(skippedCounter) + " hops not responding."))
if setting.tr_raw {
httpW.Write(result)
} else {
resultString := string(result)
resultString = regexp.MustCompile(`(?m)^\s*(\d*)\s*\*\n`).ReplaceAllStringFunc(resultString, func(w string) string {
skippedCounter++
return ""
})
httpW.Write([]byte(strings.TrimSpace(resultString)))
if skippedCounter > 0 {
httpW.Write([]byte("\n\n" + strconv.Itoa(skippedCounter) + " hops not responding."))
}
}
}
}

168
proxy/traceroute_test.go Normal file
View File

@ -0,0 +1,168 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"github.com/magiconair/properties/assert"
)
func TestTracerouteArgsToString(t *testing.T) {
result := tracerouteArgsToString("traceroute", []string{
"-a",
"-b",
"-c",
}, []string{
"google.com",
})
assert.Equal(t, result, "traceroute -a -b -c google.com")
}
func TestTracerouteTryExecuteSuccess(t *testing.T) {
_, err := tracerouteTryExecute("sh", []string{
"-c",
}, []string{
"true",
})
if err != nil {
t.Error(err)
}
}
func TestTracerouteTryExecuteFail(t *testing.T) {
_, err := tracerouteTryExecute("sh", []string{
"-c",
}, []string{
"false",
})
if err == nil {
t.Error("Should trigger error, not triggered")
}
}
func TestTracerouteDetectSuccess(t *testing.T) {
result := tracerouteDetect("sh", []string{
"-c",
"true",
})
assert.Equal(t, result, true)
}
func TestTracerouteDetectFail(t *testing.T) {
result := tracerouteDetect("sh", []string{
"-c",
"false",
})
assert.Equal(t, result, false)
}
func TestTracerouteAutodetect(t *testing.T) {
pathBackup := os.Getenv("PATH")
os.Setenv("PATH", "")
defer os.Setenv("PATH", pathBackup)
setting.tr_bin = ""
setting.tr_flags = []string{}
tracerouteAutodetect()
// Should not panic
}
func TestTracerouteAutodetectExisting(t *testing.T) {
setting.tr_bin = "mock"
setting.tr_flags = []string{"mock"}
tracerouteAutodetect()
assert.Equal(t, setting.tr_bin, "mock")
assert.Equal(t, setting.tr_flags, []string{"mock"})
}
func TestTracerouteAutodetectFlagsOnly(t *testing.T) {
pathBackup := os.Getenv("PATH")
os.Setenv("PATH", "")
defer os.Setenv("PATH", pathBackup)
setting.tr_bin = "mock"
setting.tr_flags = nil
tracerouteAutodetect()
// Should not panic
}
func TestTracerouteHandlerWithoutQuery(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/traceroute", nil)
w := httptest.NewRecorder()
tracerouteHandler(w, r)
assert.Equal(t, w.Code, http.StatusInternalServerError)
if !strings.Contains(w.Body.String(), "Invalid Request") {
t.Error("Did not get invalid request")
}
}
func TestTracerouteHandlerShlexError(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/traceroute?q="+url.QueryEscape("\"1.1.1.1"), nil)
w := httptest.NewRecorder()
tracerouteHandler(w, r)
assert.Equal(t, w.Code, http.StatusInternalServerError)
if !strings.Contains(w.Body.String(), "parse") {
t.Error("Did not get parsing error message")
}
}
func TestTracerouteHandlerNoTracerouteFound(t *testing.T) {
setting.tr_bin = ""
setting.tr_flags = nil
r := httptest.NewRequest(http.MethodGet, "/traceroute?q="+url.QueryEscape("1.1.1.1"), nil)
w := httptest.NewRecorder()
tracerouteHandler(w, r)
assert.Equal(t, w.Code, http.StatusInternalServerError)
if !strings.Contains(w.Body.String(), "not supported") {
t.Error("Did not get not supported error message")
}
}
func TestTracerouteHandlerExecuteError(t *testing.T) {
setting.tr_bin = "sh"
setting.tr_flags = []string{"-c", "false"}
setting.tr_raw = true
r := httptest.NewRequest(http.MethodGet, "/traceroute?q="+url.QueryEscape("1.1.1.1"), nil)
w := httptest.NewRecorder()
tracerouteHandler(w, r)
assert.Equal(t, w.Code, http.StatusInternalServerError)
if !strings.Contains(w.Body.String(), "Error executing traceroute") {
t.Error("Did not get not execute error message")
}
}
func TestTracerouteHandlerRaw(t *testing.T) {
setting.tr_bin = "sh"
setting.tr_flags = []string{"-c", "echo Mock"}
setting.tr_raw = true
r := httptest.NewRequest(http.MethodGet, "/traceroute?q="+url.QueryEscape("1.1.1.1"), nil)
w := httptest.NewRecorder()
tracerouteHandler(w, r)
assert.Equal(t, w.Code, http.StatusOK)
assert.Equal(t, w.Body.String(), "Mock\n")
}
func TestTracerouteHandlerPostprocess(t *testing.T) {
setting.tr_bin = "sh"
setting.tr_flags = []string{"-c", "echo \"first line\n 2 *\nthird line\""}
setting.tr_raw = false
r := httptest.NewRequest(http.MethodGet, "/traceroute?q="+url.QueryEscape("1.1.1.1"), nil)
w := httptest.NewRecorder()
tracerouteHandler(w, r)
assert.Equal(t, w.Code, http.StatusOK)
assert.Equal(t, w.Body.String(), "first line\nthird line\n\n1 hops not responding.")
}