initial copy

This commit is contained in:
2026-09-03 10:26:32 +02:00
parent 2dac8221d5
commit 5b21f58a33
82 changed files with 8527 additions and 31 deletions
+16
View File
@@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: gomod
directory: "/frontend"
schedule:
interval: daily
time: "08:00"
timezone: Asia/Shanghai
open-pull-requests-limit: 10
- package-ecosystem: gomod
directory: "/proxy"
schedule:
interval: daily
time: "08:00"
timezone: Asia/Shanghai
open-pull-requests-limit: 10
+127
View File
@@ -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
+104
View File
@@ -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
View File
@@ -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
+670 -20
View File
@@ -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>.
+13
View File
@@ -0,0 +1,13 @@
frontend:
$(MAKE) -C frontend all
proxy:
$(MAKE) -C proxy all
.DEFAULT_GOAL := all
.PHONY: all frontend proxy
all: frontend proxy
install:
install -m 755 frontend/frontend /usr/local/bin/bird-lg-go
install -m 755 proxy/proxy /usr/local/bin/bird-lgproxy-go
+286 -1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
v1.4.4
+202
View File
@@ -0,0 +1,202 @@
# Bird-lg-go API documentation
The frontend provides an API for running BIRD/traceroute/whois queries.
API Endpoint: `https://your.frontend.com/api/` (the last slash must not be omitted!)
Requests are sent as POSTS with JSON bodies.
## Table of Contents
* [Bird-lg-go API documentation](#bird-lg-go-api-documentation)
* [Table of Contents](#table-of-contents)
* [Request fields](#request-fields)
* [Example request of type bird](#example-request-of-type-bird)
* [Example request of type server_list](#example-request-of-type-server_list)
* [Response fields (when type is summary)](#response-fields-when-type-is-summary)
* [Fields for apiSummaryResultPair](#fields-for-apisummaryresultpair)
* [Fields for SummaryRowData](#fields-for-summaryrowdata)
* [Example response](#example-response)
* [Response fields (when type is bird, traceroute, whois or server_list)](#response-fields-when-type-is-bird-traceroute-whois-or-server_list)
* [Fields for apiGenericResultPair](#fields-for-apigenericresultpair)
* [Example response of type bird](#example-response-of-type-bird)
* [Example response of type server_list](#example-response-of-type-server_list)
Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc)
## Request fields
| Name | Type | Value |
| ---- | ---- | -------- |
| `servers` | array of `string` | List of servers to be queried |
| `type` | `string` | Can be `summary`, `bird`, `traceroute`, `whois` or `server_list` |
| `args` | `string` | Arguments to be passed, see below |
Argument examples for each type:
- `summary`: `args` is ignored. Recommended to set to empty string.
- `bird`: `args` is the command to be passed to bird, e.g. `show route for 8.8.8.8`
- `traceroute`: `args` is the traceroute target, e.g. `8.8.8.8` or `google.com`
- `whois`: `args` is the whois target, e.g. `8.8.8.8` or `google.com`
- `server_list`: `args` is ignored. In addition, `servers` is also ignored.
### Example request of type `bird`
```json
{
"servers": [
"alpha"
],
"type": "bird",
"args": "show route for 8.8.8.8"
}
```
### Example request of type `server_list`
```json
{
"servers": [],
"type": "server_list",
"args": ""
}
```
## Response fields (when `type` is `summary`)
| Name | Type | Value |
| ---- | ---- | -------- |
| `error` | `string` | Error message when something is wrong. Empty when everything is good |
| `result` | array of `apiSummaryResultPair` | See below |
### Fields for `apiSummaryResultPair`
| Name | Type | Value |
| ---- | ---- | -------- |
| `server` | `string` | Name of the server |
| `data` | array of `SummaryRowData` | Summaries of the server, see below |
### Fields for `SummaryRowData`
All fields below is 1:1 correspondent to the output of `birdc show protocols`.
| Name | Type |
| ---- | ---- |
| `name` | `string` |
| `proto` | `string` |
| `table` | `string` |
| `state` | `string` |
| `since` | `string` |
| `info` | `string` |
### Example response
Request:
```json
{
"servers": [
"alpha"
],
"type": "summary",
"args": ""
}
```
Response:
```json
{
"error": "",
"result": [
{
"server": "alpha",
"data": [
{
"name": "bgp1",
"proto": "BGP",
"table": "---",
"state": "start",
"since": "2021-01-15 22:40:01",
"info": "Active Socket: Operation timed out"
},
{
"name": "bgp2",
"proto": "BGP",
"table": "---",
"state": "start",
"since": "2021-01-03 08:15:48",
"info": "Established"
}
]
}
]
}
```
## Response fields (when `type` is `bird`, `traceroute`, `whois` or `server_list`)
| Name | Type | Value |
| ---- | ---- | -------- |
| `error` | `string` | Error message, empty when everything is good |
| `result` | array of `apiGenericResultPair` | See below |
### Fields for `apiGenericResultPair`
| Name | Type | Value |
| ---- | ---- | -------- |
| `server` | `string` | Name of the server; is empty when type is `whois` |
| `data` | `string` | Result from the server; is empty when type is `server_list` |
### Example response of type `bird`
Request:
```json
{
"servers": [
"alpha"
],
"type": "bird",
"args": "show status"
}
```
Response:
```json
{
"error": "",
"result": [
{
"server": "alpha",
"data": "BIRD v2.0.7-137-g61dae32b\nRouter ID is 1.2.3.4\nCurrent server time is 2021-01-17 04:21:14.792\nLast reboot on 2021-01-03 08:15:48.494\nLast reconfiguration on 2021-01-17 00:49:10.573\nDaemon is up and running\n"
}
]
}
```
### Example response of type `server_list`
Request:
```json
{
"servers": [],
"type": "server_list",
"args": ""
}
```
Response:
```json
{
"error": "",
"result": [
{
"server": "gigsgigscloud",
"data": ""
}
]
}
```
+22
View File
@@ -0,0 +1,22 @@
# Telegram Bot Webhook
The frontend can act as a Telegram Bot webhook endpoint, to add BGP route/traceroute/whois lookup functionality to your tech group.
There is no configuration necessary on the frontend, just start it up normally.
Set your Telegram Bot webhook URL to `https://your.frontend.com/telegram/alpha+beta+gamma`, where `alpha+beta+gamma` is the list of servers to be queried on Telegram commands, separated by `+`.
You may omit `alpha+beta+gamma` to use all your servers, but it is not recommended when you have lots of servers, or the message would be too long and hard to read.
## Example of setting the webhook
```bash
curl "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook?url=https://your.frontend.com:5000/telegram/alpha+beta+gamma"
```
## Supported commands
- `path`: Show bird's ASN path to target IP
- `route`: Show bird's preferred route to target IP
- `trace`: Traceroute to target IP/domain
- `whois`: Whois query
+33
View File
@@ -0,0 +1,33 @@
FROM golang AS step_0
ENV CGO_ENABLED=0 GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -ldflags "-w -s" -o /frontend
################################################################################
FROM alpine:edge AS step_1
WORKDIR /root
RUN apk add --no-cache build-base pkgconf perl gettext \
libidn2-dev libidn2-static libunistring-dev libunistring-static gnu-libiconv-dev
RUN wget https://github.com/rfc1036/whois/archive/refs/tags/v5.5.18.tar.gz \
-O whois-5.5.18.tar.gz
RUN tar xvf whois-5.5.18.tar.gz \
&& cd whois-5.5.18 \
&& sed -i "s/#if defined _POSIX_C_SOURCE && _POSIX_C_SOURCE >= 200112L/#if 1/g" config.h \
&& make whois -j4 \
LDFLAGS="-static" CONFIG_FILE="/etc/whois.conf" PKG_CONFIG="pkg-config --static" HAVE_ICONV=1 \
&& strip /root/whois-5.5.18/whois
################################################################################
FROM scratch AS step_2
ENV PATH=/
ENV BIRDLG_WHOIS=/whois
COPY --from=step_0 /frontend /
COPY --from=step_1 /root/whois-5.5.18/whois /
COPY --from=step_1 /etc/services /etc/services
ENTRYPOINT ["/frontend"]
+43
View File
@@ -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
View File
@@ -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)
}
+211
View File
@@ -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)
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"fmt"
"net"
"strings"
)
type ASNCache map[string]string
func (cache ASNCache) _lookup(asn string) string {
// Try to get ASN representation using DNS
if setting.dnsInterface != "" {
records, err := net.LookupTXT(fmt.Sprintf("AS%s.%s", asn, setting.dnsInterface))
if err == nil {
result := strings.Join(records, " ")
if resultSplit := strings.Split(result, " | "); len(resultSplit) > 1 {
result = strings.Join(resultSplit[1:], "\n")
}
return fmt.Sprintf("AS%s\n%s", asn, result)
}
}
// Try to get ASN representation using WHOIS
if setting.whoisServer != "" {
if setting.bgpmapInfo == "" {
setting.bgpmapInfo = "asn,as-name,ASName,descr"
}
records := whois(fmt.Sprintf("AS%s", asn))
if records != "" {
recordsSplit := strings.Split(records, "\n")
var result []string
for _, title := range strings.Split(setting.bgpmapInfo, ",") {
if title == "asn" {
result = append(result, "AS"+asn)
}
}
for _, title := range strings.Split(setting.bgpmapInfo, ",") {
allow_multiline := false
if title[0] == ':' && len(title) >= 2 {
title = title[1:]
allow_multiline = true
}
for _, line := range recordsSplit {
if len(line) == 0 || line[0] == '%' || !strings.Contains(line, ":") {
continue
}
linearr := strings.SplitN(line, ":", 2)
line_title := linearr[0]
content := strings.TrimSpace(linearr[1])
if line_title != title {
continue
}
result = append(result, content)
if !allow_multiline {
break
}
}
}
if len(result) > 0 {
return strings.Join(result, "\n")
}
}
}
return ""
}
func (cache ASNCache) Lookup(asn string) string {
cachedValue, cacheOk := cache[asn]
if cacheOk {
return cachedValue
}
result := cache._lookup(asn)
if len(result) == 0 {
result = fmt.Sprintf("AS%s", asn)
}
cache[asn] = result
return result
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"strings"
"testing"
"github.com/magiconair/properties/assert"
)
func TestGetASNRepresentationDNS(t *testing.T) {
checkNetwork(t)
setting.dnsInterface = "asn.cymru.com"
setting.whoisServer = ""
cache := make(ASNCache)
result := cache.Lookup("6939")
if !strings.Contains(result, "HURRICANE") {
t.Errorf("Lookup AS6939 failed, got %s", result)
}
}
func TestGetASNRepresentationDNSFallback(t *testing.T) {
checkNetwork(t)
setting.dnsInterface = "invalid.example.com"
setting.whoisServer = "whois.arin.net"
cache := make(ASNCache)
result := cache.Lookup("6939")
if !strings.Contains(result, "HURRICANE") {
t.Errorf("Lookup AS6939 failed, got %s", result)
}
}
func TestGetASNRepresentationWhois(t *testing.T) {
checkNetwork(t)
setting.dnsInterface = ""
setting.whoisServer = "whois.arin.net"
cache := make(ASNCache)
result := cache.Lookup("6939")
if !strings.Contains(result, "HURRICANE") {
t.Errorf("Lookup AS6939 failed, got %s", result)
}
}
func TestGetASNRepresentationFallback(t *testing.T) {
setting.dnsInterface = ""
setting.whoisServer = ""
cache := make(ASNCache)
result := cache.Lookup("6939")
assert.Equal(t, result, "AS6939")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+2
View File
@@ -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
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
+73
View File
@@ -0,0 +1,73 @@
// adapted from https://stackoverflow.com/a/57080195
document.querySelectorAll('table.sortable')
.forEach((table)=> {
table.querySelectorAll('th')
.forEach((element, columnNo) => {
element.addEventListener('click', event => {
if(element.classList.contains('ascSorted')) {
dir = -1;
element.classList.remove('ascSorted');
element.classList.add('descSorted');
element.innerText = element.innerText.slice(0,-2) + " ↓";
} else if(element.classList.contains('descSorted')) {
dir = 1;
element.classList.remove('descSorted');
element.classList.add('ascSorted');
element.innerText = element.innerText.slice(0,-2) + " ↑";
} else {
dir = 1;
element.classList.add('ascSorted');
element.innerText += " ↑";
}
sortTable(table, columnNo, 0, dir, 1);
});
});
});
function sortTable(table, priCol, secCol, priDir, secDir) {
const tableBody = table.querySelector('tbody');
const tableData = table2data(tableBody);
tableData.sort((a, b) => {
if(a[priCol] === b[priCol]) {
if(a[secCol] > b[secCol]) {
return secDir;
} else {
return -secDir;
}
} else if(a[priCol] > b[priCol]) {
return priDir;
} else {
return -priDir;
}
});
data2table(tableBody, tableData);
}
function table2data(tableBody) {
const tableData = [];
tableBody.querySelectorAll('tr')
.forEach(row => {
const rowData = [];
row.querySelectorAll('td')
.forEach(cell => {
rowData.push(cell.innerHTML);
});
rowData.classList = row.classList.toString();
tableData.push(rowData);
});
return tableData;
}
function data2table(tableBody, tableData) {
tableBody.querySelectorAll('tr')
.forEach((row, i) => {
const rowData = tableData[i];
row.classList = rowData.classList;
row.querySelectorAll('td')
.forEach((cell, j) => {
cell.innerHTML = rowData[j];
});
tableData.push(rowData);
});
}
+27
View File
@@ -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>
+13
View File
@@ -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>
+549
View File
@@ -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">&raquo;</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">&times;</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 += ' &mdash; <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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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>
+81
View File
@@ -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 -}}
&mdash;
{{- 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 -}}&mdash;{{- 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 -}}&mdash;{{- 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 -}}&mdash;{{- end -}}
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
+2
View File
@@ -0,0 +1,2 @@
<h2>whois {{ html .Target }}</h2>
{{ .Result }}
+121
View File
@@ -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()
}
+178
View File
@@ -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"
}
+88
View File
@@ -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")
}
}
+77
View File
@@ -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()
}
}()
}
+101
View File
@@ -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
}
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"testing"
)
func TestDN42WhoisFilter(t *testing.T) {
input := "name: Testing\ndescr: Description"
result := dn42WhoisFilter(input)
expectedResult := `name: Testing
1 line(s) skipped.
`
if result != expectedResult {
t.Errorf("Output doesn't match expected: %s", result)
}
}
func TestDN42WhoisFilterUnneeded(t *testing.T) {
input := "name: Testing\nwhatever: Description"
result := dn42WhoisFilter(input)
if result != input+"\n" {
t.Errorf("Output doesn't match expected: %s", result)
}
}
func TestShortenWhoisFilterShorterMode(t *testing.T) {
input := `
Information line that will be removed
# Comment that will be removed
Name: Redacted for privacy
Descr: This is a vvvvvvvvvvvvvvvvvvvvvvveeeeeeeeeeeeeeeeeeeerrrrrrrrrrrrrrrrrrrrrrrryyyyyyyyyyyyyyyyyyy long line that will be skipped.
Looooooooooooooooooooooong key: this line will be skipped.
Preserved1: this line isn't removed.
Preserved2: this line isn't removed.
Preserved3: this line isn't removed.
Preserved4: this line isn't removed.
Preserved5: this line isn't removed.
`
result := shortenWhoisFilter(input)
expectedResult := `Preserved1: this line isn't removed.
Preserved2: this line isn't removed.
Preserved3: this line isn't removed.
Preserved4: this line isn't removed.
Preserved5: this line isn't removed.
3 line(s) skipped.
`
if result != expectedResult {
t.Errorf("Output doesn't match expected: %s", result)
}
}
func TestShortenWhoisFilterLongerMode(t *testing.T) {
input := `
Information line that will be removed
# Comment that will be removed
Name: Redacted for privacy
Descr: This is a vvvvvvvvvvvvvvvvvvvvvvveeeeeeeeeeeeeeeeeeeerrrrrrrrrrrrrrrrrrrrrrrryyyyyyyyyyyyyyyyyyy long line that will be skipped.
Looooooooooooooooooooooong key: this line will be skipped.
Preserved1: this line isn't removed.
`
result := shortenWhoisFilter(input)
expectedResult := `Information line that will be removed
Descr: This is a vvvvvvvvvvvvvvvvvvvvvvveeeeeeeeeeeeeeeeeeeerrrrrrrrrrrrrrrrrrrrrrrryyyyyyyyyyyyyyyyyyy long line that will be skipped.
Looooooooooooooooooooooong key: this line will be skipped.
Preserved1: this line isn't removed.
7 line(s) skipped.
`
if result != expectedResult {
t.Errorf("Output doesn't match expected: %s", result)
}
}
func TestShortenWhoisFilterSkipNothing(t *testing.T) {
input := `Preserved1: this line isn't removed.
Preserved2: this line isn't removed.
Preserved3: this line isn't removed.
Preserved4: this line isn't removed.
Preserved5: this line isn't removed.
`
result := shortenWhoisFilter(input)
if result != input {
t.Errorf("Output doesn't match expected: %s", result)
}
}
+27
View File
@@ -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
)
+59
View File
@@ -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=
+111
View File
@@ -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
}
+205
View File
@@ -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")
}
}
+68
View File
@@ -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 {}
}
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"net"
"testing"
"time"
)
const (
NETWORK_UNKNOWN = 0
NETWORK_DOWN = 1
NETWORK_UP = 2
)
var networkState int = NETWORK_UNKNOWN
func checkNetwork(t *testing.T) {
if networkState == NETWORK_UNKNOWN {
conn, err := net.DialTimeout("tcp", "8.8.8.8:53", 1*time.Second)
if err != nil {
networkState = NETWORK_DOWN
} else {
networkState = NETWORK_UP
conn.Close()
}
}
if networkState == NETWORK_DOWN {
t.Skipf("Test skipped for network error")
}
}
+118
View File
@@ -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
}
+80
View File
@@ -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")
}
}
+216
View File
@@ -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())
}
+157
View File
@@ -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 = ""
})
}
+180
View File
@@ -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)
}
+540
View File
@@ -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()
}
+164
View File
@@ -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)
}
+367
View File
@@ -0,0 +1,367 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/jarcoal/httpmock"
"github.com/magiconair/properties/assert"
)
func doTestTelegramIsCommand(t *testing.T, message string, command string, expected bool) {
result := telegramIsCommand(message, command)
assert.Equal(t, result, expected)
}
func mockTelegramCall(t *testing.T, msg string, raw bool) string {
return mockTelegramEndpointCall(t, "/telegram/", msg, raw)
}
func mockTelegramEndpointCall(t *testing.T, endpoint string, msg string, raw bool) string {
request := tgWebhookRequest{
Message: tgMessage{
MessageID: 123,
Chat: tgChat{
ID: 456,
},
Text: msg,
},
}
requestJson, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
requestBody := bytes.NewReader(requestJson)
r := httptest.NewRequest(http.MethodGet, endpoint, requestBody)
w := httptest.NewRecorder()
webHandlerTelegramBot(w, r)
if raw {
return w.Body.String()
} else {
var response tgWebhookResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Error(err)
}
assert.Equal(t, response.ChatID, request.Message.Chat.ID)
assert.Equal(t, response.ReplyToMessageID, request.Message.MessageID)
return response.Text
}
}
func TestTelegramIsCommand(t *testing.T) {
setting.telegramBotName = "test_bot"
// Recognize command
doTestTelegramIsCommand(t, "/trace", "trace", true)
doTestTelegramIsCommand(t, "/trace", "trace1234", false)
doTestTelegramIsCommand(t, "/trace", "tra", false)
doTestTelegramIsCommand(t, "/trace", "abcdefg", false)
// Recognize command with parameters
doTestTelegramIsCommand(t, "/trace google.com", "trace", true)
doTestTelegramIsCommand(t, "/trace google.com", "trace1234", false)
doTestTelegramIsCommand(t, "/trace google.com", "tra", false)
doTestTelegramIsCommand(t, "/trace google.com", "abcdefg", false)
// Recognize command with bot name
doTestTelegramIsCommand(t, "/trace@test_bot", "trace", true)
doTestTelegramIsCommand(t, "/trace@test_bot", "trace1234", false)
doTestTelegramIsCommand(t, "/trace@test_bot", "tra", false)
doTestTelegramIsCommand(t, "/trace@test_bot", "abcdefg", false)
doTestTelegramIsCommand(t, "/trace@test_bot_123", "trace", false)
doTestTelegramIsCommand(t, "/trace@test_", "trace", false)
// Recognize command with bot name and parameters
doTestTelegramIsCommand(t, "/trace@test_bot google.com", "trace", true)
doTestTelegramIsCommand(t, "/trace@test_bot google.com", "trace1234", false)
doTestTelegramIsCommand(t, "/trace@test_bot google.com", "tra", false)
doTestTelegramIsCommand(t, "/trace@test_bot google.com", "abcdefg", false)
doTestTelegramIsCommand(t, "/trace@test_bot_123 google.com", "trace", false)
doTestTelegramIsCommand(t, "/trace@test google.com", "trace", false)
}
func TestTelegramBatchRequestFormatSingleServer(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock")
httpmock.RegisterResponder("GET", "http://alpha:8000/mock?q=cmd", httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
result := telegramBatchRequestFormat(setting.servers, "mock", "cmd", telegramDefaultPostProcess)
expected := "Mock\n\n"
assert.Equal(t, result, expected)
}
func TestTelegramBatchRequestFormatMultipleServers(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock")
httpmock.RegisterResponder("GET", "http://alpha:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://beta:8000/mock?q=cmd", httpResponse)
httpmock.RegisterResponder("GET", "http://gamma:8000/mock?q=cmd", httpResponse)
setting.servers = []string{
"alpha",
"beta",
"gamma",
}
setting.domain = ""
setting.proxyPort = 8000
result := telegramBatchRequestFormat(setting.servers, "mock", "cmd", telegramDefaultPostProcess)
expected := "alpha\nMock\n\nbeta\nMock\n\ngamma\nMock\n\n"
assert.Equal(t, result, expected)
}
func TestWebHandlerTelegramBotBadJSON(t *testing.T) {
requestBody := strings.NewReader("{bad json}")
r := httptest.NewRequest(http.MethodGet, "/telegram/", requestBody)
w := httptest.NewRecorder()
webHandlerTelegramBot(w, r)
response := w.Body.String()
assert.Equal(t, response, "")
}
func TestWebHandlerTelegramBotTrace(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock Response")
httpmock.RegisterResponder("GET", "http://alpha:8000/traceroute?q=1.1.1.1", httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
response := mockTelegramCall(t, "/trace 1.1.1.1", false)
assert.Equal(t, response, "```\nMock Response\n```")
}
func TestWebHandlerTelegramBotTraceWithServerList(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock Response")
httpmock.RegisterResponder("GET", "http://alpha:8000/traceroute?q=1.1.1.1", httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
response := mockTelegramEndpointCall(t, "/telegram/alpha", "/trace 1.1.1.1", false)
assert.Equal(t, response, "```\nMock Response\n```")
}
func TestWebHandlerTelegramBotRoute(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "Mock Response")
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show route for 1.1.1.1 primary"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
response := mockTelegramCall(t, "/route 1.1.1.1", false)
assert.Equal(t, response, "```\nMock Response\n```")
}
func TestWebHandlerTelegramBotPath(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, `
BGP.as_path: 123 456
`)
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show route for 1.1.1.1 all primary"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
response := mockTelegramCall(t, "/path 1.1.1.1", false)
assert.Equal(t, response, "```\n123 456\n```")
}
func TestWebHandlerTelegramBotPathMissing(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpResponse := httpmock.NewStringResponder(200, "No path in this response")
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show route for 1.1.1.1 all primary"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
response := mockTelegramCall(t, "/path 1.1.1.1", false)
assert.Equal(t, response, "```\nempty result\n```")
}
func TestWebHandlerTelegramBotWhois(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: AS6939Response,
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = ""
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois AS6939", false)
assert.Equal(t, response, "```"+server.response+"```")
}
func TestWebHandlerTelegramBotWhoisDN42Mode(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS4242422547",
response: `
Query for AS4242422547
`,
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = "dn42"
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois 2547", false)
assert.Equal(t, response, "```"+server.response+"```")
}
func TestWebHandlerTelegramBotWhoisDN42ModeFullASN(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS4242422547",
response: `
Query for AS4242422547
`,
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = "dn42"
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois 4242422547", false)
assert.Equal(t, response, "```"+server.response+"```")
}
func TestWebHandlerTelegramBotWhoisShortenMode(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: `
Information line that will be removed
# Comment that will be removed
Name: Redacted for privacy
Descr: This is a vvvvvvvvvvvvvvvvvvvvvvveeeeeeeeeeeeeeeeeeeerrrrrrrrrrrrrrrrrrrrrrrryyyyyyyyyyyyyyyyyyy long line that will be skipped.
Looooooooooooooooooooooong key: this line will be skipped.
Preserved1: this line isn't removed.
Preserved2: this line isn't removed.
Preserved3: this line isn't removed.
Preserved4: this line isn't removed.
Preserved5: this line isn't removed.
`,
}
expectedResult := `Preserved1: this line isn't removed.
Preserved2: this line isn't removed.
Preserved3: this line isn't removed.
Preserved4: this line isn't removed.
Preserved5: this line isn't removed.
3 line(s) skipped.`
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = "shorten"
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois AS6939", false)
assert.Equal(t, response, "```\n"+expectedResult+"\n```")
}
func TestWebHandlerTelegramBotHelp(t *testing.T) {
response := mockTelegramCall(t, "/help", false)
if !strings.Contains(response, "/trace") {
t.Error("Did not get help message")
}
}
func TestWebHandlerTelegramBotUnknownCommand(t *testing.T) {
response := mockTelegramCall(t, "/nonexistent", true)
assert.Equal(t, response, "")
}
func TestWebHandlerTelegramBotNotCommand(t *testing.T) {
response := mockTelegramCall(t, "random chat message", true)
assert.Equal(t, response, "")
}
func TestWebHandlerTelegramBotEmptyResponse(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: "",
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = ""
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois AS6939", false)
assert.Equal(t, response, "```\nempty result\n```")
}
func TestWebHandlerTelegramBotTruncateLongResponse(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: strings.Repeat("A", 65536),
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = ""
setting.whoisServer = server.server.Addr().String()
response := mockTelegramCall(t, "/whois AS6939", false)
assert.Equal(t, response, "```\n"+strings.Repeat("A", 4096)+"\n```")
}
+215
View File
@@ -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
}
}
+89
View File
@@ -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")
}
+155
View File
@@ -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
+27
View File
@@ -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
}
}
+14
View File
@@ -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")
}
+257
View File
@@ -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)
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/jarcoal/httpmock"
"github.com/magiconair/properties/assert"
)
func TestServerError(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/error", nil)
w := httptest.NewRecorder()
serverError(w, r)
assert.Equal(t, w.Code, http.StatusInternalServerError)
}
func TestWebHandlerWhois(t *testing.T) {
server := WhoisServer{
t: t,
expectedQuery: "AS6939",
response: AS6939Response,
}
server.Listen()
go server.Run()
defer server.Close()
setting.netSpecificMode = ""
setting.whoisServer = server.server.Addr().String()
r := httptest.NewRequest(http.MethodGet, "/whois/AS6939", nil)
w := httptest.NewRecorder()
webHandlerWhois(w, r)
assert.Equal(t, w.Code, http.StatusOK)
if !strings.Contains(w.Body.String(), "HURRICANE") {
t.Error("Body does not contain whois result")
}
}
func TestWebBackendCommunicator(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
input := readDataFile(t, "frontend/test_data/bgpmap_case1.txt")
httpResponse := httpmock.NewStringResponder(200, input)
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show route for 1.1.1.1 all"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
setting.dnsInterface = ""
setting.whoisServer = ""
r := httptest.NewRequest(http.MethodGet, "/route_bgpmap/alpha/1.1.1.1", nil)
w := httptest.NewRecorder()
handler := webBackendCommunicator("bird", "route_all")
handler(w, r)
assert.Equal(t, w.Code, http.StatusOK)
}
func TestWebHandlerBGPMap(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
input := readDataFile(t, "frontend/test_data/bgpmap_case1.txt")
httpResponse := httpmock.NewStringResponder(200, input)
httpmock.RegisterResponder("GET", "http://alpha:8000/bird?q="+url.QueryEscape("show route for 1.1.1.1 all"), httpResponse)
setting.servers = []string{"alpha"}
setting.domain = ""
setting.proxyPort = 8000
setting.dnsInterface = ""
setting.whoisServer = ""
r := httptest.NewRequest(http.MethodGet, "/route_bgpmap/alpha/1.1.1.1", nil)
w := httptest.NewRecorder()
handler := webHandlerBGPMap("bird", "route_bgpmap")
handler(w, r)
assert.Equal(t, w.Code, http.StatusOK)
}
+71
View File
@@ -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])
}
}
+224
View File
@@ -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)
}
}
+28
View File
@@ -0,0 +1,28 @@
FROM golang AS step_0
ENV CGO_ENABLED=0 GO111MODULE=on
WORKDIR /root
COPY . .
RUN go build -ldflags "-w -s" -o /proxy
################################################################################
FROM alpine:edge AS step_1
WORKDIR /root
RUN apk add --no-cache build-base linux-headers
RUN wget https://sourceforge.net/projects/traceroute/files/traceroute/traceroute-2.1.3/traceroute-2.1.3.tar.gz/download \
-O traceroute-2.1.3.tar.gz
RUN tar xvf traceroute-2.1.3.tar.gz \
&& cd traceroute-2.1.3 \
&& make -j4 LDFLAGS="-static" \
&& strip /root/traceroute-2.1.3/traceroute/traceroute
################################################################################
FROM scratch AS step_2
ENV PATH=/
COPY --from=step_0 /proxy /
COPY --from=step_1 /root/traceroute-2.1.3/traceroute/traceroute /
ENTRYPOINT ["/proxy"]
+31
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
.PHONY: all
all: $(shell find . -name \*.go -type f)
go build -ldflags "-w -s" -o proxy
+113
View File
@@ -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) {
}
}
}
+358
View File
@@ -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")
}
+26
View File
@@ -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
)
+55
View File
@@ -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
View File
@@ -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 {}
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/magiconair/properties/assert"
)
func TestHasAccessNotConfigured(t *testing.T) {
setting.allowedNets = []*net.IPNet{}
assert.Equal(t, hasAccess("whatever"), true)
}
func TestHasAccessAllowIPv4(t *testing.T) {
_, netip, _ := net.ParseCIDR("1.2.3.4/32")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("1.2.3.4:4321"), true)
}
func TestHasAccessAllowIPv4Net(t *testing.T) {
_, netip, _ := net.ParseCIDR("1.2.3.0/24")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("1.2.3.4:4321"), true)
}
func TestHasAccessDenyIPv4(t *testing.T) {
_, netip, _ := net.ParseCIDR("4.3.2.1/32")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("1.2.3.4:4321"), false)
}
func TestHasAccessAllowIPv6(t *testing.T) {
_, netip, _ := net.ParseCIDR("2001:db8::1/128")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("[2001:db8::1]:4321"), true)
}
func TestHasAccessAllowIPv6Net(t *testing.T) {
_, netip, _ := net.ParseCIDR("2001:db8::/64")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("[2001:db8::1]:4321"), true)
}
func TestHasAccessAllowIPv6DifferentForm(t *testing.T) {
_, netip, _ := net.ParseCIDR("2001:db8::1/128")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("[2001:db8::1]:4321"), true)
}
func TestHasAccessDenyIPv6(t *testing.T) {
_, netip, _ := net.ParseCIDR("2001:db8::2/128")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("[2001:db8::1]:4321"), false)
}
func TestHasAccessBadClientIP(t *testing.T) {
_, netip, _ := net.ParseCIDR("1.2.3.4/32")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("not an IP"), false)
}
func TestHasAccessBadClientIPPort(t *testing.T) {
_, netip, _ := net.ParseCIDR("1.2.3.4/32")
setting.allowedNets = []*net.IPNet{netip}
assert.Equal(t, hasAccess("not an IP:not a port"), false)
}
func TestAccessHandlerAllow(t *testing.T) {
baseHandler := http.NotFoundHandler()
wrappedHandler := accessHandler(baseHandler)
r := httptest.NewRequest(http.MethodGet, "/mock", nil)
r.RemoteAddr = "1.2.3.4:4321"
w := httptest.NewRecorder()
_, netip, _ := net.ParseCIDR("1.2.3.4/32")
setting.allowedNets = []*net.IPNet{netip}
wrappedHandler.ServeHTTP(w, r)
assert.Equal(t, w.Code, http.StatusNotFound)
}
func TestAccessHandlerDeny(t *testing.T) {
baseHandler := http.NotFoundHandler()
wrappedHandler := accessHandler(baseHandler)
r := httptest.NewRequest(http.MethodGet, "/mock", nil)
r.RemoteAddr = "1.2.3.4:4321"
w := httptest.NewRecorder()
_, netip, _ := net.ParseCIDR("4.3.2.1/32")
setting.allowedNets = []*net.IPNet{netip}
wrappedHandler.ServeHTTP(w, r)
assert.Equal(t, w.Code, http.StatusInternalServerError)
}
+127
View File
@@ -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)
}
+233
View File
@@ -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()
}
+152
View File
@@ -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."))
}
}
}
}
}
+228
View File
@@ -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])
}
}
+27
View File
@@ -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
}
}
+14
View File
@@ -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")
}