initial copy
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,127 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
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 -I 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
|
||||
|
||||
- name: Test mtr binary in proxy image
|
||||
run: |
|
||||
docker build -t local/proxy:mtr -f proxy/Dockerfile.mtr proxy/
|
||||
docker run --rm --net host --entrypoint mtr local/proxy:mtr -w -c1 -Z1 -G1 -b 127.0.0.1 || exit 1
|
||||
docker run --rm --net host --entrypoint mtr local/proxy:mtr -w -c1 -Z1 -G1 -b ::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 }}
|
||||
|
||||
- name: Build proxy docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: '{{defaultContext}}:proxy'
|
||||
file: 'Dockerfile.mtr'
|
||||
platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7
|
||||
push: true
|
||||
tags: |
|
||||
xddxdd/bird-lgproxy-go:develop-mtr
|
||||
xddxdd/bird-lgproxy-go:develop-${{ github.sha }}-mtr
|
||||
ghcr.io/xddxdd/bird-lg-go:proxy-develop-mtr
|
||||
ghcr.io/xddxdd/bird-lg-go:proxy-develop-${{ github.sha }}-mtr
|
||||
@@ -0,0 +1,104 @@
|
||||
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.53
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
goos: ${{ matrix.goos }}
|
||||
goarch: ${{ matrix.goarch }}
|
||||
project_path: "./frontend"
|
||||
binary_name: "bird-lg-go"
|
||||
pre_command: |
|
||||
export CGO_ENABLED=0
|
||||
|
||||
- name: Release proxy
|
||||
uses: wangyoucao577/go-release-action@v1.53
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
goos: ${{ matrix.goos }}
|
||||
goarch: ${{ matrix.goarch }}
|
||||
project_path: "./proxy"
|
||||
binary_name: "bird-lgproxy-go"
|
||||
pre_command: |
|
||||
export CGO_ENABLED=0
|
||||
|
||||
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 }}
|
||||
|
||||
- name: Build proxy docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: '{{defaultContext}}:proxy'
|
||||
file: 'Dockerfile.mtr'
|
||||
platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7
|
||||
push: true
|
||||
tags: |
|
||||
xddxdd/bird-lgproxy-go:latest-mtr
|
||||
xddxdd/bird-lgproxy-go:${{ github.event.release.tag_name }}-mtr
|
||||
ghcr.io/xddxdd/bird-lg-go:proxy-mtr
|
||||
ghcr.io/xddxdd/bird-lg-go:proxy-${{ github.event.release.tag_name }}-mtr
|
||||
+5
-10
@@ -1,7 +1,3 @@
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
@@ -18,10 +14,9 @@
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
.DS_Store
|
||||
frontend/frontend
|
||||
proxy/proxy
|
||||
|
||||
# don't include generated bindata file
|
||||
frontend/bindata.go
|
||||
|
||||
@@ -1,24 +1,674 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
Preamble
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
For more information, please refer to <https://unlicense.org/>
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
@@ -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
|
||||
@@ -1,2 +1,287 @@
|
||||
# bird-lg-go
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
|
||||
Features implemented:
|
||||
|
||||
- Show peering status (`show protocol` command)
|
||||
- Query route (`show route for ...`, `show route where net ~ [ ... ]`)
|
||||
- Whois and traceroute
|
||||
- Work with both Python proxy (lgproxy.py) and Go proxy (proxy dir of this project)
|
||||
- Visualize AS paths as picture (bgpmap feature)
|
||||
|
||||
Configuration can be set in:
|
||||
|
||||
- `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 specify a local whois binary (e.g. "/usr/local/bin/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 same as title_brand) |
|
||||
| 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 name regex to hide in summary tables (RE2 syntax); defaults to none if not set |
|
||||
| timeout | --time-out | BIRDLG_TIMEOUT | time before backend HTTP request times out, in seconds (default 120) |
|
||||
| connection_timeout | --connection-time-out | BIRDLG_CONNECTION_TIMEOUT | time before backend TCP connection times out, in seconds (default 5) |
|
||||
| trust_proxy_headers | --trust-proxy-headers | BIRDLG_TRUST_PROXY_HEADERS | trust X-Forwarded-For, X-Real-IP, X-Forwarded-Proto and X-Forwarded-Host headers sent by a reverse proxy (default false) |
|
||||
| vrf | --vrf | BIRDLG_VRF | VRF device to bind TCP sockets to (Linux only) |
|
||||
| peer_types | *(config file only)* | *(config file only)* | classification of BGP sessions in the summary table (badges + filter buttons), matched by protocol-name prefix. Structured list, so it can only be set from a config file. Defaults to a built-in set (Internal, Transit, IXP Route Server, Route Collector, Downstream, IXP Peering, PNI Peering, BYOIP). |
|
||||
|
||||
#### Configuring peer types
|
||||
|
||||
BGP sessions in the summary page are classified into types by matching the
|
||||
protocol name against a list of prefixes. Each type controls the badge and the
|
||||
filter button. All of this is configurable so the frontend can be handed to an
|
||||
operator using its own naming convention. Because `peer_types` is a structured
|
||||
list, it must be set from a config file (`bird-lg.yaml`, `bird-lg.json`, ...),
|
||||
not from an environment variable or flag.
|
||||
|
||||
If `peer_types` is not set, the built-in defaults are used. When set, the list
|
||||
**fully replaces** the defaults.
|
||||
|
||||
Each entry supports:
|
||||
|
||||
| Field | Required | Description |
|
||||
| ----- | -------- | ----------- |
|
||||
| `name` | yes | Badge label and identifier used to match the filter button. Must be unique. |
|
||||
| `prefixes` | yes | List of protocol-name prefixes (case-insensitive) that classify a session into this type. |
|
||||
| `icon` | yes | Font Awesome icon class, e.g. `fa-arrow-circle-up`. |
|
||||
| `color` | yes | Bootstrap colour suffix for the badge, e.g. `warning`, `info`, `primary`, `success`. |
|
||||
| `label` | no | Shorter text for the filter button (falls back to `name`). |
|
||||
| `button_color` | no | Overrides `color` for the filter button (useful when `color` is `light`). |
|
||||
| `text_dark` | no | Add `text-dark` to the badge (for light backgrounds such as `warning`). |
|
||||
| `border` | no | Add a border to the badge (for `light` badges). |
|
||||
|
||||
Sessions whose name matches no prefix are shown as `Unknown`. Order matters:
|
||||
the first type whose prefix matches wins.
|
||||
|
||||
Example `bird-lg.yaml` for an external operator with their own prefixes,
|
||||
including a `BYOIP` type:
|
||||
|
||||
```yaml
|
||||
servers: node1,node2
|
||||
domain: example.net
|
||||
peer_types:
|
||||
- name: Internal
|
||||
prefixes: ["ibgp_"]
|
||||
icon: fa-home
|
||||
color: primary
|
||||
- name: Transit
|
||||
prefixes: ["upstream_", "transit_"]
|
||||
icon: fa-arrow-circle-up
|
||||
color: warning
|
||||
text_dark: true
|
||||
- name: Customer
|
||||
prefixes: ["cust_"]
|
||||
icon: fa-arrow-circle-down
|
||||
color: secondary
|
||||
- name: BYOIP
|
||||
prefixes: ["byoip_", "byoip-"]
|
||||
icon: fa-cloud
|
||||
color: success
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
```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:
|
||||
|
||||
```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>
|
||||
|
||||
## 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.
|
||||
|
||||
Features implemented:
|
||||
|
||||
- Sending queries to BIRD
|
||||
- Sending "restrict" command to BIRD to prevent unauthorized changes
|
||||
- Executing traceroute command on Linux, FreeBSD and OpenBSD
|
||||
- Source IP restriction
|
||||
|
||||
Configuration can be set in:
|
||||
|
||||
- `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 |
|
||||
| ---------- | --------- | -------------------- | ----------- |
|
||||
| bird_socket | --bird | BIRD_SOCKET | socket file for bird (default "/var/run/bird/bird.ctl") |
|
||||
| bird_restrict_cmds | --bird-restrict-cmds | BIRDLG_BIRD_RESTRICT_CMDS | restrict bird commands to `show protocols` and `show route` only (default true) |
|
||||
| listen | --listen | BIRDLG_LISTEN / BIRDLG_PROXY_PORT | listen address (default "8000") |
|
||||
| allowed_ips | --allowed | ALLOWED_IPS | IPs or networks allowed to access this proxy, separated by commas; allow all if not set |
|
||||
| traceroute_bin | --traceroute-bin | BIRDLG_TRACEROUTE_BIN | traceroute binary file |
|
||||
| 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_max_concurrent | --traceroute-max-concurrent | BIRDLG_TRACEROUTE_MAX_CONCURRENT | max concurrent traceroute requests allowed (default 10) |
|
||||
| vrf | --vrf | BIRDLG_VRF | VRF device to bind TCP sockets to (Linux only) |
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
./proxy
|
||||
```
|
||||
|
||||
Example: start proxy with custom bird socket location:
|
||||
|
||||
```bash
|
||||
./proxy --bird /run/bird.ctl
|
||||
```
|
||||
|
||||
Example: the following docker-compose.yml entry does the same as above, but by starting a Docker container:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
bird-lgproxy:
|
||||
# Use xddxdd/bird-lgproxy-go:develop for the latest build from master branch
|
||||
# Use xddxdd/bird-lgproxy-go:latest-mtr to use MTR instead of Traceroute
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
GPL 3.0
|
||||
|
||||
+202
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,43 @@
|
||||
BOOTSTRAP_VERSION := 4.5.1
|
||||
JQUERY_VERSION := 3.5.1
|
||||
FONTAWESOME_VERSION := 5.15.4
|
||||
POPPER_VERSION := 1.16.1
|
||||
|
||||
STATIC := assets/static
|
||||
|
||||
.PHONY: all build-amd build-arm run clean update-assets
|
||||
all:
|
||||
go build -ldflags "-w -s" -o frontend
|
||||
|
||||
build-amd:
|
||||
GOARCH=amd64 go build -ldflags "-w -s" -o frontend
|
||||
|
||||
build-arm:
|
||||
GOARCH=arm64 go build -ldflags "-w -s" -o frontend
|
||||
|
||||
run:
|
||||
go build -ldflags "-w -s" -o frontend
|
||||
./frontend --servers="CR1<2602:f72e:a::1>,CR2<2602:f72e:a::2>" --proxy_port=8179 --protocol-filter=bgp
|
||||
|
||||
clean:
|
||||
rm -rf ./frontend
|
||||
|
||||
update-assets:
|
||||
mkdir -p $(STATIC)/bootstrap/css $(STATIC)/bootstrap/js
|
||||
mkdir -p $(STATIC)/jquery
|
||||
mkdir -p $(STATIC)/fontawesome/css $(STATIC)/fontawesome/webfonts
|
||||
curl -fL https://cdn.jsdelivr.net/npm/bootstrap@$(BOOTSTRAP_VERSION)/dist/css/bootstrap.min.css -o $(STATIC)/bootstrap/css/bootstrap.min.css
|
||||
curl -fL https://cdn.jsdelivr.net/npm/bootstrap@$(BOOTSTRAP_VERSION)/dist/css/bootstrap.min.css.map -o $(STATIC)/bootstrap/css/bootstrap.min.css.map
|
||||
curl -fL https://cdn.jsdelivr.net/npm/bootstrap@$(BOOTSTRAP_VERSION)/dist/js/bootstrap.min.js -o $(STATIC)/bootstrap/js/bootstrap.min.js
|
||||
curl -fL https://cdn.jsdelivr.net/npm/bootstrap@$(BOOTSTRAP_VERSION)/dist/js/bootstrap.min.js.map -o $(STATIC)/bootstrap/js/bootstrap.min.js.map
|
||||
curl -fL https://cdn.jsdelivr.net/npm/jquery@$(JQUERY_VERSION)/dist/jquery.min.js -o $(STATIC)/jquery/jquery.min.js
|
||||
mkdir -p $(STATIC)/popper
|
||||
curl -fL https://cdn.jsdelivr.net/npm/popper.js@$(POPPER_VERSION)/dist/umd/popper.min.js -o $(STATIC)/popper/popper.min.js
|
||||
curl -fL https://cdn.jsdelivr.net/npm/popper.js@$(POPPER_VERSION)/dist/umd/popper.min.js.map -o $(STATIC)/popper/popper.min.js.map
|
||||
curl -fL https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@$(FONTAWESOME_VERSION)/css/all.min.css -o $(STATIC)/fontawesome/css/all.min.css
|
||||
curl -fL https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@$(FONTAWESOME_VERSION)/webfonts/fa-brands-400.woff2 -o $(STATIC)/fontawesome/webfonts/fa-brands-400.woff2
|
||||
curl -fL https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@$(FONTAWESOME_VERSION)/webfonts/fa-brands-400.woff -o $(STATIC)/fontawesome/webfonts/fa-brands-400.woff
|
||||
curl -fL https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@$(FONTAWESOME_VERSION)/webfonts/fa-regular-400.woff2 -o $(STATIC)/fontawesome/webfonts/fa-regular-400.woff2
|
||||
curl -fL https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@$(FONTAWESOME_VERSION)/webfonts/fa-regular-400.woff -o $(STATIC)/fontawesome/webfonts/fa-regular-400.woff
|
||||
curl -fL https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@$(FONTAWESOME_VERSION)/webfonts/fa-solid-900.woff2 -o $(STATIC)/fontawesome/webfonts/fa-solid-900.woff2
|
||||
curl -fL https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@$(FONTAWESOME_VERSION)/webfonts/fa-solid-900.woff -o $(STATIC)/fontawesome/webfonts/fa-solid-900.woff
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
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"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
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 all")
|
||||
var response apiResponse
|
||||
|
||||
for i, result := range results {
|
||||
parsedSummary, err := summaryParse(result, request.Servers[i])
|
||||
if err != nil {
|
||||
response.Result = append(response.Result, &apiSummaryResultPair{
|
||||
Server: request.Servers[i],
|
||||
Data: []SummaryRowData{},
|
||||
Error: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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")
|
||||
assert.Equal(t, len(summary.Data), 7)
|
||||
// Protocol list will be sorted
|
||||
assert.Equal(t, summary.Data[0].Name, "device1")
|
||||
assert.Equal(t, summary.Data[0].Proto, "Device")
|
||||
assert.Equal(t, summary.Data[0].Table, "---")
|
||||
assert.Equal(t, summary.Data[0].State, "up")
|
||||
assert.Equal(t, summary.Data[0].Since, "2021-08-27")
|
||||
assert.Equal(t, summary.Data[0].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, "")
|
||||
|
||||
summary := response.Result[0].(*apiSummaryResultPair)
|
||||
assert.Equal(t, summary.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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
File diff suppressed because one or more lines are too long
+5
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<h2>BGPmap: {{ 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>
|
||||
function decodeBase64(base64) {
|
||||
const text = atob(base64);
|
||||
const length = text.length;
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
bytes[i] = text.charCodeAt(i);
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
|
||||
var viz = new Viz();
|
||||
viz.renderSVGElement(decodeBase64({{ .Result }}))
|
||||
.then(element => {
|
||||
document.getElementById("bgpmap").appendChild(element);
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById("bgpmap").innerHTML = "<pre>"+error+"</pre>"
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="bird-block mb-4" data-server="{{ .ServerURL }}">
|
||||
<div class="d-flex align-items-center flex-wrap mb-1" style="gap:8px">
|
||||
<h5 class="mb-0">{{ html .ServerName }}: <code>{{ html .Target }}</code></h5>
|
||||
<form class="input-group ml-auto bird-search-form" style="max-width:340px" onsubmit="return false;">
|
||||
<input type="text" class="form-control form-control-sm bird-search" placeholder="Search routes… (prefix/net)">
|
||||
<div class="input-group-append">
|
||||
<button type="button" class="btn btn-sm btn-secondary bird-all-btn" title="Toggle 'all' in route lookup">all</button>
|
||||
<button type="submit" class="btn btn-sm btn-outline-success bird-search-go"><i class="fas fa-search"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ .Result }}
|
||||
</div>
|
||||
@@ -0,0 +1,549 @@
|
||||
<!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>
|
||||
<script>(function(){document.documentElement.setAttribute('data-theme',localStorage.getItem('theme')||'light')})();</script>
|
||||
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/fontawesome/css/all.min.css">
|
||||
<style>
|
||||
.navbar-nav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.navbar form {
|
||||
min-width: 400px;
|
||||
}
|
||||
.nav-link {
|
||||
padding: 0.2rem 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Summary table row colors ──────────────────────────── */
|
||||
/* Established — soft green */
|
||||
.summary-table tbody tr.table-success > td {
|
||||
background-color: #f0faf2 !important;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.summary-table tbody tr.table-success > td a { color: #2a7a4a; }
|
||||
.summary-table tbody tr.table-success > td a:hover { color: #1d5c37; }
|
||||
.summary-table tbody tr.table-success:hover > td {
|
||||
background-color: #ddf2e4 !important;
|
||||
}
|
||||
|
||||
/* Non-established — gray */
|
||||
.summary-table tbody tr.table-danger > td {
|
||||
background-color: #e9ecef !important;
|
||||
color: #495057;
|
||||
}
|
||||
.summary-table tbody tr.table-danger > td a { color: #5a6570; }
|
||||
.summary-table tbody tr.table-danger > td a:hover { color: #343a40; }
|
||||
.summary-table tbody tr.table-danger:hover > td {
|
||||
background-color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
/* Dark — established */
|
||||
[data-theme="dark"] .summary-table tbody tr.table-success > td {
|
||||
background-color: #0d2318 !important;
|
||||
color: #c9d1d9 !important;
|
||||
}
|
||||
[data-theme="dark"] .summary-table tbody tr.table-success > td a { color: #5cbd7a; }
|
||||
[data-theme="dark"] .summary-table tbody tr.table-success > td a:hover { color: #80d49a; }
|
||||
[data-theme="dark"] .summary-table tbody tr.table-success:hover > td {
|
||||
background-color: #132d20 !important;
|
||||
}
|
||||
|
||||
/* Dark — non-established */
|
||||
[data-theme="dark"] .summary-table tbody tr.table-danger > td {
|
||||
background-color: #21262d !important;
|
||||
color: #8b949e !important;
|
||||
}
|
||||
[data-theme="dark"] .summary-table tbody tr.table-danger > td a { color: #768390; }
|
||||
[data-theme="dark"] .summary-table tbody tr.table-danger > td a:hover { color: #adbac7; }
|
||||
[data-theme="dark"] .summary-table tbody tr.table-danger:hover > td {
|
||||
background-color: #2d333b !important;
|
||||
}
|
||||
|
||||
/* ── Summary sticky controls ───────────────────────────── */
|
||||
#summary-controls {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
z-index: 100;
|
||||
}
|
||||
[data-theme="dark"] #summary-controls {
|
||||
background-color: #1a1b2e;
|
||||
border-bottom-color: #30363d;
|
||||
}
|
||||
|
||||
/* ── Dark mode ─────────────────────────────────────────── */
|
||||
[data-theme="dark"] body {
|
||||
background-color: #1a1b2e;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
[data-theme="dark"] .bg-light {
|
||||
background-color: #161b22 !important;
|
||||
}
|
||||
[data-theme="dark"] .navbar-light .navbar-brand,
|
||||
[data-theme="dark"] .navbar-light .navbar-nav .nav-link {
|
||||
color: #c9d1d9 !important;
|
||||
}
|
||||
[data-theme="dark"] .navbar-light .navbar-nav .nav-link:hover,
|
||||
[data-theme="dark"] .navbar-light .navbar-nav .nav-link.active {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
[data-theme="dark"] .navbar-light .navbar-toggler {
|
||||
border-color: #30363d;
|
||||
}
|
||||
[data-theme="dark"] .navbar-light .navbar-toggler-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
[data-theme="dark"] .form-control {
|
||||
background-color: #0d1117;
|
||||
color: #c9d1d9;
|
||||
border-color: #30363d;
|
||||
}
|
||||
[data-theme="dark"] .form-control:focus {
|
||||
background-color: #0d1117;
|
||||
color: #c9d1d9;
|
||||
border-color: #58a6ff;
|
||||
box-shadow: 0 0 0 0.2rem rgba(88,166,255,.25);
|
||||
}
|
||||
[data-theme="dark"] select.form-control option {
|
||||
background-color: #161b22;
|
||||
}
|
||||
[data-theme="dark"] .input-group-text {
|
||||
background-color: #161b22;
|
||||
color: #c9d1d9;
|
||||
border-color: #30363d;
|
||||
}
|
||||
[data-theme="dark"] .btn-outline-success {
|
||||
color: #3fb950;
|
||||
border-color: #3fb950;
|
||||
}
|
||||
[data-theme="dark"] .btn-outline-success:hover {
|
||||
background-color: #3fb950;
|
||||
color: #0d1117;
|
||||
}
|
||||
[data-theme="dark"] #theme-toggle {
|
||||
color: #c9d1d9;
|
||||
border-color: #30363d;
|
||||
}
|
||||
[data-theme="dark"] #theme-toggle:hover {
|
||||
background-color: #30363d;
|
||||
}
|
||||
[data-theme="dark"] .table {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
[data-theme="dark"] .table td,
|
||||
[data-theme="dark"] .table th {
|
||||
border-color: #30363d;
|
||||
}
|
||||
[data-theme="dark"] .thead-light th {
|
||||
background-color: #161b22;
|
||||
color: #8b949e;
|
||||
border-color: #30363d;
|
||||
}
|
||||
[data-theme="dark"] .table-striped tbody tr:nth-of-type(odd) {
|
||||
background-color: rgba(255,255,255,.03);
|
||||
}
|
||||
[data-theme="dark"] .table-hover tbody tr:hover td {
|
||||
background-color: rgba(255,255,255,.07) !important;
|
||||
}
|
||||
[data-theme="dark"] .table-success > td,
|
||||
[data-theme="dark"] .table-success > th,
|
||||
[data-theme="dark"] .table-secondary > td,
|
||||
[data-theme="dark"] .table-secondary > th,
|
||||
[data-theme="dark"] .table-danger > td,
|
||||
[data-theme="dark"] .table-danger > th,
|
||||
[data-theme="dark"] .table-info > td,
|
||||
[data-theme="dark"] .table-info > th,
|
||||
[data-theme="dark"] .table-warning > td,
|
||||
[data-theme="dark"] .table-warning > th {
|
||||
background-color: transparent !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
[data-theme="dark"] a {
|
||||
color: #58a6ff;
|
||||
}
|
||||
[data-theme="dark"] a:hover {
|
||||
color: #79b8ff;
|
||||
}
|
||||
[data-theme="dark"] pre {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
[data-theme="dark"] .badge-light {
|
||||
background-color: #30363d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
[data-theme="dark"] .modal-content {
|
||||
background-color: #1a1b2e;
|
||||
color: #c9d1d9;
|
||||
border-color: #30363d;
|
||||
}
|
||||
[data-theme="dark"] .modal-header,
|
||||
[data-theme="dark"] .modal-footer {
|
||||
border-color: #30363d;
|
||||
}
|
||||
[data-theme="dark"] .close {
|
||||
color: #c9d1d9;
|
||||
text-shadow: none;
|
||||
}
|
||||
</style>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<a class="navbar-brand" href="{{ .BrandURL }}">{{ .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>
|
||||
|
||||
<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">»</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<button id="theme-toggle" class="btn btn-sm ml-2" title="Toggle dark mode" onclick="toggleTheme()">
|
||||
<i id="theme-icon" class="fas fa-moon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script src="/static/jquery/jquery.min.js"></script>
|
||||
<script src="/static/popper/popper.min.js"></script>
|
||||
<script src="/static/bootstrap/js/bootstrap.min.js"></script>
|
||||
<script src="/static/sortTable.js"></script>
|
||||
|
||||
<div class="container">
|
||||
|
||||
{{ if eq .URLOption "summary" }}
|
||||
<div class="sticky-top py-2" id="summary-controls">
|
||||
<div class="row align-items-center mb-1">
|
||||
<div class="col">
|
||||
<input type="text" id="session-search" class="form-control form-control-sm" placeholder="Filter sessions…">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span id="session-stats" class="text-muted small"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap" style="gap:4px" id="type-filter-btns">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="hide-established" title="Show only non-established sessions"><i class="fas fa-eye-slash"></i> Hide established</button>
|
||||
<div class="border-left mx-1"></div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="type-all">All</button>
|
||||
{{ range .PeerTypes }}
|
||||
<button type="button" class="btn btn-sm btn-outline-{{ .BtnColor }} type-btn" data-type="{{ html .Name }}" data-color="{{ .BtnColor }}"><i class="fas {{ .Icon }}"></i> {{ html .BtnLabel }}</button>
|
||||
{{ end }}
|
||||
<div class="border-left mx-1"></div>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" id="hide-problems" title="Show only established sessions"><i class="fas fa-filter"></i> Only Established</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="routeModal" tabindex="-1" role="dialog" aria-labelledby="routeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="routeModalLabel"></h5>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ml-auto mr-2" id="routeModalAllToggle" title="Show communities and other details">
|
||||
<i class="fas fa-list"></i> all
|
||||
</button>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:70vh;overflow-y:auto">
|
||||
<div id="routeModalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ .Content }}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{{ if eq .URLOption "summary" }}
|
||||
$(function () {
|
||||
|
||||
/* ── Type filter ──────────────────────────────────────── */
|
||||
// null = All, string = show only that type
|
||||
var selectedType = null;
|
||||
var hideEstablished = false;
|
||||
var hideProblems = false;
|
||||
|
||||
function renderTypeItems() {
|
||||
var allActive = selectedType === null;
|
||||
$('#type-all').toggleClass('btn-secondary', allActive)
|
||||
.toggleClass('btn-outline-secondary', !allActive);
|
||||
$('.type-btn').each(function () {
|
||||
var color = $(this).data('color');
|
||||
var active = $(this).data('type') === selectedType;
|
||||
$(this).toggleClass('btn-' + color, active)
|
||||
.toggleClass('btn-outline-' + color, !active)
|
||||
.toggleClass('text-dark', active && color === 'warning');
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
var q = $('#session-search').val().toLowerCase().trim();
|
||||
$('.summary-table tbody tr').each(function () {
|
||||
var type = $(this).data('peertype') || '';
|
||||
var typeOk = selectedType === null || type === selectedType;
|
||||
var established = $(this).find('td').eq(4).text().indexOf('Established') !== -1;
|
||||
if (hideEstablished && established) { $(this).hide(); return; }
|
||||
if (hideProblems && !established) { $(this).hide(); return; }
|
||||
var searchOk = q === '' || $(this).text().toLowerCase().indexOf(q) !== -1;
|
||||
$(this).toggle(typeOk && searchOk);
|
||||
});
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
var $visible = $('.summary-table tbody tr:visible');
|
||||
var $all = $('.summary-table tbody tr');
|
||||
var up = $visible.filter(function () { return $(this).find('td:eq(3) .badge-success').length > 0; }).length;
|
||||
var down = $visible.length - up;
|
||||
var stats = $visible.length + ' session' + ($visible.length !== 1 ? 's' : '');
|
||||
if ($visible.length < $all.length) stats += ' (of ' + $all.length + ')';
|
||||
stats += ' — <span class="text-success font-weight-bold">' + up + ' up</span>';
|
||||
if (down > 0) stats += ', <span class="text-danger font-weight-bold">' + down + ' down</span>';
|
||||
$('#session-stats').html(stats);
|
||||
}
|
||||
|
||||
$('.type-btn').on('click', function () {
|
||||
var type = $(this).data('type');
|
||||
if (selectedType === type) return;
|
||||
selectedType = type;
|
||||
renderTypeItems();
|
||||
applyFilters();
|
||||
});
|
||||
$('#type-all').on('click', function () {
|
||||
selectedType = null;
|
||||
renderTypeItems();
|
||||
applyFilters();
|
||||
});
|
||||
$('#hide-established').on('click', function () {
|
||||
hideEstablished = !hideEstablished;
|
||||
if (hideEstablished) hideProblems = false;
|
||||
$(this).toggleClass('btn-outline-danger', !hideEstablished)
|
||||
.toggleClass('btn-danger', hideEstablished);
|
||||
$('#hide-problems').toggleClass('btn-outline-success', true)
|
||||
.toggleClass('btn-success', false);
|
||||
applyFilters();
|
||||
});
|
||||
$('#hide-problems').on('click', function () {
|
||||
hideProblems = !hideProblems;
|
||||
if (hideProblems) hideEstablished = false;
|
||||
$(this).toggleClass('btn-outline-success', !hideProblems)
|
||||
.toggleClass('btn-success', hideProblems);
|
||||
$('#hide-established').toggleClass('btn-outline-danger', true)
|
||||
.toggleClass('btn-danger', false);
|
||||
applyFilters();
|
||||
});
|
||||
$('#session-search').on('input', applyFilters);
|
||||
|
||||
/* ── Route modal ──────────────────────────────────────── */
|
||||
var routeCache = Object.create(null);
|
||||
var CACHE_TTL = 30000;
|
||||
var modalServer = null;
|
||||
var modalBaseCmd = null;
|
||||
var modalTitle = null;
|
||||
var modalAll = false;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function setModalContent(title, bodyHtml) {
|
||||
$('#routeModalLabel').text(title);
|
||||
$('#routeModalBody').html(bodyHtml);
|
||||
}
|
||||
|
||||
function fetchRoutes(server, cmd, title) {
|
||||
var key = server + '\x00' + cmd;
|
||||
var cached = routeCache[key];
|
||||
if (cached && (Date.now() - cached.ts) < CACHE_TTL) {
|
||||
setModalContent(title, cached.html);
|
||||
return;
|
||||
}
|
||||
setModalContent(title, '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-2x"></i></div>');
|
||||
$.ajax({
|
||||
url: '/api/', method: 'POST', contentType: 'application/json',
|
||||
data: JSON.stringify({ servers: [server], type: 'bird', args: cmd }),
|
||||
success: function (resp) {
|
||||
var text = (resp.result && resp.result[0] && resp.result[0].data) || '(empty)';
|
||||
var html = '<pre class="mb-0" style="white-space:pre-wrap">' + escapeHtml(text) + '</pre>';
|
||||
routeCache[key] = { ts: Date.now(), html: html };
|
||||
setModalContent(title, html);
|
||||
},
|
||||
error: function (xhr) {
|
||||
setModalContent(title, '<div class="alert alert-danger mb-0">Request failed: ' + escapeHtml(xhr.statusText) + '</div>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('click', '.route-link', function (e) {
|
||||
e.preventDefault();
|
||||
modalServer = $(this).data('server');
|
||||
modalBaseCmd = $(this).data('cmd');
|
||||
modalTitle = $(this).data('title');
|
||||
modalAll = false;
|
||||
$('#routeModalAllToggle').removeClass('btn-secondary').addClass('btn-outline-secondary');
|
||||
fetchRoutes(modalServer, modalBaseCmd, modalTitle);
|
||||
$('#routeModal').modal('show');
|
||||
});
|
||||
|
||||
$('#routeModalAllToggle').on('click', function () {
|
||||
if (!modalBaseCmd) return;
|
||||
modalAll = !modalAll;
|
||||
$(this).toggleClass('btn-outline-secondary', !modalAll)
|
||||
.toggleClass('btn-secondary', modalAll);
|
||||
var cmd = modalAll ? modalBaseCmd + ' all' : modalBaseCmd;
|
||||
fetchRoutes(modalServer, cmd, modalTitle);
|
||||
});
|
||||
|
||||
$('#routeModal').on('hidden.bs.modal', function () {
|
||||
modalAll = false;
|
||||
$('#routeModalAllToggle').removeClass('btn-secondary').addClass('btn-outline-secondary');
|
||||
});
|
||||
|
||||
// Dynamically add Unknown button only if such sessions exist
|
||||
if ($('.summary-table tbody tr[data-peertype="Unknown"]').length > 0) {
|
||||
var $unknownBtn = $('<button type="button" class="btn btn-sm btn-outline-secondary type-btn" data-type="Unknown" data-color="secondary"><i class="fas fa-question-circle"></i> Unknown</button>');
|
||||
$unknownBtn.on('click', function () {
|
||||
if (selectedType === 'Unknown') return;
|
||||
selectedType = 'Unknown';
|
||||
renderTypeItems();
|
||||
applyFilters();
|
||||
});
|
||||
$('.type-btn').last().after($unknownBtn);
|
||||
}
|
||||
|
||||
renderTypeItems();
|
||||
applyFilters();
|
||||
});
|
||||
{{ end }}
|
||||
|
||||
/* ── Bird output route search ─────────────────────────── */
|
||||
$(function () {
|
||||
if ($('.bird-block').length === 0) return;
|
||||
|
||||
// Per-block: "all" toggle (default: on)
|
||||
// data-all="1" means use route_where_all, "0" means route_where
|
||||
$('.bird-all-btn').data('all', 1);
|
||||
|
||||
$('.bird-all-btn').on('click', function () {
|
||||
var on = !$(this).data('all');
|
||||
$(this).data('all', on ? 1 : 0);
|
||||
$(this).toggleClass('btn-secondary', on);
|
||||
$(this).toggleClass('btn-outline-secondary', !on);
|
||||
});
|
||||
|
||||
function doSearch($block) {
|
||||
var server = $block.data('server');
|
||||
var q = $block.find('.bird-search').val().trim();
|
||||
if (!q || !server) return;
|
||||
var useAll = !!$block.find('.bird-all-btn').data('all');
|
||||
var action = useAll ? 'route_where_all' : 'route_where';
|
||||
window.location.href = '/' + action + '/' + encodeURIComponent(server) + '/' + encodeURIComponent(q);
|
||||
}
|
||||
|
||||
$('.bird-search-form').on('submit', function () {
|
||||
doSearch($(this).closest('.bird-block'));
|
||||
});
|
||||
|
||||
$('.bird-search').on('keydown', function (e) {
|
||||
if (e.key === 'Enter') doSearch($(this).closest('.bird-block'));
|
||||
});
|
||||
|
||||
$('.bird-search-go').on('click', function () {
|
||||
doSearch($(this).closest('.bird-block'));
|
||||
});
|
||||
});
|
||||
|
||||
// Global tooltip init (for bird output pages and others)
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function updateThemeIcon() {
|
||||
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
$('#theme-icon').attr('class', dark ? 'fas fa-sun' : 'fas fa-moon');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
var next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
updateThemeIcon();
|
||||
}
|
||||
|
||||
$(updateThemeIcon);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
{{ $ServerName := urlquery .ServerName }}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-sm table-hover sortable summary-table mb-0">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
{{ range .Header }}
|
||||
<th scope="col" style="cursor:pointer;white-space:nowrap;user-select:none">{{ html . }}</th>
|
||||
{{ end }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Rows }}
|
||||
<tr class="table-{{ .MappedState }}" data-peertype="{{ .PeerType }}">
|
||||
<td><a href="/detail/{{ $ServerName }}/{{ urlquery .Name }}">{{ html .Name }}</a></td>
|
||||
<td>
|
||||
{{- with .TypeInfo -}}
|
||||
<span class="badge {{ .BadgeClass }}"><i class="fas {{ .Icon }}"></i> {{ html .Name }}</span>
|
||||
{{- end -}}
|
||||
</td>
|
||||
<td>
|
||||
{{- if .RemoteASN -}}
|
||||
<a href="https://bgp.tools/as/{{ html .RemoteASN }}" target="_blank" rel="noopener"
|
||||
{{- if .ASNName }} data-toggle="tooltip" data-placement="right" title="{{ html .ASNName }}"{{ end -}}>
|
||||
AS{{ html .RemoteASN }}</a>
|
||||
{{- else -}}
|
||||
—
|
||||
{{- end -}}
|
||||
</td>
|
||||
<td>
|
||||
{{- if eq .State "up" -}}
|
||||
<span class="badge badge-success">up</span>
|
||||
{{- else if eq .State "down" -}}
|
||||
<span class="badge badge-secondary">down</span>
|
||||
{{- else if eq .State "start" -}}
|
||||
<span class="badge badge-danger">{{ html .State }}</span>
|
||||
{{- else -}}
|
||||
<span class="badge badge-secondary">{{ html .State }}</span>
|
||||
{{- end -}}
|
||||
</td>
|
||||
<td style="white-space:normal">{{ html .Info }}</td>
|
||||
<td style="white-space:nowrap">{{ html .Since }}</td>
|
||||
<td class="text-right">
|
||||
{{- if .PeerType -}}
|
||||
{{- if and .RemoteASN (gt .Imported 0) (le .Imported 179) -}}
|
||||
<a href="#" class="route-link"
|
||||
data-server="{{ $.ServerName }}"
|
||||
data-cmd="show route protocol '{{ .Name }}'"
|
||||
data-title="Imported — {{ html .Name }} (AS{{ html .RemoteASN }})">
|
||||
<span data-toggle="tooltip" data-placement="auto" title="Preferred: {{ .Preferred }} ({{ .PreferredPct }}%)">{{ .Imported }}</span>
|
||||
</a>
|
||||
{{- else -}}
|
||||
<span data-toggle="tooltip" data-placement="auto" title="Preferred: {{ .Preferred }} ({{ .PreferredPct }}%)">{{ .Imported }}</span>
|
||||
{{- end -}}
|
||||
{{- else -}}—{{- end -}}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{- if .PeerType -}}
|
||||
{{- if and (gt .Filtered 0) (le .Filtered 179) -}}
|
||||
<a href="#" class="route-link"
|
||||
data-server="{{ $.ServerName }}"
|
||||
data-cmd="show route filtered protocol '{{ .Name }}'"
|
||||
data-title="Filtered — {{ html .Name }}">{{ .Filtered }}</a>
|
||||
{{- else -}}{{ .Filtered }}{{- end -}}
|
||||
{{- else -}}—{{- end -}}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{- if .PeerType -}}
|
||||
{{- if and (gt .Exported 0) (le .Exported 179) -}}
|
||||
<a href="#" class="route-link"
|
||||
data-server="{{ $.ServerName }}"
|
||||
data-cmd="show route exported '{{ .Name }}'"
|
||||
data-title="Exported — {{ html .Name }}">{{ .Exported }}</a>
|
||||
{{- else -}}{{ .Exported }}{{- end -}}
|
||||
{{- else -}}—{{- end -}}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
<h2>whois {{ html .Target }}</h2>
|
||||
{{ .Result }}
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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(`\[(.*?) .*\]`)
|
||||
|
||||
// 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(`(?mi)^\tBGP(?:\.as)?_path: (.*?)$`)
|
||||
|
||||
func makeEdgeAttrs(preferred bool) RouteAttrs {
|
||||
result := RouteAttrs{
|
||||
"fontsize": "12.0",
|
||||
}
|
||||
if preferred {
|
||||
result["color"] = "red"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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"})
|
||||
|
||||
for serverID, server := range servers {
|
||||
response := responses[serverID]
|
||||
if len(response) == 0 {
|
||||
continue
|
||||
}
|
||||
graph.AddPoint(server, false, RouteAttrs{"color": "blue", "shape": "box"})
|
||||
routes := routeSplitRe.Split(response, -1)
|
||||
|
||||
for routeIndex, route := range routes {
|
||||
if routeIndex == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if match := routeViaRe.FindStringSubmatch(route); len(match) >= 2 {
|
||||
via = strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
|
||||
// Last AS to destination
|
||||
src := paths[len(paths)-1]
|
||||
graph.AddEdge(src, target, "", makeEdgeAttrs(routePreferred))
|
||||
}
|
||||
}
|
||||
|
||||
return graph
|
||||
}
|
||||
|
||||
func birdRouteToGraphviz(servers []string, responses []string, targetName string) string {
|
||||
graph := birdRouteToGraph(servers, responses, targetName)
|
||||
return graph.ToGraphviz()
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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 {
|
||||
buffer := &bytes.Buffer{}
|
||||
encoder := json.NewEncoder(buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
err := encoder.Encode(s)
|
||||
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
} else {
|
||||
return string(buffer.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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, fakeResult) {
|
||||
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")
|
||||
}
|
||||
// This route is written in bird3 notation
|
||||
if result.GetEdge("4242423391", "4242420604") == nil {
|
||||
t.Error("Result doesn't contain edge from 4242423391 to 4242420604 (bird3)")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
bgpToolsCache = make(map[string]string)
|
||||
bgpToolsCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
func bgpToolsLookup(asn string) string {
|
||||
bgpToolsCacheMu.RLock()
|
||||
defer bgpToolsCacheMu.RUnlock()
|
||||
return bgpToolsCache[asn]
|
||||
}
|
||||
|
||||
func bgpToolsRefresh() {
|
||||
req, err := http.NewRequest("GET", "https://bgp.tools/asns.csv", nil)
|
||||
if err != nil {
|
||||
fmt.Println("bgp.tools ASN cache refresh failed:", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "bird-lg-go looking glass - github.com/xddxdd/bird-lg-go")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Println("bgp.tools ASN cache refresh failed:", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Println("bgp.tools ASN cache refresh failed: HTTP", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
r := csv.NewReader(resp.Body)
|
||||
// skip header row
|
||||
if _, err := r.Read(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
newCache := make(map[string]string)
|
||||
for {
|
||||
record, err := r.Read()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if len(record) < 2 {
|
||||
continue
|
||||
}
|
||||
asn := strings.TrimPrefix(record[0], "AS")
|
||||
newCache[asn] = record[1]
|
||||
}
|
||||
|
||||
bgpToolsCacheMu.Lock()
|
||||
bgpToolsCache = newCache
|
||||
bgpToolsCacheMu.Unlock()
|
||||
|
||||
fmt.Printf("bgp.tools ASN cache refreshed: %d entries\n", len(newCache))
|
||||
}
|
||||
|
||||
func bgpToolsStart() {
|
||||
go func() {
|
||||
bgpToolsRefresh()
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
for range ticker.C {
|
||||
bgpToolsRefresh()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func dn42WhoisFilter(whois string) string {
|
||||
commandResult := ""
|
||||
isNextSection := false
|
||||
skippedLines := 0
|
||||
|
||||
// Filter out some long (and useless) keys
|
||||
filteredPrefix := []string{
|
||||
"descr:", "remarks:", "ds-rdata:", "auth:", "country:",
|
||||
"nserver:", "status:", "pgp-fingerprint:", "mp-import:", "mp-export:",
|
||||
"members:", "key:", "inetnum:", "inet6num:", " ", "+",
|
||||
}
|
||||
for _, s := range strings.Split(whois, "\n") {
|
||||
if len(s) == 0 {
|
||||
// This line is a separation between parts of results
|
||||
// Only keep last part of result
|
||||
isNextSection = true
|
||||
continue
|
||||
}
|
||||
shouldSkip := false
|
||||
for _, filtered := range filteredPrefix {
|
||||
if strings.HasPrefix(s, filtered) {
|
||||
shouldSkip = true
|
||||
}
|
||||
}
|
||||
if shouldSkip {
|
||||
skippedLines++
|
||||
continue
|
||||
}
|
||||
|
||||
if isNextSection {
|
||||
isNextSection = false
|
||||
skippedLines = 0
|
||||
commandResult = ""
|
||||
}
|
||||
|
||||
commandResult += s + "\n"
|
||||
}
|
||||
|
||||
if skippedLines > 0 {
|
||||
return commandResult + fmt.Sprintf("\n%d line(s) skipped.\n", skippedLines)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
module github.com/xddxdd/bird-lg-go/frontend
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/gorilla/handlers v1.5.2
|
||||
github.com/jarcoal/httpmock v1.4.1
|
||||
github.com/magiconair/properties v1.8.10
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/sys v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
|
||||
github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
)
|
||||
|
||||
type channelData struct {
|
||||
id int
|
||||
data string
|
||||
}
|
||||
|
||||
func createConnectionTimeoutRoundTripper(timeout int) http.RoundTripper {
|
||||
context := net.Dialer{
|
||||
Timeout: time.Duration(timeout) * time.Second,
|
||||
Control: vrfControl(setting.vrf),
|
||||
}
|
||||
|
||||
// Prefer httpmock's transport if activated, so unit tests can work
|
||||
if http.DefaultTransport == httpmock.DefaultTransport {
|
||||
return httpmock.DefaultTransport
|
||||
}
|
||||
|
||||
return &http.Transport{
|
||||
DialContext: context.DialContext,
|
||||
|
||||
// Default options from transport.go
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Send commands to lgproxy instances in parallel, and retrieve their responses
|
||||
func batchRequest(servers []string, endpoint string, command string) []string {
|
||||
if len(servers) > len(setting.servers) {
|
||||
return []string{"invalid request: too many servers specified"}
|
||||
}
|
||||
|
||||
// Channel and array for storing responses
|
||||
var ch chan channelData = make(chan channelData)
|
||||
var responseArray []string = make([]string, len(servers))
|
||||
var createdGoroutines int = 0
|
||||
|
||||
for i, server := range servers {
|
||||
// Check if the server is in the valid server list passed at startup
|
||||
var isValidServer bool = false
|
||||
for _, validServer := range setting.servers {
|
||||
if validServer == server {
|
||||
isValidServer = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isValidServer {
|
||||
// If the server is not valid, return a failure
|
||||
responseArray[i] = "request failed: invalid server\n"
|
||||
} else {
|
||||
// Compose URL and send the request
|
||||
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) {
|
||||
client := http.Client{
|
||||
Transport: createConnectionTimeoutRoundTripper(setting.connectionTimeOut),
|
||||
Timeout: time.Duration(setting.timeOut) * time.Second,
|
||||
}
|
||||
response, err := client.Get(url)
|
||||
if err != nil {
|
||||
ch <- channelData{i, "request failed: " + err.Error() + "\n"}
|
||||
return
|
||||
}
|
||||
|
||||
buf, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
ch <- channelData{i, "request failed: " + err.Error()}
|
||||
} else {
|
||||
ch <- channelData{i, string(buf)}
|
||||
}
|
||||
}(url, i)
|
||||
createdGoroutines++
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the responses by their ids, to return data in order
|
||||
for i := 0; i < createdGoroutines; i++ {
|
||||
var output channelData = <-ch
|
||||
responseArray[output.id] = output.data
|
||||
if len(responseArray[output.id]) == 0 {
|
||||
responseArray[output.id] = "node returned empty response, please refresh to try again."
|
||||
}
|
||||
}
|
||||
|
||||
return responseArray
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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{"alpha"}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRequestTooManyInvalidServer(t *testing.T) {
|
||||
setting.servers = []string{"alpha"}
|
||||
setting.domain = ""
|
||||
setting.proxyPort = 8000
|
||||
response := batchRequest([]string{"invalid1", "invalid2", "invalid3", "invalid4", "invalid5"}, "mock", "cmd")
|
||||
|
||||
if len(response) != 1 {
|
||||
t.Error("Should only return one response for too many servers specified")
|
||||
}
|
||||
if !strings.Contains(response[0], "too many") {
|
||||
t.Error("Did not produce too many servers error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRequestMixedValidInvalid(t *testing.T) {
|
||||
httpmock.Activate()
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
httpResponse := httpmock.NewStringResponder(200, "Mock Result")
|
||||
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([]string{"invalid1", "alpha", "invalid2"}, "mock", "cmd")
|
||||
|
||||
if len(response) != 3 {
|
||||
t.Error("Did not get response of all three mock servers")
|
||||
}
|
||||
if !strings.Contains(response[0], "invalid server") {
|
||||
t.Error("Did not produce invalid server error")
|
||||
}
|
||||
if response[1] != "Mock Result" {
|
||||
t.Error("HTTP response mismatch")
|
||||
}
|
||||
if !strings.Contains(response[2], "invalid server") {
|
||||
t.Error("Did not produce invalid server error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type settingType struct {
|
||||
servers []string
|
||||
serversDisplay []string
|
||||
domain string
|
||||
proxyPort int
|
||||
whoisServer string
|
||||
listen []string
|
||||
dnsInterface string
|
||||
netSpecificMode string
|
||||
titleBrand string
|
||||
navBarBrand string
|
||||
navBarBrandURL string
|
||||
navBarAllServer string
|
||||
navBarAllURL string
|
||||
bgpmapInfo string
|
||||
telegramBotName string
|
||||
protocolFilter []string
|
||||
nameFilter string
|
||||
timeOut int
|
||||
connectionTimeOut int
|
||||
trustProxyHeaders bool
|
||||
vrf string
|
||||
peerTypes []PeerType
|
||||
}
|
||||
|
||||
var setting settingType
|
||||
|
||||
func main() {
|
||||
parseSettings()
|
||||
ImportTemplates()
|
||||
bgpToolsStart()
|
||||
|
||||
for _, listenAddr := range setting.listen {
|
||||
go func(listenAddr string) {
|
||||
var l net.Listener
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(listenAddr, "/") {
|
||||
// Delete existing socket file, ignore errors (will fail later anyway)
|
||||
os.Remove(listenAddr)
|
||||
l, err = net.Listen("unix", listenAddr)
|
||||
} else {
|
||||
if !strings.Contains(listenAddr, ":") {
|
||||
listenAddr = ":" + listenAddr
|
||||
}
|
||||
lc := net.ListenConfig{Control: vrfControl(setting.vrf)}
|
||||
l, err = lc.Listen(context.Background(), "tcp", listenAddr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
webServerStart(l)
|
||||
}(listenAddr)
|
||||
}
|
||||
|
||||
select {}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// PeerType describes how a BGP session is classified and displayed in the
|
||||
// summary table and filter bar. Sessions are matched to a type by testing
|
||||
// their protocol name against Prefixes (case-insensitive). Everything about a
|
||||
// type -- the prefixes used to match it, its label, icon and colour -- is
|
||||
// configurable so the frontend can be handed to an external operator that uses
|
||||
// its own naming convention.
|
||||
type PeerType struct {
|
||||
// Name is both the display label of the badge and the identifier used to
|
||||
// match rows against filter buttons. Must be unique.
|
||||
Name string `mapstructure:"name"`
|
||||
// Label is the (usually shorter) text shown on the filter button; falls
|
||||
// back to Name when empty.
|
||||
Label string `mapstructure:"label"`
|
||||
// Prefixes are the protocol-name prefixes that classify a session into
|
||||
// this type, matched case-insensitively.
|
||||
Prefixes []string `mapstructure:"prefixes"`
|
||||
// Icon is the Font Awesome icon class, e.g. "fa-arrow-circle-up".
|
||||
Icon string `mapstructure:"icon"`
|
||||
// Color is the Bootstrap colour suffix used for the summary badge, e.g.
|
||||
// "warning", "info", "primary".
|
||||
Color string `mapstructure:"color"`
|
||||
// ButtonColor overrides Color for the filter button (useful when the badge
|
||||
// colour, e.g. "light", is invisible as an outline button). Falls back to
|
||||
// Color when empty.
|
||||
ButtonColor string `mapstructure:"button_color"`
|
||||
// TextDark adds the "text-dark" class to the badge (for light backgrounds
|
||||
// such as "warning").
|
||||
TextDark bool `mapstructure:"text_dark"`
|
||||
// Border adds the "border" class to the badge (for "light" badges).
|
||||
Border bool `mapstructure:"border"`
|
||||
}
|
||||
|
||||
// BadgeClass returns the CSS classes for the summary-table badge.
|
||||
func (p PeerType) BadgeClass() string {
|
||||
class := "badge-" + p.Color
|
||||
if p.TextDark {
|
||||
class += " text-dark"
|
||||
}
|
||||
if p.Border {
|
||||
class += " border"
|
||||
}
|
||||
return class
|
||||
}
|
||||
|
||||
// BtnLabel returns the text for the filter button.
|
||||
func (p PeerType) BtnLabel() string {
|
||||
if p.Label != "" {
|
||||
return p.Label
|
||||
}
|
||||
return p.Name
|
||||
}
|
||||
|
||||
// BtnColor returns the Bootstrap colour for the filter button.
|
||||
func (p PeerType) BtnColor() string {
|
||||
if p.ButtonColor != "" {
|
||||
return p.ButtonColor
|
||||
}
|
||||
return p.Color
|
||||
}
|
||||
|
||||
// defaultPeerTypes is the built-in classification, used when no peer_types are
|
||||
// configured. Order matters: the first type whose prefix matches wins.
|
||||
var defaultPeerTypes = []PeerType{
|
||||
{Name: "Internal", Prefixes: []string{"ibgp2_", "ibgp_"}, Icon: "fa-home", Color: "primary"},
|
||||
{Name: "Transit", Prefixes: []string{"up_", "up-"}, Icon: "fa-arrow-circle-up", Color: "warning", TextDark: true},
|
||||
{Name: "IXP Route Server", Label: "IXP RS", Prefixes: []string{"ixp_rs_", "rs_", "rs-"}, Icon: "fa-sitemap", Color: "info"},
|
||||
{Name: "Route Collector", Label: "Collector", Prefixes: []string{"rc_", "rc-"}, Icon: "fa-database", Color: "dark"},
|
||||
{Name: "Downstream", Prefixes: []string{"d_", "d-"}, Icon: "fa-arrow-circle-down", Color: "secondary"},
|
||||
{Name: "IXP Peering", Prefixes: []string{"ixpeer_"}, Icon: "fa-exchange-alt", Color: "light", ButtonColor: "secondary", Border: true},
|
||||
{Name: "PNI Peering", Prefixes: []string{"pni_"}, Icon: "fa-plug", Color: "light", ButtonColor: "secondary", Border: true},
|
||||
{Name: "BYOIP", Prefixes: []string{"byoip_", "byoip-"}, Icon: "fa-cloud", Color: "success"},
|
||||
}
|
||||
|
||||
// unknownPeerType renders BGP sessions that match no configured prefix.
|
||||
var unknownPeerType = PeerType{Name: "Unknown", Icon: "fa-question-circle", Color: "secondary"}
|
||||
|
||||
// activePeerTypes returns the configured peer types, falling back to the
|
||||
// built-in defaults (also keeps direct unit tests working without settings).
|
||||
func activePeerTypes() []PeerType {
|
||||
if len(setting.peerTypes) > 0 {
|
||||
return setting.peerTypes
|
||||
}
|
||||
return defaultPeerTypes
|
||||
}
|
||||
|
||||
// getPeerType classifies a BGP session by its protocol name. Non-BGP protocols
|
||||
// return "" (no type); unmatched BGP sessions return "Unknown".
|
||||
func getPeerType(name, proto string) string {
|
||||
if !strings.EqualFold(proto, "BGP") {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
for _, pt := range activePeerTypes() {
|
||||
for _, prefix := range pt.Prefixes {
|
||||
if strings.HasPrefix(lower, strings.ToLower(prefix)) {
|
||||
return pt.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// typeInfo resolves a peer-type name to its display definition.
|
||||
func typeInfo(name string) *PeerType {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range activePeerTypes() {
|
||||
if activePeerTypes()[i].Name == name {
|
||||
return &activePeerTypes()[i]
|
||||
}
|
||||
}
|
||||
return &unknownPeerType
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/magiconair/properties/assert"
|
||||
)
|
||||
|
||||
// TestGetPeerTypeDefaults verifies the built-in classification, including the
|
||||
// BYOIP type, when no peer_types are configured.
|
||||
func TestGetPeerTypeDefaults(t *testing.T) {
|
||||
setting.peerTypes = nil // force fallback to defaults
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
proto string
|
||||
want string
|
||||
}{
|
||||
{"ibgp_core", "BGP", "Internal"},
|
||||
{"up_cogent", "BGP", "Transit"},
|
||||
{"UP-HE", "BGP", "Transit"}, // case-insensitive
|
||||
{"ixp_rs_decix", "BGP", "IXP Route Server"},
|
||||
{"rc_routeviews", "BGP", "Route Collector"},
|
||||
{"d_customer1", "BGP", "Downstream"},
|
||||
{"ixpeer_foo", "BGP", "IXP Peering"},
|
||||
{"pni_bar", "BGP", "PNI Peering"},
|
||||
{"byoip_client_a", "BGP", "BYOIP"},
|
||||
{"byoip-client-b", "BGP", "BYOIP"},
|
||||
{"something_else", "BGP", "Unknown"},
|
||||
{"up_cogent", "Device", ""}, // non-BGP protocol has no peer type
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := getPeerType(c.name, c.proto); got != c.want {
|
||||
t.Errorf("getPeerType(%q, %q) = %q, want %q", c.name, c.proto, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPeerTypeConfigured verifies that an operator-provided peer_types list
|
||||
// fully overrides the built-in prefixes.
|
||||
func TestGetPeerTypeConfigured(t *testing.T) {
|
||||
setting.peerTypes = []PeerType{
|
||||
{Name: "Customer", Prefixes: []string{"cust_"}, Icon: "fa-user", Color: "primary"},
|
||||
{Name: "Upstream", Prefixes: []string{"transit_"}, Icon: "fa-arrow-up", Color: "warning"},
|
||||
}
|
||||
defer func() { setting.peerTypes = nil }()
|
||||
|
||||
assert.Equal(t, getPeerType("cust_acme", "BGP"), "Customer")
|
||||
assert.Equal(t, getPeerType("transit_ntt", "BGP"), "Upstream")
|
||||
// A default prefix no longer classifies once custom types are set.
|
||||
assert.Equal(t, getPeerType("ibgp_core", "BGP"), "Unknown")
|
||||
}
|
||||
|
||||
// TestTypeInfo verifies display-info resolution and badge/button helpers.
|
||||
func TestTypeInfo(t *testing.T) {
|
||||
setting.peerTypes = nil
|
||||
|
||||
transit := typeInfo("Transit")
|
||||
assert.Equal(t, transit.BadgeClass(), "badge-warning text-dark")
|
||||
|
||||
ixpRS := typeInfo("IXP Route Server")
|
||||
assert.Equal(t, ixpRS.BtnLabel(), "IXP RS") // short label for the filter button
|
||||
|
||||
ixpPeer := typeInfo("IXP Peering")
|
||||
assert.Equal(t, ixpPeer.BadgeClass(), "badge-light border")
|
||||
assert.Equal(t, ixpPeer.BtnColor(), "secondary") // button overrides the light badge colour
|
||||
|
||||
byoip := typeInfo("BYOIP")
|
||||
assert.Equal(t, byoip.BtnLabel(), "BYOIP") // falls back to Name
|
||||
assert.Equal(t, byoip.BtnColor(), "success")
|
||||
|
||||
// Unknown / unmatched name resolves to the fallback definition.
|
||||
assert.Equal(t, typeInfo("Unknown").Name, "Unknown")
|
||||
assert.Equal(t, typeInfo("NoSuchType").Name, "Unknown")
|
||||
|
||||
// Empty peer type (non-BGP row) has no info.
|
||||
if typeInfo("") != nil {
|
||||
t.Error("typeInfo(\"\") should be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 ...",
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
isWhois := strings.ToLower(split[0]) == "whois"
|
||||
whoisTarget := strings.Join(split[1:], "/")
|
||||
|
||||
// Use a default URL if the request URL is too short
|
||||
// The URL is for return to summary page
|
||||
if len(split) < 2 {
|
||||
path = "summary/" + strings.Join(setting.servers, "+") + "/"
|
||||
} else if len(split) == 2 {
|
||||
path += "/"
|
||||
}
|
||||
|
||||
split = strings.SplitN(path, "/", 3)
|
||||
|
||||
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,
|
||||
PeerTypes: activePeerTypes(),
|
||||
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,
|
||||
}
|
||||
|
||||
tmpl := TemplateLibrary["page"]
|
||||
err := tmpl.Execute(w, args)
|
||||
if err != nil {
|
||||
fmt.Println("Error rendering page:", err.Error())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var (
|
||||
reASNDigits = regexp.MustCompile(`(\d+)`)
|
||||
reASNBracket = regexp.MustCompile(`\[AS(\d+)`)
|
||||
reHostname = regexp.MustCompile(`([a-zA-Z0-9\-]*\.([a-zA-Z]{2,3}){1,2})(\s|$)`)
|
||||
reIPv4 = regexp.MustCompile(`(\d+\.\d+\.\d+\.\d+)`)
|
||||
reIPv6 = regexp.MustCompile(`(?i)(([a-f\d]{0,4}:){3,10}[a-f\d]{0,4})`)
|
||||
)
|
||||
|
||||
func asnLink(asn string) string {
|
||||
name := bgpToolsLookup(asn)
|
||||
attr := ` href="https://bgp.tools/as/` + asn + `" target="_blank" rel="noopener"`
|
||||
if name != "" {
|
||||
attr += ` data-toggle="tooltip" data-placement="auto" title="` + template.HTMLEscapeString(name) + `"`
|
||||
}
|
||||
return `<a` + attr + `>`
|
||||
}
|
||||
|
||||
// Write the given text to http response, and add bgp.tools links for
|
||||
// ASNs and IP addresses
|
||||
func smartFormatter(s string) template.HTML {
|
||||
var result string
|
||||
result += `<pre class="bird-output">`
|
||||
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), "bgp_path:") || strings.HasPrefix(strings.TrimSpace(line), "Neighbor AS:") || strings.HasPrefix(strings.TrimSpace(line), "Local AS:") {
|
||||
lineFormatted = reASNDigits.ReplaceAllStringFunc(line, func(asn string) string {
|
||||
return asnLink(asn) + asn + `</a>`
|
||||
})
|
||||
} else {
|
||||
lineFormatted = reHostname.ReplaceAllString(line, `<a href="/whois/${1}" class="whois">${1}</a>${3}`)
|
||||
lineFormatted = reASNBracket.ReplaceAllStringFunc(lineFormatted, func(m string) string {
|
||||
asn := m[3:] // strip "[AS"
|
||||
return `[` + asnLink(asn) + `AS` + asn + `</a>`
|
||||
})
|
||||
lineFormatted = reIPv4.ReplaceAllString(lineFormatted, `<a href="https://bgp.tools/prefix/${1}" target="_blank" rel="noopener">${1}</a>`)
|
||||
lineFormatted = reIPv6.ReplaceAllString(lineFormatted, `<a href="https://bgp.tools/prefix/${1}" target="_blank" rel="noopener">${1}</a>`)
|
||||
}
|
||||
result += `<span class="bird-line">` + lineFormatted + "\n</span>"
|
||||
}
|
||||
result += "</pre>"
|
||||
return template.HTML(result)
|
||||
}
|
||||
|
||||
// Parse bird show protocols all result (multi-line blocks per protocol)
|
||||
func summaryParse(data string, serverName string) (TemplateSummary, error) {
|
||||
args := TemplateSummary{
|
||||
ServerName: serverName,
|
||||
Raw: data,
|
||||
Header: []string{"Name", "Type", "Remote AS", "Status", "State", "Since", "Imported", "Filtered", "Exported"},
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(data), "\n")
|
||||
if len(lines) <= 1 {
|
||||
return args, errors.New(strings.TrimSpace(data))
|
||||
}
|
||||
|
||||
nameFilterRegexp := regexp.MustCompile(setting.nameFilter)
|
||||
|
||||
reNeighborAS := regexp.MustCompile(`^\s+Neighbor AS:\s+(\d+)`)
|
||||
reRoutes := regexp.MustCompile(`^\s+Routes:\s+(\d+) imported(?:,\s+(\d+) filtered)?,\s+(\d+) exported,\s+(\d+) preferred`)
|
||||
|
||||
var rows []SummaryRowData
|
||||
var cur *SummaryRowData
|
||||
|
||||
flush := func() {
|
||||
if cur == nil {
|
||||
return
|
||||
}
|
||||
if setting.nameFilter != "" && nameFilterRegexp.MatchString(cur.Name) {
|
||||
cur = nil
|
||||
return
|
||||
}
|
||||
if len(setting.protocolFilter) > 0 && !cur.ProtocolMatches(setting.protocolFilter) {
|
||||
cur = nil
|
||||
return
|
||||
}
|
||||
if cur.RemoteASN != "" {
|
||||
cur.ASNName = bgpToolsLookup(cur.RemoteASN)
|
||||
}
|
||||
rows = append(rows, *cur)
|
||||
cur = nil
|
||||
}
|
||||
|
||||
for _, line := range lines[1:] {
|
||||
if row := SummaryRowDataFromLine(line); row != nil {
|
||||
flush()
|
||||
cur = row
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
if m := reNeighborAS.FindStringSubmatch(line); m != nil {
|
||||
cur.RemoteASN = m[1]
|
||||
} else if m := reRoutes.FindStringSubmatch(line); m != nil {
|
||||
imported, _ := strconv.Atoi(m[1])
|
||||
filtered, _ := strconv.Atoi(m[2]) // empty string → 0 when no filtered field
|
||||
exported, _ := strconv.Atoi(m[3])
|
||||
preferred, _ := strconv.Atoi(m[4])
|
||||
cur.Imported += imported
|
||||
cur.Filtered += filtered
|
||||
cur.Exported += exported
|
||||
cur.Preferred += preferred
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
|
||||
args.Rows = rows
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// Output a table for the summary page
|
||||
func summaryTable(data string, serverName string) template.HTML {
|
||||
result, err := summaryParse(data, serverName)
|
||||
|
||||
if err != nil {
|
||||
return template.HTML("<pre>" + template.HTMLEscapeString(err.Error()) + "</pre>")
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const BirdSummaryData = `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 = ""
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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"`
|
||||
ConnectionTimeOut int `mapstructure:"connection_timeout"`
|
||||
TrustProxyHeaders bool `mapstructure:"trust_proxy_headers"`
|
||||
Vrf string `mapstructure:"vrf"`
|
||||
PeerTypes []PeerType `mapstructure:"peer_types"`
|
||||
}
|
||||
|
||||
// Parse settings with viper, and convert to legacy setting format
|
||||
func parseSettings() {
|
||||
// Allow both dashes and underscores in flag names
|
||||
// e.g., both --traceroute-bin and --traceroute_bin will work
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
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.StringSlice("listen", []string{"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 backend HTTP request times out, in seconds; defaults to 120 if not set")
|
||||
viper.BindPFlag("timeout", pflag.Lookup("time-out"))
|
||||
|
||||
pflag.Int("connection-time-out", 5, "time before backend TCP connection times out, in seconds; defaults to 5 if not set")
|
||||
viper.BindPFlag("connection_timeout", pflag.Lookup("connection-time-out"))
|
||||
|
||||
pflag.Bool("trust-proxy-headers", false, "Trust X-Forwared-For, X-Real-IP, X-Forwarded-Proto, X-Forwarded-Scheme and X-Forwarded-Host sent by the client")
|
||||
viper.BindPFlag("trust_proxy_headers", pflag.Lookup("trust-proxy-headers"))
|
||||
|
||||
pflag.String("vrf", "", "VRF device to bind TCP sockets to (Linux only)")
|
||||
viper.BindPFlag("vrf", pflag.Lookup("vrf"))
|
||||
|
||||
pflag.String("config", "", "path to config file (overrides default search paths)")
|
||||
|
||||
pflag.Parse()
|
||||
|
||||
if cf := pflag.Lookup("config").Value.String(); cf != "" {
|
||||
viper.SetConfigFile(cf)
|
||||
}
|
||||
|
||||
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
|
||||
setting.connectionTimeOut = viperSettings.ConnectionTimeOut
|
||||
setting.trustProxyHeaders = viperSettings.TrustProxyHeaders
|
||||
setting.vrf = viperSettings.Vrf
|
||||
|
||||
// Peer type classification: use the operator-provided list if present,
|
||||
// otherwise fall back to the built-in defaults.
|
||||
if len(viperSettings.PeerTypes) > 0 {
|
||||
setting.peerTypes = viperSettings.PeerTypes
|
||||
} else {
|
||||
setting.peerTypes = defaultPeerTypes
|
||||
}
|
||||
|
||||
fmt.Printf("%#v\n", setting)
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// resetFlags resets pflag and viper state for testing
|
||||
func resetFlags() {
|
||||
// Reset pflag
|
||||
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ContinueOnError)
|
||||
// Reset viper
|
||||
viper.Reset()
|
||||
}
|
||||
|
||||
// TestFlagNormalizer tests that the flag normalizer converts underscores to dashes
|
||||
func TestFlagNormalizer(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
// Set up the normalizer
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
// Define a test flag
|
||||
pflag.String("test-flag", "default", "test flag")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "dash format",
|
||||
args: []string{"--test-flag=dash-value"},
|
||||
expected: "dash-value",
|
||||
},
|
||||
{
|
||||
name: "underscore format",
|
||||
args: []string{"--test_flag=underscore-value"},
|
||||
expected: "underscore-value",
|
||||
},
|
||||
{
|
||||
name: "mixed format",
|
||||
args: []string{"--test_flag=mixed-value"},
|
||||
expected: "mixed-value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset flag value
|
||||
resetFlags()
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
pflag.String("test-flag", "default", "test flag")
|
||||
|
||||
// Parse the args
|
||||
err := pflag.CommandLine.Parse(tt.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse args: %v", err)
|
||||
}
|
||||
|
||||
// Get the value
|
||||
val, err := pflag.CommandLine.GetString("test-flag")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get flag value: %v", err)
|
||||
}
|
||||
|
||||
if val != tt.expected {
|
||||
t.Errorf("Expected %q, got %q", tt.expected, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerConfigNormalization tests that server configuration flags accept both formats
|
||||
func TestServerConfigNormalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedServers string
|
||||
expectedDomain string
|
||||
expectedProxy int
|
||||
expectedWhois string
|
||||
}{
|
||||
{
|
||||
name: "dash format",
|
||||
args: []string{
|
||||
"--servers=server1,server2",
|
||||
"--domain=example.com",
|
||||
"--proxy-port=9000",
|
||||
"--whois=whois.example.com",
|
||||
},
|
||||
expectedServers: "server1,server2",
|
||||
expectedDomain: "example.com",
|
||||
expectedProxy: 9000,
|
||||
expectedWhois: "whois.example.com",
|
||||
},
|
||||
{
|
||||
name: "underscore format",
|
||||
args: []string{
|
||||
"--servers=srv1,srv2",
|
||||
"--domain=test.org",
|
||||
"--proxy_port=8080",
|
||||
"--whois=whois.test.org",
|
||||
},
|
||||
expectedServers: "srv1,srv2",
|
||||
expectedDomain: "test.org",
|
||||
expectedProxy: 8080,
|
||||
expectedWhois: "whois.test.org",
|
||||
},
|
||||
{
|
||||
name: "mixed format",
|
||||
args: []string{
|
||||
"--servers=node1,node2",
|
||||
"--domain=mixed.net",
|
||||
"--proxy-port=7000",
|
||||
"--whois=whois.mixed.net",
|
||||
},
|
||||
expectedServers: "node1,node2",
|
||||
expectedDomain: "mixed.net",
|
||||
expectedProxy: 7000,
|
||||
expectedWhois: "whois.mixed.net",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
// Set up the normalizer
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
// Define flags (using dash format as the standard)
|
||||
pflag.String("servers", "", "server names")
|
||||
pflag.String("domain", "", "domain suffix")
|
||||
pflag.Int("proxy-port", 8000, "proxy port")
|
||||
pflag.String("whois", "whois.verisign-grs.com", "whois server")
|
||||
|
||||
// Parse the args
|
||||
err := pflag.CommandLine.Parse(tt.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse args: %v", err)
|
||||
}
|
||||
|
||||
// Verify values
|
||||
servers, _ := pflag.CommandLine.GetString("servers")
|
||||
if servers != tt.expectedServers {
|
||||
t.Errorf("servers: expected %q, got %q", tt.expectedServers, servers)
|
||||
}
|
||||
|
||||
domain, _ := pflag.CommandLine.GetString("domain")
|
||||
if domain != tt.expectedDomain {
|
||||
t.Errorf("domain: expected %q, got %q", tt.expectedDomain, domain)
|
||||
}
|
||||
|
||||
proxyPort, _ := pflag.CommandLine.GetInt("proxy-port")
|
||||
if proxyPort != tt.expectedProxy {
|
||||
t.Errorf("proxy-port: expected %d, got %d", tt.expectedProxy, proxyPort)
|
||||
}
|
||||
|
||||
whois, _ := pflag.CommandLine.GetString("whois")
|
||||
if whois != tt.expectedWhois {
|
||||
t.Errorf("whois: expected %q, got %q", tt.expectedWhois, whois)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIConfigNormalization tests that UI configuration flags accept both formats
|
||||
func TestUIConfigNormalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedTitle string
|
||||
expectedNavBrand string
|
||||
expectedNavURL string
|
||||
expectedAllServer string
|
||||
expectedAllURL string
|
||||
}{
|
||||
{
|
||||
name: "dash format",
|
||||
args: []string{
|
||||
"--title-brand=My LG",
|
||||
"--navbar-brand=My Network",
|
||||
"--navbar-brand-url=/home",
|
||||
"--navbar-all-servers=All Nodes",
|
||||
"--navbar-all-url=overview",
|
||||
},
|
||||
expectedTitle: "My LG",
|
||||
expectedNavBrand: "My Network",
|
||||
expectedNavURL: "/home",
|
||||
expectedAllServer: "All Nodes",
|
||||
expectedAllURL: "overview",
|
||||
},
|
||||
{
|
||||
name: "underscore format",
|
||||
args: []string{
|
||||
"--title_brand=Test LG",
|
||||
"--navbar_brand=Test Network",
|
||||
"--navbar_brand_url=/index",
|
||||
"--navbar_all_servers=All Systems",
|
||||
"--navbar_all_url=all",
|
||||
},
|
||||
expectedTitle: "Test LG",
|
||||
expectedNavBrand: "Test Network",
|
||||
expectedNavURL: "/index",
|
||||
expectedAllServer: "All Systems",
|
||||
expectedAllURL: "all",
|
||||
},
|
||||
{
|
||||
name: "mixed format",
|
||||
args: []string{
|
||||
"--title-brand=Mixed LG",
|
||||
"--navbar_brand=Mixed Net",
|
||||
"--navbar-brand-url=/main",
|
||||
"--navbar_all_servers=Everything",
|
||||
"--navbar-all-url=global",
|
||||
},
|
||||
expectedTitle: "Mixed LG",
|
||||
expectedNavBrand: "Mixed Net",
|
||||
expectedNavURL: "/main",
|
||||
expectedAllServer: "Everything",
|
||||
expectedAllURL: "global",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
// Set up the normalizer
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
// Define flags
|
||||
pflag.String("title-brand", "Bird-lg Go", "title brand")
|
||||
pflag.String("navbar-brand", "", "navbar brand")
|
||||
pflag.String("navbar-brand-url", "/", "navbar brand url")
|
||||
pflag.String("navbar-all-servers", "All Servers", "all servers text")
|
||||
pflag.String("navbar-all-url", "all", "all servers url")
|
||||
|
||||
// Parse the args
|
||||
err := pflag.CommandLine.Parse(tt.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse args: %v", err)
|
||||
}
|
||||
|
||||
// Verify values
|
||||
titleBrand, _ := pflag.CommandLine.GetString("title-brand")
|
||||
if titleBrand != tt.expectedTitle {
|
||||
t.Errorf("title-brand: expected %q, got %q", tt.expectedTitle, titleBrand)
|
||||
}
|
||||
|
||||
navbarBrand, _ := pflag.CommandLine.GetString("navbar-brand")
|
||||
if navbarBrand != tt.expectedNavBrand {
|
||||
t.Errorf("navbar-brand: expected %q, got %q", tt.expectedNavBrand, navbarBrand)
|
||||
}
|
||||
|
||||
navbarBrandURL, _ := pflag.CommandLine.GetString("navbar-brand-url")
|
||||
if navbarBrandURL != tt.expectedNavURL {
|
||||
t.Errorf("navbar-brand-url: expected %q, got %q", tt.expectedNavURL, navbarBrandURL)
|
||||
}
|
||||
|
||||
navbarAllServers, _ := pflag.CommandLine.GetString("navbar-all-servers")
|
||||
if navbarAllServers != tt.expectedAllServer {
|
||||
t.Errorf("navbar-all-servers: expected %q, got %q", tt.expectedAllServer, navbarAllServers)
|
||||
}
|
||||
|
||||
navbarAllURL, _ := pflag.CommandLine.GetString("navbar-all-url")
|
||||
if navbarAllURL != tt.expectedAllURL {
|
||||
t.Errorf("navbar-all-url: expected %q, got %q", tt.expectedAllURL, navbarAllURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNetworkConfigNormalization tests that network configuration flags accept both formats
|
||||
func TestNetworkConfigNormalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedDNS string
|
||||
expectedNetMode string
|
||||
expectedBgpmap string
|
||||
}{
|
||||
{
|
||||
name: "dash format",
|
||||
args: []string{
|
||||
"--dns-interface=asn.cymru.com",
|
||||
"--net-specific-mode=dn42",
|
||||
"--bgpmap-info=asn,as-name",
|
||||
},
|
||||
expectedDNS: "asn.cymru.com",
|
||||
expectedNetMode: "dn42",
|
||||
expectedBgpmap: "asn,as-name",
|
||||
},
|
||||
{
|
||||
name: "underscore format",
|
||||
args: []string{
|
||||
"--dns_interface=origin.asn.cymru.com",
|
||||
"--net_specific_mode=",
|
||||
"--bgpmap_info=asn,descr",
|
||||
},
|
||||
expectedDNS: "origin.asn.cymru.com",
|
||||
expectedNetMode: "",
|
||||
expectedBgpmap: "asn,descr",
|
||||
},
|
||||
{
|
||||
name: "mixed format",
|
||||
args: []string{
|
||||
"--dns-interface=test.cymru.com",
|
||||
"--net_specific_mode=dn42",
|
||||
"--bgpmap-info=asn,ASName",
|
||||
},
|
||||
expectedDNS: "test.cymru.com",
|
||||
expectedNetMode: "dn42",
|
||||
expectedBgpmap: "asn,ASName",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
// Set up the normalizer
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
// Define flags
|
||||
pflag.String("dns-interface", "asn.cymru.com", "dns interface")
|
||||
pflag.String("net-specific-mode", "", "network mode")
|
||||
pflag.String("bgpmap-info", "asn,as-name,ASName,descr", "bgpmap info")
|
||||
|
||||
// Parse the args
|
||||
err := pflag.CommandLine.Parse(tt.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse args: %v", err)
|
||||
}
|
||||
|
||||
// Verify values
|
||||
dnsInterface, _ := pflag.CommandLine.GetString("dns-interface")
|
||||
if dnsInterface != tt.expectedDNS {
|
||||
t.Errorf("dns-interface: expected %q, got %q", tt.expectedDNS, dnsInterface)
|
||||
}
|
||||
|
||||
netMode, _ := pflag.CommandLine.GetString("net-specific-mode")
|
||||
if netMode != tt.expectedNetMode {
|
||||
t.Errorf("net-specific-mode: expected %q, got %q", tt.expectedNetMode, netMode)
|
||||
}
|
||||
|
||||
bgpmapInfo, _ := pflag.CommandLine.GetString("bgpmap-info")
|
||||
if bgpmapInfo != tt.expectedBgpmap {
|
||||
t.Errorf("bgpmap-info: expected %q, got %q", tt.expectedBgpmap, bgpmapInfo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterConfigNormalization tests that filter configuration flags accept both formats
|
||||
func TestFilterConfigNormalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedProtocol string
|
||||
expectedName string
|
||||
expectedTelegramBot string
|
||||
}{
|
||||
{
|
||||
name: "dash format",
|
||||
args: []string{
|
||||
"--protocol-filter=bgp,ospf",
|
||||
"--name-filter=test.*",
|
||||
"--telegram-bot-name=mybot",
|
||||
},
|
||||
expectedProtocol: "bgp,ospf",
|
||||
expectedName: "test.*",
|
||||
expectedTelegramBot: "mybot",
|
||||
},
|
||||
{
|
||||
name: "underscore format",
|
||||
args: []string{
|
||||
"--protocol_filter=static,kernel",
|
||||
"--name_filter=ignore_.*",
|
||||
"--telegram_bot_name=testbot",
|
||||
},
|
||||
expectedProtocol: "static,kernel",
|
||||
expectedName: "ignore_.*",
|
||||
expectedTelegramBot: "testbot",
|
||||
},
|
||||
{
|
||||
name: "mixed format",
|
||||
args: []string{
|
||||
"--protocol-filter=bgp",
|
||||
"--name_filter=^temp",
|
||||
"--telegram-bot-name=prodbot",
|
||||
},
|
||||
expectedProtocol: "bgp",
|
||||
expectedName: "^temp",
|
||||
expectedTelegramBot: "prodbot",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
// Set up the normalizer
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
// Define flags
|
||||
pflag.String("protocol-filter", "", "protocol filter")
|
||||
pflag.String("name-filter", "", "name filter")
|
||||
pflag.String("telegram-bot-name", "", "telegram bot name")
|
||||
|
||||
// Parse the args
|
||||
err := pflag.CommandLine.Parse(tt.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse args: %v", err)
|
||||
}
|
||||
|
||||
// Verify values
|
||||
protocolFilter, _ := pflag.CommandLine.GetString("protocol-filter")
|
||||
if protocolFilter != tt.expectedProtocol {
|
||||
t.Errorf("protocol-filter: expected %q, got %q", tt.expectedProtocol, protocolFilter)
|
||||
}
|
||||
|
||||
nameFilter, _ := pflag.CommandLine.GetString("name-filter")
|
||||
if nameFilter != tt.expectedName {
|
||||
t.Errorf("name-filter: expected %q, got %q", tt.expectedName, nameFilter)
|
||||
}
|
||||
|
||||
telegramBotName, _ := pflag.CommandLine.GetString("telegram-bot-name")
|
||||
if telegramBotName != tt.expectedTelegramBot {
|
||||
t.Errorf("telegram-bot-name: expected %q, got %q", tt.expectedTelegramBot, telegramBotName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeoutConfigNormalization tests that timeout configuration flags accept both formats
|
||||
func TestTimeoutConfigNormalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedTimeout int
|
||||
expectedConnTimeout int
|
||||
expectedTrustProxy bool
|
||||
}{
|
||||
{
|
||||
name: "dash format",
|
||||
args: []string{
|
||||
"--time-out=60",
|
||||
"--connection-time-out=10",
|
||||
"--trust-proxy-headers=true",
|
||||
},
|
||||
expectedTimeout: 60,
|
||||
expectedConnTimeout: 10,
|
||||
expectedTrustProxy: true,
|
||||
},
|
||||
{
|
||||
name: "underscore format",
|
||||
args: []string{
|
||||
"--time_out=180",
|
||||
"--connection_time_out=15",
|
||||
"--trust_proxy_headers=false",
|
||||
},
|
||||
expectedTimeout: 180,
|
||||
expectedConnTimeout: 15,
|
||||
expectedTrustProxy: false,
|
||||
},
|
||||
{
|
||||
name: "mixed format",
|
||||
args: []string{
|
||||
"--time-out=90",
|
||||
"--connection_time_out=8",
|
||||
"--trust-proxy-headers=true",
|
||||
},
|
||||
expectedTimeout: 90,
|
||||
expectedConnTimeout: 8,
|
||||
expectedTrustProxy: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
// Set up the normalizer
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
// Define flags
|
||||
pflag.Int("time-out", 120, "timeout")
|
||||
pflag.Int("connection-time-out", 5, "connection timeout")
|
||||
pflag.Bool("trust-proxy-headers", false, "trust proxy headers")
|
||||
|
||||
// Parse the args
|
||||
err := pflag.CommandLine.Parse(tt.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse args: %v", err)
|
||||
}
|
||||
|
||||
// Verify values
|
||||
timeout, _ := pflag.CommandLine.GetInt("time-out")
|
||||
if timeout != tt.expectedTimeout {
|
||||
t.Errorf("time-out: expected %d, got %d", tt.expectedTimeout, timeout)
|
||||
}
|
||||
|
||||
connTimeout, _ := pflag.CommandLine.GetInt("connection-time-out")
|
||||
if connTimeout != tt.expectedConnTimeout {
|
||||
t.Errorf("connection-time-out: expected %d, got %d", tt.expectedConnTimeout, connTimeout)
|
||||
}
|
||||
|
||||
trustProxy, _ := pflag.CommandLine.GetBool("trust-proxy-headers")
|
||||
if trustProxy != tt.expectedTrustProxy {
|
||||
t.Errorf("trust-proxy-headers: expected %v, got %v", tt.expectedTrustProxy, trustProxy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSettings(t *testing.T) {
|
||||
resetFlags()
|
||||
parseSettings()
|
||||
resetFlags()
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type tgChat struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type tgMessage struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
Chat tgChat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type tgWebhookRequest struct {
|
||||
Message tgMessage `json:"message"`
|
||||
}
|
||||
|
||||
type tgWebhookResponse struct {
|
||||
Method string `json:"method"`
|
||||
ChatID int64 `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ReplyToMessageID int64 `json:"reply_to_message_id"`
|
||||
ParseMode string `json:"parse_mode"`
|
||||
}
|
||||
|
||||
func telegramIsCommand(message string, command string) bool {
|
||||
b := false
|
||||
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
|
||||
}
|
||||
|
||||
func telegramDefaultPostProcess(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func telegramBatchRequestFormat(servers []string, endpoint string, command string, postProcess func(string) string) string {
|
||||
results := batchRequest(servers, endpoint, command)
|
||||
result := ""
|
||||
for i, r := range results {
|
||||
if len(servers) > 1 {
|
||||
result += servers[i] + "\n"
|
||||
}
|
||||
result += postProcess(r) + "\n\n"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func webHandlerTelegramBot(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse only needed fields of incoming JSON body
|
||||
var err error
|
||||
var request tgWebhookRequest
|
||||
err = json.NewDecoder(r.Body).Decode(&request)
|
||||
if err != nil {
|
||||
println(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Do not respond if not a tg Bot command (starting with /)
|
||||
if len(request.Message.Text) == 0 || request.Message.Text[0] != '/' {
|
||||
return
|
||||
}
|
||||
|
||||
// Select only one server based on webhook URL
|
||||
var servers []string
|
||||
if len(r.URL.Path[len("/telegram/"):]) == 0 {
|
||||
servers = setting.servers
|
||||
} else {
|
||||
servers = strings.Split(r.URL.Path[len("/telegram/"):], "+")
|
||||
}
|
||||
|
||||
// Parse target
|
||||
target := ""
|
||||
if strings.Contains(request.Message.Text, " ") {
|
||||
target = strings.Join(strings.Split(request.Message.Text, " ")[1:], " ")
|
||||
target = strings.TrimSpace(target)
|
||||
}
|
||||
|
||||
// Execute command
|
||||
commandResult := ""
|
||||
|
||||
// - traceroute
|
||||
if telegramIsCommand(request.Message.Text, "trace") {
|
||||
commandResult = telegramBatchRequestFormat(servers, "traceroute", target, telegramDefaultPostProcess)
|
||||
|
||||
} else if telegramIsCommand(request.Message.Text, "route") {
|
||||
commandResult = telegramBatchRequestFormat(servers, "bird", "show route for "+target+" primary", telegramDefaultPostProcess)
|
||||
|
||||
} else if telegramIsCommand(request.Message.Text, "path") {
|
||||
commandResult = telegramBatchRequestFormat(servers, "bird", "show route for "+target+" all primary", func(result string) string {
|
||||
for _, s := range strings.Split(result, "\n") {
|
||||
if strings.Contains(s, "BGP.as_path: ") || strings.Contains(s, "bgp_path: ") {
|
||||
return strings.TrimSpace(strings.Split(s, ":")[1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
} else if telegramIsCommand(request.Message.Text, "whois") {
|
||||
if setting.netSpecificMode == "dn42" || setting.netSpecificMode == "dn42_generic" {
|
||||
targetNumber, err := strconv.ParseUint(target, 10, 64)
|
||||
if err == nil {
|
||||
if targetNumber < 10000 {
|
||||
targetNumber += 4242420000
|
||||
target = "AS" + strconv.FormatUint(targetNumber, 10)
|
||||
} else {
|
||||
target = "AS" + target
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
} else if telegramIsCommand(request.Message.Text, "help") {
|
||||
commandResult = `
|
||||
/path <IP>
|
||||
/route <IP>
|
||||
/trace <IP>
|
||||
/whois <Target>
|
||||
`
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
commandResult = strings.TrimSpace(commandResult)
|
||||
if len(commandResult) <= 0 {
|
||||
commandResult = "empty result"
|
||||
}
|
||||
|
||||
if len(commandResult) > 4096 {
|
||||
commandResult = commandResult[0:4096]
|
||||
}
|
||||
|
||||
// Create a JSON response
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
response := &tgWebhookResponse{
|
||||
Method: "sendMessage",
|
||||
ChatID: request.Message.Chat.ID,
|
||||
Text: "```\n" + commandResult + "\n```",
|
||||
ReplyToMessageID: request.Message.MessageID,
|
||||
ParseMode: "Markdown",
|
||||
}
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
println(err.Error())
|
||||
return
|
||||
}
|
||||
// println(string(data))
|
||||
w.Write(data)
|
||||
}
|
||||
@@ -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```")
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
ServersDisplay []string
|
||||
|
||||
// Parameters related to current request
|
||||
AllServersLinkActive bool
|
||||
AllServerTitle string
|
||||
AllServersURL string
|
||||
AllServersURLCustom string
|
||||
|
||||
// Whois specific handling (for its unique URL)
|
||||
IsWhois bool
|
||||
WhoisTarget string
|
||||
|
||||
URLOption string
|
||||
URLServer string
|
||||
URLCommand string
|
||||
|
||||
// Peer types for the summary filter bar
|
||||
PeerTypes []PeerType
|
||||
|
||||
// Generated content to be displayed
|
||||
Title string
|
||||
Brand string
|
||||
BrandURL string
|
||||
Content template.HTML
|
||||
}
|
||||
|
||||
// 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"`
|
||||
PeerType string `json:"peer_type"`
|
||||
RemoteASN string `json:"remote_asn"`
|
||||
ASNName string `json:"asn_name"`
|
||||
Imported int `json:"imported"`
|
||||
Filtered int `json:"filtered"`
|
||||
Exported int `json:"exported"`
|
||||
Preferred int `json:"preferred"`
|
||||
}
|
||||
|
||||
func (r SummaryRowData) PreferredPct() int {
|
||||
if r.Imported == 0 {
|
||||
return 0
|
||||
}
|
||||
return r.Preferred * 100 / r.Imported
|
||||
}
|
||||
|
||||
// TypeInfo resolves this row's peer type to its display definition (icon,
|
||||
// colour, label) for use in the summary template. Returns nil for non-BGP rows.
|
||||
func (r SummaryRowData) TypeInfo() *PeerType {
|
||||
return typeInfo(r.PeerType)
|
||||
}
|
||||
|
||||
// utility functions to allow filtering of results in the template
|
||||
|
||||
func (r SummaryRowData) NameHasPrefix(prefix string) bool {
|
||||
return strings.HasPrefix(r.Name, prefix)
|
||||
}
|
||||
|
||||
func (r SummaryRowData) NameContains(prefix string) bool {
|
||||
return strings.Contains(r.Name, prefix)
|
||||
}
|
||||
|
||||
func (r SummaryRowData) ProtocolMatches(protocols []string) bool {
|
||||
for _, protocol := range protocols {
|
||||
if strings.EqualFold(r.Proto, protocol) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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",
|
||||
}
|
||||
|
||||
func SummaryRowDataFromLine(line string) *SummaryRowData {
|
||||
lineSplitted := splitSummaryLine.FindStringSubmatch(line)
|
||||
if lineSplitted == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var row SummaryRowData
|
||||
row.Name = strings.TrimSpace(lineSplitted[1])
|
||||
row.Proto = strings.TrimSpace(lineSplitted[2])
|
||||
row.Table = strings.TrimSpace(lineSplitted[3])
|
||||
row.State = strings.TrimSpace(lineSplitted[4])
|
||||
row.Since = strings.TrimSpace(lineSplitted[5])
|
||||
row.Info = strings.TrimSpace(lineSplitted[6])
|
||||
row.PeerType = getPeerType(row.Name, row.Proto)
|
||||
|
||||
if row.PeerType != "" {
|
||||
// BGP session: colour by established state
|
||||
if strings.Contains(row.Info, "Established") {
|
||||
row.MappedState = "success"
|
||||
} else {
|
||||
row.MappedState = "danger"
|
||||
}
|
||||
} else if strings.Contains(row.Info, "Passive") {
|
||||
row.MappedState = summaryStateMap["passive"]
|
||||
} else {
|
||||
row.MappedState = summaryStateMap[row.State]
|
||||
}
|
||||
|
||||
return &row
|
||||
}
|
||||
|
||||
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
|
||||
ServerURL 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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)
|
||||
}
|
||||
|
||||
func TestSummaryRowDataFromLine(t *testing.T) {
|
||||
data := SummaryRowDataFromLine("sys_device Device --- up 2025-06-27 21:23:08")
|
||||
|
||||
assert.Equal(t, data.Name, "sys_device")
|
||||
assert.Equal(t, data.Proto, "Device")
|
||||
assert.Equal(t, data.Table, "---")
|
||||
assert.Equal(t, data.State, "up")
|
||||
assert.Equal(t, data.Since, "2025-06-27 21:23:08")
|
||||
}
|
||||
|
||||
func TestSummaryRowDataFromLineNumeric(t *testing.T) {
|
||||
data := SummaryRowDataFromLine("12345 Device --- up 2025-06-27 21:23:08")
|
||||
|
||||
assert.Equal(t, data.Name, "12345")
|
||||
assert.Equal(t, data.Proto, "Device")
|
||||
assert.Equal(t, data.Table, "---")
|
||||
assert.Equal(t, data.State, "up")
|
||||
assert.Equal(t, data.Since, "2025-06-27 21:23:08")
|
||||
}
|
||||
|
||||
func TestSummaryRowDataFromLinePipe(t *testing.T) {
|
||||
data := SummaryRowDataFromLine("pipe Pipe --- up 2025-06-27 21:23:08 master4 <=> pipe_v4")
|
||||
|
||||
assert.Equal(t, data.Name, "pipe")
|
||||
assert.Equal(t, data.Proto, "Pipe")
|
||||
assert.Equal(t, data.Table, "---")
|
||||
assert.Equal(t, data.State, "up")
|
||||
assert.Equal(t, data.Since, "2025-06-27 21:23:08")
|
||||
assert.Equal(t, data.Info, "master4 <=> pipe_v4")
|
||||
}
|
||||
|
||||
func TestSummaryRowDataFromLineBGP(t *testing.T) {
|
||||
data := SummaryRowDataFromLine("bgp BGP --- up 2025-06-30 20:45:33 Established")
|
||||
|
||||
assert.Equal(t, data.Name, "bgp")
|
||||
assert.Equal(t, data.Proto, "BGP")
|
||||
assert.Equal(t, data.Table, "---")
|
||||
assert.Equal(t, data.State, "up")
|
||||
assert.Equal(t, data.Since, "2025-06-30 20:45:33")
|
||||
assert.Equal(t, data.Info, "Established")
|
||||
}
|
||||
|
||||
func TestSummaryRowDataFromLineBGPPassive(t *testing.T) {
|
||||
data := SummaryRowDataFromLine("passive BGP --- start 2025-06-27 21:23:08 Passive")
|
||||
|
||||
assert.Equal(t, data.Name, "passive")
|
||||
assert.Equal(t, data.Proto, "BGP")
|
||||
assert.Equal(t, data.Table, "---")
|
||||
assert.Equal(t, data.State, "start")
|
||||
assert.Equal(t, data.Since, "2025-06-27 21:23:08")
|
||||
assert.Equal(t, data.Info, "Passive")
|
||||
}
|
||||
|
||||
func TestSummaryRowDataFromLineWithDash(t *testing.T) {
|
||||
data := SummaryRowDataFromLine("ibgp_test-01 BGP --- up 07:16:51.656 Established")
|
||||
|
||||
assert.Equal(t, data.Name, "ibgp_test-01")
|
||||
assert.Equal(t, data.Proto, "BGP")
|
||||
assert.Equal(t, data.Table, "---")
|
||||
assert.Equal(t, data.State, "up")
|
||||
assert.Equal(t, data.Since, "07:16:51.656")
|
||||
assert.Equal(t, data.Info, "Established")
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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-bird3
|
||||
preference: 100
|
||||
igp_metric: 0
|
||||
from: 172.22.96.65
|
||||
source: BGP
|
||||
bgp_origin: IGP
|
||||
bgp_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)
|
||||
Internal route handling values: 0L 13G 0S id 3743
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// vrfControl returns a Control function that binds sockets to a VRF device
|
||||
// via SO_BINDTODEVICE. Returns nil when vrfName is empty.
|
||||
func vrfControl(vrfName string) func(network, address string, c syscall.RawConn) error {
|
||||
if vrfName == "" {
|
||||
return nil
|
||||
}
|
||||
return func(network, address string, c syscall.RawConn) error {
|
||||
var sysErr error
|
||||
err := c.Control(func(fd uintptr) {
|
||||
sysErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, vrfName)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sysErr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import "syscall"
|
||||
|
||||
// vrfControl returns a Control function that binds sockets to a VRF device.
|
||||
// On non-Linux platforms, VRF is not supported; panics if vrfName is non-empty.
|
||||
func vrfControl(vrfName string) func(network, address string, c syscall.RawConn) error {
|
||||
if vrfName == "" {
|
||||
return nil
|
||||
}
|
||||
panic("VRF binding is only supported on Linux")
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
)
|
||||
|
||||
var primitiveMap = map[string]string{
|
||||
"summary": "show protocols all",
|
||||
"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",
|
||||
}
|
||||
|
||||
var webServerPrepared uint32 = 0
|
||||
|
||||
// serve up a generic error
|
||||
func serverError(w http.ResponseWriter, _ *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),
|
||||
template.HTML(buffer.String()),
|
||||
)
|
||||
}
|
||||
|
||||
// serve up results from bird
|
||||
func webBackendCommunicator(endpoint string, command string) func(w http.ResponseWriter, r *http.Request) {
|
||||
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:], "/", 3)
|
||||
var urlCommands string
|
||||
if len(split) >= 3 {
|
||||
urlCommands = split[2]
|
||||
}
|
||||
|
||||
var backendCommand string
|
||||
if strings.Contains(backendCommandPrimitive, "%") {
|
||||
backendCommand = fmt.Sprintf(backendCommandPrimitive, urlCommands)
|
||||
} else {
|
||||
backendCommand = backendCommandPrimitive
|
||||
}
|
||||
backendCommand = strings.TrimSpace(backendCommand)
|
||||
|
||||
servers := strings.Split(split[1], "+")
|
||||
|
||||
var responses []string = batchRequest(servers, endpoint, backendCommand)
|
||||
var content string
|
||||
for i, response := range responses {
|
||||
|
||||
var result template.HTML
|
||||
if (endpoint == "bird") && backendCommand == "show protocols all" && len(response) > 4 && strings.ToLower(response[0:4]) == "name" {
|
||||
result = summaryTable(response, servers[i])
|
||||
} else {
|
||||
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,
|
||||
ServerURL: servers[i],
|
||||
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()
|
||||
}
|
||||
|
||||
renderPageTemplate(
|
||||
w, r,
|
||||
" - "+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",
|
||||
"route_where_bgpmap": "show route where net ~ [ %s ] all",
|
||||
})[command]
|
||||
|
||||
if !commandPresent {
|
||||
panic("invalid command: " + command)
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
split := strings.Split(r.URL.Path[1:], "/")
|
||||
urlCommands := strings.Join(split[2:], "/")
|
||||
|
||||
var backendCommand string
|
||||
if strings.Contains(backendCommandPrimitive, "%") {
|
||||
backendCommand = fmt.Sprintf(backendCommandPrimitive, urlCommands)
|
||||
} else {
|
||||
backendCommand = backendCommandPrimitive
|
||||
}
|
||||
|
||||
var servers []string = strings.Split(split[1], "+")
|
||||
var responses []string = batchRequest(servers, endpoint, backendCommand)
|
||||
|
||||
// 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),
|
||||
template.HTML(buffer.String()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// set up routing paths
|
||||
func webServerPrepare() {
|
||||
// redirect main page to all server summary
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/summary/"+url.PathEscape(strings.Join(setting.servers, "+")), http.StatusFound)
|
||||
})
|
||||
|
||||
// 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.Handle("/static/", fs)
|
||||
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"))
|
||||
http.HandleFunc("/route_where/", webBackendCommunicator("bird", "route_where"))
|
||||
http.HandleFunc("/route_where_all/", webBackendCommunicator("bird", "route_where_all"))
|
||||
http.HandleFunc("/route_where_bgpmap/", webHandlerBGPMap("bird", "route_where_bgpmap"))
|
||||
http.HandleFunc("/route_generic/", webBackendCommunicator("bird", "route_generic"))
|
||||
http.HandleFunc("/generic/", webBackendCommunicator("bird", "generic"))
|
||||
http.HandleFunc("/traceroute/", webBackendCommunicator("traceroute", "traceroute"))
|
||||
http.HandleFunc("/whois/", webHandlerWhois)
|
||||
http.HandleFunc("/api/", apiHandler)
|
||||
http.HandleFunc("/telegram/", webHandlerTelegramBot)
|
||||
}
|
||||
|
||||
// start webserver
|
||||
func webServerStart(l net.Listener) {
|
||||
if atomic.SwapUint32(&webServerPrepared, 1) == 0 {
|
||||
webServerPrepare()
|
||||
}
|
||||
|
||||
var handler http.Handler
|
||||
handler = http.DefaultServeMux
|
||||
if setting.trustProxyHeaders {
|
||||
handler = handlers.ProxyHeaders(handler)
|
||||
}
|
||||
handler = handlers.LoggingHandler(os.Stdout, handler)
|
||||
http.Serve(l, handler)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/shlex"
|
||||
)
|
||||
|
||||
// addDefaultWhoisPort adds the default whois port (43) if not specified.
|
||||
// Handles IPv4, IPv6 (bare and bracketed), and domain names.
|
||||
func addDefaultWhoisPort(server string) string {
|
||||
if _, _, err := net.SplitHostPort(server); err != nil {
|
||||
// No port specified, add default whois port
|
||||
// Strip brackets from IPv6 addresses like [::1] before JoinHostPort adds them back
|
||||
host := server
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
return net.JoinHostPort(host, "43")
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
// Send a whois request
|
||||
func whois(s string) string {
|
||||
if setting.whoisServer == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
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 := addDefaultWhoisPort(setting.whoisServer)
|
||||
|
||||
conn, err := (&net.Dialer{Timeout: 5 * time.Second, Control: vrfControl(setting.vrf)}).Dial("tcp", whoisServer)
|
||||
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])
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddDefaultWhoisPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// IPv4 addresses
|
||||
{"IPv4 without port", "192.0.2.1", "192.0.2.1:43"},
|
||||
{"IPv4 with port", "192.0.2.1:4343", "192.0.2.1:4343"},
|
||||
|
||||
// IPv6 addresses - bare format
|
||||
{"IPv6 bare without port", "::1", "[::1]:43"},
|
||||
{"IPv6 bare full without port", "2001:db8::1", "[2001:db8::1]:43"},
|
||||
|
||||
// IPv6 addresses - bracketed format
|
||||
{"IPv6 bracketed without port", "[::1]", "[::1]:43"},
|
||||
{"IPv6 bracketed full without port", "[2001:db8::1]", "[2001:db8::1]:43"},
|
||||
{"IPv6 bracketed with port", "[::1]:4343", "[::1]:4343"},
|
||||
{"IPv6 bracketed full with port", "[2001:db8::1]:4343", "[2001:db8::1]:4343"},
|
||||
|
||||
// Domain names
|
||||
{"Domain without port", "whois.example.com", "whois.example.com:43"},
|
||||
{"Domain with port", "whois.example.com:4343", "whois.example.com:4343"},
|
||||
|
||||
// Edge cases
|
||||
{"Localhost without port", "localhost", "localhost:43"},
|
||||
{"Localhost with port", "localhost:4343", "localhost:4343"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := addDefaultWhoisPort(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("addDefaultWhoisPort(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type WhoisServer struct {
|
||||
t *testing.T
|
||||
expectedQuery string
|
||||
response string
|
||||
server net.Listener
|
||||
listenAddr string // Address to listen on, defaults to "127.0.0.1:0"
|
||||
}
|
||||
|
||||
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() error {
|
||||
var err error
|
||||
listenAddr := s.listenAddr
|
||||
if listenAddr == "" {
|
||||
listenAddr = "127.0.0.1:0"
|
||||
}
|
||||
s.server, err = net.Listen("tcp", listenAddr)
|
||||
return 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,
|
||||
}
|
||||
|
||||
if err := server.Listen(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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:1"
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoisIPv6(t *testing.T) {
|
||||
server := WhoisServer{
|
||||
t: t,
|
||||
expectedQuery: "AS6939",
|
||||
response: AS6939Response,
|
||||
listenAddr: "[::1]:0",
|
||||
}
|
||||
|
||||
if err := server.Listen(); err != nil {
|
||||
t.Skip("IPv6 not available:", err)
|
||||
}
|
||||
go server.Run()
|
||||
defer server.Close()
|
||||
|
||||
setting.whoisServer = server.server.Addr().String()
|
||||
result := whois("AS6939")
|
||||
if !strings.Contains(result, "HURRICANE") {
|
||||
t.Errorf("Whois AS6939 over IPv6 failed, got %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoisIPv6WithoutPort(t *testing.T) {
|
||||
server := WhoisServer{
|
||||
t: t,
|
||||
expectedQuery: "AS6939",
|
||||
response: AS6939Response,
|
||||
listenAddr: "[::1]:43",
|
||||
}
|
||||
|
||||
if err := server.Listen(); err != nil {
|
||||
t.Skip("IPv6 not available or port 43 not bindable:", err)
|
||||
}
|
||||
go server.Run()
|
||||
defer server.Close()
|
||||
|
||||
// Test that bare IPv6 address (without port) gets default port 43 appended
|
||||
setting.whoisServer = "::1"
|
||||
result := whois("AS6939")
|
||||
if !strings.Contains(result, "HURRICANE") {
|
||||
t.Errorf("Whois AS6939 over IPv6 (bare address) failed, got %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoisIPv6ConnectionError(t *testing.T) {
|
||||
// Use IPv6 loopback with a port that should be refused
|
||||
setting.whoisServer = "[::1]:1"
|
||||
result := whois("AS6939")
|
||||
if !strings.Contains(result, "connect: connection refused") && !strings.Contains(result, "network is unreachable") {
|
||||
t.Errorf("Whois AS6939 IPv6 connection error produced unexpected output, got %s", result)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,31 @@
|
||||
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://www.bitwizard.nl/mtr/files/mtr-0.94.tar.gz \
|
||||
-O mtr-0.94.tar.gz
|
||||
RUN tar xvf mtr-0.94.tar.gz \
|
||||
&& cd mtr-0.94 \
|
||||
&& ./configure --without-gtk --without-ncurses --without-jansson --without-ipinfo --disable-bash-completion \
|
||||
&& make -j4 LDFLAGS="-static" \
|
||||
&& strip /root/mtr-0.94/mtr \
|
||||
&& strip /root/mtr-0.94/mtr-packet
|
||||
|
||||
################################################################################
|
||||
|
||||
FROM scratch AS step_2
|
||||
ENV PATH=/
|
||||
COPY --from=step_0 /proxy /
|
||||
COPY --from=step_1 /root/mtr-0.94/mtr /
|
||||
COPY --from=step_1 /root/mtr-0.94/mtr-packet /
|
||||
ENTRYPOINT ["/proxy"]
|
||||
@@ -0,0 +1,3 @@
|
||||
.PHONY: all
|
||||
all: $(shell find . -name \*.go -type f)
|
||||
go build -ldflags "-w -s" -o proxy
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
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, MAX_LINE_SIZE)
|
||||
pos := 0
|
||||
for {
|
||||
// Leave one byte for newline character
|
||||
if pos >= MAX_LINE_SIZE-1 {
|
||||
break
|
||||
}
|
||||
_, err := bird.Read(c[pos : pos+1])
|
||||
if err != nil {
|
||||
w.Write([]byte(err.Error()))
|
||||
return false
|
||||
}
|
||||
if c[pos] == byte('\n') {
|
||||
break
|
||||
}
|
||||
pos++
|
||||
}
|
||||
|
||||
c = c[:pos+1]
|
||||
c[pos] = '\n'
|
||||
// print(string(c[:]))
|
||||
|
||||
// Remove preceding status number, different situations
|
||||
if pos > 4 && isNumeric(c[0]) && isNumeric(c[1]) && isNumeric(c[2]) && isNumeric(c[3]) {
|
||||
// There is a status number at beginning, remove first 5 bytes
|
||||
if w != nil && pos > 6 {
|
||||
pos = 5
|
||||
w.Write(c[pos:])
|
||||
}
|
||||
return c[0] != byte('0') && c[0] != byte('8') && c[0] != byte('9')
|
||||
} else {
|
||||
if w != nil {
|
||||
w.Write(c[1:])
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Write a command to a bird socket
|
||||
func birdWriteln(bird io.Writer, s string) {
|
||||
bird.Write([]byte(s + "\n"))
|
||||
}
|
||||
|
||||
// List of allowed bird commands when restriction is enabled
|
||||
var allowedBirdCommands = []string{
|
||||
"show protocols",
|
||||
"show route",
|
||||
}
|
||||
|
||||
// Check if a bird command is allowed based on the allowed commands list
|
||||
// Accepts exact match or command followed by space and parameters
|
||||
func isBirdCommandAllowed(query string) bool {
|
||||
for _, cmd := range allowedBirdCommands {
|
||||
if query == cmd || strings.HasPrefix(query, cmd+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Handles BIRDv4 queries
|
||||
func birdHandler(httpW http.ResponseWriter, httpR *http.Request) {
|
||||
query := string(httpR.URL.Query().Get("q"))
|
||||
if query == "" {
|
||||
invalidHandler(httpW, httpR)
|
||||
} else {
|
||||
// Check if command restriction is enabled
|
||||
if setting.birdRestrictCmds {
|
||||
if !isBirdCommandAllowed(query) {
|
||||
httpW.WriteHeader(http.StatusForbidden)
|
||||
httpW.Write([]byte("Forbidden: only 'show protocols' and 'show route' commands are allowed\n"))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Initialize BIRDv4 socket
|
||||
bird, err := net.Dial("unix", setting.birdSocket)
|
||||
if err != nil {
|
||||
httpW.WriteHeader(http.StatusInternalServerError)
|
||||
httpW.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
defer bird.Close()
|
||||
|
||||
birdReadln(bird, nil)
|
||||
birdWriteln(bird, "restrict")
|
||||
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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
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")
|
||||
}
|
||||
|
||||
func TestIsBirdCommandAllowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expected bool
|
||||
}{
|
||||
{"show protocols with params", "show protocols all", true},
|
||||
{"show protocols exact match", "show protocols", true},
|
||||
{"show protocols with single space", "show protocols ", true},
|
||||
{"show route with params", "show route for 1.2.3.4", true},
|
||||
{"show route exact match", "show route", true},
|
||||
{"show route with single space", "show route ", true},
|
||||
{"show status - forbidden", "show status", false},
|
||||
{"configure - forbidden", "configure", false},
|
||||
{"reload - forbidden", "reload", false},
|
||||
{"empty string", "", false},
|
||||
{"just show", "show", false},
|
||||
{"show protocol - missing s", "show protocol all", false},
|
||||
{"show protocolsaaa - no space", "show protocolsaaa", false},
|
||||
{"case sensitive", "Show Protocols all", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isBirdCommandAllowed(tt.query)
|
||||
assert.Equal(t, result, tt.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBirdHandlerWithRestrictedCommandAllowed(t *testing.T) {
|
||||
server := BirdServer{
|
||||
t: t,
|
||||
expectedQuery: "show protocols all",
|
||||
response: "Mock Response",
|
||||
injectError: "",
|
||||
}
|
||||
|
||||
server.Listen()
|
||||
go server.Run()
|
||||
defer server.Close()
|
||||
|
||||
setting.birdSocket = server.socket
|
||||
setting.birdRestrictCmds = true
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/bird?q="+url.QueryEscape("show protocols all"), nil)
|
||||
w := httptest.NewRecorder()
|
||||
birdHandler(w, r)
|
||||
|
||||
assert.Equal(t, w.Code, http.StatusOK)
|
||||
assert.Equal(t, w.Body.String(), "Mock Response\n")
|
||||
}
|
||||
|
||||
func TestBirdHandlerWithRestrictedCommandForbidden(t *testing.T) {
|
||||
setting.birdSocket = "/any/path" // Won't be used since we return 403 before connecting
|
||||
setting.birdRestrictCmds = true
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/bird?q="+url.QueryEscape("show status"), nil)
|
||||
w := httptest.NewRecorder()
|
||||
birdHandler(w, r)
|
||||
|
||||
assert.Equal(t, w.Code, http.StatusForbidden)
|
||||
assert.Equal(t, strings.Contains(w.Body.String(), "Forbidden"), true)
|
||||
}
|
||||
|
||||
func TestBirdHandlerWithRestrictedCommandShowRoute(t *testing.T) {
|
||||
server := BirdServer{
|
||||
t: t,
|
||||
expectedQuery: "show route for 1.2.3.4",
|
||||
response: "Mock Route Response",
|
||||
injectError: "",
|
||||
}
|
||||
|
||||
server.Listen()
|
||||
go server.Run()
|
||||
defer server.Close()
|
||||
|
||||
setting.birdSocket = server.socket
|
||||
setting.birdRestrictCmds = true
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/bird?q="+url.QueryEscape("show route for 1.2.3.4"), nil)
|
||||
w := httptest.NewRecorder()
|
||||
birdHandler(w, r)
|
||||
|
||||
assert.Equal(t, w.Code, http.StatusOK)
|
||||
assert.Equal(t, w.Body.String(), "Mock Route Response\n")
|
||||
}
|
||||
|
||||
func TestBirdHandlerWithRestrictedCommandConfigureForbidden(t *testing.T) {
|
||||
setting.birdSocket = "/any/path" // Won't be used since we return 403 before connecting
|
||||
setting.birdRestrictCmds = true
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/bird?q="+url.QueryEscape("configure"), nil)
|
||||
w := httptest.NewRecorder()
|
||||
birdHandler(w, r)
|
||||
|
||||
assert.Equal(t, w.Code, http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestBirdHandlerWithRestrictionDisabled(t *testing.T) {
|
||||
server := BirdServer{
|
||||
t: t,
|
||||
expectedQuery: "show status",
|
||||
response: "Mock Status Response",
|
||||
injectError: "",
|
||||
}
|
||||
|
||||
server.Listen()
|
||||
go server.Run()
|
||||
defer server.Close()
|
||||
|
||||
setting.birdSocket = server.socket
|
||||
setting.birdRestrictCmds = false
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/bird?q="+url.QueryEscape("show status"), nil)
|
||||
w := httptest.NewRecorder()
|
||||
birdHandler(w, r)
|
||||
|
||||
assert.Equal(t, w.Code, http.StatusOK)
|
||||
assert.Equal(t, w.Body.String(), "Mock Status Response\n")
|
||||
}
|
||||
|
||||
func TestBirdHandlerWithRestrictionDisabledEdgeCase(t *testing.T) {
|
||||
server := BirdServer{
|
||||
t: t,
|
||||
expectedQuery: "show protocolsaaa",
|
||||
response: "Mock Response",
|
||||
injectError: "",
|
||||
}
|
||||
|
||||
server.Listen()
|
||||
go server.Run()
|
||||
defer server.Close()
|
||||
|
||||
setting.birdSocket = server.socket
|
||||
setting.birdRestrictCmds = false
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/bird?q="+url.QueryEscape("show protocolsaaa"), nil)
|
||||
w := httptest.NewRecorder()
|
||||
birdHandler(w, r)
|
||||
|
||||
assert.Equal(t, w.Code, http.StatusOK)
|
||||
assert.Equal(t, w.Body.String(), "Mock Response\n")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
module github.com/xddxdd/bird-lg-go/proxy
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/gorilla/handlers v1.5.2
|
||||
github.com/magiconair/properties v1.8.10
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/sys v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
)
|
||||
|
||||
// Check if a byte is character for number
|
||||
func isNumeric(b byte) bool {
|
||||
return b >= byte('0') && b <= byte('9')
|
||||
}
|
||||
|
||||
// Default handler, returns 500 Internal Server Error
|
||||
func invalidHandler(httpW http.ResponseWriter, httpR *http.Request) {
|
||||
httpW.WriteHeader(http.StatusInternalServerError)
|
||||
httpW.Write([]byte("Invalid Request\n"))
|
||||
}
|
||||
|
||||
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) {
|
||||
if hasAccess(httpR.RemoteAddr) {
|
||||
next.ServeHTTP(httpW, httpR)
|
||||
} else {
|
||||
invalidHandler(httpW, httpR)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type settingType struct {
|
||||
birdSocket string
|
||||
birdRestrictCmds bool
|
||||
listen []string
|
||||
allowedNets []*net.IPNet
|
||||
tr_bin string
|
||||
tr_flags []string
|
||||
tr_raw bool
|
||||
tr_max_concurrent int
|
||||
vrf string
|
||||
}
|
||||
|
||||
var setting settingType
|
||||
|
||||
// Wrapper of tracer
|
||||
func main() {
|
||||
parseSettings()
|
||||
initTracerouteSemaphore(setting.tr_max_concurrent)
|
||||
tracerouteAutodetect()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Prepare HTTP server
|
||||
mux.HandleFunc("/", invalidHandler)
|
||||
mux.HandleFunc("/bird", birdHandler)
|
||||
mux.HandleFunc("/bird6", birdHandler)
|
||||
mux.HandleFunc("/traceroute", tracerouteHandler)
|
||||
mux.HandleFunc("/traceroute6", tracerouteHandler)
|
||||
|
||||
for _, listenAddr := range setting.listen {
|
||||
go func(addr string) {
|
||||
fmt.Printf("Listening on %s...\n", addr)
|
||||
|
||||
var l net.Listener
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(addr, "/") {
|
||||
// Delete existing socket file, ignore errors (will fail later anyway)
|
||||
os.Remove(addr)
|
||||
l, err = net.Listen("unix", addr)
|
||||
} else {
|
||||
if !strings.Contains(addr, ":") {
|
||||
addr = ":" + addr
|
||||
}
|
||||
lc := net.ListenConfig{Control: vrfControl(setting.vrf)}
|
||||
l, err = lc.Listen(context.Background(), "tcp", addr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
http.Serve(l, handlers.LoggingHandler(os.Stdout, accessHandler(mux)))
|
||||
}(listenAddr)
|
||||
}
|
||||
|
||||
select {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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"`
|
||||
BirdRestrictCmds bool `mapstructure:"bird_restrict_cmds"`
|
||||
Listen []string `mapstructure:"listen"`
|
||||
AllowedNets string `mapstructure:"allowed_ips"`
|
||||
TracerouteBin string `mapstructure:"traceroute_bin"`
|
||||
TracerouteFlags string `mapstructure:"traceroute_flags"`
|
||||
TracerouteRaw bool `mapstructure:"traceroute_raw"`
|
||||
TracerouteMaxConcurrent int `mapstructure:"traceroute_max_concurrent"`
|
||||
Vrf string `mapstructure:"vrf"`
|
||||
}
|
||||
|
||||
// Parse settings with viper, and convert to legacy setting format
|
||||
func parseSettings() {
|
||||
// Allow both dashes and underscores in flag names
|
||||
// e.g., both --traceroute-bin and --traceroute_bin will work
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
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.StringSlice("listen", []string{"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.Int("traceroute-max-concurrent", 10, "max concurrent traceroute requests allowed")
|
||||
viper.BindPFlag("traceroute_max_concurrent", pflag.Lookup("traceroute-max-concurrent"))
|
||||
|
||||
pflag.Bool("bird-restrict-cmds", true, "restrict bird commands to 'show protocols' and 'show route' only")
|
||||
viper.BindPFlag("bird_restrict_cmds", pflag.Lookup("bird-restrict-cmds"))
|
||||
|
||||
pflag.String("vrf", "", "VRF device to bind TCP sockets to (Linux only)")
|
||||
viper.BindPFlag("vrf", pflag.Lookup("vrf"))
|
||||
|
||||
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.birdRestrictCmds = viperSettings.BirdRestrictCmds
|
||||
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
|
||||
setting.tr_max_concurrent = viperSettings.TracerouteMaxConcurrent
|
||||
setting.vrf = viperSettings.Vrf
|
||||
|
||||
fmt.Printf("%#v\n", setting)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// resetFlags resets pflag and viper state for testing
|
||||
func resetFlags() {
|
||||
// Reset pflag
|
||||
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ContinueOnError)
|
||||
// Reset viper
|
||||
viper.Reset()
|
||||
}
|
||||
|
||||
// TestFlagNormalizer tests that the flag normalizer converts underscores to dashes
|
||||
func TestFlagNormalizer(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
// Set up the normalizer
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
// Define a test flag
|
||||
pflag.String("test-flag", "default", "test flag")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "dash format",
|
||||
args: []string{"--test-flag=dash-value"},
|
||||
expected: "dash-value",
|
||||
},
|
||||
{
|
||||
name: "underscore format",
|
||||
args: []string{"--test_flag=underscore-value"},
|
||||
expected: "underscore-value",
|
||||
},
|
||||
{
|
||||
name: "mixed format",
|
||||
args: []string{"--test_flag=mixed-value"},
|
||||
expected: "mixed-value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset flag value
|
||||
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ContinueOnError)
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
pflag.String("test-flag", "default", "test flag")
|
||||
|
||||
// Parse the args
|
||||
err := pflag.CommandLine.Parse(tt.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse args: %v", err)
|
||||
}
|
||||
|
||||
// Get the value
|
||||
val, err := pflag.CommandLine.GetString("test-flag")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get flag value: %v", err)
|
||||
}
|
||||
|
||||
if val != tt.expected {
|
||||
t.Errorf("Expected %q, got %q", tt.expected, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTracerouteFlagsNormalization tests that traceroute-related flags accept both formats
|
||||
func TestTracerouteFlagsNormalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedBin string
|
||||
expectedFlags string
|
||||
expectedRaw bool
|
||||
expectedMax int
|
||||
}{
|
||||
{
|
||||
name: "dash format",
|
||||
args: []string{
|
||||
"--traceroute-bin=/usr/bin/traceroute",
|
||||
"--traceroute-flags=-n -q 1",
|
||||
"--traceroute-raw=true",
|
||||
"--traceroute-max-concurrent=5",
|
||||
},
|
||||
expectedBin: "/usr/bin/traceroute",
|
||||
expectedFlags: "-n -q 1",
|
||||
expectedRaw: true,
|
||||
expectedMax: 5,
|
||||
},
|
||||
{
|
||||
name: "underscore format",
|
||||
args: []string{
|
||||
"--traceroute_bin=/usr/bin/mtr",
|
||||
"--traceroute_flags=-w -c1",
|
||||
"--traceroute_raw=false",
|
||||
"--traceroute_max_concurrent=20",
|
||||
},
|
||||
expectedBin: "/usr/bin/mtr",
|
||||
expectedFlags: "-w -c1",
|
||||
expectedRaw: false,
|
||||
expectedMax: 20,
|
||||
},
|
||||
{
|
||||
name: "mixed format",
|
||||
args: []string{
|
||||
"--traceroute-bin=/bin/traceroute",
|
||||
"--traceroute_flags=-I",
|
||||
"--traceroute-raw=true",
|
||||
"--traceroute_max_concurrent=15",
|
||||
},
|
||||
expectedBin: "/bin/traceroute",
|
||||
expectedFlags: "-I",
|
||||
expectedRaw: true,
|
||||
expectedMax: 15,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
// Set up the normalizer
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
// Define flags (using dash format as the standard)
|
||||
pflag.String("traceroute-bin", "", "traceroute binary")
|
||||
pflag.String("traceroute-flags", "", "traceroute flags")
|
||||
pflag.Bool("traceroute-raw", false, "traceroute raw")
|
||||
pflag.Int("traceroute-max-concurrent", 10, "max concurrent")
|
||||
|
||||
// Parse the args
|
||||
err := pflag.CommandLine.Parse(tt.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse args: %v", err)
|
||||
}
|
||||
|
||||
// Verify values
|
||||
bin, _ := pflag.CommandLine.GetString("traceroute-bin")
|
||||
if bin != tt.expectedBin {
|
||||
t.Errorf("traceroute-bin: expected %q, got %q", tt.expectedBin, bin)
|
||||
}
|
||||
|
||||
flags, _ := pflag.CommandLine.GetString("traceroute-flags")
|
||||
if flags != tt.expectedFlags {
|
||||
t.Errorf("traceroute-flags: expected %q, got %q", tt.expectedFlags, flags)
|
||||
}
|
||||
|
||||
raw, _ := pflag.CommandLine.GetBool("traceroute-raw")
|
||||
if raw != tt.expectedRaw {
|
||||
t.Errorf("traceroute-raw: expected %v, got %v", tt.expectedRaw, raw)
|
||||
}
|
||||
|
||||
maxConcurrent, _ := pflag.CommandLine.GetInt("traceroute-max-concurrent")
|
||||
if maxConcurrent != tt.expectedMax {
|
||||
t.Errorf("traceroute-max-concurrent: expected %d, got %d", tt.expectedMax, maxConcurrent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBirdRestrictCmdsNormalization tests that bird-restrict-cmds accepts both formats
|
||||
func TestBirdRestrictCmdsNormalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "dash format true",
|
||||
args: []string{"--bird-restrict-cmds=true"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "underscore format false",
|
||||
args: []string{"--bird_restrict_cmds=false"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "mixed format true",
|
||||
args: []string{"--bird-restrict_cmds=true"},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetFlags()
|
||||
|
||||
// Set up the normalizer
|
||||
pflag.CommandLine.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
|
||||
// Define flag
|
||||
pflag.Bool("bird-restrict-cmds", true, "restrict commands")
|
||||
|
||||
// Parse the args
|
||||
err := pflag.CommandLine.Parse(tt.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse args: %v", err)
|
||||
}
|
||||
|
||||
// Verify value
|
||||
val, _ := pflag.CommandLine.GetBool("bird-restrict-cmds")
|
||||
if val != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSettings(t *testing.T) {
|
||||
resetFlags()
|
||||
parseSettings()
|
||||
resetFlags()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/shlex"
|
||||
)
|
||||
|
||||
var tracerouteSemaphore chan struct{}
|
||||
|
||||
func initTracerouteSemaphore(maxConcurrent int) {
|
||||
if maxConcurrent > 0 {
|
||||
tracerouteSemaphore = make(chan struct{}, maxConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func tracerouteArgsToString(cmd string, args []string, target []string) string {
|
||||
var cmdCombined = append([]string{cmd}, args...)
|
||||
cmdCombined = append(cmdCombined, target...)
|
||||
return strings.Join(cmdCombined, " ")
|
||||
}
|
||||
|
||||
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 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) {
|
||||
// Check concurrency limit
|
||||
if setting.tr_max_concurrent > 0 {
|
||||
select {
|
||||
case tracerouteSemaphore <- struct{}{}:
|
||||
// Successfully acquired semaphore slot
|
||||
defer func() { <-tracerouteSemaphore }()
|
||||
default:
|
||||
// Semaphore is full, reject request
|
||||
httpW.WriteHeader(http.StatusServiceUnavailable)
|
||||
httpW.Write([]byte("Too many concurrent traceroute requests. Please try again later.\n"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
skippedCounter := 0
|
||||
|
||||
if setting.tr_bin == "" {
|
||||
httpW.WriteHeader(http.StatusInternalServerError)
|
||||
httpW.Write([]byte("traceroute not supported on this node.\n"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err = tracerouteTryExecute(setting.tr_bin, setting.tr_flags, args)
|
||||
if err != nil {
|
||||
httpW.WriteHeader(http.StatusInternalServerError)
|
||||
httpW.Write([]byte(fmt.Sprintf("Error executing traceroute: %s\n\n", err.Error())))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
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."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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) {
|
||||
initTracerouteSemaphore(setting.tr_max_concurrent)
|
||||
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) {
|
||||
initTracerouteSemaphore(setting.tr_max_concurrent)
|
||||
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) {
|
||||
initTracerouteSemaphore(setting.tr_max_concurrent)
|
||||
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) {
|
||||
initTracerouteSemaphore(setting.tr_max_concurrent)
|
||||
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) {
|
||||
initTracerouteSemaphore(setting.tr_max_concurrent)
|
||||
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) {
|
||||
initTracerouteSemaphore(setting.tr_max_concurrent)
|
||||
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.")
|
||||
}
|
||||
|
||||
func TestTracerouteHandlerConcurrencyLimit(t *testing.T) {
|
||||
// Set a low limit for testing
|
||||
maxConcurrent := 2
|
||||
initTracerouteSemaphore(maxConcurrent)
|
||||
setting.tr_max_concurrent = maxConcurrent
|
||||
|
||||
// Use a slow command to keep requests running
|
||||
setting.tr_bin = "sh"
|
||||
setting.tr_flags = []string{"-c", "sleep 1; echo Done"}
|
||||
setting.tr_raw = true
|
||||
|
||||
// Run two rounds to verify semaphores are properly released
|
||||
for round := 1; round <= 2; round++ {
|
||||
t.Logf("Round %d: Testing concurrency limit", round)
|
||||
|
||||
// Launch more concurrent requests than the limit
|
||||
numRequests := 5
|
||||
responses := make(chan int, numRequests)
|
||||
|
||||
for i := 0; i < numRequests; i++ {
|
||||
go func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/traceroute?q="+url.QueryEscape("1.1.1.1"), nil)
|
||||
w := httptest.NewRecorder()
|
||||
tracerouteHandler(w, r)
|
||||
responses <- w.Code
|
||||
}()
|
||||
}
|
||||
|
||||
// Collect all responses
|
||||
statusCodes := make(map[int]int)
|
||||
for i := 0; i < numRequests; i++ {
|
||||
code := <-responses
|
||||
statusCodes[code]++
|
||||
}
|
||||
|
||||
// Verify that some requests succeeded (200) and some were rejected (503)
|
||||
if statusCodes[http.StatusOK] == 0 {
|
||||
t.Errorf("Round %d: Expected at least one request to succeed with 200", round)
|
||||
}
|
||||
if statusCodes[http.StatusServiceUnavailable] == 0 {
|
||||
t.Errorf("Round %d: Expected at least one request to be rejected with 503", round)
|
||||
}
|
||||
|
||||
// Verify we didn't get any unexpected status codes
|
||||
totalRequests := statusCodes[http.StatusOK] + statusCodes[http.StatusServiceUnavailable]
|
||||
if totalRequests != numRequests {
|
||||
t.Errorf("Round %d: Expected %d total requests, got %d", round, numRequests, totalRequests)
|
||||
}
|
||||
|
||||
t.Logf("Round %d: Got %d successful (200) and %d rejected (503) requests",
|
||||
round, statusCodes[http.StatusOK], statusCodes[http.StatusServiceUnavailable])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// vrfControl returns a Control function that binds sockets to a VRF device
|
||||
// via SO_BINDTODEVICE. Returns nil when vrfName is empty.
|
||||
func vrfControl(vrfName string) func(network, address string, c syscall.RawConn) error {
|
||||
if vrfName == "" {
|
||||
return nil
|
||||
}
|
||||
return func(network, address string, c syscall.RawConn) error {
|
||||
var sysErr error
|
||||
err := c.Control(func(fd uintptr) {
|
||||
sysErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, vrfName)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sysErr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import "syscall"
|
||||
|
||||
// vrfControl returns a Control function that binds sockets to a VRF device.
|
||||
// On non-Linux platforms, VRF is not supported; panics if vrfName is non-empty.
|
||||
func vrfControl(vrfName string) func(network, address string, c syscall.RawConn) error {
|
||||
if vrfName == "" {
|
||||
return nil
|
||||
}
|
||||
panic("VRF binding is only supported on Linux")
|
||||
}
|
||||
Reference in New Issue
Block a user