Web UI, packaging, systemd units, installer scripts, namespace integration tests

React/TS analyser (virtualised packet list, protocol tree, verified hex mapping),
setup wizard, network/devices/health/library/settings pages. Installer with
backup and rollback, uninstall/repair/diagnose. Docker+netns integration suite
covering DHCP, DNS, NAT, isolation, IPv6 blocking, capture and export.
This commit is contained in:
Andy Dixon
2026-10-09 17:56:02 +01:00
parent 31e39bdbef
commit 01b568aaf7
38 changed files with 4897 additions and 1 deletions
+2
View File
@@ -3,3 +3,5 @@
/web/dist/*
!/web/dist/.gitkeep
*.test
*.test
/.testbin/
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
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.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are 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.
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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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 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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
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 AGPL, see
<https://www.gnu.org/licenses/>.
+46
View File
@@ -0,0 +1,46 @@
# PiLens build. Produces a self-contained arm64 package; nothing is compiled on the Pi.
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOARCH ?= arm64
PKG := pilens-$(VERSION)-linux-$(GOARCH)
LDFLAGS := -s -w -X main.Version=$(VERSION)
GO := CGO_ENABLED=0 GOOS=linux GOARCH=$(GOARCH) go
.PHONY: all web build package test test-docker test-netns clean
all: package
web:
cd web && npm ci --no-audit --no-fund && npm run build
touch web/dist/.gitkeep
build: web
mkdir -p dist/$(PKG)/bin
$(GO) build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(PKG)/bin/pilens ./cmd/pilens
$(GO) build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(PKG)/bin/pilens-helper ./cmd/pilens-helper
package: build
cp -r systemd packaging/setup.example.yaml dist/$(PKG)/
cp scripts/*.sh dist/$(PKG)/
cp -r docs README.md LICENSE dist/$(PKG)/
echo $(VERSION) > dist/$(PKG)/VERSION
cd dist && tar --owner=0 --group=0 -czf $(PKG).tar.gz $(PKG) && sha256sum $(PKG).tar.gz > $(PKG).tar.gz.sha256
@echo "package: dist/$(PKG).tar.gz"
# Unit tests that need no external tools.
test:
go vet ./...
go test ./...
cd web && npx tsc --noEmit
# Capture/tshark and namespace tests inside a Debian trixie container (mirrors Raspberry Pi OS).
test-docker:
docker build -q -t pilens-test tests/integration
mkdir -p .testbin
for p in capture api pcapfile; do CGO_ENABLED=0 go test -c -o .testbin/$$p.test ./internal/$$p; done
CGO_ENABLED=0 go build -o .testbin/pilens-helper ./cmd/pilens-helper
CGO_ENABLED=0 go build -o .testbin/pilens ./cmd/pilens
docker run --rm --cap-add NET_ADMIN --cap-add SYS_ADMIN --security-opt apparmor=unconfined \
-e PILENS_LIVE_TEST=1 -v $(CURDIR):/src:ro -v $(CURDIR)/.testbin:/t:ro pilens-test /src/tests/integration/run.sh
clean:
rm -rf dist .testbin web/node_modules
+2 -1
View File
@@ -12,6 +12,7 @@ import (
type Node struct {
Label string `json:"label"`
Field string `json:"field,omitempty"`
Show string `json:"show,omitempty"` // display value, used to build filters
Pos int `json:"pos"`
Size int `json:"size"`
Verified bool `json:"verified"` // bytes at pos..pos+size are proven to belong to this field
@@ -60,7 +61,7 @@ func ParsePDML(b []byte, frame []byte) ([]Node, error) {
// convert returns the node and whether any field in its subtree contradicts the frame bytes.
func convert(p pdmlNode, frame []byte) (Node, bool) {
n := Node{Field: p.Name}
n := Node{Field: p.Name, Show: p.Show}
n.Label = p.Showname
if n.Label == "" {
n.Label = p.Show
+44
View File
@@ -0,0 +1,44 @@
# Non-interactive first-run configuration:
# sudo ./install.sh --non-interactive --config setup.yaml
# Delete this file afterwards if it contains a plaintext password.
authorised_use: true # required: this appliance only inspects traffic you are authorised to inspect
admin:
username: admin
password_file: /root/pilens-admin-password # or: password: "at least 12 characters"
network:
management:
interfaces: [eth0]
allowed_subnets: [192.168.1.0/24]
port: 8443
uplink: eth0
ap:
enabled: true
interface: wlan0
ssid: PiLens-Lab
passphrase: "change-me-please"
security: wpa2 # wpa2 | wpa3
band: bg # bg = 2.4 GHz, a = 5 GHz
channel: 0 # 0 = automatic
hidden: false
country: GB
client_isolation: false
lan:
address: 192.168.50.1/24
dhcp_start: 192.168.50.100
dhcp_end: 192.168.50.199
lease_minutes: 720
upstream_dns: [] # empty = the Pi's own resolvers
reservations: [] # - {mac: "aa:bb:cc:dd:ee:ff", ip: 192.168.50.20, hostname: phone}
dns_overrides: [] # - {name: api.test.example, ip: 192.168.50.1}
forwarding:
mode: internet # internet | deny
allow_cidrs: []
storage:
max_bytes: 8589934592 # 8 GiB of capture files
reserve_bytes: 1073741824 # keep 1 GiB free
segment_bytes: 67108864 # 64 MiB pcapng segments
import_bytes: 1073741824
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Collect a diagnostic bundle. Secrets (Wi-Fi passphrase, setup token, keys,
# sessions, captured packets) are excluded or redacted by default.
# sudo ./diagnose.sh -> /tmp/pilens-diagnose-<time>.tar.gz
set -uo pipefail
[ "$(id -u)" = 0 ] || { echo "run as root"; exit 1; }
TS=$(date +%Y%m%d-%H%M%S)
D=$(mktemp -d /tmp/pilens-diagnose-XXXX)
chmod 700 "$D"
run() { local f="$D/$1"; shift; { echo "\$ $*"; "$@"; } >"$f" 2>&1 || true; }
redact() { sed -E 's/("passphrase"[[:space:]]*:[[:space:]]*")[^"]*/\1REDACTED/g; s/^(psk=).*/\1REDACTED/; s/(setup_token=).*/\1REDACTED/'; }
run versions.txt sh -c 'cat /opt/pilens/VERSION; uname -a; cat /etc/os-release; tr -d "\0" </proc/device-tree/model; echo; tshark --version | head -1; nmcli --version; dnsmasq --version | head -1; nft --version'
run services.txt systemctl status --no-pager pilens pilens-helper pilens-firewall pilens-dnsmasq NetworkManager
run journal.txt journalctl --no-pager -n 3000 -u pilens -u pilens-helper -u pilens-firewall -u pilens-dnsmasq
run journal-nm.txt journalctl --no-pager -n 800 -u NetworkManager
run nft-pilens.txt nft list table inet pilens
run nft-tables.txt nft list tables
run ip.txt sh -c 'ip -br link; ip addr; ip route; ip -6 route; sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding'
run nm.txt sh -c 'nmcli general; nmcli device; nmcli connection show'
run nm-pilens-ap.txt nmcli connection show pilens-ap
run wifi.txt sh -c 'iw dev; iw reg get; iw list'
run capture.txt sh -c 'getcap /usr/bin/dumpcap; id pilens; ls -la /var/lib/pilens /var/lib/pilens-helper'
run resources.txt sh -c 'df -h; free -m; uptime; vcgencmd get_throttled; vcgencmd measure_temp; timedatectl'
cp /var/lib/pilens-helper/dnsmasq.conf "$D/dnsmasq.conf" 2>/dev/null
redact </var/lib/pilens-helper/last-good.json >"$D/network-config.json" 2>/dev/null
redact <"$D/nm-pilens-ap.txt" >"$D/x" && mv "$D/x" "$D/nm-pilens-ap.txt"
for f in "$D"/*; do redact <"$f" >"$f.r" && mv "$f.r" "$f"; done
OUT=/tmp/pilens-diagnose-$TS.tar.gz
tar -C "$D" -czf "$OUT" . && chmod 600 "$OUT"
rm -rf "$D"
echo "diagnostic bundle: $OUT"
echo "Excluded: database, captures, TLS keys, setup token. Review the bundle before you share it."
+218
View File
@@ -0,0 +1,218 @@
#!/bin/bash
# PiLens installer / upgrader.
# sudo ./install.sh interactive
# sudo ./install.sh --non-interactive --config setup.yaml
# Options: --mgmt-iface IF --mgmt-subnet CIDR[,CIDR] --yes --allow-unsupported
#
# This script does NOT reconfigure any existing network connection. The access
# point is created later, by the setup wizard (or --config), through the helper's
# transactional apply with automatic rollback.
set -euo pipefail
SRC="$(cd "$(dirname "$0")" && pwd)"
PREFIX=/opt/pilens
DATA=/var/lib/pilens
LOG=/var/log/pilens-install.log
NONINT=0; CONFIG=""; YES=0; ALLOW=0; MGMT_IF=""; MGMT_NET=""
while [ $# -gt 0 ]; do
case "$1" in
--non-interactive) NONINT=1; YES=1 ;;
--config) CONFIG="$2"; shift ;;
--yes|-y) YES=1 ;;
--allow-unsupported) ALLOW=1 ;;
--mgmt-iface) MGMT_IF="$2"; shift ;;
--mgmt-subnet) MGMT_NET="$2"; shift ;;
-h|--help) sed -n '2,9p' "$0"; exit 0 ;;
*) echo "unknown option $1"; exit 2 ;;
esac
shift
done
c_b=$'\e[1m'; c_y=$'\e[33m'; c_r=$'\e[31m'; c_g=$'\e[32m'; c_0=$'\e[0m'
say() { echo "${c_b}==>${c_0} $*"; echo "[$(date -Is)] $*" >>"$LOG"; }
warn() { echo "${c_y}warning:${c_0} $*"; echo "[$(date -Is)] WARN $*" >>"$LOG"; }
die() { echo "${c_r}error:${c_0} $*" >&2; echo "[$(date -Is)] ERROR $*" >>"$LOG"; exit 1; }
confirm() { [ "$YES" = 1 ] && return 0; read -r -p "$1 [y/N] " a; [[ "$a" =~ ^[Yy] ]]; }
[ "$(id -u)" = 0 ] || die "run as root: sudo $0"
[ -x "$SRC/bin/pilens" ] && [ -x "$SRC/bin/pilens-helper" ] || die "binaries not found in $SRC/bin (run from the extracted release package)"
touch "$LOG"; chmod 600 "$LOG"
# ---------------------------------------------------------------- 1. platform
say "Checking platform"
. /etc/os-release
ARCH=$(uname -m)
SUPPORTED=1
[ "$ARCH" = aarch64 ] || { warn "architecture $ARCH (expected aarch64)"; SUPPORTED=0; }
case "${VERSION_CODENAME:-}" in bookworm|trixie) ;; *) warn "OS ${PRETTY_NAME:-unknown} is not a tested Raspberry Pi OS / Debian release"; SUPPORTED=0 ;; esac
[ $SUPPORTED = 1 ] || [ $ALLOW = 1 ] || die "unsupported platform (use --allow-unsupported to continue anyway)"
MODEL=$(tr -d '\0' </proc/device-tree/model 2>/dev/null || echo "unknown")
echo " ${PRETTY_NAME} · ${ARCH} · ${MODEL}"
systemctl is-active --quiet NetworkManager || die "NetworkManager is not running. PiLens requires NetworkManager (default on Raspberry Pi OS Bookworm and later)."
echo " NetworkManager $(nmcli --version | awk '{print $NF}')"
# ---------------------------------------------------------------- 2. interfaces and management
say "Detecting interfaces"
UPLINK=$(ip -4 route show default | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')
SSH_IP=$(echo "${SSH_CONNECTION:-}" | awk '{print $1}')
SSH_LOCAL=$(echo "${SSH_CONNECTION:-}" | awk '{print $3}')
SSH_IF=""
[ -n "$SSH_LOCAL" ] && SSH_IF=$(ip -o -4 addr show | awk -v a="$SSH_LOCAL" '{split($4,x,"/"); if(x[1]==a) print $2}')
WIFI=$(for d in /sys/class/net/*; do [ -d "$d/wireless" ] || [ -e "$d/phy80211" ] && basename "$d"; done | head -1 || true)
ip -br addr | grep -v '^lo' | sed 's/^/ /'
echo " default route: ${UPLINK:-none} wifi: ${WIFI:-none} your SSH session: ${SSH_IP:-local console}${SSH_IF:+ via $SSH_IF}"
if [ -n "$SSH_IF" ] && [ "$SSH_IF" = "$WIFI" ]; then
warn "your SSH session arrives over $WIFI. That interface becomes the access point in the setup wizard, which will disconnect this session."
warn "reconnect over Ethernet before running the wizard. Management must use a wired interface."
MGMT_IF=${MGMT_IF:-$UPLINK}
fi
MGMT_IF=${MGMT_IF:-${SSH_IF:-$UPLINK}}
[ -n "$MGMT_IF" ] || die "could not determine a management interface; pass --mgmt-iface"
[ "$MGMT_IF" != "$WIFI" ] || die "the management interface cannot be the Wi-Fi interface"
if [ -z "$MGMT_NET" ]; then
MGMT_NET=$(ip -o -4 route show dev "$MGMT_IF" scope link proto kernel | awk '{print $1}' | head -1)
# A NATed or routed admin source outside the local subnet must be allowed explicitly.
if [ -n "$SSH_IP" ] && ! python3 -c "import ipaddress,sys; sys.exit(0 if ipaddress.ip_address('$SSH_IP') in ipaddress.ip_network('$MGMT_NET') else 1)" 2>/dev/null; then
MGMT_NET="$MGMT_NET,$SSH_IP/32"
fi
fi
echo " management: interface ${c_b}$MGMT_IF${c_0}, allowed sources ${c_b}$MGMT_NET${c_0}"
echo " (a wired network is not trusted just because it is wired; narrow this with --mgmt-subnet)"
# ---------------------------------------------------------------- 3-5. capabilities, resources, conflicts
say "Checking Wi-Fi, resources and conflicts"
if command -v iw >/dev/null && [ -n "$WIFI" ]; then
iw list 2>/dev/null | awk '/Supported interface modes/{f=1;next} f&&/^\t\t\*/{print} f&&!/^\t\t\*/{f=0}' | grep -q '\* AP$' && echo " AP mode supported" || warn "Wi-Fi radio does not report AP mode support"
elif [ -z "$WIFI" ]; then warn "no Wi-Fi interface found; the access point cannot be configured"; fi
FREE_MB=$(df -Pm / | awk 'NR==2{print $4}'); MEM_MB=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo)
echo " free disk ${FREE_MB} MiB, memory ${MEM_MB} MiB"
[ "$FREE_MB" -ge 1024 ] || die "at least 1 GiB free disk space is required"
[ "$MEM_MB" -ge 900 ] || warn "less than 1 GiB RAM: large captures and dissection will be slow"
for s in dnsmasq hostapd isc-dhcp-server udhcpd; do
systemctl is-active --quiet "$s" 2>/dev/null && warn "$s.service is running and may conflict with PiLens DHCP/DNS on the AP interface"
done
systemctl is-enabled --quiet nftables 2>/dev/null && grep -q 'flush ruleset' /etc/nftables.conf 2>/dev/null && \
warn "nftables.service is enabled and /etc/nftables.conf flushes the whole ruleset. pilens-firewall is ordered after it, but reloading nftables manually will remove the PiLens table until 'sudo systemctl restart pilens-firewall'."
UPGRADE=0; [ -x "$PREFIX/bin/pilens" ] && UPGRADE=1
# ---------------------------------------------------------------- 6. explain
echo
if [ $UPGRADE = 1 ]; then
say "Existing installation found ($(cat $PREFIX/VERSION 2>/dev/null || echo unknown)); this will UPGRADE to $(cat "$SRC/VERSION")."
echo " Data in $DATA (captures, settings, accounts) is kept."
else
cat <<EOF
${c_b}This will:${c_0}
• apt install: tshark wireshark-common nftables dnsmasq-base iw (dumpcap capture rights for group 'wireshark')
• create system user 'pilens' (no login), add it to group 'wireshark'
• install binaries and docs in $PREFIX, data in $DATA, systemd units in /etc/systemd/system
• start pilens-firewall, pilens-helper, pilens (the web UI on port 8443, $MGMT_IF only)
• back up NetworkManager profiles and the current nftables ruleset to /var/backups/pilens/
${c_b}It will not:${c_0} change any existing network connection, touch other firewall tables,
or enable IP forwarding. The access point is created later, in the setup wizard.
EOF
fi
confirm "Proceed?" || die "aborted by user"
# ---------------------------------------------------------------- 7. backup
TS=$(date +%Y%m%d-%H%M%S)
BK=/var/backups/pilens/$TS
say "Backing up configuration to $BK"
mkdir -p "$BK"; chmod 700 /var/backups/pilens "$BK"
cp -a /etc/NetworkManager/system-connections "$BK/" 2>/dev/null || true
nft list ruleset >"$BK/nft-ruleset.txt" 2>/dev/null || true
sysctl -n net.ipv4.ip_forward >"$BK/ip_forward" 2>/dev/null || true
cp -a /etc/systemd/system/pilens*.service "$BK/" 2>/dev/null || true
[ -d "$PREFIX" ] && cp -a "$PREFIX" "$BK/opt-pilens" || true
ROLLBACK=1
rollback() {
[ "${ROLLBACK:-0}" = 1 ] || return 0
echo; warn "installation failed; rolling back (log: $LOG)"
if [ $UPGRADE = 1 ] && [ -d "$BK/opt-pilens" ]; then
rm -rf "$PREFIX"; cp -a "$BK/opt-pilens" "$PREFIX"
cp -a "$BK"/pilens*.service /etc/systemd/system/ 2>/dev/null || true
systemctl daemon-reload; systemctl restart pilens-helper pilens 2>/dev/null || true
warn "previous version restored"
else
systemctl disable --now pilens pilens-helper pilens-firewall pilens-dnsmasq 2>/dev/null || true
rm -f /etc/systemd/system/pilens*.service; systemctl daemon-reload
rm -rf "$PREFIX"
warn "PiLens services removed. Installed apt packages were left in place: they are harmless when unused."
fi
}
trap rollback EXIT
# ---------------------------------------------------------------- 8. packages
say "Installing packages"
echo "wireshark-common wireshark-common/install-setuid boolean true" | debconf-set-selections
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >>"$LOG" 2>&1 || warn "apt-get update failed; trying with cached package lists"
apt-get install -y -qq --no-install-recommends tshark wireshark-common nftables dnsmasq-base iw ca-certificates >>"$LOG" 2>&1 || die "apt-get install failed (see $LOG)"
dpkg-reconfigure -f noninteractive wireshark-common >>"$LOG" 2>&1 || true
getcap /usr/bin/dumpcap | grep -q cap_net_raw || die "dumpcap has no capture capabilities after install (getcap /usr/bin/dumpcap)"
# dnsmasq-base must not bring a competing system dnsmasq service.
systemctl is-enabled --quiet dnsmasq 2>/dev/null && warn "dnsmasq.service (full package) is enabled. Disable it, or make sure it does not listen on the AP interface."
# ---------------------------------------------------------------- 9-12. files, user, units
say "Installing PiLens $(cat "$SRC/VERSION")"
getent group pilens >/dev/null || groupadd --system pilens
id pilens >/dev/null 2>&1 || useradd --system --gid pilens --home-dir "$DATA" --no-create-home --shell /usr/sbin/nologin pilens
usermod -aG wireshark pilens
systemctl stop pilens 2>/dev/null || true
mkdir -p "$PREFIX"
install -m 0755 "$SRC/bin/pilens" "$SRC/bin/pilens-helper" -D -t "$PREFIX/bin"
for f in install.sh uninstall.sh repair.sh diagnose.sh setup.example.yaml VERSION README.md LICENSE; do install -m 0644 "$SRC/$f" "$PREFIX/$f"; done
chmod 0755 "$PREFIX"/*.sh
rm -rf "$PREFIX/docs"; cp -r "$SRC/docs" "$PREFIX/docs"
install -d -o pilens -g pilens -m 0750 "$DATA"
install -d -o root -g root -m 0700 /var/lib/pilens-helper
install -m 0644 "$SRC"/systemd/*.service /etc/systemd/system/
systemctl daemon-reload
# ---------------------------------------------------------------- 15. initialise data
say "Initialising database and management certificate"
INIT=$(runuser -u pilens -- "$PREFIX/bin/pilens" init -data "$DATA" -mgmt-iface "$MGMT_IF" -mgmt-subnet "$MGMT_NET")
FP=$(echo "$INIT" | sed -n 's/^certificate_sha256=//p')
TOKEN=$(echo "$INIT" | sed -n 's/^setup_token=//p')
# ---------------------------------------------------------------- 18. enable and start
say "Starting services"
systemctl enable pilens-firewall pilens-helper pilens-dnsmasq pilens >>"$LOG" 2>&1
systemctl restart pilens-firewall pilens-helper
systemctl restart pilens
# ---------------------------------------------------------------- 19. health
say "Verifying service health"
for i in $(seq 1 30); do
curl -fsk --max-time 2 https://127.0.0.1:8443/api/health >/dev/null 2>&1 && break
sleep 1
done
curl -fsk --max-time 2 https://127.0.0.1:8443/api/health >/dev/null || { journalctl -u pilens -n 30 --no-pager; die "pilens did not become healthy"; }
[ -S /run/pilens/helper.sock ] || die "helper socket missing (journalctl -u pilens-helper)"
echo " ${c_g}pilens and pilens-helper are healthy${c_0}"
ROLLBACK=0
trap - EXIT
if [ -n "$CONFIG" ]; then
say "Applying non-interactive configuration from $CONFIG"
TMPC=$(mktemp -p "$DATA" setup.XXXXXX.yaml); install -o pilens -m 0600 "$CONFIG" "$TMPC"
if ! runuser -u pilens -- "$PREFIX/bin/pilens" setup -data "$DATA" -config "$TMPC"; then rm -f "$TMPC"; die "non-interactive setup failed; the network configuration was rolled back"; fi
rm -f "$TMPC"
warn "delete $CONFIG if it contains a plaintext password"
TOKEN=""
fi
MIP=$(ip -o -4 addr show dev "$MGMT_IF" | awk '{split($4,a,"/"); print a[1]; exit}')
echo
echo "${c_g}${c_b}PiLens $(cat "$SRC/VERSION") installed.${c_0}"
echo " Open: ${c_b}https://${MIP}:8443/${c_0} (from $MGMT_NET)"
echo " Certificate: SHA-256 $FP"
echo " Your browser will warn that the certificate is self-signed. Check that this fingerprint matches before you continue."
[ -n "$TOKEN" ] && echo " Setup token: ${c_b}$TOKEN${c_0} (needed once, in the setup wizard)"
echo " Logs: journalctl -u pilens -u pilens-helper -u pilens-dnsmasq"
echo " Backup: $BK"
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# Re-establish permissions, capabilities and services without touching data or the
# applied network configuration. sudo ./repair.sh
set -uo pipefail
[ "$(id -u)" = 0 ] || { echo "run as root"; exit 1; }
ok() { echo " ✓ $*"; }; bad() { echo " ✗ $*"; }
echo "Repairing PiLens"
id pilens >/dev/null 2>&1 || { useradd --system --home-dir /var/lib/pilens --no-create-home --shell /usr/sbin/nologin pilens; ok "recreated user pilens"; }
usermod -aG wireshark pilens && ok "pilens in group wireshark"
install -d -o pilens -g pilens -m 0750 /var/lib/pilens && ok "/var/lib/pilens ownership"
chown -R pilens:pilens /var/lib/pilens
install -d -o root -g root -m 0700 /var/lib/pilens-helper && ok "/var/lib/pilens-helper ownership"
if ! getcap /usr/bin/dumpcap 2>/dev/null | grep -q cap_net_raw; then
echo "wireshark-common wireshark-common/install-setuid boolean true" | debconf-set-selections
DEBIAN_FRONTEND=noninteractive dpkg-reconfigure -f noninteractive wireshark-common >/dev/null 2>&1
fi
getcap /usr/bin/dumpcap | grep -q cap_net_raw && ok "dumpcap capture capabilities" || bad "dumpcap lacks capabilities"
systemctl daemon-reload
systemctl enable pilens-firewall pilens-helper pilens-dnsmasq pilens >/dev/null 2>&1
systemctl reset-failed 'pilens*' 2>/dev/null
systemctl restart pilens-firewall && ok "firewall table restored from last good configuration" || bad "pilens-firewall failed"
systemctl restart pilens-helper && ok "helper restarted" || bad "helper failed"
[ -f /var/lib/pilens-helper/dnsmasq.conf ] && { systemctl restart pilens-dnsmasq && ok "DHCP/DNS restarted" || bad "pilens-dnsmasq failed"; }
if nmcli -g GENERAL.STATE connection show pilens-ap 2>/dev/null | grep -q activated; then ok "access point active"
elif nmcli connection show pilens-ap >/dev/null 2>&1; then nmcli connection up pilens-ap >/dev/null 2>&1 && ok "access point re-activated" || bad "access point failed to start (check: journalctl -u NetworkManager)"; fi
systemctl restart pilens && sleep 3
curl -fsk --max-time 3 https://127.0.0.1:8443/api/health >/dev/null && ok "web service healthy" || bad "web service unhealthy: journalctl -u pilens -n 50"
echo
echo "If the administrator password is lost: sudo -u pilens /opt/pilens/bin/pilens reset-password -user <name>"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Remove PiLens. Captures and settings in /var/lib/pilens are KEPT unless --purge.
# sudo ./uninstall.sh [--purge] [--yes]
set -euo pipefail
PURGE=0; YES=0
for a in "$@"; do case "$a" in --purge) PURGE=1 ;; --yes|-y) YES=1 ;; *) echo "unknown option $a"; exit 2 ;; esac; done
[ "$(id -u)" = 0 ] || { echo "run as root"; exit 1; }
confirm() { [ "$YES" = 1 ] && return 0; read -r -p "$1 [y/N] " a; [[ "$a" =~ ^[Yy] ]]; }
echo "This removes the PiLens services, the 'pilens-ap' access-point profile, the 'inet pilens' firewall table and /opt/pilens."
[ $PURGE = 1 ] && echo "--purge: ALSO deletes /var/lib/pilens: every capture, setting and account."
confirm "Continue?" || exit 1
if [ $PURGE = 1 ]; then
read -r -p "Type DELETE to permanently delete all captures: " x
[ "$x" = DELETE ] || { echo "not confirmed; keeping data"; PURGE=0; }
fi
systemctl disable --now pilens pilens-dnsmasq pilens-helper pilens-firewall 2>/dev/null || true
nmcli connection delete pilens-ap 2>/dev/null && echo "removed access-point profile (other Wi-Fi profiles were never modified and will reconnect)" || true
rm -f /etc/NetworkManager/system-connections/pilens-ap.nmconnection
nft delete table inet pilens 2>/dev/null && echo "removed firewall table inet pilens" || true
echo "note: IPv4 forwarding was left as it is now ($(sysctl -n net.ipv4.ip_forward)); other services may depend on it"
rm -f /etc/systemd/system/pilens*.service
systemctl daemon-reload
rm -rf /opt/pilens /var/lib/pilens-helper /run/pilens
if [ $PURGE = 1 ]; then
rm -rf /var/lib/pilens
userdel pilens 2>/dev/null || true
echo "data purged"
else
echo "kept /var/lib/pilens (captures, database). Remove it with: sudo rm -rf /var/lib/pilens"
fi
echo "apt packages (tshark, wireshark-common, nftables, dnsmasq-base, iw) were left installed."
echo "Configuration backups are in /var/backups/pilens/."
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=PiLens DHCP and DNS for the access-point network
Documentation=file:/opt/pilens/docs/architecture.md
After=network.target pilens-helper.service
ConditionPathExists=/var/lib/pilens-helper/dnsmasq.conf
[Service]
Type=simple
# Bound only to the AP interface (interface=/bind-dynamic in the generated config).
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --conf-file=/var/lib/pilens-helper/dnsmasq.conf --pid-file
Restart=on-failure
RestartSec=2
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=PiLens firewall restore (before networking)
Documentation=file:/opt/pilens/docs/architecture.md
DefaultDependencies=no
After=local-fs.target nftables.service
Before=network-pre.target NetworkManager.service
Wants=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
# Loads only the "inet pilens" table from the last successfully applied configuration.
ExecStart=/opt/pilens/bin/pilens-helper -boot
[Install]
WantedBy=multi-user.target
+32
View File
@@ -0,0 +1,32 @@
[Unit]
Description=PiLens privileged network helper
Documentation=file:/opt/pilens/docs/architecture.md
After=pilens-firewall.service NetworkManager.service
Wants=pilens-firewall.service
[Service]
Type=simple
ExecStart=/opt/pilens/bin/pilens-helper -socket /run/pilens/helper.sock -user pilens
Restart=on-failure
RestartSec=2
RuntimeDirectory=pilens
RuntimeDirectoryMode=0750
RuntimeDirectoryPreserve=yes
StateDirectory=pilens-helper
StateDirectoryMode=0700
# Sandboxing: root, but limited to what applying network configuration needs.
ProtectSystem=strict
ReadWritePaths=/etc/NetworkManager/system-connections /var/lib/pilens-helper /run/pilens /proc/sys/net/ipv4/ip_forward
ProtectHome=yes
PrivateTmp=yes
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_CHOWN CAP_FOWNER CAP_DAC_OVERRIDE
RestrictAddressFamilies=AF_UNIX AF_NETLINK AF_INET AF_INET6
ProtectKernelModules=yes
ProtectKernelLogs=yes
LockPersonality=yes
RestrictRealtime=yes
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
+48
View File
@@ -0,0 +1,48 @@
[Unit]
Description=PiLens network forensics appliance (web UI, capture engine)
Documentation=file:/opt/pilens/docs/architecture.md
After=network-online.target pilens-helper.service
Wants=network-online.target pilens-helper.service
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=notify
User=pilens
Group=pilens
SupplementaryGroups=wireshark
ExecStart=/opt/pilens/bin/pilens serve -data /var/lib/pilens -listen :8443
Restart=on-failure
RestartSec=3
WatchdogSec=60
# Shutdown stops any running capture and hashes its last segment.
TimeoutStopSec=60
KillSignal=SIGTERM
StateDirectory=pilens
StateDirectoryMode=0750
UMask=0027
# dumpcap gains cap_net_raw/cap_net_admin from its file capabilities, which do
# not work under NoNewPrivileges. The bounding set caps what any child can gain.
CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN
AmbientCapabilities=
ProtectSystem=strict
ReadWritePaths=/var/lib/pilens
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=no
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_PACKET
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
+140
View File
@@ -0,0 +1,140 @@
#!/bin/bash
# Integration tests, run inside the pilens-test container (see `make test-docker`).
# 1. Go test binaries that need tshark/dumpcap.
# 2. A routed topology in Linux network namespaces exercising the rendered
# firewall, DHCP/DNS and the real pilens service end to end:
#
# [ns up] up0 10.99.0.1/24 (mgmt host + router) lo: 198.51.100.7 (public), 172.16.5.5 (private)
# |
# [ns pi] eth0 10.99.0.2/24 ── nft inet pilens ── wlan0 192.168.50.1/24 (pilens serve, dnsmasq)
# |
# [ns cl] c0 (DHCP client)
set -uo pipefail
T=/t; SRC=/src
PASS=0; FAIL=0
ok() { echo " PASS $*"; PASS=$((PASS+1)); }
fail() { echo " FAIL $*"; FAIL=$((FAIL+1)); }
check() { local name="$1"; shift; if "$@" >/tmp/out 2>&1; then ok "$name"; else fail "$name"; sed 's/^/ /' /tmp/out | tail -5; fi; }
refuse() { local name="$1"; shift; if "$@" >/tmp/out 2>&1; then fail "$name (unexpectedly succeeded)"; else ok "$name"; fi; }
echo "== Go tests needing tshark/dumpcap"
for p in pcapfile capture api; do
( cd "$SRC/internal/$p" && "$T/$p.test" -test.timeout 120s >/tmp/gt.$p 2>&1 ) && ok "go test ./internal/$p" || { fail "go test ./internal/$p"; tail -20 /tmp/gt.$p; }
done
echo "== Network namespace topology"
for n in up pi cl; do ip netns add $n; done
ip link add up0 netns up type veth peer name eth0 netns pi
ip link add wlan0 netns pi type veth peer name c0 netns cl
ip -n up addr add 10.99.0.1/24 dev up0
ip -n up addr add 198.51.100.7/32 dev lo; ip -n up addr add 172.16.5.5/32 dev lo
ip -n pi addr add 10.99.0.2/24 dev eth0
ip -n pi addr add 192.168.50.1/24 dev wlan0
for n in up pi cl; do ip -n $n link set lo up; done
ip -n up link set up0 up; ip -n pi link set eth0 up; ip -n pi link set wlan0 up; ip -n cl link set c0 up
ip -n pi route add default via 10.99.0.1
ip -n up route add 192.168.50.0/24 via 10.99.0.2 # a route that would bypass NAT if forwarding were open
# IPv6 everywhere, to prove AP clients cannot escape over it
ip -n up addr add fd01::1/64 dev up0 nodad; ip -n pi addr add fd01::2/64 dev eth0 nodad
ip -n pi addr add fd50::1/64 dev wlan0 nodad; ip -n cl addr add fd50::10/64 dev c0 nodad
ip -n cl route add fd01::/64 via fd50::1; ip -n up route add fd50::/64 via fd01::2
ip netns exec pi sysctl -qw net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1
# public and private services upstream
ip netns exec up python3 -m http.server 80 --bind 198.51.100.7 >/dev/null 2>&1 &
ip netns exec up python3 -m http.server 80 --bind 172.16.5.5 >/dev/null 2>&1 &
ip netns exec up python3 -m http.server 8080 --bind 10.99.0.1 >/dev/null 2>&1 &
# an unrelated table that must survive every apply
ip netns exec pi nft add table inet other
ip netns exec pi nft add chain inet other keepme
render() { # $1 = forwarding mode
cat >/tmp/net.json <<EOF
{"management":{"interfaces":["eth0"],"allowed_subnets":["10.99.0.0/24"],"port":8443},"uplink":"eth0",
"ap":{"enabled":true,"interface":"wlan0","ssid":"PiLens-Test","passphrase":"integration-test","security":"wpa2","band":"bg","channel":6,"country":"GB"},
"lan":{"address":"192.168.50.1/24","dhcp_start":"192.168.50.100","dhcp_end":"192.168.50.150","lease_minutes":60,"upstream_dns":["10.99.0.1"],
"reservations":[],"dns_overrides":[{"name":"override.pilens.test","ip":"192.0.2.55"}]},
"forwarding":{"mode":"$1","allow_cidrs":[]}}
EOF
"$T/pilens-helper" -render /tmp/net.json >/tmp/render.txt || return 1
awk '/^### nftables/{f="/tmp/ruleset.nft";next} /^### dnsmasq.conf/{f="/tmp/dnsmasq.conf";next} /^### /{f="";next} f{print > f}' /tmp/render.txt
}
check "render configuration (internet mode)" render internet
check "nft -c accepts ruleset" ip netns exec pi nft -c -f /tmp/ruleset.nft
check "apply ruleset" ip netns exec pi nft -f /tmp/ruleset.nft
check "re-apply ruleset is atomic and idempotent" ip netns exec pi nft -f /tmp/ruleset.nft
check "unrelated nft table untouched" ip netns exec pi nft list chain inet other keepme
mkdir -p /var/lib/pilens-helper
ip netns exec pi dnsmasq --conf-file=/tmp/dnsmasq.conf --pid-file=/tmp/dnsmasq.pid --log-facility=/tmp/dnsmasq.log
sleep 1
echo "== DHCP and DNS"
cat >/tmp/udhcpc.sh <<'EOF'
#!/bin/sh
[ "$1" = bound ] || [ "$1" = renew ] || exit 0
ip addr add "$ip/${mask:-24}" dev "$interface"
for r in $router; do ip route add default via "$r"; done
echo "$dns" >/tmp/client.dns; echo "$ip" >/tmp/client.ip
EOF
chmod +x /tmp/udhcpc.sh
check "client obtains a DHCP lease" ip netns exec cl busybox udhcpc -i c0 -n -q -t 5 -s /tmp/udhcpc.sh
CIP=$(cat /tmp/client.ip 2>/dev/null)
check "lease is inside the pool ($CIP)" sh -c "echo '$CIP' | grep -Eq '^192\.168\.50\.(1[0-4][0-9]|150)$'"
check "DHCP gives the Pi as DNS server" grep -q 192.168.50.1 /tmp/client.dns
check "default route is the Pi" sh -c "ip -n cl route | grep -q 'default via 192.168.50.1'"
check "local DNS override answers" sh -c "ip netns exec cl dig +short +time=2 +tries=1 @192.168.50.1 override.pilens.test | grep -qx 192.0.2.55"
check "lease file written" grep -q "$CIP" /var/lib/pilens-helper/dnsmasq.leases
echo "== Forwarding policy (internet mode)"
check "client reaches public destination" ip netns exec cl curl -s -m 3 -o /dev/null http://198.51.100.7/
check "public server sees NATed source (10.99.0.2)" sh -c "ip netns exec up ss -tn state time-wait | grep -q 10.99.0.2 || ip netns exec cl curl -s -m 3 http://198.51.100.7/ >/dev/null"
refuse "client blocked from RFC1918 destination 172.16.5.5" ip netns exec cl curl -s -m 3 -o /dev/null http://172.16.5.5/
refuse "client blocked from management/upstream subnet 10.99.0.1" ip netns exec cl curl -s -m 3 -o /dev/null http://10.99.0.1:8080/
refuse "IPv6 from client is not forwarded" ip netns exec cl ping -6 -c 1 -W 2 fd01::1
refuse "client blocked from Pi SSH port" ip netns exec cl bash -c 'exec 3<>/dev/tcp/192.168.50.1/22'
refuse "upstream cannot open connections into the AP subnet" ip netns exec up ping -c 1 -W 2 "$CIP"
echo "== Real pilens service in the pi namespace"
mkdir -p /tmp/pdata
INIT=$(ip netns exec pi "$T/pilens" init -data /tmp/pdata -mgmt-iface eth0 -mgmt-subnet 10.99.0.0/24)
TOKEN=$(echo "$INIT" | sed -n 's/^setup_token=//p')
ip netns exec pi "$T/pilens" serve -data /tmp/pdata -listen :8443 -helper-socket /tmp/nohelper.sock >/tmp/pilens.log 2>&1 &
for i in $(seq 1 30); do ip netns exec up curl -fsk -m 1 https://10.99.0.2:8443/api/health >/dev/null 2>&1 && break; sleep 0.5; done
check "management reachable from allowed subnet" ip netns exec up curl -fsk -m 3 https://10.99.0.2:8443/api/health
refuse "management NOT reachable from AP client" ip netns exec cl curl -fsk -m 3 https://192.168.50.1:8443/api/health
refuse "management NOT reachable from AP client via the uplink address" ip netns exec cl curl -fsk -m 3 https://10.99.0.2:8443/api/health
J=/tmp/cookies
api() { ip netns exec up curl -sk -m 30 -b $J -c $J -H 'X-Requested-With: pilens' -H "X-CSRF-Token: ${CSRF:-}" -H 'Content-Type: application/json' "$@"; }
R=$(api -X POST https://10.99.0.2:8443/api/setup/admin -d "{\"token\":\"$TOKEN\",\"username\":\"admin\",\"password\":\"integration-password\",\"authorised\":true}")
CSRF=$(echo "$R" | python3 -c 'import json,sys; print(json.load(sys.stdin)["csrf"])' 2>/dev/null)
check "admin created with setup token" test -n "$CSRF"
R=$(api -X POST https://10.99.0.2:8443/api/captures -d '{"name":"netns","interfaces":["wlan0"]}')
SID=$(echo "$R" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null)
check "capture started on wlan0 ($SID)" test -n "$SID"
sleep 2
for i in 1 2 3; do ip netns exec cl curl -s -m 3 -o /dev/null http://198.51.100.7/; ip netns exec cl dig +short +time=1 +tries=1 @192.168.50.1 override.pilens.test >/dev/null; done
sleep 2
check "capture stopped" api -fX POST https://10.99.0.2:8443/api/captures/live/stop
N=$(api "https://10.99.0.2:8443/api/captures/$SID" | python3 -c 'import json,sys; print(json.load(sys.stdin)["session"]["packets"])')
check "client traffic captured ($N packets)" test "${N:-0}" -gt 10
check "display filter http.request over API" sh -c "$(declare -f api); J=$J CSRF=$CSRF; api 'https://10.99.0.2:8443/api/captures/$SID/packets?filter=http.request' | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d[\"total\"]==3, d'"
check "packet detail with verified byte ranges" sh -c "$(declare -f api); J=$J CSRF=$CSRF; api 'https://10.99.0.2:8443/api/captures/$SID/packets/1' | grep -q '\"verified\":true'"
api -o /tmp/export.pcapng "https://10.99.0.2:8443/api/captures/$SID/export"
check "export opens in tshark with all packets" sh -c "[ \$(tshark -r /tmp/export.pcapng 2>/dev/null | wc -l) -eq $N ]"
check "integrity verification ok" sh -c "$(declare -f api); J=$J CSRF=$CSRF; api -X POST https://10.99.0.2:8443/api/captures/$SID/verify | grep -q '\"status\":\"ok\"'"
refuse "API rejects state change without CSRF token" ip netns exec up curl -fsk -m 5 -b $J -X POST https://10.99.0.2:8443/api/captures/live/stop
echo "== Deny mode"
check "render configuration (deny mode)" render deny
check "apply deny ruleset" ip netns exec pi nft -f /tmp/ruleset.nft
refuse "client cannot reach public destination in deny mode" ip netns exec cl curl -s -m 3 -o /dev/null http://198.51.100.7/
check "DNS still served in deny mode" sh -c "ip netns exec cl dig +short +time=2 +tries=1 @192.168.50.1 override.pilens.test | grep -qx 192.0.2.55"
check "unrelated nft table still present" ip netns exec pi nft list chain inet other keepme
echo
echo "integration: $PASS passed, $FAIL failed"
[ $FAIL = 0 ]
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>PiLens</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1295
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "pilens-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"typescript": "7.0.2",
"vite": "8.3.4"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#11161d"/><circle cx="14" cy="14" r="7.5" fill="none" stroke="#f2a93b" stroke-width="2.5"/><path d="M19.5 19.5 26 26" stroke="#f2a93b" stroke-width="3" stroke-linecap="round"/><path d="M9.5 14h2l1.5-3 2 6 1.5-3h2" fill="none" stroke="#5ccfe0" stroke-width="1.6" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 396 B

+113
View File
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useState } from "react";
import { api, setCSRF, connectEvents, disconnectEvents } from "./api";
import { useHash, usePersist, Toaster, go } from "./ui";
import Login from "./pages/Login";
import Setup from "./pages/Setup";
import Overview from "./pages/Overview";
import Analyser from "./pages/Analyser";
import Library from "./pages/Library";
import NetworkPage from "./pages/Network";
import Devices from "./pages/Devices";
import Health from "./pages/Health";
import Settings from "./pages/Settings";
export interface Prefs { theme: "dark" | "light" | "system"; density: "compact" | "comfortable"; scale: number; motion: "system" | "reduce" }
export const defaultPrefs: Prefs = { theme: "dark", density: "compact", scale: 1, motion: "system" };
interface Sess { user: string; csrf: string; totp_enabled: boolean; setup_complete: boolean; version: string }
// Only implemented features appear here. Later-phase pages are added when they work.
const NAV: [string, string, string][] = [
["/", "Overview", "◉"],
["/analyse", "Live Capture", "≋"],
["/library", "Capture Library", "▤"],
["/devices", "Devices", "⌬"],
["", "", ""],
["/network", "Network Settings", "⇄"],
["/health", "System Health", "♥"],
["/settings", "Settings", "⚙"],
];
export default function App() {
const [prefs, setPrefs] = usePersist<Prefs>("prefs", defaultPrefs);
const [sess, setSess] = useState<Sess | null>(null);
const [status, setStatus] = useState<{ needs_admin: boolean; setup_complete: boolean; version: string } | null>(null);
const [loading, setLoading] = useState(true);
const [focus, setFocus] = usePersist("focus", false);
const [path, query] = useHash();
useEffect(() => {
const r = document.documentElement;
const theme = prefs.theme === "system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : prefs.theme;
r.dataset.theme = theme;
r.dataset.density = prefs.density;
r.dataset.motion = prefs.motion;
r.style.setProperty("--scale", String(prefs.scale));
}, [prefs]);
const refresh = useCallback(async () => {
try {
const st = await api("/api/setup/status");
setStatus(st);
try {
const s = await api<Sess>("/api/auth/session");
setCSRF(s.csrf);
setSess(s);
} catch { setSess(null); }
} catch { setStatus(null); }
setLoading(false);
}, []);
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
const f = () => { setSess(null); disconnectEvents(); };
addEventListener("pilens:unauthorised", f);
return () => removeEventListener("pilens:unauthorised", f);
}, []);
useEffect(() => { if (sess) connectEvents(); }, [sess]);
useEffect(() => {
const k = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (/INPUT|TEXTAREA|SELECT/.test(t.tagName)) return;
if (e.key === "F" && e.shiftKey) { setFocus((f) => !f); e.preventDefault(); }
if (e.altKey && /^[1-7]$/.test(e.key)) { const n = NAV.filter((x) => x[0])[+e.key - 1]; if (n) { go(n[0]); e.preventDefault(); } }
};
addEventListener("keydown", k);
return () => removeEventListener("keydown", k);
}, [setFocus]);
if (loading) return <div className="center"><span className="hint">Connecting to appliance…</span></div>;
if (!status) return <div className="center"><div className="card authcard"><h2>Appliance unreachable</h2><p className="hint">The PiLens service did not respond. Check that pilens.service is running (<code>sudo ./diagnose.sh</code>).</p><button onClick={refresh}>Retry</button></div></div>;
if (status.needs_admin || (sess && !sess.setup_complete)) return <><Setup status={status} session={sess} onDone={refresh} /><Toaster /></>;
if (!sess) return <><Login onLogin={refresh} /><Toaster /></>;
const page = (() => {
if (path === "/" ) return <Overview />;
if (path.startsWith("/analyse")) return <Analyser sessionId={path.split("/")[2] ?? ""} initialFilter={query.get("filter") ?? ""} focus={focus} setFocus={setFocus} />;
if (path === "/library") return <Library />;
if (path === "/devices") return <Devices />;
if (path === "/network") return <NetworkPage />;
if (path === "/health") return <Health />;
if (path === "/settings") return <Settings prefs={prefs} setPrefs={setPrefs} session={sess} onChange={refresh} />;
return <div className="page"><h1>Not found</h1></div>;
})();
return (
<div className={"shell" + (focus && path.startsWith("/analyse") ? " focus" : "")}>
<aside className="nav">
<div className="brand"><img src="/favicon.svg" alt="" /><div><b>PiLens</b><span>network forensics</span></div></div>
<nav aria-label="Main">
{NAV.map(([href, label, icon], i) => href === "" ? <div className="sep" key={i} /> :
<a key={href} href={"#" + href} className={(href === "/" ? path === "/" : path.startsWith(href)) ? "active" : ""}><span aria-hidden style={{ width: "1.1rem", textAlign: "center", opacity: .8 }}>{icon}</span>{label}</a>)}
</nav>
<div className="foot">
<span>Signed in as <b>{sess.user}</b></span>
<span className="row spread"><span>v{sess.version}</span><button className="small ghost" onClick={async () => { await api("/api/auth/logout", { method: "POST" }).catch(() => {}); setSess(null); disconnectEvents(); }}>Sign out</button></span>
</div>
</aside>
<main className="main">{page}</main>
<Toaster />
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
// Thin API client. All state-changing requests carry the per-session CSRF token.
let csrf = "";
export const setCSRF = (t: string) => { csrf = t; };
export class ApiError extends Error {
constructor(public status: number, message: string, public body?: any) { super(message); }
}
export async function api<T = any>(path: string, opts: { method?: string; body?: any; raw?: BodyInit; signal?: AbortSignal } = {}): Promise<T> {
const headers: Record<string, string> = { "X-Requested-With": "pilens" };
const method = opts.method ?? (opts.body !== undefined || opts.raw ? "POST" : "GET");
if (method !== "GET") headers["X-CSRF-Token"] = csrf;
let body: BodyInit | undefined = opts.raw;
if (opts.body !== undefined) { headers["Content-Type"] = "application/json"; body = JSON.stringify(opts.body); }
const r = await fetch(path, { method, headers, body, credentials: "same-origin", signal: opts.signal });
const text = await r.text();
let data: any = undefined;
try { data = text ? JSON.parse(text) : undefined; } catch { data = text; }
if (!r.ok) {
if (r.status === 401 && path !== "/api/auth/login") window.dispatchEvent(new Event("pilens:unauthorised"));
throw new ApiError(r.status, (data && data.error) || r.statusText, data);
}
return data as T;
}
// ---- types mirroring the Go API ----
export interface Row { n: number; t: number; s: string; d: string; p: string; l: number; i: string; sp?: number; dp?: number; st?: number; tf?: string; ttl?: number; vl?: number; c?: string }
export interface Page { total: number; first: number; offset: number; rows: Row[]; nums?: number[]; filtered: boolean }
export interface TreeNode { label: string; field?: string; show?: string; pos: number; size: number; verified: boolean; children?: TreeNode[] }
export interface Detail { row: Row; bytes: string; tree: TreeNode[] | null; segment: number; note?: string }
export interface Session {
id: string; name: string; description: string; kind: "live" | "import"; state: string; interfaces: string[]; capture_filter: string;
started_at: number; ended_at: number; packets: number; bytes: number; drops: string; error: string; preserved: boolean;
tags: string; notes: string; ring_segments: number; meta: any; paused: boolean; ingest_lag: number;
}
export interface Segment { seq: number; file: string; format: string; base: number; count: number; bytes: number; sha256: string; complete: boolean; deleted: boolean }
export interface Iface { name: string; mac: string; addrs: string[] | null; up: boolean; carrier: boolean; wireless: boolean; driver: string; default_gateway?: string; virtual: boolean; speed_mbps?: number }
export interface Network {
management: { interfaces: string[]; allowed_subnets: string[]; port: number };
uplink: string;
ap: { enabled: boolean; interface: string; ssid: string; passphrase?: string; security: string; band: string; channel: number; hidden: boolean; country: string; client_isolation: boolean };
lan: { address: string; dhcp_start: string; dhcp_end: string; lease_minutes: number; upstream_dns: string[] | null; reservations: { mac: string; ip: string; hostname?: string }[] | null; dns_overrides: { name: string; ip: string }[] | null };
forwarding: { mode: string; allow_cidrs: string[] | null };
}
export interface ApplyResult { applied: boolean; rolled_back: boolean; steps: string[]; error?: string }
export interface Limits { max_bytes: number; reserve_bytes: number; segment_bytes: number; import_bytes: number }
// ---- live events (SSE) ----
type Handler = (type: string, data: any) => void;
const handlers = new Set<Handler>();
let es: EventSource | null = null;
export function connectEvents() {
if (es) return;
es = new EventSource("/api/events");
for (const t of ["hello", "packets", "capture", "resync", "ping"]) {
es.addEventListener(t, (e) => {
let d: any = {};
try { d = JSON.parse((e as MessageEvent).data); } catch { /* ignore */ }
handlers.forEach((h) => h(t, d));
});
}
es.onerror = () => handlers.forEach((h) => h("disconnected", {})); // EventSource reconnects by itself
}
export function disconnectEvents() { es?.close(); es = null; }
export function onEvent(h: Handler) { handlers.add(h); return () => { handlers.delete(h); }; }
// ---- formatting ----
export const fmtBytes = (n: number) => {
if (!isFinite(n)) return "–";
const u = ["B", "KiB", "MiB", "GiB", "TiB"]; let i = 0;
while (Math.abs(n) >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
};
export const fmtTime = (ms: number) => ms ? new Date(ms).toLocaleString() : "–";
export const fmtDur = (s: number) => {
if (!isFinite(s) || s < 0) return "–";
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = Math.floor(s % 60);
return h ? `${h}h ${m}m` : m ? `${m}m ${x}s` : `${x}s`;
};
+78
View File
@@ -0,0 +1,78 @@
// Hex / ASCII inspector over the original captured bytes.
import { useEffect, useMemo, useRef, useState } from "react";
import { copyText } from "../ui";
interface Props { bytes: Uint8Array; hl: [number, number] | null; onByte: (b: number) => void }
const printable = (b: number) => b >= 0x20 && b < 0x7f;
function findAll(bytes: Uint8Array, needle: number[]): Set<number> {
const out = new Set<number>();
if (!needle.length) return out;
outer: for (let i = 0; i + needle.length <= bytes.length; i++) {
for (let j = 0; j < needle.length; j++) if (bytes[i + j] !== needle[j]) continue outer;
for (let j = 0; j < needle.length; j++) out.add(i + j);
}
return out;
}
export default function HexView({ bytes, hl, onByte }: Props) {
const [decOff, setDecOff] = useState(false);
const [q, setQ] = useState("");
const [mode, setMode] = useState<"hex" | "text">("text");
const ref = useRef<HTMLDivElement>(null);
const matches = useMemo(() => {
if (!q) return new Set<number>();
if (mode === "text") return findAll(bytes, [...new TextEncoder().encode(q)]);
const h = q.replace(/[^0-9a-f]/gi, "");
if (h.length % 2) return new Set<number>();
return findAll(bytes, h.match(/../g)!.map((x) => parseInt(x, 16)));
}, [q, mode, bytes]);
useEffect(() => {
if (hl) ref.current?.querySelector(`[data-line="${Math.floor(hl[0] / 16)}"]`)?.scrollIntoView({ block: "nearest" });
}, [hl]);
const on = (i: number) => hl !== null && i >= hl[0] && i < hl[0] + hl[1];
const lines = [];
for (let off = 0; off < bytes.length; off += 16) {
const hexes = [], ascs = [];
for (let i = off; i < off + 16; i++) {
if (i >= bytes.length) { hexes.push(<span key={i} className="b" />); continue; }
const b = bytes[i];
const cls = (on(i) ? " on" : "") + (matches.has(i) ? " match" : "");
hexes.push(<span key={i} className={"b" + cls} onMouseDown={() => onByte(i)}>{b.toString(16).padStart(2, "0")}</span>);
ascs.push(<span key={i} className={"a" + cls + (printable(b) ? "" : " np")} onMouseDown={() => onByte(i)}>{printable(b) ? String.fromCharCode(b) : "·"}</span>);
}
lines.push(
<div key={off} className="hl" data-line={off / 16}>
<span className="off">{decOff ? String(off).padStart(5, "0") : off.toString(16).padStart(4, "0")}</span>
<span className="bytes">{hexes}</span>
<span className="asc">{ascs}</span>
</div>,
);
}
const sel = hl ? bytes.slice(hl[0], hl[0] + hl[1]) : bytes;
const asHex = () => [...sel].map((b) => b.toString(16).padStart(2, "0")).join(" ");
const asText = () => [...sel].map((b) => (printable(b) ? String.fromCharCode(b) : ".")).join("");
return (
<>
<div className="pane-title">
<span>Bytes · {bytes.length}{hl ? ` · selection ${hl[1]} @ ${decOff ? hl[0] : "0x" + hl[0].toString(16)}` : ""}</span>
<span className="sp" />
<select className="small" value={mode} onChange={(e) => setMode(e.target.value as "hex" | "text")} aria-label="Search mode" style={{ padding: "0 .3rem", fontSize: ".75rem" }}>
<option value="text">text</option><option value="hex">hex</option>
</select>
<input placeholder="find in bytes" value={q} onChange={(e) => setQ(e.target.value)} style={{ padding: "0 .4rem", fontSize: ".75rem", width: 140 }} aria-label="Find in bytes" />
{q && <span>{matches.size ? `${matches.size} bytes matched` : "no match"}</span>}
<button className="small ghost" onClick={() => setDecOff(!decOff)}>{decOff ? "dec" : "hex"} offsets</button>
<button className="small ghost" onClick={() => copyText(asHex())}>Copy hex</button>
<button className="small ghost" onClick={() => copyText(asText())}>Copy text</button>
</div>
<div className="hex" ref={ref} tabIndex={0} aria-label="Packet bytes">{lines}</div>
</>
);
}
+125
View File
@@ -0,0 +1,125 @@
// Network configuration fields shared by the setup wizard and Network Settings.
import type { Iface, Network } from "../api";
export type SetNet = (f: (n: Network) => Network) => void;
const list = (s: string) => s.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
export function ifaceLabel(i: Iface) {
const a = (i.addrs ?? []).filter((x) => !x.includes(":")).join(", ");
return `${i.name}${i.wireless ? " (Wi-Fi)" : ""}${a ? " — " + a : ""}${i.carrier ? "" : " — no link"}`;
}
export function ManagementFields({ n, set, ifaces, remote }: { n: Network; set: SetNet; ifaces: Iface[]; remote?: string }) {
const toggle = (name: string) => set((x) => {
const has = x.management.interfaces.includes(name);
return { ...x, management: { ...x.management, interfaces: has ? x.management.interfaces.filter((i) => i !== name) : [...x.management.interfaces, name] } };
});
return (
<div className="stack">
<div className="choice">
{ifaces.filter((i) => !i.virtual || i.name.startsWith("eth") || i.name.includes(".")).map((i) => (
<label key={i.name}>
<input type="checkbox" checked={n.management.interfaces.includes(i.name)} onChange={() => toggle(i.name)} disabled={i.name === n.ap.interface} />
<span><span className="mono">{ifaceLabel(i)}</span><small>{i.name === n.ap.interface ? "This is the access-point interface; it can never carry management." : i.default_gateway ? "Has the default route." : ""}</small></span>
</label>
))}
</div>
<label>Allowed management source subnets
<input className="mono" value={n.management.allowed_subnets.join(", ")} onChange={(e) => set((x) => ({ ...x, management: { ...x.management, allowed_subnets: list(e.target.value) } }))} placeholder="192.168.1.0/24" />
<span className="hint">Only these source addresses can reach the web UI (port {n.management.port}). {remote && <>Your browser connects from <code>{remote}</code>; it must be inside one of them.</>} Being wired does not make a network trusted: keep this as narrow as you can.</span>
</label>
</div>
);
}
export function UplinkField({ n, set, ifaces }: { n: Network; set: SetNet; ifaces: Iface[] }) {
return (
<label>Upstream (uplink) interface
<select value={n.uplink} onChange={(e) => set((x) => ({ ...x, uplink: e.target.value }))}>
<option value="">— choose —</option>
{ifaces.filter((i) => i.name !== n.ap.interface).map((i) => <option key={i.name} value={i.name}>{ifaceLabel(i)}</option>)}
</select>
<span className="hint">AP client traffic is NATed out of this interface. Usually the Ethernet port with the default route.</span>
</label>
);
}
export function WifiFields({ n, set, ifaces, passSet, bands }: { n: Network; set: SetNet; ifaces: Iface[]; passSet: boolean; bands?: string[] }) {
const ap = n.ap;
const upd = (p: Partial<Network["ap"]>) => set((x) => ({ ...x, ap: { ...x.ap, ...p } }));
const chans = ap.band === "a" ? [36, 40, 44, 48, 149, 153, 157, 161, 165] : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
return (
<div className="stack">
<div className="fieldgrid">
<label>Wi-Fi interface
<select value={ap.interface} onChange={(e) => upd({ interface: e.target.value })}>
<option value="">— choose —</option>
{ifaces.filter((i) => i.wireless).map((i) => <option key={i.name} value={i.name}>{i.name}{i.driver ? ` (${i.driver})` : ""}</option>)}
</select>
</label>
<label>SSID<input value={ap.ssid} maxLength={32} onChange={(e) => upd({ ssid: e.target.value })} /></label>
<label>Security
<select value={ap.security} onChange={(e) => upd({ security: e.target.value })}>
<option value="wpa2">WPA2-Personal (widest compatibility)</option>
<option value="wpa3">WPA3-Personal (SAE; depends on driver/firmware)</option>
</select>
</label>
<label>Passphrase
<input type="password" autoComplete="new-password" value={ap.passphrase ?? ""} placeholder={passSet ? "unchanged (stored)" : "8–63 characters"} onChange={(e) => upd({ passphrase: e.target.value })} />
</label>
<label>Country (regulatory domain)<input className="mono" maxLength={2} value={ap.country} onChange={(e) => upd({ country: e.target.value.toUpperCase() })} /></label>
<label>Band
<select value={ap.band} onChange={(e) => upd({ band: e.target.value, channel: 0 })}>
<option value="bg">2.4 GHz</option>
<option value="a" disabled={bands && !bands.includes("5GHz")}>5 GHz{bands && !bands.includes("5GHz") ? " (not supported by this radio)" : ""}</option>
</select>
</label>
<label>Channel
<select value={ap.channel} onChange={(e) => upd({ channel: +e.target.value })}>
<option value={0}>Automatic</option>
{chans.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</label>
</div>
<div className="row">
<label className="inline"><input type="checkbox" checked={ap.enabled} onChange={(e) => upd({ enabled: e.target.checked })} />Access point enabled (starts automatically at boot)</label>
<label className="inline"><input type="checkbox" checked={ap.hidden} onChange={(e) => upd({ hidden: e.target.checked })} />Hide SSID</label>
<label className="inline"><input type="checkbox" checked={ap.client_isolation} onChange={(e) => upd({ client_isolation: e.target.checked })} />Client isolation</label>
</div>
<p className="hint">The maximum number of clients cannot be set through NetworkManager, so it is not offered. WPA3 is verified when applied: if the radio cannot start the AP, the previous configuration is restored.</p>
</div>
);
}
export function LanFields({ n, set }: { n: Network; set: SetNet }) {
const l = n.lan;
const upd = (p: Partial<Network["lan"]>) => set((x) => ({ ...x, lan: { ...x.lan, ...p } }));
return (
<div className="fieldgrid">
<label>Gateway address / prefix<input className="mono" value={l.address} onChange={(e) => upd({ address: e.target.value })} /><span className="hint">The Pi's address on the AP network.</span></label>
<label>DHCP pool start<input className="mono" value={l.dhcp_start} onChange={(e) => upd({ dhcp_start: e.target.value })} /></label>
<label>DHCP pool end<input className="mono" value={l.dhcp_end} onChange={(e) => upd({ dhcp_end: e.target.value })} /></label>
<label>Lease duration (minutes)<input type="number" min={2} value={l.lease_minutes} onChange={(e) => upd({ lease_minutes: +e.target.value })} /></label>
<label>Upstream DNS servers<input className="mono" value={(l.upstream_dns ?? []).join(", ")} placeholder="system resolvers" onChange={(e) => upd({ upstream_dns: list(e.target.value) })} /><span className="hint">Empty: use the Pi's own resolvers.</span></label>
</div>
);
}
export function ForwardingFields({ n, set }: { n: Network; set: SetNet }) {
const f = n.forwarding;
return (
<div className="stack">
<div className="choice" role="radiogroup">
<label><input type="radio" checked={f.mode === "internet"} onChange={() => set((x) => ({ ...x, forwarding: { ...x.forwarding, mode: "internet" } }))} />
<span>Internet access<small>AP clients reach public destinations through the uplink. Private ranges (RFC 1918, link-local, CGNAT), multicast and the management network stay blocked.</small></span></label>
<label><input type="radio" checked={f.mode === "deny"} onChange={() => set((x) => ({ ...x, forwarding: { ...x.forwarding, mode: "deny" } }))} />
<span>No forwarding<small>Clients get an address and DNS from the Pi but nothing is routed. Useful for observing what a device tries to reach.</small></span></label>
</div>
<label>Explicitly allowed private destinations (CIDR)
<input className="mono" value={(f.allow_cidrs ?? []).join(", ")} placeholder="none" onChange={(e) => set((x) => ({ ...x, forwarding: { ...x.forwarding, allow_cidrs: list(e.target.value) } }))} />
<span className="hint">For example a lab server on the upstream LAN. Applies only in Internet access mode.</span>
</label>
<p className="hint">IPv6 forwarding is not implemented, so IPv6 is disabled on the AP and IPv6 from clients is dropped. This means it cannot become an uninspected path.</p>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
// Virtualised packet list. Only visible rows are rendered; rows arrive lazily.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Row } from "../api";
export interface ColDef { id: string; title: string; w: number; get: (r: Row, ctx: { t0: number; abs: boolean }) => string | number | undefined; right?: boolean }
export const COLUMNS: ColDef[] = [
{ id: "no", title: "No.", w: 72, get: (r) => r.n, right: true },
{ id: "time", title: "Time", w: 128, get: (r, c) => c.abs ? new Date(r.t * 1000).toISOString().slice(11, 26) : (r.t - c.t0).toFixed(6), right: true },
{ id: "src", title: "Source", w: 170, get: (r) => r.s },
{ id: "dst", title: "Destination", w: 170, get: (r) => r.d },
{ id: "proto", title: "Protocol", w: 86, get: (r) => r.p },
{ id: "len", title: "Length", w: 64, get: (r) => r.l, right: true },
{ id: "info", title: "Info", w: 640, get: (r) => r.i },
{ id: "sport", title: "Src port", w: 72, get: (r) => r.sp, right: true },
{ id: "dport", title: "Dst port", w: 72, get: (r) => r.dp, right: true },
{ id: "stream", title: "TCP stream", w: 84, get: (r) => r.st, right: true },
{ id: "flags", title: "TCP flags", w: 120, get: (r) => r.tf },
{ id: "ttl", title: "TTL/Hop", w: 64, get: (r) => r.ttl, right: true },
{ id: "vlan", title: "VLAN", w: 56, get: (r) => r.vl, right: true },
];
export interface ColState { id: string; w: number; on: boolean }
export const defaultCols: ColState[] = COLUMNS.map((c) => ({ id: c.id, w: c.w, on: ["no", "time", "src", "dst", "proto", "len", "info"].includes(c.id) }));
export interface ColourRule { field: "proto" | "info" | "src" | "dst"; contains: string; colour: string }
// Wireshark's default colouring-rule names (reported by tshark --color) mapped to our palette.
export function rowClass(r: Row): string {
const p = r.p.toUpperCase();
switch (r.c) {
case "Bad TCP": case "ICMP errors": case "Checksum Errors": case "TTL low or unexpected": return "c-bad";
case "TCP RST": case "SCTP ABORT": return "c-rst";
case "TCP SYN/FIN": return "c-synfin";
case "ARP": return "c-arp";
case "ICMP": return "c-icmp";
case "HTTP": return "c-http";
case "Broadcast": return "c-bcast";
}
if (/^(DNS|MDNS|LLMNR|NBNS)/.test(p)) return "c-dns";
if (/^(TLS|SSL|QUIC|DTLS)/.test(p)) return "c-tls";
if (/^HTTP/.test(p)) return "c-http";
if (r.c === "UDP") return "c-udp";
return "c-tcp";
}
interface Props {
total: number;
rowAt: (pos: number) => Row | undefined;
need: (start: number, end: number) => void;
selected: number;
onSelect: (pos: number) => void;
onContext: (pos: number, e: React.MouseEvent) => void;
cols: ColState[];
setCols: (c: ColState[]) => void;
rowH: number;
follow: boolean;
t0: number;
absTime: boolean;
rules: ColourRule[];
version: number; // bumps when row data changes
}
export default function PacketTable(p: Props) {
const ref = useRef<HTMLDivElement>(null);
const [scroll, setScroll] = useState({ top: 0, h: 400 });
const defs = p.cols.filter((c) => c.on).map((c) => ({ ...COLUMNS.find((d) => d.id === c.id)!, w: c.w }));
const HDR = 24;
const first = Math.max(0, Math.floor((scroll.top) / p.rowH) - 10);
const last = Math.min(p.total, Math.ceil((scroll.top + scroll.h) / p.rowH) + 10);
useEffect(() => { if (last > first) p.need(first, last); }, [first, last, p.total, p.version]); // eslint-disable-line
useLayoutEffect(() => {
const el = ref.current!;
const ro = new ResizeObserver(() => setScroll((s) => ({ ...s, h: el.clientHeight })));
ro.observe(el);
return () => ro.disconnect();
}, []);
useLayoutEffect(() => {
if (p.follow && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [p.follow, p.total]);
// keep the selection visible when moved by keyboard
useEffect(() => {
const el = ref.current;
if (!el || p.selected < 0) return;
const y = p.selected * p.rowH;
if (y < el.scrollTop) el.scrollTop = y;
else if (y + p.rowH > el.scrollTop + el.clientHeight - HDR) el.scrollTop = y + p.rowH - el.clientHeight + HDR;
}, [p.selected]); // eslint-disable-line
const onKey = (e: React.KeyboardEvent) => {
const page = Math.max(1, Math.floor((ref.current?.clientHeight ?? 400) / p.rowH) - 1);
const mv: Record<string, number> = { ArrowDown: 1, ArrowUp: -1, PageDown: page, PageUp: -page, j: 1, k: -1 };
let n = p.selected;
if (e.key in mv) n = Math.max(0, Math.min(p.total - 1, (n < 0 ? 0 : n) + mv[e.key]));
else if (e.key === "Home") n = 0;
else if (e.key === "End") n = p.total - 1;
else return;
e.preventDefault();
p.onSelect(n);
};
const startResize = (i: number, e: React.MouseEvent) => {
e.preventDefault(); e.stopPropagation();
const x0 = e.clientX, id = defs[i].id, w0 = defs[i].w;
const move = (ev: MouseEvent) => p.setCols(p.cols.map((c) => c.id === id ? { ...c, w: Math.max(40, w0 + ev.clientX - x0) } : c));
const up = () => { removeEventListener("mousemove", move); removeEventListener("mouseup", up); };
addEventListener("mousemove", move); addEventListener("mouseup", up);
};
const width = defs.reduce((a, c) => a + c.w, 0);
const custom = (r: Row) => p.rules.find((x) => x.contains && String((x.field === "proto" ? r.p : x.field === "info" ? r.i : x.field === "src" ? r.s : r.d) ?? "").toLowerCase().includes(x.contains.toLowerCase()));
const rows = [];
for (let i = first; i < last; i++) {
const r = p.rowAt(i);
const style: React.CSSProperties = { top: HDR + i * p.rowH, width };
if (!r) {
rows.push(<div key={i} className="prow ph" style={style}><div style={{ width: defs[0]?.w }}>…</div><div>not yet indexed</div></div>);
continue;
}
const cr = custom(r);
if (cr) style.background = cr.colour;
rows.push(
<div key={i} role="row" aria-selected={i === p.selected} className={"prow " + (cr ? "" : rowClass(r)) + (i === p.selected ? " sel" : "")} style={style}
onMouseDown={() => p.onSelect(i)} onContextMenu={(e) => { e.preventDefault(); p.onSelect(i); p.onContext(i, e); }}>
{defs.map((c) => <div key={c.id} style={{ width: c.w, textAlign: c.right ? "right" : undefined }}>{c.get(r, { t0: p.t0, abs: p.absTime }) ?? ""}</div>)}
</div>,
);
}
return (
<div ref={ref} className="ptable" tabIndex={0} role="grid" aria-label="Packet list" aria-rowcount={p.total} onKeyDown={onKey}
onScroll={(e) => setScroll({ top: e.currentTarget.scrollTop, h: e.currentTarget.clientHeight })}>
<div className="hdr" role="row" style={{ width }}>
{defs.map((c, i) => <div key={c.id} role="columnheader" style={{ width: c.w, justifyContent: c.right ? "flex-end" : undefined }}>{c.title}<span className="rz" onMouseDown={(e) => startResize(i, e)} /></div>)}
</div>
<div style={{ height: p.total * p.rowH, width }} />
{rows}
{p.total === 0 && <div className="empty" style={{ position: "absolute", top: 60, width: "100%" }}>No packets.</div>}
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
// Collapsible protocol dissection tree (from tshark PDML).
import { useEffect, useMemo, useRef } from "react";
import type { TreeNode } from "../api";
import { usePersist } from "../ui";
export interface Flat { node: TreeNode; path: string; depth: number; hasKids: boolean; open: boolean; isProto: boolean }
export function flatten(nodes: TreeNode[], open: Set<string>, prefix = "", depth = 0, out: Flat[] = []): Flat[] {
nodes.forEach((n, i) => {
const path = prefix + i;
const hasKids = !!n.children?.length;
const key = n.field || n.label;
const isOpen = hasKids && (open.has(path) || open.has("f:" + key));
out.push({ node: n, path, depth, hasKids, open: isOpen, isProto: depth === 0 });
if (isOpen) flatten(n.children!, open, path + ".", depth + 1, out);
});
return out;
}
/** Finds the path of the deepest verified node covering byte offset b. */
export function pathForByte(nodes: TreeNode[], b: number, prefix = ""): string | null {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
if (n.verified && b >= n.pos && b < n.pos + n.size && n.field !== "frame") {
const deeper = n.children ? pathForByte(n.children, b, prefix + i + ".") : null;
return deeper ?? prefix + i;
}
if (n.field === "frame" || !n.verified) {
const d = n.children ? pathForByte(n.children, b, prefix + i + ".") : null;
if (d) return d;
}
}
return null;
}
export function nodeAt(nodes: TreeNode[], path: string): TreeNode | undefined {
let cur: TreeNode | undefined;
let list: TreeNode[] | undefined = nodes;
for (const i of path.split(".")) { cur = list?.[+i]; list = cur?.children; }
return cur;
}
interface Props { tree: TreeNode[]; selected: string; onSelect: (path: string) => void; onContext: (n: TreeNode, e: React.MouseEvent) => void }
export default function ProtoTree({ tree, selected, onSelect, onContext }: Props) {
// Expanded subtrees are remembered by field name across packets, like Wireshark.
const [openList, setOpenList] = usePersist<string[]>("tree.open", []);
const open = useMemo(() => {
const s = new Set(openList);
// make sure the selected node's ancestors are open
const parts = selected.split(".");
for (let i = 1; i < parts.length; i++) s.add(parts.slice(0, i).join("."));
return s;
}, [openList, selected]);
const flat = flatten(tree, open);
const ref = useRef<HTMLDivElement>(null);
const toggle = (f: Flat, to = !f.open) => {
const key = "f:" + (f.node.field || f.node.label);
setOpenList((l) => {
const s = new Set(l);
s.delete(f.path);
if (to) s.add(key); else s.delete(key);
return [...s].slice(-400);
});
};
useEffect(() => { ref.current?.querySelector(".tn.sel")?.scrollIntoView({ block: "nearest" }); }, [selected]);
const onKey = (e: React.KeyboardEvent) => {
const i = flat.findIndex((f) => f.path === selected);
const f = flat[i];
if (e.key === "ArrowDown" && i < flat.length - 1) onSelect(flat[i + 1].path);
else if (e.key === "ArrowUp" && i > 0) onSelect(flat[i - 1].path);
else if (e.key === "ArrowRight" && f?.hasKids && !f.open) toggle(f, true);
else if (e.key === "ArrowLeft" && f) { if (f.open) toggle(f, false); else if (f.depth) onSelect(f.path.split(".").slice(0, -1).join(".")); }
else if (e.key === "Enter" && f?.hasKids) toggle(f);
else return;
e.preventDefault();
};
return (
<div className="tree" ref={ref} tabIndex={0} role="tree" aria-label="Protocol tree" onKeyDown={onKey}>
{flat.map((f) => (
<div key={f.path} role="treeitem" aria-expanded={f.hasKids ? f.open : undefined} aria-selected={f.path === selected}
className={"tn" + (f.path === selected ? " sel" : "")} style={{ paddingLeft: 6 + f.depth * 16 }}
onMouseDown={() => onSelect(f.path)} onDoubleClick={() => f.hasKids && toggle(f)}
onContextMenu={(e) => { e.preventDefault(); onSelect(f.path); onContext(f.node, e); }}
title={f.node.field ? `${f.node.field}${f.node.size ? ` · ${f.node.size} byte(s) at offset ${f.node.pos}` : ""}` : undefined}>
<span className="tw" onMouseDown={(e) => { e.stopPropagation(); if (f.hasKids) toggle(f); }}>{f.hasKids ? (f.open ? "▾" : "▸") : ""}</span>
<span className={f.isProto ? "proto" : "fld"}>{f.node.label}</span>
{f.path === selected && f.node.size > 0 && !f.node.verified && <span className="nomap">byte range not verifiable (reassembled or generated data)</span>}
</div>
))}
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
+418
View File
@@ -0,0 +1,418 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, onEvent, fmtBytes, type Detail, type Page, type Row, type Session, type TreeNode, type Iface } from "../api";
import PacketTable, { COLUMNS, defaultCols, type ColState, type ColourRule } from "../components/PacketTable";
import ProtoTree, { nodeAt, pathForByte } from "../components/ProtoTree";
import HexView from "../components/HexView";
import { Badge, ContextMenu, Modal, copyText, go, toast, usePersist, usePoll, type CtxItem } from "../ui";
const PAGE = 500;
interface Props { sessionId: string; initialFilter: string; focus: boolean; setFocus: (f: boolean) => void }
/** Quote a value for a Wireshark display filter. */
const dq = (s: string) => '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
const isAddr = (s: string) => /^[0-9.]+$/.test(s) || /^[0-9a-f:]+$/i.test(s);
function addrFilter(a: string) {
if (/^\d+\.\d+\.\d+\.\d+$/.test(a)) return "ip.addr == " + a;
if (/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i.test(a)) return "eth.addr == " + a;
return "ipv6.addr == " + a;
}
export default function Analyser({ sessionId, initialFilter, focus, setFocus }: Props) {
const [sessions, , reloadSessions] = usePoll<Session[]>("/api/captures", 5000);
const live = sessions?.find((s) => s.state === "running");
const id = sessionId || live?.id || sessions?.[0]?.id || "";
const [sess, setSess] = useState<Session | null>(null);
// filter state
const [filterText, setFilterText] = useState(initialFilter);
const [filter, setFilter] = useState(initialFilter);
const [filterErr, setFilterErr] = useState("");
const [filterValid, setFilterValid] = useState<boolean | null>(null);
const [history, setHistory] = usePersist<string[]>("filter.history", []);
const [saved, , reloadSaved] = usePoll<{ ID: number; Name: string; Kind: string; Expr: string }[]>("/api/filters/saved");
// packet data
const cache = useRef(new Map<number, Row>());
const pages = useRef(new Set<number>());
const nums = useRef<number[]>([]);
const firstNum = useRef(1);
const [total, setTotal] = useState(0);
const [version, setVersion] = useState(0);
const [loadingFilter, setLoadingFilter] = useState(false);
const [frozen, setFrozen] = useState(false);
const [pending, setPending] = useState(0);
const [follow, setFollow] = usePersist("follow", true);
const [uiDropped, setUiDropped] = useState(0);
const [t0, setT0] = useState(0);
// selection / detail
const [sel, setSel] = useState(-1);
const [detail, setDetail] = useState<Detail | null>(null);
const [detailErr, setDetailErr] = useState("");
const [treeSel, setTreeSel] = useState("");
// layout
const [cols, setCols] = usePersist<ColState[]>("columns", defaultCols);
const [absTime, setAbsTime] = usePersist("abstime", false);
const [split, setSplit] = usePersist("split", [0.5, 0.28]);
const [rules] = usePersist<ColourRule[]>("colour.rules", []);
const [prefs] = usePersist<{ density?: string }>("prefs", {});
const rowH = prefs.density === "comfortable" ? 27 : 22;
const [colsOpen, setColsOpen] = useState(false);
const [startOpen, setStartOpen] = useState(false);
const [ctx, setCtx] = useState<{ at: { x: number; y: number }; items: CtxItem[] } | null>(null);
const filterRef = useRef<HTMLInputElement>(null);
const reset = useCallback(() => {
cache.current.clear(); pages.current.clear(); nums.current = [];
setVersion((v) => v + 1);
}, []);
const loadPage = useCallback(async (pg: number, force = false) => {
if (!id || (!force && pages.current.has(pg))) return;
pages.current.add(pg);
try {
const p = await api<Page>(`/api/captures/${id}/packets?offset=${pg * PAGE}&limit=${PAGE}&filter=${encodeURIComponent(filter)}`);
if (p.filtered) {
(p.nums ?? []).forEach((n, i) => { nums.current[pg * PAGE + i] = n; });
const byNum = new Map(p.rows.map((r) => [r.n, r]));
(p.nums ?? []).forEach((n, i) => { const r = byNum.get(n); if (r) cache.current.set(pg * PAGE + i, r); });
} else {
firstNum.current = p.first || 1;
p.rows.forEach((r) => cache.current.set(r.n - firstNum.current, r));
}
if (pg === 0 && p.rows[0]) setT0((t) => t || p.rows[0].t);
setTotal(p.total);
setVersion((v) => v + 1);
setFilterErr("");
} catch (e) {
pages.current.delete(pg);
setFilterErr((e as Error).message);
}
}, [id, filter]);
// (re)load when session or filter changes
useEffect(() => {
reset(); setSel(-1); setDetail(null); setTotal(0); setT0(0);
if (!id) return;
setLoadingFilter(!!filter);
loadPage(0, true).finally(() => setLoadingFilter(false));
}, [id, filter]); // eslint-disable-line
useEffect(() => {
if (!id) { setSess(null); return; }
api<{ session: Session }>(`/api/captures/${id}`).then((d) => setSess(d.session)).catch(() => setSess(null));
}, [id, sessions]);
const need = useCallback((a: number, b: number) => {
for (let pg = Math.floor(a / PAGE); pg <= Math.floor((b - 1) / PAGE); pg++) {
if (pages.current.has(pg)) continue; // live rows for loaded pages arrive via events
for (let i = Math.max(a, pg * PAGE); i < Math.min(b, (pg + 1) * PAGE); i++) if (!cache.current.has(i)) { loadPage(pg); break; }
}
}, [loadPage]);
// live updates
const frozenRef = useRef(frozen); frozenRef.current = frozen;
useEffect(() => {
let raf = 0, dirty = false, maxPos = -1;
const flush = () => { raf = 0; if (dirty) { dirty = false; setTotal((t) => Math.max(t, maxPos + 1)); setVersion((v) => v + 1); } };
return onEvent((type, d) => {
if (type === "capture" && d.id === id) setSess(d);
if (d.session !== id) return;
if (type === "resync") { if (d.ui_dropped) setUiDropped(d.ui_dropped); if (!frozenRef.current) { reset(); loadPage(0, true); } return; }
if (type !== "packets" || filter) return;
if (frozenRef.current) { setPending((n) => n + d.rows.length); return; }
for (const r of d.rows as Row[]) {
const pos = r.n - firstNum.current;
cache.current.set(pos, r);
maxPos = Math.max(maxPos, pos);
if (!t0 && pos === 0) setT0(r.t);
}
dirty = true;
if (!raf) raf = window.setTimeout(flush, 200) as unknown as number; // ≤5 renders/s regardless of packet rate
});
}, [id, filter, reset, loadPage, t0]);
// filtered live views: re-evaluate periodically (tshark runs server-side)
useEffect(() => {
if (!filter || sess?.state !== "running" || frozen) return;
const t = setInterval(() => { pages.current.clear(); loadPage(Math.max(0, Math.floor((total - 1) / PAGE)), true); }, 3000);
return () => clearInterval(t);
}, [filter, sess?.state, frozen, total, loadPage]);
// load detail for selection
useEffect(() => {
const r = cache.current.get(sel);
if (sel < 0 || !r || !id) { setDetail(null); return; }
const ctl = new AbortController();
setDetailErr("");
api<{ detail: Detail; error?: string }>(`/api/captures/${id}/packets/${r.n}`, { signal: ctl.signal })
.then((d) => { setDetail(d.detail); setDetailErr(d.error ?? ""); setTreeSel(""); })
.catch((e) => { if (e.name !== "AbortError") { setDetail(null); setDetailErr(e.message); } });
return () => ctl.abort();
}, [sel, id, version > 0 && cache.current.has(sel)]); // eslint-disable-line
const bytes = useMemo(() => {
const h = detail?.bytes ?? "";
const b = new Uint8Array(h.length / 2);
for (let i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16);
return b;
}, [detail]);
const selNode = detail?.tree && treeSel ? nodeAt(detail.tree, treeSel) : undefined;
const hl: [number, number] | null = selNode && selNode.verified && selNode.size > 0 ? [selNode.pos, selNode.size] : null;
// filter validation (debounced, via tshark)
useEffect(() => {
if (!filterText.trim()) { setFilterValid(null); return; }
const t = setTimeout(() => {
api("/api/filters/validate", { body: { kind: "display", filter: filterText } })
.then((r) => { setFilterValid(r.valid); setFilterErr(r.valid ? "" : r.error); }).catch(() => {});
}, 350);
return () => clearTimeout(t);
}, [filterText]);
const applyFilter = (f = filterText) => {
setFilterText(f);
setFilter(f.trim());
if (f.trim()) setHistory((h) => [f.trim(), ...h.filter((x) => x !== f.trim())].slice(0, 30));
};
// keyboard shortcuts
useEffect(() => {
const k = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (e.key === "/" && !/INPUT|TEXTAREA|SELECT/.test(t.tagName)) { filterRef.current?.focus(); e.preventDefault(); }
if (e.key === "Escape" && t === filterRef.current) { filterRef.current?.blur(); }
if (/INPUT|TEXTAREA|SELECT/.test(t.tagName)) return;
if (e.key === " " && !e.ctrlKey) { setFrozen((f) => !f); e.preventDefault(); }
if (e.key === "f" && !e.ctrlKey && !e.metaKey) { setFollow((f) => !f); }
};
addEventListener("keydown", k);
return () => removeEventListener("keydown", k);
}, [setFollow]);
const unfreeze = () => { setFrozen(false); setPending(0); reset(); loadPage(Math.max(0, Math.floor((total - 1) / PAGE)), true); loadPage(0, true); };
const control = async (path: string, body?: any) => {
try { await api(path, { method: "POST", body }); reloadSessions(); } catch (e) { toast((e as Error).message, true); }
};
const dragSplit = (which: 0 | 1, e: React.MouseEvent) => {
e.preventDefault();
const box = (e.currentTarget.parentElement as HTMLElement).getBoundingClientRect();
const target = e.currentTarget as HTMLElement;
target.classList.add("drag");
const move = (ev: MouseEvent) => {
const f = Math.min(.85, Math.max(.1, (ev.clientY - box.top) / box.height));
setSplit((s) => which === 0 ? [f, Math.min(s[1], .9 - f)] : [s[0], Math.max(.08, f - s[0])]);
};
const up = () => { target.classList.remove("drag"); removeEventListener("mousemove", move); removeEventListener("mouseup", up); };
addEventListener("mousemove", move); addEventListener("mouseup", up);
};
const rowMenu = (pos: number, e: React.MouseEvent) => {
const r = cache.current.get(pos);
if (!r) return;
const and = (f: string) => filter ? `(${filter}) && ${f}` : f;
const conv = r.sp !== undefined && r.dp !== undefined && isAddr(r.s) && isAddr(r.d)
? `${addrFilter(r.s).replace("addr ==", "addr eq")} && ${addrFilter(r.d).replace("addr ==", "addr eq")} && ${r.st !== undefined ? "tcp" : "udp"}.port in {${r.sp} ${r.dp}}` : "";
const items: CtxItem[] = [
...(isAddr(r.s) ? [{ label: `Filter: ${addrFilter(r.s)}`, onClick: () => applyFilter(addrFilter(r.s)) }] : []),
...(isAddr(r.d) ? [{ label: `Filter: ${addrFilter(r.d)}`, onClick: () => applyFilter(addrFilter(r.d)) }] : []),
...(r.st !== undefined ? [{ label: `Filter: tcp.stream == ${r.st}`, onClick: () => applyFilter(`tcp.stream == ${r.st}`) }] : []),
...(conv ? [{ label: "Filter: this conversation", onClick: () => applyFilter(conv) }] : []),
...(filter ? [{ label: "Exclude source from current filter", onClick: () => isAddr(r.s) && applyFilter(and(`!(${addrFilter(r.s)})`)) }] : []),
{ label: "", sep: true },
{ label: "Copy summary", onClick: () => copyText(`${r.n}\t${r.t}\t${r.s}\t${r.d}\t${r.p}\t${r.l}\t${r.i}`) },
{ label: "Copy bytes as hex", onClick: () => detail && copyText(detail.bytes) },
];
setCtx({ at: { x: e.clientX, y: e.clientY }, items });
};
const treeMenu = (n: TreeNode, e: React.MouseEvent) => {
if (!n.field) return;
const v = n.show ?? "";
const expr = !v ? n.field : /^-?[0-9.]+$|^0x[0-9a-f]+$/i.test(v) || /^[0-9a-f:.]+$/i.test(v) && v.length > 3 ? `${n.field} == ${v}` : `${n.field} == ${dq(v)}`;
setCtx({
at: { x: e.clientX, y: e.clientY }, items: [
{ label: `Apply as filter: ${expr.length > 60 ? expr.slice(0, 60) + "…" : expr}`, onClick: () => applyFilter(expr) },
{ label: `Prepare filter (edit first)`, onClick: () => { setFilterText(expr); filterRef.current?.focus(); } },
{ label: `Filter: field present (${n.field})`, onClick: () => applyFilter(n.field!) },
{ label: "", sep: true },
{ label: "Copy value", onClick: () => copyText(v) },
{ label: "Copy line", onClick: () => copyText(n.label) },
{ label: "Copy field name", onClick: () => copyText(n.field!) },
],
});
};
const running = sess?.state === "running";
const lag = sess?.ingest_lag ?? 0;
return (
<div className="analyser">
<div className="toolbar" role="toolbar" aria-label="Capture controls">
<select value={id} onChange={(e) => go("/analyse/" + e.target.value)} aria-label="Capture session" style={{ maxWidth: 280 }}>
{!id && <option value="">No capture sessions</option>}
{sessions?.map((s) => <option key={s.id} value={s.id}>{s.state === "running" ? "● " : ""}{s.name} ({s.packets.toLocaleString()} pkts)</option>)}
</select>
<div className="divider" />
{!live && <button className="primary" onClick={() => setStartOpen(true)}>● Start capture</button>}
{live && <>
<button className="danger" onClick={() => control("/api/captures/live/stop")} title="Stop capture and finalise files">■ Stop</button>
<button className={live.paused ? "on" : ""} onClick={() => control("/api/captures/live/pause", { paused: !live.paused })}
title="Pause capture: dumpcap keeps running but packets are discarded and not stored. Recorded in the session log.">
{live.paused ? "▶ Resume capture" : "❚❚ Pause capture"}</button>
</>}
<button className={frozen ? "on" : ""} onClick={() => frozen ? unfreeze() : setFrozen(true)} title="Pause view (Space): stop updating this table. Capture continues.">
{frozen ? `Resume view${pending ? ` (+${pending.toLocaleString()})` : ""}` : "Pause view"}</button>
<button className={follow ? "on" : ""} onClick={() => setFollow(!follow)} title="Auto-scroll to newest packet (f)">⤓ Follow</button>
<div className="divider" />
<button onClick={() => setColsOpen(true)}>Columns</button>
<button onClick={() => setAbsTime(!absTime)} title="Toggle relative / absolute UTC time">{absTime ? "UTC time" : "Relative time"}</button>
{id && <>
<a className="btn" href={`/api/captures/${id}/export`} download>Export all</a>
{filter && <a className="btn" href={`/api/captures/${id}/export?filter=${encodeURIComponent(filter)}`} download>Export displayed</a>}
</>}
<span style={{ flex: 1 }} />
<button className="ghost" onClick={() => setFocus(!focus)} title="Focus mode (Shift+F)">{focus ? "Exit focus" : "Focus"}</button>
</div>
<div className="filterbar">
<span className="kind" title="Display filters select stored packets after capture. They are evaluated by tshark and use Wireshark syntax.">Display filter</span>
<input ref={filterRef} list="filter-hist" value={filterText} placeholder="e.g. dns || tls.handshake.type == 1 · press / to focus"
className={filterValid === false ? "invalid" : filterValid ? "valid" : ""} spellCheck={false} aria-label="Display filter"
onChange={(e) => setFilterText(e.target.value)} onKeyDown={(e) => e.key === "Enter" && applyFilter()} />
<datalist id="filter-hist">{history.map((h) => <option key={h} value={h} />)}{saved?.filter((s) => s.Kind === "display").map((s) => <option key={s.ID} value={s.Expr}>{s.Name}</option>)}</datalist>
<button onClick={() => applyFilter()} disabled={filterValid === false}>Apply</button>
{filter && <button className="ghost" onClick={() => applyFilter("")}>Clear</button>}
<button className="ghost" disabled={!filterText || filterValid === false} onClick={async () => {
const name = prompt("Save filter as:", filterText.slice(0, 40));
if (name) { await api("/api/filters/saved", { body: { Name: name, Kind: "display", Expr: filterText } }); reloadSaved(); toast("Filter saved"); }
}}>Save</button>
{saved && saved.filter((s) => s.Kind === "display").length > 0 && (
<select value="" onChange={(e) => e.target.value && applyFilter(e.target.value)} aria-label="Saved filters" style={{ maxWidth: 160 }}>
<option value="">Saved…</option>
{saved.filter((s) => s.Kind === "display").map((s) => <option key={s.ID} value={s.Expr}>{s.Name}</option>)}
</select>
)}
</div>
{filterErr && <div className="filtererr" role="alert">{filterErr}</div>}
{!id ? (
<div className="page"><div className="empty"><h2>No captures yet</h2><p>Start a live capture on the access-point interface, or import a pcap/pcapng file in the Capture Library.</p><button className="primary" onClick={() => setStartOpen(true)}>Start capture</button></div></div>
) : (
<div className="panes">
<div className="pane" style={{ flex: `0 0 ${split[0] * 100}%` }}>
<PacketTable total={total} rowAt={(i) => cache.current.get(i)} need={need} selected={sel} onSelect={(i) => { setSel(i); if (running) setFollow(false); }}
onContext={rowMenu} cols={cols} setCols={setCols} rowH={rowH} follow={follow && running && !frozen && !filter} t0={t0} absTime={absTime} rules={rules} version={version} />
</div>
<div className="splitter" role="separator" aria-orientation="horizontal" onMouseDown={(e) => dragSplit(0, e)} />
<div className="pane" style={{ flex: `0 0 ${split[1] * 100}%` }}>
<div className="pane-title"><span>Packet details{detail ? ` · #${detail.row.n}` : ""}</span><span className="sp" />{detail?.note && <span title={detail.note}>ⓘ segment {detail.segment}</span>}</div>
{detailErr && <div className="filtererr">{detailErr}</div>}
{detail?.tree ? <ProtoTree tree={detail.tree} selected={treeSel} onSelect={setTreeSel} onContext={treeMenu} />
: <div className="empty">{sel < 0 ? "Select a packet." : "Dissecting…"}</div>}
</div>
<div className="splitter" role="separator" aria-orientation="horizontal" onMouseDown={(e) => dragSplit(1, e)} />
<div className="pane" style={{ flex: 1 }}>
{detail ? <HexView bytes={bytes} hl={hl} onByte={(b) => { const p = detail.tree && pathForByte(detail.tree, b); if (p) setTreeSel(p); }} />
: <><div className="pane-title">Bytes</div><div className="empty">–</div></>}
</div>
</div>
)}
<div className="statusbar" aria-live="off">
{sess ? <>
<Badge kind={running ? (sess.paused ? "warn" : "live") : sess.state === "failed" ? "bad" : "plain"}>{running ? (sess.paused ? "capture paused" : "capturing") : sess.state}</Badge>
<span>Captured <b>{sess.packets.toLocaleString()}</b></span>
<span title="Packets written to disk but not yet summarised by tshark">Ingest lag <b>{lag.toLocaleString()}</b></span>
<span>Displayed <b>{total.toLocaleString()}</b>{loadingFilter ? " (filtering…)" : ""}</span>
<span title="Live updates this browser missed because it fell behind. Captured packets are unaffected; the view re-synchronised.">UI updates dropped <b>{uiDropped}</b></span>
<span title="Packets the kernel or dumpcap could not capture. Reported by dumpcap when the capture stops.">Capture drops <b>{running ? "reported at stop" : (sess.drops.match(/(\d+)\/(\d+)/g)?.map((x) => x.split("/")[1]).join("+") ?? "n/a")}</b></span>
<span>{fmtBytes(sess.bytes)}</span>
{sess.error && <span className="err" title={sess.error}>⚠ {sess.error.slice(0, 80)}</span>}
<span style={{ flex: 1 }} />
<span className="hint">Encrypted payloads are shown as captured: still encrypted.</span>
</> : <span>No session selected</span>}
</div>
<ColumnsDialog open={colsOpen} cols={cols} setCols={setCols} onClose={() => setColsOpen(false)} />
<StartDialog open={startOpen} onClose={() => setStartOpen(false)} onStarted={(s) => { setStartOpen(false); reloadSessions(); go("/analyse/" + s.id); }} />
<ContextMenu at={ctx?.at ?? null} items={ctx?.items ?? []} onClose={() => setCtx(null)} />
</div>
);
}
function ColumnsDialog({ open, cols, setCols, onClose }: { open: boolean; cols: ColState[]; setCols: (c: ColState[]) => void; onClose: () => void }) {
// add any columns introduced since the layout was saved
const all = [...cols, ...defaultCols.filter((d) => !cols.some((c) => c.id === d.id)).map((d) => ({ ...d, on: false }))];
const mv = (i: number, d: number) => { const a = [...all]; const [x] = a.splice(i, 1); a.splice(Math.max(0, Math.min(a.length, i + d)), 0, x); setCols(a); };
return (
<Modal open={open} title="Columns" onClose={onClose} footer={<><button onClick={() => setCols(defaultCols)}>Reset</button><button className="primary" onClick={onClose}>Done</button></>}>
<p className="hint">Show, hide and reorder columns. Drag the edge of a column header to resize it. The layout is saved in this browser.</p>
<div className="stack" style={{ gap: ".25rem" }}>
{all.map((c, i) => (
<div key={c.id} className="row spread">
<label className="inline"><input type="checkbox" checked={c.on} onChange={(e) => setCols(all.map((x) => x.id === c.id ? { ...x, on: e.target.checked } : x))} />{COLUMNS.find((d) => d.id === c.id)?.title}</label>
<span className="row"><button className="small" onClick={() => mv(i, -1)} aria-label="Move up">↑</button><button className="small" onClick={() => mv(i, 1)} aria-label="Move down">↓</button></span>
</div>
))}
</div>
</Modal>
);
}
function StartDialog({ open, onClose, onStarted }: { open: boolean; onClose: () => void; onStarted: (s: Session) => void }) {
const [ifaces, setIfaces] = useState<Iface[]>([]);
const [chosen, setChosen] = useState<string[]>([]);
const [name, setName] = useState("");
const [bpf, setBpf] = useState("");
const [bpfErr, setBpfErr] = useState("");
const [ring, setRing] = useState(0);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
useEffect(() => {
if (!open) return;
Promise.all([api("/api/system/interfaces"), api("/api/network").catch(() => null)]).then(([d, n]) => {
setIfaces(d.interfaces ?? []);
const ap = n?.config?.ap?.interface;
setChosen((c) => c.length ? c : ap ? [ap] : []);
});
}, [open]);
useEffect(() => {
if (!bpf.trim()) { setBpfErr(""); return; }
const t = setTimeout(() => api("/api/filters/validate", { body: { kind: "capture", filter: bpf, interface: chosen[0] ?? "any" } }).then((r) => setBpfErr(r.valid ? "" : r.error)), 400);
return () => clearTimeout(t);
}, [bpf, chosen]);
const start = async () => {
setBusy(true); setErr("");
try { onStarted(await api<Session>("/api/captures", { body: { name, interfaces: chosen, capture_filter: bpf, ring_segments: ring } })); }
catch (e) { setErr((e as Error).message); }
setBusy(false);
};
return (
<Modal open={open} title="Start capture" onClose={onClose} footer={<><button onClick={onClose}>Cancel</button><button className="primary" disabled={busy || !chosen.length || !!bpfErr} onClick={start}>{busy ? "Starting…" : "Start"}</button></>}>
<label>Name<input value={name} placeholder="optional" onChange={(e) => setName(e.target.value)} /></label>
<div><span className="hint">Interfaces</span>
<div className="choice">{ifaces.map((i) => (
<label key={i.name}><input type="checkbox" checked={chosen.includes(i.name)} onChange={(e) => setChosen(e.target.checked ? [...chosen, i.name] : chosen.filter((x) => x !== i.name))} />
<span className="mono">{i.name}{i.wireless ? " (Wi-Fi)" : ""}<small>{(i.addrs ?? []).join(" ")}</small></span></label>))}
</div>
</div>
<label>Capture filter (BPF, applied by the kernel while capturing)
<input className={"mono" + (bpfErr ? " invalid" : "")} value={bpf} placeholder="e.g. not port 22" onChange={(e) => setBpf(e.target.value)} />
<span className="hint">Packets excluded here are never stored. To narrow what you see after capture, use a display filter instead.</span>
{bpfErr && <span className="err mono">{bpfErr}</span>}
</label>
<label>Ring buffer: keep only the newest N segment files (0 = keep all, within the storage quota)
<input type="number" min={0} value={ring} onChange={(e) => setRing(+e.target.value)} /></label>
{err && <div className="err">{err}</div>}
</Modal>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useState } from "react";
import { api, fmtBytes, fmtDur, fmtTime } from "../api";
import { Badge, Modal, go, toast, usePoll } from "../ui";
export default function Devices() {
const [d, err, reload] = usePoll<{ clients: any[]; errors: string[] }>("/api/ap/clients", 10000);
const [edit, setEdit] = useState<any>(null);
const list = [...(d?.clients ?? [])].sort((a, b) => Number(b.associated) - Number(a.associated) || b.last_seen - a.last_seen);
const now = Date.now() / 1000;
return (
<div className="page">
<div className="page-head"><div><h1>Devices</h1>
<p>Clients of the PiLens access point, keyed by MAC address. Devices that randomise their MAC address appear as separate entries, and PiLens never merges them automatically.</p></div></div>
{err && <div className="banner bad"><div>{err}</div></div>}
{d?.errors?.map((e, i) => <div key={i} className="banner warn" style={{ marginBottom: ".5rem" }}><div>{e}</div></div>)}
<div className="card" style={{ padding: 0 }}>
<table className="data">
<thead><tr><th>Device</th><th>MAC</th><th>IP</th><th>State</th><th>Signal</th><th>Rx / Tx</th><th>Connected</th><th>Lease expires</th><th>First / last seen</th><th></th></tr></thead>
<tbody>
{list.map((c) => (
<tr key={c.mac}>
<td><b>{c.label || c.hostname || "—"}</b>{c.label && c.hostname && <div className="hint">{c.hostname}</div>}{c.notes && <div className="hint" title={c.notes}>📝 {c.notes.slice(0, 40)}</div>}</td>
<td className="mono">{c.mac}{c.randomised_mac && <div><Badge kind="plain">randomised</Badge></div>}</td>
<td className="mono">{c.ip || "—"}</td>
<td>{c.associated ? <Badge kind="ok">associated</Badge> : <Badge>offline</Badge>}</td>
<td className="mono">{c.signal_dbm != null ? `${c.signal_dbm} dBm` : "—"}</td>
<td className="mono">{c.associated ? `${fmtBytes(c.rx_bytes)} / ${fmtBytes(c.tx_bytes)}` : "—"}</td>
<td className="mono">{c.associated ? fmtDur(c.connected_s) : "—"}{c.associated && c.inactive_ms > 0 && <div className="hint">idle {fmtDur(c.inactive_ms / 1000)}</div>}</td>
<td className="mono">{c.lease_expiry ? (c.lease_expiry > now ? "in " + fmtDur(c.lease_expiry - now) : "expired") : "—"}</td>
<td className="hint">{fmtTime(c.first_seen * 1000)}<br />{c.last_seen ? fmtTime(c.last_seen * 1000) : "—"}</td>
<td><div className="row" style={{ flexWrap: "nowrap" }}>
<button className="small" onClick={() => setEdit({ ...c })}>Label</button>
<button className="small" onClick={() => go(`/analyse?filter=${encodeURIComponent("eth.addr == " + c.mac)}`)} title="Open the analyser filtered to this MAC">Packets</button>
</div></td>
</tr>
))}
{list.length === 0 && <tr><td colSpan={10} className="empty">No devices have connected yet.</td></tr>}
</tbody>
</table>
</div>
<p className="hint" style={{ marginTop: ".6rem" }}>Rx/Tx are radio-level counters reported by the Wi-Fi driver for the current association. Signal is measured by the Pi's radio.</p>
<Modal open={!!edit} title={`Device ${edit?.mac ?? ""}`} onClose={() => setEdit(null)} footer={<><button onClick={() => setEdit(null)}>Cancel</button>
<button className="primary" onClick={async () => { try { await api(`/api/devices/${edit.mac}`, { method: "PUT", body: { Label: edit.label, Notes: edit.notes } }); setEdit(null); reload(); } catch (e) { toast((e as Error).message, true); } }}>Save</button></>}>
{edit && <><label>Label<input value={edit.label} maxLength={100} onChange={(e) => setEdit({ ...edit, label: e.target.value })} /></label>
<label>Analyst notes<textarea rows={5} value={edit.notes} onChange={(e) => setEdit({ ...edit, notes: e.target.value })} /></label></>}
</Modal>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { fmtBytes, fmtDur } from "../api";
import { Badge, usePoll } from "../ui";
export default function Health() {
const [h, err] = usePoll<any>("/api/system/health", 5000);
const [ifs] = usePoll<any>("/api/system/interfaces", 15000);
if (!h) return <div className="page">{err ? <div className="banner bad"><div>{err}</div></div> : "Loading…"}</div>;
const s = h.system, hs = h.helper;
const svc: [string, boolean | null, string][] = [
["Network helper (root)", !h.helper_error, h.helper_error || "responding"],
["Firewall table inet pilens", hs?.table_loaded ?? null, hs?.table_loaded ? "loaded" : "not loaded"],
["Access point (NetworkManager)", hs ? hs.ap_state === "activated" : null, hs?.ap_state ?? "unknown"],
["DHCP / DNS (pilens-dnsmasq)", hs?.dnsmasq_active ?? null, hs?.dnsmasq_active ? "active" : "inactive"],
["IPv4 forwarding", hs?.ip_forward ?? null, hs?.ip_forward ? "enabled" : "disabled"],
["Packet capture (dumpcap)", h.capture.running ? true : null, h.capture.running ? `capturing on ${h.capture.session.interfaces.join(", ")}` : "idle"],
["Dissector", !!h.capture.tshark, h.capture.tshark],
["Clock synchronisation", s.clock_synchronised === "yes", s.clock_synchronised],
["TLS interception proxy", null, "not part of this version"],
];
return (
<div className="page">
<div className="page-head"><div><h1>System Health</h1><p>Each check reports what was measured, not just whether a process exists.</p></div></div>
<div className="grid g4" style={{ marginBottom: ".9rem" }}>
<div className="card"><h3>CPU</h3><div className="metric">{s.cpu_percent.toFixed(0)}%</div><div className="hint">load {s.load.split(" ").slice(0, 3).join(" ")}</div></div>
<div className="card"><h3>Memory</h3><div className="metric">{fmtBytes(s.mem_total - s.mem_available)}</div><div className="hint">of {fmtBytes(s.mem_total)}</div></div>
<div className="card"><h3>Temperature</h3><div className="metric">{s.temp_c != null ? s.temp_c.toFixed(1) + " °C" : "n/a"}</div>
<div className="hint">{s.throttled ? s.throttled : "throttling status unavailable"}</div></div>
<div className="card"><h3>Uptime</h3><div className="metric">{fmtDur(s.uptime_s)}</div><div className="hint">{s.model}</div></div>
</div>
{s.throttle_flags?.length > 0 && <div className="banner warn" style={{ marginBottom: "1rem" }}><div><b>Firmware reports power or thermal problems</b>{s.throttle_flags.join(" · ")}. Under-voltage on a Pi causes packet loss and storage corruption. Use the official power supply.</div></div>}
<div className="grid g2">
<div className="card"><h3>Services</h3>
<table className="data"><tbody>{svc.map(([n, ok, d]) => <tr key={n}><td>{n}</td><td><Badge kind={ok === null ? "plain" : ok ? "ok" : "bad"}>{d}</Badge></td></tr>)}</tbody></table>
{hs?.last_error && <p className="err">Last apply error: {hs.last_error}</p>}
</div>
<div className="card"><h3>Storage</h3>
<dl className="kv">
{Object.entries(s.disks as Record<string, { total: number; free: number }>).map(([k, v]) => <><dt key={k}>{k} filesystem</dt><dd>{fmtBytes(v.free)} free of {fmtBytes(v.total)}</dd></>)}
<dt>Capture files</dt><dd>{fmtBytes(h.storage.used)} of {fmtBytes(h.storage.limits.max_bytes)} quota</dd>
<dt>Reserve</dt><dd>{fmtBytes(h.storage.limits.reserve_bytes)} always kept free</dd>
<dt>Location</dt><dd className="mono">{h.storage.dir}</dd>
</dl>
{h.storage.low_warning && <p className="warnc">Storage is low.</p>}
</div>
<div className="card"><h3>Network interfaces</h3>
<table className="data"><thead><tr><th>Name</th><th>Link</th><th>Addresses</th><th>Driver</th><th>rx dropped</th></tr></thead><tbody>
{ifs?.interfaces?.map((i: any) => <tr key={i.name}><td className="mono">{i.name}{i.wireless && " 📶"}</td><td><Badge kind={i.carrier ? "ok" : "bad"}>{i.up ? (i.carrier ? "up" : "no carrier") : "down"}</Badge></td>
<td className="mono" style={{ fontSize: ".78rem" }}>{(i.addrs ?? []).join(" ")}</td><td className="mono">{i.driver}</td><td className="mono">{i.rx_dropped}</td></tr>)}
</tbody></table>
{ifs?.wifi?.map((w: any, k: number) => <p key={k} className="hint">Radio {w.phy || "?"}: AP mode {w.ap_mode ? "supported" : "NOT supported"}; bands {w.bands?.join(", ") || "unknown"}{w.error ? " · " + w.error : ""}</p>)}
</div>
<div className="card"><h3>Software</h3>
<dl className="kv"><dt>PiLens</dt><dd className="mono">{h.version}</dd><dt>OS</dt><dd>{s.os}</dd><dt>Kernel</dt><dd className="mono">{s.kernel}</dd>
<dt>NetworkManager</dt><dd className="mono">{s.networkmanager}</dd><dt>TShark</dt><dd className="mono">{h.capture.tshark}</dd><dt>Regulatory domain</dt><dd className="mono">{hs?.regdomain || "unknown"}</dd></dl>
<p className="hint">For a full diagnostic bundle (secrets redacted), run <code>sudo /opt/pilens/diagnose.sh</code> on the appliance.</p>
</div>
</div>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useRef, useState } from "react";
import { api, fmtBytes, fmtTime, type Session, type Segment } from "../api";
import { Badge, Modal, go, toast, usePoll } from "../ui";
export default function Library() {
const [sessions, err, reload] = usePoll<Session[]>("/api/captures", 5000);
const [q, setQ] = useState("");
const [edit, setEdit] = useState<Session | null>(null);
const [del, setDel] = useState<Session | null>(null);
const [info, setInfo] = useState<{ s: Session; segs: Segment[]; verify?: any[] } | null>(null);
const [uploading, setUploading] = useState(false);
const file = useRef<HTMLInputElement>(null);
const list = (sessions ?? []).filter((s) => !q || (s.name + " " + s.tags + " " + s.description + " " + s.notes).toLowerCase().includes(q.toLowerCase()));
const upload = async (f: File) => {
setUploading(true);
try {
const s = await api<Session>(`/api/captures/import?name=${encodeURIComponent(f.name)}`, { raw: f });
toast(`Imported ${f.name}; indexing…`);
reload();
go("/analyse/" + s.id);
} catch (e) { toast((e as Error).message, true); }
setUploading(false);
};
return (
<div className="page">
<div className="page-head">
<div><h1>Capture Library</h1><p>Stored sessions. Original packet files are kept unmodified, with SHA-256 hashes.</p></div>
<div className="row">
<input placeholder="Search name, tags, notes" value={q} onChange={(e) => setQ(e.target.value)} />
<input ref={file} type="file" accept=".pcap,.pcapng,.cap" hidden onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
<button onClick={() => file.current?.click()} disabled={uploading}>{uploading ? "Uploading…" : "Import pcap/pcapng"}</button>
</div>
</div>
{err && <div className="banner bad"><div>{err}</div></div>}
<div className="card" style={{ padding: 0 }}>
<table className="data">
<thead><tr><th>Name</th><th>State</th><th>Started</th><th>Duration</th><th>Packets</th><th>Size</th><th>Source</th><th></th></tr></thead>
<tbody>
{list.map((s) => (
<tr key={s.id}>
<td><a href={"#/analyse/" + s.id}>{s.name}</a>{s.preserved && <> <Badge kind="ok">preserved</Badge></>}{s.tags && <div className="hint">{s.tags}</div>}</td>
<td><Badge kind={s.state === "running" ? "live" : s.state === "failed" ? "bad" : s.state === "importing" ? "warn" : "plain"}>{s.state}</Badge>{s.error && <div className="err hint" title={s.error}>{s.error.slice(0, 60)}</div>}</td>
<td className="hint">{fmtTime(s.started_at)}</td>
<td className="mono">{s.ended_at ? ((s.ended_at - s.started_at) / 1000).toFixed(0) + " s" : "–"}</td>
<td className="mono">{s.packets.toLocaleString()}</td>
<td className="mono">{fmtBytes(s.bytes)}</td>
<td className="mono">{s.kind === "import" ? "import" : s.interfaces.join(",")}{s.capture_filter && <div className="hint" title="capture filter">{s.capture_filter}</div>}</td>
<td><div className="row" style={{ flexWrap: "nowrap" }}>
<button className="small" onClick={async () => setInfo({ s, segs: (await api(`/api/captures/${s.id}`)).segments ?? [] })}>Integrity</button>
<a className="btn small" href={`/api/captures/${s.id}/export`} download>Export</a>
<button className="small" onClick={() => setEdit({ ...s })}>Edit</button>
<button className="small danger" disabled={s.state === "running"} onClick={() => setDel(s)}>Delete</button>
</div></td>
</tr>
))}
{list.length === 0 && <tr><td colSpan={8} className="empty">No sessions.</td></tr>}
</tbody>
</table>
</div>
<Modal open={!!edit} title="Edit session" onClose={() => setEdit(null)} footer={<><button onClick={() => setEdit(null)}>Cancel</button>
<button className="primary" onClick={async () => { try { await api(`/api/captures/${edit!.id}`, { method: "PATCH", body: { Name: edit!.name, Description: edit!.description, Tags: edit!.tags, Notes: edit!.notes, Preserved: edit!.preserved } }); setEdit(null); reload(); } catch (e) { toast((e as Error).message, true); } }}>Save</button></>}>
{edit && <>
<label>Name<input value={edit.name} onChange={(e) => setEdit({ ...edit, name: e.target.value })} /></label>
<label>Description<input value={edit.description} onChange={(e) => setEdit({ ...edit, description: e.target.value })} /></label>
<label>Tags<input value={edit.tags} placeholder="comma separated" onChange={(e) => setEdit({ ...edit, tags: e.target.value })} /></label>
<label>Operator notes<textarea rows={5} value={edit.notes} onChange={(e) => setEdit({ ...edit, notes: e.target.value })} /></label>
<label className="inline"><input type="checkbox" checked={edit.preserved} onChange={(e) => setEdit({ ...edit, preserved: e.target.checked })} />Preserve: never delete automatically (retention or ring buffer)</label>
</>}
</Modal>
<Modal open={!!del} title="Delete capture session?" onClose={() => setDel(null)} footer={<><button onClick={() => setDel(null)}>Cancel</button>
<button className="danger" onClick={async () => { try { await api(`/api/captures/${del!.id}?confirm=${del!.id}`, { method: "DELETE" }); setDel(null); reload(); toast("Session deleted"); } catch (e) { toast((e as Error).message, true); } }}>Delete permanently</button></>}>
<p>This permanently deletes <b>{del?.name}</b>: {del?.packets.toLocaleString()} packets, {fmtBytes(del?.bytes ?? 0)} of original capture files, and all derived indexes. It cannot be undone.</p>
{del?.preserved && <div className="banner warn"><div>This session is marked <b>preserved</b>.</div></div>}
</Modal>
<Modal open={!!info} title="Integrity" onClose={() => setInfo(null)} footer={<>
<a className="btn" href={`/api/captures/${info?.s.id}/manifest`} download>Download manifest</a>
<button className="primary" onClick={async () => { const v = await api(`/api/captures/${info!.s.id}/verify`, { method: "POST" }); setInfo({ ...info!, verify: v }); }}>Verify hashes now</button></>}>
{info && <>
<dl className="kv">
<dt>Session</dt><dd className="mono">{info.s.id}</dd>
<dt>Clock synced</dt><dd>{String(info.s.meta?.clock_synchronised ?? "unknown")}</dd>
<dt>Software</dt><dd className="mono">PiLens {info.s.meta?.pilens_version} · {info.s.meta?.tshark_version}</dd>
{info.s.drops && <><dt>Drops</dt><dd className="mono" style={{ whiteSpace: "pre-wrap" }}>{info.s.drops}</dd></>}
</dl>
<table className="data"><thead><tr><th>#</th><th>File</th><th>Packets</th><th>SHA-256</th><th>Check</th></tr></thead><tbody>
{info.segs.map((g) => { const v = info.verify?.find((x) => x.seq === g.seq); return (
<tr key={g.seq}><td>{g.seq}</td><td className="mono">{g.file}{g.deleted && " (rotated out)"}</td><td className="mono">{g.count}</td>
<td className="mono" style={{ fontSize: ".72rem", wordBreak: "break-all" }}>{g.sha256 || "(still being written)"}</td>
<td>{v ? <Badge kind={v.status === "ok" ? "ok" : v.status === "mismatch" || v.status === "missing" ? "bad" : "warn"}>{v.status}</Badge> : ""}</td></tr>); })}
</tbody></table>
<p className="hint">Segment files are the original captured bytes. Packet summaries and dissections are derived on demand and are not evidence.</p>
</>}
</Modal>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useState } from "react";
import { api, ApiError } from "../api";
export default function Login({ onLogin }: { onLogin: () => void }) {
const [u, setU] = useState("");
const [p, setP] = useState("");
const [code, setCode] = useState("");
const [needTotp, setNeedTotp] = useState(false);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true); setErr("");
try {
await api("/api/auth/login", { body: { username: u, password: p, totp: code } });
onLogin();
} catch (x) {
if (x instanceof ApiError && x.message === "totp_required") { setNeedTotp(true); setErr(""); }
else setErr((x as Error).message);
}
setBusy(false);
};
return (
<div className="center">
<form className="card authcard stack" onSubmit={submit}>
<div className="row"><img src="/favicon.svg" width={34} alt="" /><div><h1>PiLens</h1><span className="hint">Appliance sign-in</span></div></div>
<label>Username<input autoFocus autoComplete="username" value={u} onChange={(e) => setU(e.target.value)} /></label>
<label>Password<input type="password" autoComplete="current-password" value={p} onChange={(e) => setP(e.target.value)} /></label>
{needTotp && <label>Authenticator code<input className="mono" inputMode="numeric" autoComplete="one-time-code" autoFocus maxLength={6} value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))} /></label>}
{err && <div className="err" role="alert">{err}</div>}
<button className="primary" disabled={busy || !u || !p}>{busy ? "Signing in…" : "Sign in"}</button>
<p className="hint">Captured traffic may contain sensitive personal data. Access is logged.</p>
</form>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { api, type ApplyResult, type Iface, type Network } from "../api";
import { Badge, Steps, toast } from "../ui";
import { ForwardingFields, LanFields, ManagementFields, UplinkField, WifiFields } from "../components/NetForm";
export default function NetworkPage() {
const [n, setN] = useState<Network | null>(null);
const [orig, setOrig] = useState("");
const [passSet, setPassSet] = useState(false);
const [ifaces, setIfaces] = useState<Iface[]>([]);
const [remote, setRemote] = useState("");
const [bands, setBands] = useState<string[]>();
const [status, setStatus] = useState<any>(null);
const [errors, setErrors] = useState<string[]>([]);
const [result, setResult] = useState<ApplyResult | null>(null);
const [busy, setBusy] = useState(false);
const load = () => {
api("/api/network").then((d) => {
const c = { ...d.config, lan: { ...d.config.lan, reservations: d.config.lan.reservations ?? [], dns_overrides: d.config.lan.dns_overrides ?? [] } };
setN(c); setOrig(JSON.stringify(c)); setPassSet(d.passphrase_set);
});
api("/api/system/interfaces").then((d) => { setIfaces(d.interfaces ?? []); setRemote(d.remote_addr); setBands((d.wifi ?? []).find((w: any) => w.ap_mode)?.bands); });
api("/api/system/health").then((h) => setStatus(h));
};
useEffect(load, []);
if (!n) return <div className="page">Loading…</div>;
const set = (f: (n: Network) => Network) => setN((x) => (x ? f(x) : x));
const dirty = JSON.stringify(n) !== orig;
const apply = async () => {
setBusy(true); setErrors([]); setResult(null);
try {
const v = await api("/api/network/validate", { body: n });
if (!v.valid) { setErrors(v.errors); setBusy(false); return; }
const r = await api("/api/network/apply", { body: n });
setResult(r.result); toast("Configuration applied and verified");
load();
} catch (e: any) { setResult(e.body?.result ?? null); setErrors([e.message]); }
setBusy(false);
};
const res = n.lan.reservations ?? [];
const ovr = n.lan.dns_overrides ?? [];
const hs = status?.helper;
return (
<div className="page">
<div className="page-head">
<div><h1>Network Settings</h1><p>Access point, DHCP/DNS, routing and management access. Changes are applied as one transaction and rolled back if any check fails.</p></div>
<div className="row">
{dirty && <span className="warnc">Unsaved changes</span>}
<button onClick={load} disabled={busy}>Revert</button>
<button className="primary" onClick={apply} disabled={busy || !dirty}>{busy ? "Applying…" : "Validate & apply"}</button>
</div>
</div>
{hs && <div className="row" style={{ marginBottom: "1rem" }}>
<Badge kind={hs.ap_state === "activated" ? "ok" : "bad"}>AP {hs.ap_state}</Badge>
<Badge kind={hs.dnsmasq_active ? "ok" : "bad"}>DHCP/DNS {hs.dnsmasq_active ? "active" : "inactive"}</Badge>
<Badge kind={hs.table_loaded ? "ok" : "bad"}>firewall {hs.table_loaded ? "loaded" : "missing"}</Badge>
<Badge kind={hs.ip_forward ? "ok" : "warn"}>IPv4 forwarding {hs.ip_forward ? "on" : "off"}</Badge>
<Badge kind="plain">regdomain {hs.regdomain || "?"}</Badge>
</div>}
{status?.helper_error && <div className="banner bad"><div><b>Network helper unavailable</b>{status.helper_error}</div></div>}
{errors.length > 0 && <div className="banner bad" role="alert" style={{ marginBottom: "1rem" }}><div>{errors.map((e, i) => <div key={i}>{e}</div>)}</div></div>}
{result && <div style={{ marginBottom: "1rem" }}><Steps steps={result.steps} />{result.rolled_back && <div className="banner warn" style={{ marginTop: ".5rem" }}><div><b>Rolled back</b>The previous working configuration was restored.</div></div>}</div>}
<div className="grid g2">
<div className="card stack"><h3>Wi-Fi access point</h3><WifiFields n={n} set={set} ifaces={ifaces} passSet={passSet} bands={bands} /></div>
<div className="card stack"><h3>Client network (DHCP)</h3><LanFields n={n} set={set} /></div>
<div className="card stack"><h3>DHCP reservations</h3>
{res.map((r, i) => <div key={i} className="row">
<input className="mono" placeholder="MAC" value={r.mac} onChange={(e) => set((x) => ({ ...x, lan: { ...x.lan, reservations: res.map((y, j) => j === i ? { ...y, mac: e.target.value } : y) } }))} />
<input className="mono" placeholder="IP" value={r.ip} onChange={(e) => set((x) => ({ ...x, lan: { ...x.lan, reservations: res.map((y, j) => j === i ? { ...y, ip: e.target.value } : y) } }))} />
<input placeholder="hostname" value={r.hostname ?? ""} onChange={(e) => set((x) => ({ ...x, lan: { ...x.lan, reservations: res.map((y, j) => j === i ? { ...y, hostname: e.target.value } : y) } }))} />
<button className="small ghost" aria-label="Remove" onClick={() => set((x) => ({ ...x, lan: { ...x.lan, reservations: res.filter((_, j) => j !== i) } }))}>✕</button></div>)}
<div><button className="small" onClick={() => set((x) => ({ ...x, lan: { ...x.lan, reservations: [...res, { mac: "", ip: "" }] } }))}>+ Add reservation</button></div>
</div>
<div className="card stack"><h3>Local DNS overrides</h3>
<p className="hint">Answer an exact name with a fixed address for AP clients, for authorised testing. Subdomains are not affected.</p>
{ovr.map((o, i) => <div key={i} className="row">
<input className="mono" placeholder="name.example" value={o.name} onChange={(e) => set((x) => ({ ...x, lan: { ...x.lan, dns_overrides: ovr.map((y, j) => j === i ? { ...y, name: e.target.value } : y) } }))} />
<input className="mono" placeholder="address" value={o.ip} onChange={(e) => set((x) => ({ ...x, lan: { ...x.lan, dns_overrides: ovr.map((y, j) => j === i ? { ...y, ip: e.target.value } : y) } }))} />
<button className="small ghost" aria-label="Remove" onClick={() => set((x) => ({ ...x, lan: { ...x.lan, dns_overrides: ovr.filter((_, j) => j !== i) } }))}>✕</button></div>)}
<div><button className="small" onClick={() => set((x) => ({ ...x, lan: { ...x.lan, dns_overrides: [...ovr, { name: "", ip: "" }] } }))}>+ Add override</button></div>
</div>
<div className="card stack"><h3>Forwarding policy</h3><UplinkField n={n} set={set} ifaces={ifaces} /><ForwardingFields n={n} set={set} /></div>
<div className="card stack"><h3>Management access</h3><ManagementFields n={n} set={set} ifaces={ifaces} remote={remote} /></div>
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from "react";
import { onEvent, fmtBytes, fmtTime, type Session } from "../api";
import { Badge, usePoll, go } from "../ui";
export default function Overview() {
const [h, herr] = usePoll<any>("/api/system/health", 10000);
const [clients] = usePoll<{ clients: any[] }>("/api/ap/clients", 15000);
const [sessions] = usePoll<Session[]>("/api/captures", 10000);
const [audit] = usePoll<any[]>("/api/audit", 30000);
const [rate, setRate] = useState<{ pps: number; bps: number } | null>(null);
const last = useRef<{ t: number; p: number; b: number } | null>(null);
// Packet and byte rates from live capture counters (only while a capture runs).
useEffect(() => onEvent((type, d) => {
if (type !== "capture") return;
if (d.state !== "running") { setRate(null); last.current = null; return; }
const now = performance.now();
if (last.current) {
const dt = (now - last.current.t) / 1000;
if (dt > 0.5) setRate({ pps: (d.packets - last.current.p) / dt, bps: (d.bytes - last.current.b) * 8 / dt });
}
last.current = { t: now, p: d.packets, b: d.bytes };
}), []);
const fwd = h?.forwarding;
const live = h?.capture?.session as Session | undefined;
const assoc = clients?.clients.filter((c) => c.associated) ?? [];
const alerts: [string, "warn" | "bad"][] = [];
if (herr) alerts.push(["Health endpoint unavailable: " + herr, "bad"]);
if (h?.helper_error) alerts.push(["Network helper unreachable: " + h.helper_error, "bad"]);
if (h?.helper?.last_error) alerts.push(["Last network change failed and was rolled back: " + h.helper.last_error, "warn"]);
if (h?.system?.throttle_flags?.length) alerts.push(["Raspberry Pi firmware reports: " + h.system.throttle_flags.join(", ") + ". Check the power supply and cooling.", "warn"]);
if (h?.storage?.low_warning) alerts.push(["Capture storage is nearly full.", "warn"]);
if (h && h.system?.clock_synchronised !== "yes") alerts.push([`System clock is not NTP-synchronised (${h.system?.clock_synchronised}). Capture timestamps may be inaccurate. This is recorded in each session.`, "warn"]);
if (live?.error) alerts.push(["Capture: " + live.error, "bad"]);
const fwdKind = fwd?.state === "forwarding" ? "ok" : fwd?.state === "blocked" ? "warn" : "bad";
return (
<div className="page">
<div className="page-head"><div><h1>Overview</h1><p>Appliance state at a glance</p></div>
<button className="primary" onClick={() => go("/analyse")}>Open live capture →</button></div>
{fwd && <div className={"banner " + fwdKind} style={{ marginBottom: "1rem" }}>
<div><b>Forwarding: {fwd.state.toUpperCase()}</b>{fwd.detail}</div></div>}
{alerts.map(([m, k], i) => <div key={i} className={"banner " + k} style={{ marginBottom: ".6rem" }}><div>{m}</div></div>)}
<div className="grid g4" style={{ marginBottom: ".9rem" }}>
<div className="card"><h3>Capture</h3>
<div className="metric">{live ? live.packets.toLocaleString() : "idle"}{live && <small>packets</small>}</div>
<div className="row" style={{ marginTop: ".4rem" }}>{live ? <Badge kind={live.paused ? "warn" : "live"}>{live.paused ? "paused" : "capturing"} on {live.interfaces.join(", ")}</Badge> : <Badge>not capturing</Badge>}</div>
</div>
<div className="card"><h3>Traffic rate</h3>
<div className="metric">{rate ? Math.round(rate.pps).toLocaleString() : "–"}<small>pkt/s</small></div>
<div className="hint">{rate ? `${(rate.bps / 1e6).toFixed(2)} Mbit/s captured` : "Measured from the running capture only"}</div>
</div>
<div className="card"><h3>Access point</h3>
<div className="metric">{assoc.length}<small>associated</small></div>
<div className="row" style={{ marginTop: ".4rem" }}><Badge kind={h?.helper?.ap_state === "activated" ? "ok" : "bad"}>{h?.helper?.ap_state ?? "unknown"}</Badge>
<Badge kind={h?.helper?.dnsmasq_active ? "ok" : "bad"}>DHCP/DNS {h?.helper?.dnsmasq_active ? "up" : "down"}</Badge></div>
</div>
<div className="card"><h3>Upstream</h3>
<div className="metric" style={{ fontSize: "1.2rem" }}>{h?.upstream?.interface ?? "–"}</div>
<div className="row" style={{ marginTop: ".4rem" }}>
<Badge kind={h?.upstream?.default_route ? "ok" : "bad"}>{h?.upstream?.default_route ? `default route via ${h.upstream.gateway}` : "no default route"}</Badge></div>
</div>
<div className="card"><h3>Storage</h3>
<div className="metric" style={{ fontSize: "1.2rem" }}>{h ? fmtBytes(h.storage.used) : "–"}<small>of {h ? fmtBytes(h.storage.limits.max_bytes) : "–"}</small></div>
<div className="hint">{h ? fmtBytes(h.storage.free) + " free on disk" : ""}</div>
</div>
<div className="card"><h3>System</h3>
<div className="metric" style={{ fontSize: "1.2rem" }}>{h ? `${h.system.cpu_percent.toFixed(0)}% CPU` : "–"}</div>
<div className="hint">{h ? `${fmtBytes(h.system.mem_total - h.system.mem_available)} / ${fmtBytes(h.system.mem_total)} RAM${h.system.temp_c ? ` · ${h.system.temp_c.toFixed(1)} °C` : ""}` : ""}</div>
</div>
</div>
<div className="grid g2">
<div className="card"><h3>Connected clients</h3>
{assoc.length === 0 ? <div className="empty">No clients associated.</div> :
<table className="data"><thead><tr><th>Device</th><th>IP</th><th>Signal</th><th>Connected</th></tr></thead><tbody>
{assoc.map((c) => <tr key={c.mac}><td>{c.label || c.hostname || <span className="mono">{c.mac}</span>}</td><td className="mono">{c.ip}</td><td>{c.signal_dbm ?? "–"} dBm</td><td>{Math.round(c.connected_s / 60)} min</td></tr>)}
</tbody></table>}
</div>
<div className="card"><h3>Recent capture sessions</h3>
{!sessions?.length ? <div className="empty">None yet.</div> :
<table className="data"><tbody>{sessions.slice(0, 6).map((s) => <tr key={s.id} style={{ cursor: "pointer" }} onClick={() => go("/analyse/" + s.id)}>
<td>{s.name}</td><td className="mono">{s.packets.toLocaleString()} pkts</td><td><Badge kind={s.state === "running" ? "live" : s.state === "failed" ? "bad" : "plain"}>{s.state}</Badge></td><td className="hint">{fmtTime(s.started_at)}</td></tr>)}</tbody></table>}
</div>
<div className="card"><h3>Recent activity (audit)</h3>
<table className="data"><tbody>{audit?.slice(0, 8).map((a, i) => <tr key={i}><td className="hint">{fmtTime(a.TS)}</td><td>{a.Actor}</td><td className="mono">{a.Action}</td></tr>)}</tbody></table>
</div>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from "react";
import { api, fmtBytes, fmtTime, type Limits } from "../api";
import { toast, usePersist, usePoll } from "../ui";
import type { Prefs } from "../App";
import type { ColourRule } from "../components/PacketTable";
interface Props { prefs: Prefs; setPrefs: (p: Prefs) => void; session: { user: string; totp_enabled: boolean }; onChange: () => void }
export default function Settings({ prefs, setPrefs, session, onChange }: Props) {
const [pw, setPw] = useState({ Current: "", New: "", Confirm: "" });
const [totp, setTotp] = useState<{ secret: string; uri: string } | null>(null);
const [code, setCode] = useState("");
const [disablePw, setDisablePw] = useState("");
const [storage, , reloadStorage] = usePoll<{ limits: Limits; used: number; free: number }>("/api/storage");
const [lim, setLim] = useState<Limits | null>(null);
const [audit] = usePoll<any[]>("/api/audit");
const [rules, setRules] = usePersist<ColourRule[]>("colour.rules", []);
const L = lim ?? storage?.limits;
const act = async (f: () => Promise<any>, ok: string) => { try { await f(); toast(ok); } catch (e) { toast((e as Error).message, true); } };
return (
<div className="page">
<div className="page-head"><div><h1>Settings</h1></div></div>
<div className="grid g2">
<div className="card stack"><h3>Appearance</h3>
<div className="fieldgrid">
<label>Theme<select value={prefs.theme} onChange={(e) => setPrefs({ ...prefs, theme: e.target.value as Prefs["theme"] })}><option value="dark">Dark</option><option value="light">Light</option><option value="system">Follow system</option></select></label>
<label>Density<select value={prefs.density} onChange={(e) => setPrefs({ ...prefs, density: e.target.value as Prefs["density"] })}><option value="compact">Compact</option><option value="comfortable">Comfortable</option></select></label>
<label>Font scale<select value={prefs.scale} onChange={(e) => setPrefs({ ...prefs, scale: +e.target.value })}>{[0.9, 1, 1.1, 1.2, 1.35].map((s) => <option key={s} value={s}>{Math.round(s * 100)}%</option>)}</select></label>
<label>Motion<select value={prefs.motion} onChange={(e) => setPrefs({ ...prefs, motion: e.target.value as Prefs["motion"] })}><option value="system">Follow system</option><option value="reduce">Reduce</option></select></label>
</div>
<p className="hint">Keyboard: <code>/</code> focus filter · <code>↑↓ PgUp PgDn Home End</code> move through packets · <code>←→</code> collapse/expand tree · <code>Space</code> pause view · <code>f</code> follow · <code>Shift+F</code> focus mode · <code>Alt+1…7</code> pages</p>
</div>
<div className="card stack"><h3>Packet colouring rules</h3>
<p className="hint">Rules here are checked first. Otherwise PiLens colours rows using Wireshark's built-in colouring rules, as reported by tshark.</p>
{rules.map((r, i) => <div key={i} className="row">
<select value={r.field} onChange={(e) => setRules(rules.map((x, j) => j === i ? { ...x, field: e.target.value as ColourRule["field"] } : x))}><option value="proto">Protocol</option><option value="info">Info</option><option value="src">Source</option><option value="dst">Destination</option></select>
<span>contains</span>
<input value={r.contains} onChange={(e) => setRules(rules.map((x, j) => j === i ? { ...x, contains: e.target.value } : x))} />
<input type="color" value={r.colour.slice(0, 7)} onChange={(e) => setRules(rules.map((x, j) => j === i ? { ...x, colour: e.target.value + "40" } : x))} aria-label="Colour" />
<button className="small ghost" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label="Remove">✕</button>
</div>)}
<div><button className="small" onClick={() => setRules([...rules, { field: "proto", contains: "", colour: "#5ccfe040" }])}>+ Add rule</button></div>
</div>
<div className="card stack"><h3>Password ({session.user})</h3>
<input type="password" placeholder="Current password" autoComplete="current-password" value={pw.Current} onChange={(e) => setPw({ ...pw, Current: e.target.value })} />
<input type="password" placeholder="New password (≥ 12 characters)" autoComplete="new-password" value={pw.New} onChange={(e) => setPw({ ...pw, New: e.target.value })} />
<input type="password" placeholder="Confirm" autoComplete="new-password" value={pw.Confirm} onChange={(e) => setPw({ ...pw, Confirm: e.target.value })} />
<div><button disabled={!pw.New || pw.New !== pw.Confirm} onClick={() => act(() => api("/api/auth/password", { body: { Current: pw.Current, New: pw.New } }).then(() => setPw({ Current: "", New: "", Confirm: "" })), "Password changed; other sessions signed out")}>Change password</button></div>
</div>
<div className="card stack"><h3>Two-factor authentication (TOTP)</h3>
{session.totp_enabled ? <>
<p className="okc">Enabled.</p>
<input type="password" placeholder="Password to disable" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} />
<div><button className="danger" onClick={() => act(() => api("/api/auth/totp/disable", { body: { Password: disablePw } }).then(onChange), "TOTP disabled")}>Disable TOTP</button></div>
</> : totp ? <>
<p>Add this secret to an authenticator app (manual entry, SHA-1, 6 digits, 30 s):</p>
<code style={{ fontSize: "1.05rem", letterSpacing: ".08em", wordBreak: "break-all" }}>{totp.secret}</code>
<details><summary className="hint">otpauth URI</summary><code style={{ wordBreak: "break-all" }}>{totp.uri}</code></details>
<input className="mono" placeholder="6-digit code" maxLength={6} value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))} />
<div><button className="primary" onClick={() => act(() => api("/api/auth/totp/enable", { body: { Code: code } }).then(() => { setTotp(null); onChange(); }), "TOTP enabled")}>Confirm and enable</button></div>
</> : <div><button onClick={() => act(async () => setTotp(await api("/api/auth/totp/begin", { method: "POST" })), "Secret generated")}>Set up TOTP</button></div>}
</div>
{L && <div className="card stack"><h3>Capture storage</h3>
<p className="hint">Using {fmtBytes(storage!.used)}; {fmtBytes(storage!.free)} free on disk.</p>
<div className="fieldgrid">
<label>Quota (GiB)<input type="number" min={1} value={+(L.max_bytes / 2 ** 30).toFixed(2)} onChange={(e) => setLim({ ...L, max_bytes: Math.round(+e.target.value * 2 ** 30) })} /></label>
<label>Reserve free (GiB)<input type="number" min={0.1} step={0.5} value={+(L.reserve_bytes / 2 ** 30).toFixed(2)} onChange={(e) => setLim({ ...L, reserve_bytes: Math.round(+e.target.value * 2 ** 30) })} /></label>
<label>Segment size (MiB)<input type="number" min={1} max={1024} value={Math.round(L.segment_bytes / 2 ** 20)} onChange={(e) => setLim({ ...L, segment_bytes: +e.target.value * 2 ** 20 })} /></label>
<label>Max import (MiB)<input type="number" min={1} value={Math.round(L.import_bytes / 2 ** 20)} onChange={(e) => setLim({ ...L, import_bytes: +e.target.value * 2 ** 20 })} /></label>
</div>
<div><button disabled={!lim} onClick={() => act(() => api("/api/storage", { method: "PUT", body: lim }).then(() => { setLim(null); reloadStorage(); }), "Storage limits saved")}>Save limits</button></div>
<p className="hint">Sensitive-content note: captures contain whatever crossed the network, unencrypted parts included. Delete sessions you no longer need. Exports are recorded in the audit log.</p>
</div>}
<div className="card" style={{ gridColumn: "1 / -1" }}><h3>Audit log</h3>
<div style={{ maxHeight: 360, overflow: "auto" }}>
<table className="data"><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Detail</th><th>Source</th></tr></thead><tbody>
{audit?.map((a, i) => <tr key={i}><td className="hint" style={{ whiteSpace: "nowrap" }}>{fmtTime(a.TS)}</td><td>{a.Actor}</td><td className="mono">{a.Action}</td><td className="mono" style={{ fontSize: ".78rem", wordBreak: "break-all" }}>{a.Detail}</td><td className="mono">{a.Remote}</td></tr>)}
</tbody></table>
</div>
</div>
</div>
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useState } from "react";
import { api, setCSRF, type ApplyResult, type Iface, type Network, type Limits, fmtBytes } from "../api";
import { usePersist, Steps } from "../ui";
import { ForwardingFields, LanFields, ManagementFields, UplinkField, WifiFields } from "../components/NetForm";
const STEPS = ["Welcome", "Authorised use", "Administrator", "Management access", "Uplink", "Wi-Fi", "Client network", "Forwarding", "Inspection mode", "Storage", "Review & apply", "Finish"];
interface Props { status: { needs_admin: boolean }; session: { user: string } | null; onDone: () => void }
export default function Setup({ status, session, onDone }: Props) {
// Progress survives reloads and power loss; the server decides what is actually done.
const [step, setStep] = usePersist("wizard.step", 0);
const [net, setNet] = usePersist<Network | null>("wizard.net", null);
const [ack, setAck] = useState(false);
const [admin, setAdmin] = useState({ token: "", username: "admin", password: "", confirm: "" });
const [ifaces, setIfaces] = useState<Iface[]>([]);
const [remote, setRemote] = useState("");
const [bands, setBands] = useState<string[] | undefined>();
const [errors, setErrors] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<ApplyResult | null>(null);
const [limits, setLimits] = useState<Limits | null>(null);
const [expert, setExpert] = usePersist("wizard.expert", false);
const authed = !!session;
const cur = status.needs_admin ? Math.min(step, 2) : Math.max(step, 3);
useEffect(() => {
if (!authed) return;
api("/api/system/interfaces").then((d) => {
setIfaces(d.interfaces ?? []);
setRemote(d.remote_addr);
const wifi = (d.wifi ?? []).find((w: any) => w.ap_mode);
setBands(wifi?.bands);
});
api("/api/storage").then((s) => setLimits(s.limits));
if (!net) api("/api/network").then((d) => {
const n: Network = d.config;
const list: Iface[] = [];
api("/api/system/interfaces").then((x) => {
list.push(...(x.interfaces ?? []));
const wl = list.find((i) => i.wireless);
const up = list.find((i) => i.default_gateway && !i.wireless) ?? list.find((i) => !i.wireless && !i.virtual);
setNet({ ...n, ap: { ...n.ap, interface: n.ap.interface || wl?.name || "" }, uplink: n.uplink || up?.name || "",
lan: { ...n.lan, reservations: n.lan.reservations ?? [], dns_overrides: n.lan.dns_overrides ?? [] } });
});
});
}, [authed]); // eslint-disable-line
const set = (f: (n: Network) => Network) => setNet((n) => (n ? f(n) : n));
const createAdmin = async () => {
setBusy(true); setErrors([]);
try {
const r = await api("/api/setup/admin", { body: { token: admin.token, username: admin.username, password: admin.password, authorised: ack } });
setCSRF(r.csrf);
setStep(3);
onDone();
} catch (e) { setErrors([(e as Error).message]); }
setBusy(false);
};
const validate = async () => {
if (!net) return false;
const r = await api("/api/network/validate", { body: net });
setErrors(r.errors ?? []);
return r.valid as boolean;
};
const apply = async () => {
setBusy(true); setResult(null); setErrors([]);
try {
if (!(await validate())) { setBusy(false); return; }
if (limits) await api("/api/storage", { method: "PUT", body: limits });
const r = await api("/api/network/apply", { body: net });
setResult(r.result);
setStep(11);
} catch (e: any) {
setResult(e.body?.result ?? null);
setErrors([e.message]);
}
setBusy(false);
};
const finish = async () => {
await api("/api/setup/complete", { method: "POST" });
for (const k of ["wizard.step", "wizard.net", "wizard.expert"]) localStorage.removeItem("pilens." + k);
onDone();
};
const next = () => { setErrors([]); setStep(cur + 1); };
const back = () => { setErrors([]); setStep(Math.max(status.needs_admin ? 0 : 3, cur - 1)); };
const pwOk = admin.password.length >= 12 && admin.password === admin.confirm;
const n = net;
const body = () => {
switch (cur) {
case 0: return <>
<h1>Welcome to PiLens</h1>
<p>This appliance turns the Raspberry Pi into a separate, routed Wi-Fi network. Devices you connect to it are captured and analysed.</p>
<div className="grid g2">
<div className="card"><h3>What it does</h3><ul className="hint">
<li>Runs a WPA2/WPA3 access point with its own DHCP and DNS</li>
<li>Routes clients to the internet through Ethernet, with a default-deny firewall</li>
<li>Captures complete packets (pcapng) with SHA-256 integrity hashes</li>
<li>Dissects protocols with TShark, the Wireshark engine</li></ul></div>
<div className="card"><h3>What it cannot do</h3><ul className="hint">
<li>Read encrypted content (HTTPS, QUIC, VPNs, encrypted DNS). It sees packets and metadata only.</li>
<li>TLS interception is not part of this version</li>
<li>Forward IPv6 (it is blocked rather than left uninspected)</li></ul></div>
</div>
<label className="inline"><input type="checkbox" checked={expert} onChange={(e) => setExpert(e.target.checked)} />Expert mode (show all options, fewer explanations)</label>
</>;
case 1: return <>
<h1>Authorised use</h1>
<p>Network traffic routinely contains passwords, tokens, personal messages and health or financial data. Intercepting other people's communications without authority is unlawful in most jurisdictions.</p>
<div className="banner warn"><div><b>Only connect devices you own or are explicitly authorised to inspect.</b>This appliance has no covert or impersonation features, and it must not be used to capture third-party traffic.</div></div>
<label className="inline"><input type="checkbox" checked={ack} onChange={(e) => setAck(e.target.checked)} />I confirm this appliance will only be used on devices and networks I am authorised to inspect.</label>
</>;
case 2: return <>
<h1>Create the administrator</h1>
<p className="hint">The installer printed a one-time setup token. It is also stored in <code>/var/lib/pilens/setup-token</code> (readable only by root and the pilens service).</p>
<label>Setup token<input className="mono" autoFocus value={admin.token} onChange={(e) => setAdmin({ ...admin, token: e.target.value.trim() })} /></label>
<label>Username<input autoComplete="username" value={admin.username} onChange={(e) => setAdmin({ ...admin, username: e.target.value })} /></label>
<div className="fieldgrid">
<label>Password<input type="password" autoComplete="new-password" value={admin.password} onChange={(e) => setAdmin({ ...admin, password: e.target.value })} /><span className="hint">At least 12 characters. A passphrase is ideal.</span></label>
<label>Confirm<input type="password" autoComplete="new-password" className={admin.confirm && admin.confirm !== admin.password ? "invalid" : ""} value={admin.confirm} onChange={(e) => setAdmin({ ...admin, confirm: e.target.value })} /></label>
</div>
{!ack && <p className="err">Go back and confirm authorised use first.</p>}
</>;
case 3: return n && <>
<h1>Management access</h1>
<p>Choose where the management UI can be reached from. It is never reachable from the Wi-Fi access point, and it should never be exposed to the internet.</p>
<ManagementFields n={n} set={set} ifaces={ifaces} remote={remote} />
</>;
case 4: return n && <><h1>Upstream connection</h1><UplinkField n={n} set={set} ifaces={ifaces} />
{n.uplink && ifaces.find((i) => i.name === n.uplink)?.wireless && <div className="banner warn"><div>Using Wi-Fi as the uplink is unusual. The access point needs a dedicated radio.</div></div>}</>;
case 5: return n && <><h1>Wi-Fi access point</h1><WifiFields n={n} set={set} ifaces={ifaces} passSet={false} bands={bands} />
{ifaces.some((i) => i.name === n.ap.interface && (i.addrs ?? []).some((a) => !a.includes(":") && remote && a.startsWith(remote.split(".").slice(0, 3).join(".")))) &&
<div className="banner warn"><div><b>{n.ap.interface} is currently connected to your network.</b>Turning it into an access point disconnects that link. Make sure you reach this page through the management interface.</div></div>}</>;
case 6: return n && <><h1>Client network</h1><p className="hint">AP clients get addresses from this subnet. It must not overlap the management or upstream networks.</p><LanFields n={n} set={set} /></>;
case 7: return n && <><h1>Forwarding policy</h1><ForwardingFields n={n} set={set} /></>;
case 8: return <>
<h1>Inspection mode</h1>
<div className="choice">
<label><input type="radio" checked readOnly /><span>Capture only<small>Packets are captured unmodified. Encrypted payloads stay encrypted. You see addresses, ports, DNS where unencrypted, TLS handshake metadata and timing.</small></span></label>
<label className="disabled"><input type="radio" disabled /><span>Authorised HTTP/HTTPS inspection<small>Not available in this version. It requires a client-installed CA and is planned for a later release.</small></span></label>
<label className="disabled"><input type="radio" disabled /><span>Strict inspection policy<small>Not available in this version.</small></span></label>
</div>
</>;
case 9: return limits && <>
<h1>Storage and retention</h1>
<div className="fieldgrid">
<label>Capture storage quota (GiB)<input type="number" min={1} value={Math.round(limits.max_bytes / 2 ** 30)} onChange={(e) => setLimits({ ...limits, max_bytes: +e.target.value * 2 ** 30 })} /></label>
<label>Always keep free (GiB)<input type="number" min={1} value={Math.round(limits.reserve_bytes / 2 ** 30)} onChange={(e) => setLimits({ ...limits, reserve_bytes: +e.target.value * 2 ** 30 })} /></label>
<label>Segment file size (MiB)<input type="number" min={1} max={1024} value={Math.round(limits.segment_bytes / 2 ** 20)} onChange={(e) => setLimits({ ...limits, segment_bytes: +e.target.value * 2 ** 20 })} /></label>
</div>
<p className="hint">When the quota is reached, the oldest sessions that are <b>not</b> marked preserved are deleted. If none are left, capture stops with an error. Preserved captures are never overwritten.</p>
</>;
case 10: return n && <>
<h1>Review and apply</h1>
<dl className="kv">
<dt>Management</dt><dd className="mono">{n.management.interfaces.join(", ")} from {n.management.allowed_subnets.join(", ")}</dd>
<dt>Uplink</dt><dd className="mono">{n.uplink}</dd>
<dt>Access point</dt><dd className="mono">{n.ap.interface} “{n.ap.ssid}” {n.ap.security.toUpperCase()} {n.ap.band === "a" ? "5 GHz" : "2.4 GHz"} ch {n.ap.channel || "auto"} {n.ap.country}</dd>
<dt>Client network</dt><dd className="mono">{n.lan.address} pool {n.lan.dhcp_start}–{n.lan.dhcp_end}</dd>
<dt>Forwarding</dt><dd>{n.forwarding.mode === "internet" ? "Internet access (private ranges blocked)" : "Disabled"}</dd>
{limits && <><dt>Storage</dt><dd>{fmtBytes(limits.max_bytes)} quota, {fmtBytes(limits.reserve_bytes)} reserve</dd></>}
</dl>
<p className="hint">Applying writes the NetworkManager profile, DHCP/DNS service and firewall table, then checks that each is healthy. If any check fails, the previous state is restored automatically.</p>
<Steps steps={result?.steps} />
</>;
case 11: return <>
<h1>Ready</h1>
<Steps steps={result?.steps} />
<div className="banner ok"><div><b>The access point is up.</b>Connect an authorised test device to “{n?.ap.ssid}”, then open <b>Live Capture</b> and start a capture on <code>{n?.ap.interface}</code>.</div></div>
<div className="banner info"><div><b>About HTTPS</b>In capture-only mode, HTTPS content stays encrypted. You will see TLS handshakes (SNI, versions, certificates where visible), not page contents. That is expected.</div></div>
</>;
}
};
const canNext = cur === 1 ? ack : cur === 2 ? pwOk && ack && admin.token.length > 0 : !!n || cur < 3;
return (
<div className="center">
<div className="wizard">
<aside aria-label="Setup steps">
{STEPS.map((s, i) => <div key={s} className={i === cur ? "cur" : i < cur ? "done" : ""}><i>{i < cur ? "✓" : i + 1}</i>{s}</div>)}
</aside>
<section>
{body()}
{errors.length > 0 && <div className="banner bad" role="alert"><div>{errors.map((e, i) => <div key={i}>{e}</div>)}</div></div>}
<div className="actions">
<button onClick={back} disabled={cur === 0 || cur === 3 || cur === 11 || busy}>Back</button>
{cur === 2 ? <button className="primary" disabled={!canNext || busy} onClick={createAdmin}>{busy ? "Creating…" : "Create administrator"}</button>
: cur === 10 ? <button className="primary" disabled={busy} onClick={apply}>{busy ? "Applying… (up to a minute)" : "Apply configuration"}</button>
: cur === 11 ? <button className="primary" onClick={finish}>Open dashboard</button>
: <button className="primary" disabled={!canNext} onClick={async () => { if (cur >= 3 && cur <= 7 && cur === 7 && !(await validate())) return; next(); }}>Continue</button>}
</div>
</section>
</div>
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
/* PiLens: instrument-panel aesthetic. No external fonts or assets. */
:root {
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
--scale: 1;
--row-h: 22px;
--radius: 6px;
--bg: #0c0f13;
--panel: #12161c;
--panel-2: #171c23;
--raise: #1d232c;
--line: #262d37;
--line-2: #323b47;
--text: #d8dee6;
--text-2: #a3adba;
--muted: #74808f;
--accent: #f2a93b;
--accent-ink: #1a1206;
--cyan: #5ccfe0;
--ok: #4fc485;
--warn: #e8b93a;
--bad: #ef6461;
--sel: #2b4a6f;
--sel-line: #4f8fd6;
--hl: rgba(242, 169, 59, .32);
--hl-soft: rgba(92, 207, 224, .16);
--shadow: 0 8px 28px rgba(0, 0, 0, .45);
/* packet colouring, keyed by Wireshark coloring-rule name or protocol */
--pc-tcp: transparent;
--pc-udp: rgba(92, 207, 224, .055);
--pc-dns: rgba(122, 162, 247, .13);
--pc-http: rgba(79, 196, 133, .14);
--pc-tls: rgba(171, 130, 255, .12);
--pc-synfin: rgba(160, 160, 160, .10);
--pc-arp: rgba(232, 185, 58, .12);
--pc-icmp: rgba(242, 169, 59, .10);
--pc-bad: rgba(239, 100, 97, .22);
--pc-badtext: #ff9b98;
--pc-rst: rgba(239, 100, 97, .14);
--pc-bcast: rgba(255, 255, 255, .04);
}
:root[data-theme="light"] {
--bg: #eef1f4;
--panel: #ffffff;
--panel-2: #f6f8fa;
--raise: #ffffff;
--line: #dde2e8;
--line-2: #c9d0d9;
--text: #1a2029;
--text-2: #3d4754;
--muted: #6a7482;
--accent: #c27a10;
--accent-ink: #ffffff;
--cyan: #127f91;
--ok: #1d8a52;
--warn: #a87a00;
--bad: #c53230;
--sel: #cfe2fb;
--sel-line: #3b7fd0;
--hl: rgba(242, 169, 59, .45);
--hl-soft: rgba(18, 127, 145, .14);
--shadow: 0 8px 24px rgba(20, 30, 40, .14);
--pc-udp: rgba(18, 127, 145, .07);
--pc-dns: rgba(70, 110, 220, .12);
--pc-http: rgba(29, 138, 82, .13);
--pc-tls: rgba(120, 80, 210, .10);
--pc-synfin: rgba(0, 0, 0, .06);
--pc-arp: rgba(200, 150, 0, .14);
--pc-icmp: rgba(200, 120, 0, .10);
--pc-bad: rgba(197, 50, 48, .18);
--pc-badtext: #a3201e;
--pc-rst: rgba(197, 50, 48, .10);
--pc-bcast: rgba(0, 0, 0, .03);
}
:root[data-density="comfortable"] { --row-h: 27px; }
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
html { font-size: calc(14px * var(--scale)); }
body { margin: 0; background: var(--bg); color: var(--text); font: 1rem/1.45 var(--font-ui); -webkit-font-smoothing: antialiased; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
:root[data-motion="reduce"] * { transition: none !important; animation: none !important; }
a { color: var(--cyan); text-decoration: none; }
a:hover { text-decoration: underline; }
code, .mono { font-family: var(--font-mono); font-size: .93em; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
h1 { font-size: 1.45rem; }
h2 { font-size: 1.08rem; }
h3 { font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); font-weight: 600; }
p { margin: .4rem 0; }
:focus-visible { outline: 2px solid var(--sel-line); outline-offset: 1px; }
/* ---- controls ---- */
button, .btn {
font: inherit; font-size: .92rem; color: var(--text); background: var(--raise); border: 1px solid var(--line-2);
border-radius: var(--radius); padding: .38rem .8rem; cursor: pointer; display: inline-flex; align-items: center; gap: .4rem;
white-space: nowrap; transition: background .12s, border-color .12s;
}
button:hover:not(:disabled) { border-color: var(--muted); }
button:disabled { opacity: .45; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
button.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 50%, var(--line-2)); }
button.ghost { background: transparent; border-color: transparent; }
button.ghost:hover:not(:disabled) { background: var(--raise); border-color: var(--line); }
button.small { padding: .18rem .5rem; font-size: .82rem; }
button.on { border-color: var(--accent); color: var(--accent); }
input, select, textarea {
font: inherit; font-size: .92rem; color: var(--text); background: var(--panel-2); border: 1px solid var(--line-2);
border-radius: var(--radius); padding: .38rem .55rem; min-width: 0;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--sel-line); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sel-line) 25%, transparent); }
input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); width: 1rem; height: 1rem; padding: 0; }
input.mono, textarea.mono { font-family: var(--font-mono); }
input.invalid { border-color: var(--bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad) 18%, transparent); }
input.valid { border-color: var(--ok); }
label { display: grid; gap: .25rem; font-size: .85rem; color: var(--text-2); }
label.inline { display: inline-flex; align-items: center; gap: .45rem; color: var(--text); font-size: .92rem; }
.hint { color: var(--muted); font-size: .82rem; }
.err { color: var(--bad); }
.okc { color: var(--ok); }
.warnc { color: var(--warn); }
/* ---- shell ---- */
.shell { display: grid; grid-template-columns: 212px 1fr; height: 100%; }
.shell.focus { grid-template-columns: 0 1fr; }
.shell.focus .nav { display: none; }
.nav { background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; }
.brand { display: flex; align-items: center; gap: .6rem; padding: .9rem 1rem .8rem; border-bottom: 1px solid var(--line); }
.brand img { width: 26px; height: 26px; }
.brand b { font-size: 1.05rem; letter-spacing: .02em; }
.brand span { font-size: .72rem; color: var(--muted); display: block; line-height: 1; }
.nav nav { padding: .5rem; display: grid; gap: 1px; overflow: auto; }
.nav nav a { display: flex; align-items: center; gap: .6rem; padding: .42rem .6rem; color: var(--text-2); border-radius: var(--radius); font-size: .92rem; }
.nav nav a:hover { background: var(--panel-2); color: var(--text); text-decoration: none; }
.nav nav a.active { background: var(--raise); color: var(--text); box-shadow: inset 2px 0 0 var(--accent); }
.nav nav .sep { height: 1px; background: var(--line); margin: .45rem .3rem; }
.nav .foot { margin-top: auto; padding: .7rem 1rem; border-top: 1px solid var(--line); font-size: .78rem; color: var(--muted); display: grid; gap: .35rem; }
.main { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.page { padding: 1.2rem 1.4rem 2rem; overflow: auto; flex: 1; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; }
.page-head p { color: var(--muted); margin: .2rem 0 0; }
.statusbar { display: flex; gap: 1.1rem; align-items: center; padding: .25rem .8rem; border-top: 1px solid var(--line); background: var(--panel); font-size: .78rem; color: var(--text-2); white-space: nowrap; overflow: hidden; }
.statusbar b { color: var(--text); font-weight: 600; font-family: var(--font-mono); }
/* ---- cards / layout ---- */
.grid { display: grid; gap: .9rem; }
.g2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.g3 { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.g4 { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.1rem; min-width: 0; }
.card > h3 { margin-bottom: .6rem; }
.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
.spread { justify-content: space-between; }
.stack { display: grid; gap: .7rem; }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: .3rem 1rem; font-size: .9rem; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.metric { font: 600 1.6rem/1.1 var(--font-mono); letter-spacing: -.02em; }
.metric small { font-size: .8rem; color: var(--muted); font-weight: 400; margin-left: .25rem; }
.badge { display: inline-flex; align-items: center; gap: .35rem; font-size: .75rem; padding: .1rem .5rem; border-radius: 99px; border: 1px solid var(--line-2); color: var(--text-2); white-space: nowrap; }
.badge::before { content: ""; width: .5rem; height: .5rem; border-radius: 50%; background: var(--muted); }
.badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); } .badge.ok::before { background: var(--ok); }
.badge.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); } .badge.warn::before { background: var(--warn); }
.badge.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); } .badge.bad::before { background: var(--bad); }
.badge.live::before { background: var(--bad); animation: pulse 1.3s infinite; }
.badge.plain::before { display: none; }
@keyframes pulse { 50% { opacity: .25; } }
.banner { border: 1px solid var(--line-2); border-left: 4px solid var(--muted); background: var(--panel); border-radius: 8px; padding: .7rem 1rem; display: flex; gap: .8rem; align-items: flex-start; }
.banner.ok { border-left-color: var(--ok); }
.banner.warn { border-left-color: var(--warn); }
.banner.bad { border-left-color: var(--bad); }
.banner.info { border-left-color: var(--cyan); }
.banner b { display: block; }
table.data { width: 100%; border-collapse: collapse; font-size: .88rem; }
table.data th { text-align: left; color: var(--muted); font-weight: 600; font-size: .75rem; text-transform: uppercase; letter-spacing: .06em; padding: .45rem .6rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
table.data td { padding: .45rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
table.data tr:hover td { background: var(--panel-2); }
.empty { color: var(--muted); text-align: center; padding: 2rem 1rem; }
/* ---- dialogs / popovers ---- */
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line-2); border-radius: 12px; padding: 0; box-shadow: var(--shadow); max-width: min(560px, 94vw); width: 100%; }
dialog::backdrop { background: rgba(0, 0, 0, .55); }
dialog .dh { padding: 1rem 1.2rem .4rem; }
dialog .db { padding: .4rem 1.2rem 1rem; display: grid; gap: .8rem; }
dialog .df { padding: .8rem 1.2rem; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: .5rem; }
.popover { position: absolute; z-index: 30; background: var(--raise); border: 1px solid var(--line-2); border-radius: 8px; box-shadow: var(--shadow); padding: .5rem; min-width: 220px; }
.ctx { position: fixed; z-index: 50; background: var(--raise); border: 1px solid var(--line-2); border-radius: 8px; box-shadow: var(--shadow); padding: .3rem; min-width: 220px; display: grid; }
.ctx button { justify-content: flex-start; border: 0; background: transparent; padding: .35rem .6rem; border-radius: 5px; }
.ctx button:hover { background: var(--sel); }
.ctx hr { border: 0; border-top: 1px solid var(--line); margin: .25rem 0; width: 100%; }
.toast { position: fixed; bottom: 1.2rem; right: 1.2rem; z-index: 60; background: var(--raise); border: 1px solid var(--line-2); border-left: 4px solid var(--cyan); padding: .7rem 1rem; border-radius: 8px; box-shadow: var(--shadow); max-width: 420px; }
.toast.bad { border-left-color: var(--bad); }
/* ---- analyser ---- */
.analyser { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.toolbar { display: flex; gap: .5rem; align-items: center; padding: .45rem .7rem; border-bottom: 1px solid var(--line); background: var(--panel); flex-wrap: wrap; }
.toolbar .grow { flex: 1; min-width: 240px; }
.toolbar .divider { width: 1px; align-self: stretch; background: var(--line); margin: 0 .15rem; }
.filterbar { display: flex; gap: .4rem; align-items: center; padding: .4rem .7rem; border-bottom: 1px solid var(--line); background: var(--panel-2); }
.filterbar .kind { font: 600 .7rem var(--font-ui); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); padding-right: .3rem; }
.filterbar input { flex: 1; font-family: var(--font-mono); }
.filtererr { padding: .25rem .8rem; font-size: .8rem; color: var(--bad); background: color-mix(in srgb, var(--bad) 8%, var(--panel)); border-bottom: 1px solid var(--line); font-family: var(--font-mono); white-space: pre-wrap; }
.panes { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.pane { min-height: 60px; overflow: hidden; position: relative; display: flex; flex-direction: column; background: var(--panel); }
.splitter { height: 5px; background: var(--line); cursor: row-resize; flex: none; position: relative; }
.splitter:hover, .splitter.drag { background: var(--sel-line); }
.pane-title { font: 600 .68rem var(--font-ui); text-transform: uppercase; letter-spacing: .09em; color: var(--muted); padding: .25rem .7rem; border-bottom: 1px solid var(--line); display: flex; gap: .7rem; align-items: center; background: var(--panel-2); flex: none; }
.pane-title .sp { flex: 1; }
.pane-title button { text-transform: none; letter-spacing: 0; font-weight: 400; }
.ptable { flex: 1; overflow: auto; position: relative; font-family: var(--font-mono); font-size: .84rem; outline: none; contain: strict; }
.ptable .hdr { position: sticky; top: 0; z-index: 2; display: flex; background: var(--panel-2); border-bottom: 1px solid var(--line-2); font-family: var(--font-ui); font-size: .75rem; color: var(--muted); font-weight: 600; height: 24px; min-width: max-content; }
.ptable .hdr div { position: relative; padding: 0 .5rem; display: flex; align-items: center; flex: none; overflow: hidden; white-space: nowrap; user-select: none; }
.ptable .hdr .rz { position: absolute; right: 0; top: 0; bottom: 0; width: 6px; cursor: col-resize; }
.ptable .hdr .rz:hover { background: var(--sel-line); }
.ptable .prow { position: absolute; left: 0; display: flex; height: var(--row-h); line-height: var(--row-h); min-width: 100%; cursor: default; white-space: nowrap; border-bottom: 1px solid color-mix(in srgb, var(--line) 45%, transparent); }
.ptable .prow > div { flex: none; padding: 0 .5rem; overflow: hidden; text-overflow: ellipsis; }
.ptable .prow.sel { background: var(--sel) !important; box-shadow: inset 2px 0 0 var(--sel-line); }
.ptable .prow.ph { color: var(--muted); font-style: italic; }
.c-tcp { background: var(--pc-tcp); }
.c-udp { background: var(--pc-udp); }
.c-dns { background: var(--pc-dns); }
.c-http { background: var(--pc-http); }
.c-tls { background: var(--pc-tls); }
.c-synfin { background: var(--pc-synfin); }
.c-arp { background: var(--pc-arp); }
.c-icmp { background: var(--pc-icmp); }
.c-bad { background: var(--pc-bad); color: var(--pc-badtext); }
.c-rst { background: var(--pc-rst); }
.c-bcast { background: var(--pc-bcast); }
.tree { flex: 1; overflow: auto; font-family: var(--font-mono); font-size: .84rem; padding: .25rem 0; outline: none; }
.tree .tn { display: flex; align-items: center; padding: 0 .5rem; height: 21px; white-space: nowrap; cursor: default; }
.tree .tn:hover { background: var(--panel-2); }
.tree .tn.sel { background: var(--sel); }
.tree .tw { width: 1.1rem; flex: none; color: var(--muted); text-align: center; font-size: .7rem; cursor: pointer; }
.tree .nomap { color: var(--muted); margin-left: .5rem; font-size: .75rem; }
.tree .proto { color: var(--text); font-weight: 600; }
.tree .fld { color: var(--text-2); }
.hex { flex: 1; overflow: auto; font-family: var(--font-mono); font-size: .84rem; padding: .35rem .7rem; user-select: none; outline: none; }
.hex .hl { display: flex; gap: 1.2rem; height: 20px; align-items: center; }
.hex .off { color: var(--muted); min-width: 5ch; }
.hex .bytes { display: flex; }
.hex .b { width: 2.6ch; text-align: center; cursor: default; border-radius: 2px; }
.hex .b:nth-child(8) { margin-right: .9ch; }
.hex .asc { display: flex; color: var(--text-2); }
.hex .a { width: 1.15ch; text-align: center; border-radius: 2px; }
.hex .on { background: var(--hl); color: var(--text); }
.hex .sel { outline: 1px solid var(--sel-line); }
.hex .match { background: var(--hl-soft); }
.hex .np { color: var(--muted); }
.livebar { display: flex; gap: .4rem; align-items: center; }
/* ---- auth / wizard ---- */
.center { min-height: 100%; display: grid; place-items: center; padding: 2rem 1rem; }
.authcard { width: min(420px, 100%); }
.wizard { width: min(860px, 100%); display: grid; grid-template-columns: 220px 1fr; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; box-shadow: var(--shadow); }
.wizard aside { background: var(--panel-2); border-right: 1px solid var(--line); padding: 1.2rem .8rem; display: grid; align-content: start; gap: 2px; }
.wizard aside div { font-size: .85rem; color: var(--muted); padding: .32rem .6rem; border-radius: 6px; display: flex; gap: .55rem; }
.wizard aside div i { font-style: normal; font-family: var(--font-mono); width: 1.4rem; text-align: right; }
.wizard aside div.cur { color: var(--text); background: var(--raise); }
.wizard aside div.done { color: var(--text-2); }
.wizard aside div.done i { color: var(--ok); }
.wizard section { padding: 1.5rem 1.6rem; display: grid; gap: 1rem; align-content: start; min-height: 520px; }
.wizard .actions { display: flex; justify-content: space-between; margin-top: auto; padding-top: 1rem; border-top: 1px solid var(--line); }
.steps { font-family: var(--font-mono); font-size: .85rem; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: .6rem .8rem; max-height: 260px; overflow: auto; }
.steps div.fail { color: var(--bad); }
.fieldgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .8rem; }
.choice { display: grid; gap: .5rem; }
.choice label { display: flex; gap: .7rem; align-items: flex-start; border: 1px solid var(--line-2); border-radius: 8px; padding: .65rem .8rem; cursor: pointer; color: var(--text); }
.choice label:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); }
.choice label.disabled { opacity: .55; cursor: not-allowed; }
.choice small { display: block; color: var(--muted); }
@media (max-width: 900px) {
.shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
.nav { flex-direction: row; border-right: 0; border-bottom: 1px solid var(--line); overflow-x: auto; }
.nav nav { display: flex; padding: .3rem; }
.nav nav .sep, .nav .foot, .brand span { display: none; }
.wizard { grid-template-columns: 1fr; }
.wizard aside { display: none; }
}
+113
View File
@@ -0,0 +1,113 @@
// Small shared UI pieces and hooks.
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { api } from "./api";
export function useHash(): [string, URLSearchParams] {
const read = () => {
const h = location.hash.replace(/^#/, "") || "/";
const [p, q] = h.split("?");
return [p, new URLSearchParams(q ?? "")] as [string, URLSearchParams];
};
const [v, setV] = useState(read);
useEffect(() => {
const f = () => setV(read());
addEventListener("hashchange", f);
return () => removeEventListener("hashchange", f);
}, []);
return v;
}
export const go = (path: string) => { location.hash = path; };
/** Polls an API path; returns [data, error, reload]. */
export function usePoll<T>(path: string | null, ms = 0): [T | undefined, string, () => void] {
const [data, setData] = useState<T>();
const [err, setErr] = useState("");
const [tick, setTick] = useState(0);
const reload = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
if (!path) return;
let alive = true;
const ctl = new AbortController();
const run = () => api<T>(path, { signal: ctl.signal }).then((d) => { if (alive) { setData(d); setErr(""); } }, (e) => { if (alive && e.name !== "AbortError") setErr(e.message); });
run();
const t = ms ? setInterval(run, ms) : undefined;
return () => { alive = false; ctl.abort(); if (t) clearInterval(t); };
}, [path, ms, tick]);
return [data, err, reload];
}
export function usePersist<T>(key: string, init: T): [T, (v: T | ((p: T) => T)) => void] {
const [v, setV] = useState<T>(() => {
try { const s = localStorage.getItem("pilens." + key); return s ? JSON.parse(s) : init; } catch { return init; }
});
const set = useCallback((x: T | ((p: T) => T)) => {
setV((p) => {
const n = typeof x === "function" ? (x as (p: T) => T)(p) : x;
try { localStorage.setItem("pilens." + key, JSON.stringify(n)); } catch { /* storage unavailable */ }
return n;
});
}, [key]);
return [v, set];
}
let toastSet: ((t: { msg: string; bad?: boolean } | null) => void) | null = null;
export function toast(msg: string, bad = false) { toastSet?.({ msg, bad }); }
export function Toaster() {
const [t, setT] = useState<{ msg: string; bad?: boolean } | null>(null);
useEffect(() => { toastSet = setT; return () => { toastSet = null; }; }, []);
useEffect(() => { if (t) { const x = setTimeout(() => setT(null), t.bad ? 8000 : 3500); return () => clearTimeout(x); } }, [t]);
return t ? <div className={"toast" + (t.bad ? " bad" : "")} role="status" onClick={() => setT(null)}>{t.msg}</div> : null;
}
export function Modal({ open, title, children, footer, onClose }: { open: boolean; title: ReactNode; children: ReactNode; footer?: ReactNode; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current!;
if (open && !d.open) d.showModal();
if (!open && d.open) d.close();
}, [open]);
return (
<dialog ref={ref} onClose={onClose} onCancel={onClose}>
<div className="dh"><h2>{title}</h2></div>
<div className="db">{children}</div>
{footer && <div className="df">{footer}</div>}
</dialog>
);
}
export function Badge({ kind, children }: { kind?: "ok" | "warn" | "bad" | "live" | "plain"; children: ReactNode }) {
return <span className={"badge " + (kind ?? "")}>{children}</span>;
}
export function Steps({ steps, error }: { steps?: string[]; error?: string }) {
if (!steps?.length && !error) return null;
return (
<div className="steps" aria-live="polite">
{steps?.map((s, i) => <div key={i} className={/FAILED|failed/.test(s) ? "fail" : ""}>{s.startsWith("FAILED") ? "✗ " : "✓ "}{s}</div>)}
{error && <div className="fail">✗ {error}</div>}
</div>
);
}
export interface CtxItem { label: string; onClick?: () => void; sep?: boolean }
export function ContextMenu({ at, items, onClose }: { at: { x: number; y: number } | null; items: CtxItem[]; onClose: () => void }) {
useEffect(() => {
if (!at) return;
const f = () => onClose();
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
setTimeout(() => addEventListener("click", f), 0);
addEventListener("keydown", k);
return () => { removeEventListener("click", f); removeEventListener("keydown", k); };
}, [at, onClose]);
if (!at) return null;
return (
<div className="ctx" role="menu" style={{ left: Math.min(at.x, innerWidth - 240), top: Math.min(at.y, innerHeight - items.length * 32 - 10) }}>
{items.map((it, i) => it.sep ? <hr key={i} /> : <button key={i} role="menuitem" onClick={() => { it.onClick?.(); onClose(); }}>{it.label}</button>)}
</div>
);
}
export async function copyText(s: string) {
try { await navigator.clipboard.writeText(s); toast("Copied to clipboard"); } catch { toast("Clipboard unavailable (requires HTTPS and permission)", true); }
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Dev: `PILENS_API=https://pi:8443 npm run dev` proxies the API to a running appliance.
export default defineConfig({
plugins: [react()],
build: { outDir: "dist", emptyOutDir: true, sourcemap: false, chunkSizeWarningLimit: 800 },
server: {
proxy: { "/api": { target: process.env.PILENS_API ?? "https://127.0.0.1:8443", secure: false, changeOrigin: false } },
},
});