Tailscale for jailbroken PS5

A payload that runs the Tailscale client (tsnet, userspace networking) on a
jailbroken PS5: a C launcher built with ps5-payload-sdk that loads a Go
program in-process, an installer that registers it with the console's
payload autoloader and adds a home screen icon, and the patch that makes Go
1.27.1 speak the PS5's FreeBSD 9 era syscall interface.
This commit is contained in:
holdmysocks committed 2026-10-02 10:29:31 -04:00
commit 4554502591
46 files changed
+7033

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
* text=auto eol=lf
*.ps1 text eol=crlf
*.png binary
*.patch text eol=lf
+14
View File
@@ -0,0 +1,14 @@
# Toolchains and third-party trees (see docs/BUILDING.md)
/toolchain/
/goroot/
/goroot-*/
/ref/
# Build output, caches and scratch space
/out/
/scratch/
/.gotmp/
/.gocache/
# Local working notes
/NOTES.md
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 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 General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+278
View File
@@ -0,0 +1,278 @@
# Tailscale for jailbroken PS5
Puts a jailbroken PS5 on your [Tailscale](https://tailscale.com) network.
- **Reach the console from anywhere.** FTP, the payload loader, web tools:
whatever listens on the console is available at its tailnet address from
your other Tailscale devices.
- **Stream games to the console over Tailscale.** A Moonlight client on the
PS5 (such as ProsperoLight) can connect to a Sunshine host on your tailnet.
- **Starts by itself** after each jailbreak if you use a payload autoloader,
and adds a home screen icon for its status page.
It runs the real Tailscale client code (v1.104.0) as a single payload.
![The status page](docs/status-page.png)
> **Unofficial and experimental.** This project is not affiliated with or
> endorsed by Tailscale Inc. or Sony. It has been tested on one console. It
> runs homebrew with full privileges on a jailbroken system; use it at your
> own risk.
## What it is not
The PS5 kernel has no tunnel device, so Tailscale cannot become a system-wide
VPN here. It runs inside one process:
- Games and PSN traffic do **not** go through Tailscale.
- Other apps on the console cannot open connections to tailnet addresses
directly. They can through a *local forward* (see
[game streaming](#game-streaming-moonlight-to-sunshine) and
[configuration](#configuration)).
- No exit node, subnet routing, Tailscale SSH, Taildrop or Funnel.
## Requirements
- A jailbroken PS5 with an ELF loader listening on port 9021
(for example [elfldr](https://github.com/ps5-payload-dev/elfldr)).
- A Tailscale account.
- Optional: a payload autoloader, so Tailscale starts after every jailbreak.
[Payload Manager](https://github.com/itsPLK/ps5-payload-manager) and
`ps5_autoloader` folders are detected.
Tested on firmware 13.42 with elfldr 0.26 and Payload Manager 0.5.2.
## Install
1. Download `tailscale-installer.elf` from the
[latest release](../../releases/latest).
2. Send it to the console's ELF loader, once. Any payload sender works:
```bash
# Linux / macOS
socat -t 60 - TCP:<console-ip>:9021 < tailscale-installer.elf
```
```powershell
# Windows (script from this repository)
.\tools\ps5send.ps1 -File tailscale-installer.elf -PS5Host <console-ip> -Seconds 70
```
The installer prints what it does. It:
- stores the daemon payload (`tailscale.elf`),
- adds it to the end of your autoloader's load order, if it finds one,
- adds a **Tailscale** icon to the home screen (media section),
- starts Tailscale.
3. Open `http://<console-ip>:8090` on a phone or PC. Scan the QR code or
follow the link and log in to Tailscale. If your tailnet uses device
approval, approve the console in the admin console.
The console now has a tailnet address, shown on the status page.
Sending the installer again upgrades and restarts Tailscale. The login is
kept.
## Using it
### Reaching the console
Connect to the console's tailnet address or MagicDNS name on the port you
want, for example FTP on 2121 or the payload loader on 9021.
- Every TCP port that something on the console listens on is forwarded.
Ports with no listener refuse the connection.
- UDP is not forwarded in this direction.
- To keep a port off the tailnet, add it to `blockedPorts` in the
[configuration](#configuration).
### The status page
`http://<console address>:8090`, on the LAN or over the tailnet, or the
**Tailscale** icon on the home screen. It shows the connection state, the
login link, and your devices, and has controls for game streaming, logging
out, stopping and uninstalling.
It has **no password**, like the console's other homebrew services. Anyone on
your LAN, or on your tailnet if your ACLs allow it, can use it.
### Game streaming (Moonlight to Sunshine)
This lets a Moonlight client on the PS5 stream from a PC that is somewhere
else, over Tailscale.
On the PC:
1. Install [Sunshine](https://github.com/LizardByte/Sunshine) and leave it on
its default port (47989).
2. Install Tailscale and log in to the same tailnet as the console.
On the console:
1. Open the status page and find **Game streaming**.
2. Choose the device that runs Sunshine and press **Save**. The page shows
"Forwarding 127.0.0.1 to *your-pc* (7 ports)".
3. In your Moonlight client on the PS5, add a host manually with the address
**`127.0.0.1`**. Do not enter the PC's tailnet address: the client cannot
reach it.
4. Pair as usual: the client shows a PIN, which you enter in Sunshine's web
interface on the PC.
How it works: the daemon listens on `127.0.0.1` on Sunshine's ports (TCP
47984, 47989, 48010 and UDP 47998, 47999, 48000, 48002) and relays them to
the chosen host through Tailscale. To the Moonlight client the Sunshine host
appears to be the console itself.
Notes:
- One Sunshine host at a time. Change it on the status page at any time.
- A wired connection on the console helps, as with any streaming.
- **Do not change the console's network (Wi-Fi to Ethernet, connection
settings) while a stream is running.** That froze the test console once;
the cause was not established.
- Tested with ProsperoLight.
### Other apps on the console
`forwards` in the [configuration](#configuration) relays any localhost port
to a tailnet host in the same way, TCP or UDP.
The daemon also runs an HTTP proxy on `127.0.0.1:8118` that reaches tailnet
hosts, for apps that have their own proxy setting. **Do not set it as the
PS5's system proxy.** The system then sends everything through it, including
pages on `127.0.0.1`, and it is not running until Tailscale has been loaded.
On the test console that stopped Payload Manager's page from opening.
## Configuration
`/data/tailscale/config.json` is created on first start. Every field is
optional. Restart Tailscale (send the payload again) to apply edits.
```json
{
"hostname": "ps5",
"authKey": "",
"webAddr": ":8090",
"httpProxyAddr": "127.0.0.1:8118",
"controlURL": "",
"sunshineHost": "",
"forwards": [
{"proto": "tcp", "listen": "127.0.0.1:8096", "target": "my-nas:8096"}
],
"blockedPorts": [],
"verbose": false
}
```
| Field | Meaning |
| --- | --- |
| `hostname` | The console's name on the tailnet. |
| `authKey` | A Tailscale auth key, to log in without the browser step. |
| `webAddr` | Where the status page listens. |
| `httpProxyAddr` | Where the HTTP proxy listens. Empty turns it off. |
| `controlURL` | A coordination server other than Tailscale's. |
| `sunshineHost` | The Sunshine host; set from the status page. |
| `forwards` | Extra local forwards: `proto` is `tcp` or `udp`, `listen` a localhost address, `target` a tailnet host and port. |
| `blockedPorts` | Local TCP ports that are never exposed to the tailnet. |
| `verbose` | Put Tailscale's own log in the main log as well. |
Files on the console:
| Path | What |
| --- | --- |
| `/data/tailscale/config.json` | Settings. |
| `/data/tailscale/state/` | Tailscale's state, including the login. |
| `/data/tailscale/tailscale.log` | The daemon's log, rotated at 2 MB. |
| `/data/tailscale/tailscale-debug.log` | Tailscale's detailed log, up to 4 MB plus one older file. |
| `/data/pldmgr/payloads/Tailscale/tailscale.elf` | The daemon, when Payload Manager is used. |
| `/data/tailscale/tailscale.elf` | The daemon, when no autoloader was found. |
| `/user/app/TSCL00001/` | The home screen icon. |
## Uninstall
Press **Uninstall** on the status page. It removes the autoloader entry and
the daemon payload and stops Tailscale. It asks whether to also log out and
delete the saved login.
Two things are left to do by hand:
- Delete the home screen icon (Options button, then Delete).
- Remove the device in the Tailscale admin console.
## Troubleshooting
- **The status page does not open on the console, but does from a PC.**
Check that the PS5's proxy server setting is "Do Not Use".
- **The Moonlight client cannot find the host.** The host to add is
`127.0.0.1`, and a Sunshine host must be selected on the status page.
Sunshine must be on its default port.
- **"Not logged in" after logging in.** Press **Log in again** for a fresh
link.
- **Something else.** `http://<console>:8090/api/logs?full=1` is the daemon's
log and `/api/logs?debug=1` is Tailscale's detailed log. Please attach them
to bug reports, after checking them for anything you consider private.
## Security
- The status page and its controls are unauthenticated.
- All listening TCP ports on the console become reachable from your tailnet,
including the payload loader, which runs anything sent to it. Use Tailscale
ACLs if other people share your tailnet.
- The local forwards and the proxy listen on `127.0.0.1` only and are not
exposed to the tailnet.
## Resource use
About 60 MB of memory and next to no CPU when idle. The daemon runs at the
lowest scheduling priority on at most 4 cores, so it gives way to games.
## What has and has not been tested
Tested on the one console: install and upgrade, login with device approval,
autostart after a reboot through Payload Manager, reaching the console over
the tailnet, a ProsperoLight stream from a Sunshine host through the forward,
the HTTP proxy, the home screen icon.
Not tested: rest mode, the `ps5_autoloader` and USB autoloader paths,
Uninstall on a console, other firmware versions, coordination servers other
than Tailscale's.
## Building
See [docs/BUILDING.md](docs/BUILDING.md). The build runs on Windows with
PowerShell, clang, ps5-payload-sdk and a patched Go 1.27.1.
## How it works
`tailscale.elf` is a small C program built with
[ps5-payload-sdk](https://github.com/ps5-payload-dev/sdk) with a Go program
embedded in it. The SDK's startup code lifts the PS5's restriction on where
system calls may be issued from. The C part drops the process to the lowest
scheduling priority, maps the Go program into memory and enters it the way
the FreeBSD kernel would. Go's runtime and standard library are patched to
speak the PS5's FreeBSD 9 era system call interface. The Go program is a
[tsnet](https://pkg.go.dev/tailscale.com/tsnet) server that pipes every
incoming tailnet TCP connection to the same port on localhost.
[docs/TECHNICAL.md](docs/TECHNICAL.md) has the details, including what was
learned about running Go on the PS5.
## Credits
- [Tailscale](https://github.com/tailscale/tailscale), whose client this runs.
- John Törnblom's [ps5-payload-sdk](https://github.com/ps5-payload-dev/sdk)
and [elfldr](https://github.com/ps5-payload-dev/elfldr).
- itsPLK's [Payload Manager](https://github.com/itsPLK/ps5-payload-manager),
whose app-icon installer this follows.
## License
The project's own code is licensed under the GNU General Public License
version 3 or later (see [LICENSE](LICENSE)); the payloads link the SDK's
GPL-licensed startup code. `patches/` is a patch to the Go source tree and is
under Go's BSD-style license. Tailscale is BSD-3-Clause. The release binaries
also contain Tailscale's dependencies under their own licenses; they are
listed in `tsd/go.mod`.
"Tailscale" is a trademark of Tailscale Inc. "PlayStation" and "PS5" are
trademarks of Sony Interactive Entertainment Inc.
+106
View File
@@ -0,0 +1,106 @@
# Building
The build scripts are PowerShell and were written on Windows 11. Nothing is
installed system-wide; everything lives in the repository folder.
## Layout the scripts expect
```
toolchain\llvm\bin\ clang.exe, ld.lld.exe (LLVM 20 or newer; 23.1.2 was used)
toolchain\ps5-payload-sdk\ ps5-payload-sdk release (v0.43 was used)
goroot\ Go 1.27.1 with patches\go1.27.1-ps5.patch applied
```
These folders are not in the repository.
## Setting up the toolchain
1. **LLVM.** Download `clang+llvm-23.1.2-x86_64-pc-windows-msvc.tar.xz` from
the [LLVM releases](https://github.com/llvm/llvm-project/releases) and
extract at least `bin\clang.exe`, `bin\ld.lld.exe`, `bin\lld.exe` and
`lib\clang\` into `toolchain\llvm`.
```powershell
tar -xf llvm.tar.xz -C toolchain\llvm --strip-components=1 "*/bin/clang.exe" "*/bin/lld.exe" "*/bin/ld.lld.exe" "*/bin/llvm-readelf.exe" "*/lib/clang/*"
```
2. **ps5-payload-sdk.** Download `ps5-payload-sdk.zip` from the
[SDK releases](https://github.com/ps5-payload-dev/sdk/releases) and unpack
it into `toolchain\`, which creates `toolchain\ps5-payload-sdk`.
3. **Go.** Download `go1.27.1.windows-amd64.zip` from
[go.dev](https://go.dev/dl/), unpack it, rename the `go` folder to
`goroot`, apply the patch, and rebuild the go command and the linker (the
patch changes a package both of them compile in):
```powershell
tar -xf go1.27.1.windows-amd64.zip
Rename-Item go goroot
Set-Location goroot
git -c core.autocrlf=false apply -p1 ..\patches\go1.27.1-ps5.patch
Copy-Item bin\go.exe bin\go-bootstrap.exe
$env:GOTOOLCHAIN = 'local'
.\bin\go-bootstrap.exe install cmd/go cmd/link
Set-Location ..
```
If Windows Security blocks the build, keep Go's temporary and cache folders
inside the repository folder (the scripts do: `.gotmp`, `.gocache`) and
exclude that folder.
## Building the payloads
```powershell
# daemon payload: C launcher + Go program -> out\tailscale.elf
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.2.1
# installer -> out\tailscale-installer.elf (embeds out\tailscale.elf)
.\tools\build-installer.ps1
```
## Sending to the console
```powershell
$env:PS5_HOST = '192.168.1.50' # your console
.\tools\ps5send.ps1 -File out\tailscale-installer.elf -Seconds 70
```
`ps5send.ps1` prints whatever the payload writes back.
## Tests
The daemon's tests run on the build machine:
```powershell
Set-Location tsd
..\goroot\bin\go.exe test .
```
The daemon also runs on Windows for work on the status page. Build `tsd`
without the PS5 settings, set `PS5TS_DATA` to an empty folder and put a
`config.json` with a free `webAddr` in it.
## Probes
`probe-c\` and `probe-go\` are the small payloads used to find out how the
console behaves. They are useful when porting to another firmware.
```powershell
. .\tools\env.ps1
Invoke-PS5CC -O1 -Wall -o out\sysprobe.elf probe-c\sysprobe.c
# Go probe with the debug loader and a watchdog that kills it after 120 s
.\tools\build-payload.ps1 -GoDir probe-go -Name probe -DebugLoader -Watchdog 120 -Send -Seconds 150
```
Read the scheduling section of [TECHNICAL.md](TECHNICAL.md) before writing
new tests: a payload that spins on every core at the default priority freezes
the console.
## Updating Tailscale or Go
- Tailscale: `go get tailscale.com@<version>` in `tsd` with the patched Go,
then rebuild. A Tailscale release that needs a newer Go needs the patch
ported to that Go first.
- Go: apply `patches\go1.27.1-ps5.patch` to the new tree and fix what does
not apply. The patch touches nine files; TECHNICAL.md says why for each.
+162
View File
@@ -0,0 +1,162 @@
# Technical notes
How Tailscale, a Go program, runs on a PS5, and what was learned about the
console along the way. Everything here was measured on one console
(firmware 13.42, `kern.osreldate` 900000); treat it as observations, not a
specification.
## Architecture
`tailscale.elf` is one payload made of two parts.
**The launcher** (`launcher/`, C, built with ps5-payload-sdk):
1. The SDK's crt runs first. Among other things it patches the process so
that system calls may be issued from any address. Without that, the kernel
only accepts syscalls made from libkernel, and Go makes its own.
2. `main.c` raises privileges, moves the process to the lowest scheduling
priority (see [Scheduling](#scheduling)) and sets `GOMAXPROCS=4`.
3. `goload.c` maps the embedded Go program (a position-independent ELF),
applies its `R_X86_64_RELATIVE` relocations, makes its text executable,
builds the argc/argv/envp/auxv block a FreeBSD kernel would pass, switches
to a fresh 1 MB stack and jumps to the Go entry point. The embedded copy
is then released with `madvise(MADV_FREE)`.
**The daemon** (`tsd/`, Go): a `tsnet` server.
- Inbound: tsnet's fallback TCP handler pipes each tailnet connection to
`127.0.0.1:<same port>`. It dials the local port before accepting, so
ports with no listener are refused properly.
- Outbound: local forwards (`localforward.go`) listen on localhost and relay
TCP and UDP to a tailnet host through `tsnet.Server.Dial`. UDP is relayed
per client address with an idle timeout. The Sunshine setting is a preset
of seven such forwards.
- A status page and small JSON API on port 8090, an HTTP proxy on
`127.0.0.1:8118`, PS5 notifications by writing a request to
`/dev/notification0`.
- A payload that is sent again stops the running instance (through the
status page, or failing that by the pid it recorded) and takes over.
**The installer** (`installer/`, C) embeds `tailscale.elf`. It stores it
where the console's autoloader looks, appends it to the load order, registers
a home screen app whose `param.json` has a `deeplinkUri` to the status page,
and starts the daemon by sending it to the ELF loader on `127.0.0.1:9021`.
## The PS5 as a Go target
Go is built for `GOOS=freebsd GOARCH=amd64` with cgo off and
`-buildmode=pie`, from a Go 1.27.1 tree with
`patches/go1.27.1-ps5.patch`. What the patch changes and why:
| File | Change | Reason |
| --- | --- | --- |
| `internal/platform/supported.go` | Allow internally linked PIE on freebsd/amd64. | The image must load at any address and no C toolchain is involved. |
| `runtime/defs_freebsd_amd64.go` | 48 bytes of padding in `ucontext` before `uc_mcontext`. | The PS5's signal context has extra fields there. Without it, nil-dereference panics and preemption read garbage. |
| `runtime/defs_freebsd_amd64.go`, `sys_freebsd_amd64.s` | `kevent` 363 with the 32 byte struct. | The FreeBSD 12 `kevent` (560) does not exist. |
| `runtime/sys_freebsd_amd64.s`, `os_freebsd.go` | `pipe2` built from `pipe` and `fcntl`. | `pipe2` (542) does not exist. |
| `runtime/os_freebsd.go` | CPU count from `hw.ncpu`. | `kern.smp.maxcpus` does not exist. |
| `syscall/asm_unix_amd64.s`, `asm9_unix2_amd64.s`, `ps5_freebsd_amd64.go` | Syscall emulation layer. | See below. |
| `internal/routebsd/interface_freebsd.go` | Find the link address using `if_data`'s own length field. | Sony's `if_data` is 0xb0 bytes, so interface names came back empty. |
| `os/executable_sysctl.go` | `os.Executable` falls back to a name from argv[0]. | The kernel has no path for a payload; tsnet treats the error as fatal. |
### The syscall table
The kernel implements the FreeBSD 9 system call numbers. **Every number from
532 up is a Sony syscall with an unrelated meaning**, so nothing FreeBSD
added later exists: `pipe2`, `accept4`, `ppoll`, `utimensat`, `futimens`,
`getrandom`, the 64-bit inode `fstat`/`fstatat`/`getdirentries`/`statfs`
family, the extended `kevent`. Issuing those numbers would call something
else entirely.
Go's `Syscall`, `Syscall6`, `RawSyscall`, `RawSyscall6` and `Syscall9` are
patched to divert numbers >= 532 to `ps5_freebsd_amd64.go`, which rebuilds
them from FreeBSD 9 calls and converts the structures (old `stat`, `dirent`
and `statfs` layouts). Anything not emulated returns `ENOSYS`. Because
`golang.org/x/sys/unix` and `internal/syscall/unix` go through the same entry
points, they are covered too. `socket` and `socketpair` are also diverted, to
apply `SOCK_NONBLOCK`/`SOCK_CLOEXEC` with `fcntl`.
### Other kernel behaviour
- Page size is 16384. The Go linker aligns segments to 4096; the loader
applies protection per 16 KiB page.
- Working as on FreeBSD: `thr_new`, `_umtx_op`, `sigaction`, `sigaltstack`,
`thr_kill`, kqueue including `EVFILT_USER`, `mmap` reservations with
`PROT_NONE` (64 MB and 4 GB tried), `MAP_FIXED`, `madvise(MADV_FREE)`,
`sysctl` for `hw.pagesize`, `hw.ncpu`, `kern.arandom`, `kern.boottime`,
`NET_RT_IFLIST` and `NET_RT_DUMP`, `AF_ROUTE` sockets.
- Directory reads: `/data` (nullfs) and `/user` (bfs) only accept a buffer of
exactly 65536 bytes for `getdirentries`. The emulation always reads 64 KiB
blocks and hands the converted entries out across calls.
- Unix domain sockets cannot be bound on `/data`.
- There is no `/etc/resolv.conf` and no CA bundle. Go's resolver falls back
to `127.0.0.1:53`; the daemon imports `x509roots/fallback` for TLS roots.
- A payload's stdin, stdout and stderr are the ELF loader's TCP connection.
Go kills a process whose write to fd 1 or 2 fails with `EPIPE`, so the
daemon moves that connection to another descriptor and points 1 and 2 at
its log file.
- The SDK's `kernel_mprotect()` rewrites the protection of the whole kernel
map entry that contains the address. The loader first splits the text range
off with an ordinary `mprotect()`.
- When the network is reconfigured (connection settings changed, Wi-Fi to
Ethernet), listening sockets fail with errno 163, a Sony-specific code, and
do not recover. The daemon's listeners reopen themselves
(`tsd/listener.go`).
## Scheduling
This is the part to read before running any new test on a console.
Payload threads start as FIFO threads at priority 700 (`rtprio_thread`
reports class 10, priority 700; the range is 256 to 767 and lower runs
first). A FIFO thread that never blocks is never descheduled. A Go test with
a busy loop on each of the 16 logical cores froze the console completely:
the kernel still answered ping, but no user process ran, and only a
power-cycle recovered it.
`rtprio_thread(RTP_SET)` silently ignores priorities outside that range, so
the usual `RTP_PRIO_NORMAL, 0` does nothing and reports success. These are
accepted and read back: class 3 (time-sharing) at 767, class 2 (round-robin)
at 767, class 10 (FIFO) at 767. New threads inherit the setting.
The launcher sets class 3, priority 767 before Go starts, reads it back, and
refuses to start if it did not take. With that, Go's signal-based preemption
works across cores (4 spinning goroutines, 5 garbage collections in about
350 ms). Test builds can add a watchdog thread that kills the process after
a fixed time (`build-payload.ps1 -Watchdog`).
## Home screen icon
`/user/app/TSCL00001/sce_sys/param.json` with `applicationCategoryType` 65536
and a `deeplinkUri`, plus `icon0.png`, registered with
`sceAppInstUtilAppInstallTitleDir`. The console opens the link in its
browser.
Linking `libSceAppInstUtil` alone leaves the payload stopped before it runs.
It needs `-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService`, in
that order, as in the SDK's `install_app` sample.
## Autoloaders
- Payload Manager: load order in `/data/pldmgr/autoload.txt`, one file name
per line, `!N` for a delay in milliseconds. It finds a named file anywhere
below `/data/pldmgr` and launches it by streaming it to `127.0.0.1:9021`.
- `ps5_autoloader`: `autoload.txt` in `/data/ps5_autoloader` or the same
folder on a USB drive, with the payloads next to it.
The table of autoloaders exists twice, in `installer/main.c` and
`tsd/autostart.go`.
## Known problems
- Once, switching the console from Wi-Fi to Ethernet during a Moonlight
stream through the forwards froze the console. At that moment two daemon
instances were running because of a bug that has since been fixed; whether
that, the Moonlight client or something else caused the freeze is not
known.
- Once, a first login completed in the browser but the daemon's follow-up
request was answered with "auth path not found". The daemon now requests a
new link when it sees that error; the recovery path has not been observed
in practice.
- Whether Tailscale's own UDP sockets recover after a network
reconfiguration has not been examined.
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"titleId": "TSCL00001",
"applicationCategoryType": 65536,
"deeplinkUri": "http://127.0.0.1:8090/",
"localizedParameters": {
"defaultLanguage": "en-US",
"en-US": {
"titleName": "Tailscale"
}
}
}
+402
View File
@@ -0,0 +1,402 @@
/* Tailscale installer payload for jailbroken PS5s.
*
* Stores the Tailscale daemon payload on the console, registers it with the
* payload autoloader if one is set up, adds a home screen icon that opens
* the status page, and starts the daemon through the ELF loader on this
* console. Build with tools\build-installer.ps1, which sets DAEMON_ELF and
* ASSET_DIR and links the system libraries the app installer needs. */
#include <errno.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <ps5/kernel.h>
#ifndef DAEMON_ELF
#error "DAEMON_ELF must name the daemon payload to embed"
#endif
#define DATA_DIR "/data/tailscale"
#define DAEMON_NAME "tailscale.elf"
#define LOADER_PORT 9021
extern const uint8_t daemon_elf[];
extern const uint8_t daemon_elf_end[];
__asm__(".section .rodata\n"
".balign 16\n"
".global daemon_elf\n"
"daemon_elf:\n"
".incbin \"" DAEMON_ELF "\"\n"
".global daemon_elf_end\n"
"daemon_elf_end:\n"
".text\n");
/* The home screen launcher: a media app whose only content is a link, which
* the console opens in its browser. ASSET_DIR is set by the build. */
#ifndef ASSET_DIR
#error "ASSET_DIR must name the folder with param.json and icon0.png"
#endif
#define LAUNCHER_TITLE_ID "TSCL00001"
#define LAUNCHER_DIR "/user/app/" LAUNCHER_TITLE_ID
#define INCASSET(name, file) \
__asm__(".section .rodata\n" \
".balign 16\n" \
".global " #name "\n" #name ":\n" \
".incbin \"" file "\"\n" \
".global " #name "_end\n" #name "_end:\n" \
".text\n"); \
extern const uint8_t name[]; \
extern const uint8_t name##_end[];
INCASSET(launcher_param_json, ASSET_DIR "/param.json")
INCASSET(launcher_icon_png, ASSET_DIR "/icon0.png")
int sceAppInstUtilInitialize(void);
int sceAppInstUtilTerminate(void);
int sceAppInstUtilAppInstallAll(void *);
/* Where payload autoloaders keep their load order, and where the daemon has
* to be stored for each. Keep in sync with tsd/autostart.go. */
static const struct autoloader {
const char *name;
const char *list; /* load order: one file name per line */
const char *payload_dir; /* where the payload goes */
} autoloaders[] = {
/* Payload Manager finds entries by file name anywhere below /data/pldmgr
* and keeps each payload in a folder of its own. */
{"Payload Manager", "/data/pldmgr/autoload.txt", "/data/pldmgr/payloads/Tailscale"},
{"PS5 autoloader", "/data/ps5_autoloader/autoload.txt", "/data/ps5_autoloader"},
{"PS5 autoloader (usb0)", "/mnt/usb0/ps5_autoloader/autoload.txt", "/mnt/usb0/ps5_autoloader"},
{"PS5 autoloader (usb1)", "/mnt/usb1/ps5_autoloader/autoload.txt", "/mnt/usb1/ps5_autoloader"},
{"PS5 autoloader (usb2)", "/mnt/usb2/ps5_autoloader/autoload.txt", "/mnt/usb2/ps5_autoloader"},
{"PS5 autoloader (usb3)", "/mnt/usb3/ps5_autoloader/autoload.txt", "/mnt/usb3/ps5_autoloader"},
};
typedef struct notify_request {
char useless1[45];
char message[3075];
} notify_request_t;
int sceKernelSendNotificationRequest(int, notify_request_t *, size_t, int);
static void
notify(const char *fmt, ...) {
notify_request_t req;
va_list args;
memset(&req, 0, sizeof(req));
va_start(args, fmt);
vsnprintf(req.message, sizeof(req.message), fmt, args);
va_end(args);
sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
}
static int
write_all(int fd, const uint8_t *data, size_t size) {
while (size > 0) {
ssize_t n = write(fd, data, size);
if (n < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
data += n;
size -= n;
}
return 0;
}
/* Write a file through a temporary name so a failed write never leaves a
* truncated payload where the autoloader would pick it up. */
static int
write_file(const char *path, const uint8_t *data, size_t size) {
char tmp[512];
int fd;
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
if ((fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0755)) < 0) {
return -1;
}
if (write_all(fd, data, size)) {
close(fd);
unlink(tmp);
return -1;
}
close(fd);
if (rename(tmp, path)) {
unlink(tmp);
return -1;
}
return 0;
}
static int
install_daemon(const char *dir) {
char path[512];
mkdir(dir, 0755);
snprintf(path, sizeof(path), "%s/%s", dir, DAEMON_NAME);
if (write_file(path, daemon_elf, daemon_elf_end - daemon_elf)) {
printf(" could not write %s: %s\n", path, strerror(errno));
return -1;
}
printf(" wrote %s (%.1f MB)\n", path, (daemon_elf_end - daemon_elf) / 1048576.0);
return 0;
}
/* Report whether the load order already lists the daemon on a line of its own. */
static int
autoload_lists_daemon(const char *text) {
size_t namelen = strlen(DAEMON_NAME);
for (const char *line = text; line && *line;) {
const char *end = strchr(line, '\n');
size_t len = end ? (size_t)(end - line) : strlen(line);
while (len > 0 && (line[len - 1] == '\r' || line[len - 1] == ' ' || line[len - 1] == '\t')) {
len--;
}
if (len == namelen && !strncmp(line, DAEMON_NAME, namelen)) {
return 1;
}
line = end ? end + 1 : 0;
}
return 0;
}
/* If this autoloader is set up on the console, store the daemon for it and
* add it to the end of the load order. Returns 1 if registered, 0 if the
* autoloader is not present, -1 on error. */
static int
register_autoload(const struct autoloader *al) {
struct stat st;
char *text;
size_t got;
FILE *f;
if (stat(al->list, &st)) {
return 0;
}
printf("%s:\n", al->name);
if (install_daemon(al->payload_dir)) {
return -1;
}
if (!(f = fopen(al->list, "rb"))) {
printf(" could not read %s: %s\n", al->list, strerror(errno));
return -1;
}
text = calloc(1, st.st_size + 1);
got = fread(text, 1, st.st_size, f);
fclose(f);
text[got] = 0;
if (autoload_lists_daemon(text)) {
printf(" %s already lists %s\n", al->list, DAEMON_NAME);
} else {
if (!(f = fopen(al->list, "ab"))) {
printf(" could not update %s: %s\n", al->list, strerror(errno));
free(text);
return -1;
}
if (got > 0 && text[got - 1] != '\n') {
fputc('\n', f);
}
fputs(DAEMON_NAME "\n", f);
fclose(f);
printf(" added %s to the end of %s\n", DAEMON_NAME, al->list);
}
free(text);
return 1;
}
/* Report whether the file at path already has exactly these contents. */
static int
file_matches(const char *path, const uint8_t *data, size_t size) {
struct stat st;
uint8_t *buf;
int same = 0;
FILE *f;
if (stat(path, &st) || (size_t)st.st_size != size || !(f = fopen(path, "rb"))) {
return 0;
}
if ((buf = malloc(size))) {
same = fread(buf, 1, size, f) == size && !memcmp(buf, data, size);
free(buf);
}
fclose(f);
return same;
}
/* Put a "Tailscale" icon on the home screen that opens the status page in
* the console's browser. Does nothing if it is already there and current.
* Returns 0 on success. */
static int
install_launcher_app(void) {
int (*install_title_dir)(const char *, const char *, void *) = 0;
size_t param_size = launcher_param_json_end - launcher_param_json;
size_t icon_size = launcher_icon_png_end - launcher_icon_png;
uint32_t handle;
int err;
if (file_matches(LAUNCHER_DIR "/sce_sys/param.json", launcher_param_json, param_size) &&
file_matches(LAUNCHER_DIR "/sce_sys/icon0.png", launcher_icon_png, icon_size)) {
printf(" already installed\n");
return 0;
}
if ((err = sceAppInstUtilInitialize())) {
printf(" sceAppInstUtilInitialize failed: 0x%08x\n", err);
return -1;
}
mkdir(LAUNCHER_DIR, 0755);
mkdir(LAUNCHER_DIR "/sce_sys", 0755);
if (write_file(LAUNCHER_DIR "/sce_sys/param.json", launcher_param_json, param_size) ||
write_file(LAUNCHER_DIR "/sce_sys/icon0.png", launcher_icon_png, icon_size)) {
printf(" could not write to %s: %s\n", LAUNCHER_DIR, strerror(errno));
sceAppInstUtilTerminate();
return -1;
}
/* Register just this title where the firmware supports it; otherwise ask
* for a rescan of everything under /user/app. */
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle)) {
install_title_dir = (void *)kernel_dynlib_resolve(-1, handle, "Wudg3Xe3heE");
}
if (install_title_dir) {
err = install_title_dir(LAUNCHER_TITLE_ID, "/user/app/", 0);
} else {
err = sceAppInstUtilAppInstallAll(0);
}
sceAppInstUtilTerminate();
if (err) {
printf(" registering the app failed: 0x%08x\n", err);
return -1;
}
printf(" installed (%s), opens http://127.0.0.1:8090/\n", LAUNCHER_TITLE_ID);
return 0;
}
/* Hand the daemon to the ELF loader on this console and relay what it prints
* for a while, so that the login link reaches whoever sent the installer. */
static int
start_daemon(int relay_seconds) {
struct sockaddr_in addr = {0};
struct timeval start, now;
char buf[4096];
int fd;
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
return -1;
}
addr.sin_family = AF_INET;
addr.sin_port = htons(LOADER_PORT);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
close(fd);
return -1;
}
if (write_all(fd, daemon_elf, daemon_elf_end - daemon_elf)) {
close(fd);
return -1;
}
gettimeofday(&start, 0);
for (;;) {
struct timeval tv = {1, 0};
fd_set rfds;
gettimeofday(&now, 0);
if (now.tv_sec - start.tv_sec >= relay_seconds) {
break;
}
FD_ZERO(&rfds);
FD_SET(fd, &rfds);
if (select(fd + 1, &rfds, 0, 0, &tv) <= 0) {
continue;
}
ssize_t n = read(fd, buf, sizeof(buf));
if (n <= 0) {
break;
}
if (write_all(STDOUT_FILENO, (uint8_t *)buf, n)) {
break;
}
}
close(fd);
return 0;
}
int
main(void) {
pid_t pid = getpid();
intptr_t rootvnode;
int registered = 0;
setvbuf(stdout, 0, _IONBF, 0);
/* Run as root outside the sandbox so /data and USB drives are writable. */
if ((rootvnode = kernel_get_root_vnode())) {
kernel_set_proc_rootdir(pid, rootvnode);
kernel_set_proc_jaildir(pid, 0);
}
kernel_set_ucred_uid(pid, 0);
kernel_set_ucred_ruid(pid, 0);
kernel_set_ucred_svuid(pid, 0);
kernel_set_ucred_rgid(pid, 0);
kernel_set_ucred_svgid(pid, 0);
#ifdef LAUNCHER_ONLY
/* Test build: only (re)install the home screen icon. */
printf("Home screen icon:\n");
return install_launcher_app() ? 1 : 0;
#endif
printf("Tailscale for PS5 installer\n\n");
mkdir(DATA_DIR, 0755);
for (size_t i = 0; i < sizeof(autoloaders) / sizeof(autoloaders[0]); i++) {
if (register_autoload(&autoloaders[i]) > 0) {
registered++;
}
}
if (!registered) {
/* No autoloader: keep the payload where the user can find it. */
printf("No payload autoloader found on this console.\n");
if (install_daemon(DATA_DIR)) {
notify("Tailscale install failed:\ncould not write to %s", DATA_DIR);
return 1;
}
printf(" Tailscale will not start by itself after a reboot.\n"
" Send %s/%s to the ELF loader to start it.\n",
DATA_DIR, DAEMON_NAME);
}
printf("Home screen icon:\n");
install_launcher_app();
printf("\nStarting Tailscale...\n");
if (start_daemon(45)) {
printf("Could not reach the ELF loader on port %d: %s\n", LOADER_PORT, strerror(errno));
notify("Tailscale is installed but could not be started:\nno ELF loader on port %d.", LOADER_PORT);
return 1;
}
printf("\nDone. The status page is on port 8090 of this console.\n");
return 0;
}
+269
View File
@@ -0,0 +1,269 @@
/* In-process loader for a Go program built for the PS5 (GOOS=freebsd,
* -buildmode=pie, internal linking).
*
* The SDK crt has already widened the address range that is allowed to issue
* syscalls, so the Go runtime can use its own raw SYSCALL instructions. All
* that is left is to map the image, relocate it, and enter it the way the
* FreeBSD kernel would: %rdi pointing at argc/argv/envp/auxv. */
#include <elf.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <ps5/kernel.h>
#include "goload.h"
#define PS5_PAGE_SIZE 0x4000ul
#define PAGE_TRUNC(x) ((x) & ~(PS5_PAGE_SIZE - 1))
#define PAGE_ROUND(x) PAGE_TRUNC((x) + PS5_PAGE_SIZE - 1)
#define GO_STACK_SIZE (1ul << 20)
#ifndef AT_PAGESZ
#define AT_PAGESZ 6
#endif
#ifndef AT_NULL
#define AT_NULL 0
#endif
void goload_enter(uintptr_t entry, uintptr_t *args, void *stack_top) __attribute__((noreturn));
/* Switch to the new stack and jump to the Go entry point. Never returns. */
__asm__(".intel_syntax noprefix\n"
".text\n"
".global goload_enter\n"
".type goload_enter, @function\n"
"goload_enter:\n"
" mov rax, rdi\n"
" mov rdi, rsi\n"
" mov rsp, rdx\n"
" xor ebp, ebp\n"
" jmp rax\n"
".att_syntax prefix\n");
#ifdef GOLOAD_DEBUG
#include <signal.h>
#include <stdlib.h>
#include <ucontext.h>
static uintptr_t dbg_base;
static uintptr_t dbg_vaddr;
/* Report faults that happen before the Go runtime installs its own signal
* handlers. Only async-signal-safe calls are used. */
static void
dbg_fault(int sig, siginfo_t *info, void *uctx) {
ucontext_t *uc = uctx;
/* The PS5 kernel puts 0x30 extra bytes between uc_sigmask and uc_mcontext. */
mcontext_t *mc = (mcontext_t *)((char *)&uc->uc_mcontext + 0x30);
char buf[1024];
uintptr_t rip = mc->mc_rip;
int n = snprintf(buf, sizeof(buf),
"goload: fatal signal %d code=%d addr=%p rip=%#lx (image vaddr %#lx) rsp=%#lx\n"
" rax=%#lx rbx=%#lx rcx=%#lx rdx=%#lx rsi=%#lx rdi=%#lx rbp=%#lx\n"
" r8=%#lx r9=%#lx r10=%#lx r11=%#lx r12=%#lx r13=%#lx r14=%#lx r15=%#lx\n"
" trapno=%ld err=%#lx flags=%#lx fsbase=%#lx getpid=%p\n",
sig, info->si_code, info->si_addr, (unsigned long)rip,
(unsigned long)(rip - dbg_base + dbg_vaddr), (unsigned long)mc->mc_rsp,
(unsigned long)mc->mc_rax, (unsigned long)mc->mc_rbx, (unsigned long)mc->mc_rcx,
(unsigned long)mc->mc_rdx, (unsigned long)mc->mc_rsi, (unsigned long)mc->mc_rdi,
(unsigned long)mc->mc_rbp, (unsigned long)mc->mc_r8, (unsigned long)mc->mc_r9,
(unsigned long)mc->mc_r10, (unsigned long)mc->mc_r11, (unsigned long)mc->mc_r12,
(unsigned long)mc->mc_r13, (unsigned long)mc->mc_r14, (unsigned long)mc->mc_r15,
(long)mc->mc_trapno, (unsigned long)mc->mc_err, (unsigned long)mc->mc_rflags,
(unsigned long)mc->mc_fsbase, (void *)getpid);
write(2, buf, n);
_exit(128 + sig);
}
static void
dbg_install(uintptr_t base, uintptr_t vaddr) {
static const int sigs[] = {SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGSYS, SIGTRAP, SIGABRT};
struct sigaction sa = {0};
stack_t ss = {0};
dbg_base = base;
dbg_vaddr = vaddr;
ss.ss_size = 64 * 1024;
ss.ss_sp = malloc(ss.ss_size);
sigaltstack(&ss, 0);
sa.sa_sigaction = dbg_fault;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigfillset(&sa.sa_mask);
for (size_t i = 0; i < sizeof(sigs) / sizeof(sigs[0]); i++) {
sigaction(sigs[i], &sa, 0);
}
}
#endif
static int
image_check(const uint8_t *image, size_t size) {
const Elf64_Ehdr *ehdr = (const Elf64_Ehdr *)image;
if (size < sizeof(*ehdr) || memcmp(ehdr->e_ident, ELFMAG, SELFMAG)) {
return -1;
}
if (ehdr->e_type != ET_DYN || ehdr->e_machine != EM_X86_64) {
return -1;
}
if (ehdr->e_phoff + (size_t)ehdr->e_phnum * sizeof(Elf64_Phdr) > size) {
return -1;
}
return 0;
}
int
goload_run(const uint8_t *image, size_t size, char *const argv[], char *const envp[]) {
const Elf64_Ehdr *ehdr = (const Elf64_Ehdr *)image;
const Elf64_Phdr *phdr = (const Elf64_Phdr *)(image + ehdr->e_phoff);
const Elf64_Dyn *dyn = 0;
const Elf64_Rela *rela = 0;
size_t relasz = 0;
uintptr_t min_vaddr = UINTPTR_MAX;
uintptr_t max_vaddr = 0;
uintptr_t bias;
uint8_t *base;
uint8_t *stack;
uintptr_t *sp;
int argc = 0;
int envc = 0;
if (image_check(image, size)) {
fprintf(stderr, "goload: not a relocatable x86-64 ELF image\n");
return -1;
}
for (int i = 0; i < ehdr->e_phnum; i++) {
if (phdr[i].p_type != PT_LOAD || !phdr[i].p_memsz) {
continue;
}
if (phdr[i].p_offset + phdr[i].p_filesz > size) {
fprintf(stderr, "goload: truncated image\n");
return -1;
}
if (phdr[i].p_vaddr < min_vaddr) {
min_vaddr = phdr[i].p_vaddr;
}
if (phdr[i].p_vaddr + phdr[i].p_memsz > max_vaddr) {
max_vaddr = phdr[i].p_vaddr + phdr[i].p_memsz;
}
}
if (min_vaddr >= max_vaddr) {
fprintf(stderr, "goload: image has no loadable segments\n");
return -1;
}
min_vaddr = PAGE_TRUNC(min_vaddr);
max_vaddr = PAGE_ROUND(max_vaddr);
base = mmap(0, max_vaddr - min_vaddr, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (base == MAP_FAILED) {
perror("goload: mmap image");
return -1;
}
bias = (uintptr_t)base - min_vaddr;
for (int i = 0; i < ehdr->e_phnum; i++) {
if (phdr[i].p_type == PT_LOAD && phdr[i].p_filesz) {
memcpy((void *)(bias + phdr[i].p_vaddr), image + phdr[i].p_offset, phdr[i].p_filesz);
} else if (phdr[i].p_type == PT_DYNAMIC) {
dyn = (const Elf64_Dyn *)(bias + phdr[i].p_vaddr);
}
}
/* The Go linker only emits R_X86_64_RELATIVE for an internally linked PIE. */
for (; dyn && dyn->d_tag != DT_NULL; dyn++) {
if (dyn->d_tag == DT_RELA) {
rela = (const Elf64_Rela *)(bias + dyn->d_un.d_ptr);
} else if (dyn->d_tag == DT_RELASZ) {
relasz = dyn->d_un.d_val;
}
}
for (size_t i = 0; rela && i < relasz / sizeof(*rela); i++) {
if (ELF64_R_TYPE(rela[i].r_info) != R_X86_64_RELATIVE) {
fprintf(stderr, "goload: unsupported relocation type %u\n", (unsigned)ELF64_R_TYPE(rela[i].r_info));
return -1;
}
*(uintptr_t *)(bias + rela[i].r_offset) = bias + rela[i].r_addend;
}
/* Segments are 4 KiB aligned but the PS5 uses 16 KiB pages, so protection
* is applied to whole pages: executable ones become R+X, the rest stays
* R+W. A page shared by text and read-only data ends up executable, which
* is harmless. */
for (int i = 0; i < ehdr->e_phnum; i++) {
if (phdr[i].p_type != PT_LOAD || !(phdr[i].p_flags & PF_X)) {
continue;
}
uintptr_t start = PAGE_TRUNC(bias + phdr[i].p_vaddr);
uintptr_t end = PAGE_ROUND(bias + phdr[i].p_vaddr + phdr[i].p_memsz);
/* kernel_mprotect rewrites the protection of the whole kernel map entry
* that contains the address, so first let a regular mprotect split the
* text range off into an entry of its own. */
if (mprotect((void *)start, end - start, PROT_READ)) {
perror("goload: mprotect");
return -1;
}
if (kernel_mprotect(-1, start, end - start, PROT_READ | PROT_EXEC)) {
fprintf(stderr, "goload: kernel_mprotect failed\n");
return -1;
}
}
stack = mmap(0, GO_STACK_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (stack == MAP_FAILED) {
perror("goload: mmap stack");
return -1;
}
while (argv && argv[argc]) {
argc++;
}
while (envp && envp[envc]) {
envc++;
}
/* argc, argv[], NULL, envp[], NULL, auxv pairs, AT_NULL */
sp = (uintptr_t *)(stack + GO_STACK_SIZE);
sp -= 1 + (argc + 1) + (envc + 1) + 4;
sp = (uintptr_t *)((uintptr_t)sp & ~0xful);
uintptr_t *p = sp;
*p++ = (uintptr_t)argc;
for (int i = 0; i < argc; i++) {
*p++ = (uintptr_t)argv[i];
}
*p++ = 0;
for (int i = 0; i < envc; i++) {
*p++ = (uintptr_t)envp[i];
}
*p++ = 0;
*p++ = AT_PAGESZ;
*p++ = PS5_PAGE_SIZE;
*p++ = AT_NULL;
*p++ = 0;
#ifdef GOLOAD_DEBUG
fprintf(stderr, "goload: image %p..%p entry %#lx stack %p..%p argc=%d\n", base,
base + (max_vaddr - min_vaddr), (unsigned long)(bias + ehdr->e_entry), stack, stack + GO_STACK_SIZE, argc);
dbg_install((uintptr_t)base, min_vaddr);
#endif
fflush(stdout);
fflush(stderr);
/* The embedded image has been copied and is not read again. Let the kernel
* reclaim its pages rather than keep two copies of the program in memory. */
uintptr_t entry = bias + ehdr->e_entry;
uintptr_t free_start = PAGE_ROUND((uintptr_t)image);
uintptr_t free_end = PAGE_TRUNC((uintptr_t)image + size);
if (free_end > free_start) {
madvise((void *)free_start, free_end - free_start, MADV_FREE);
}
goload_enter(entry, sp, sp);
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
/* Map the Go PIE image held in memory, relocate it and jump into it on a
* fresh stack. Only returns (with -1) if loading failed. */
int goload_run(const uint8_t *image, size_t size, char *const argv[], char *const envp[]);
+160
View File
@@ -0,0 +1,160 @@
/* PS5 payload entry point: prepare the process, then hand control to the
* embedded Go program. Build with -DGO_IMAGE="path/to/program.bin". */
#include <stdint.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/mman.h>
#include <ps5/kernel.h>
#include "goload.h"
#ifndef GO_IMAGE
#error "GO_IMAGE must name the Go binary to embed"
#endif
/* How many OS threads may run Go code at once. The console has 16 logical
* cores, but this is a background service and games need them more. */
#ifndef GO_MAXPROCS
#define GO_MAXPROCS "4"
#endif
extern const uint8_t go_image[];
extern const uint8_t go_image_end[];
__asm__(".section .rodata\n"
".balign 0x4000\n"
".global go_image\n"
"go_image:\n"
".incbin \"" GO_IMAGE "\"\n"
".global go_image_end\n"
"go_image_end:\n"
".text\n");
#define SYS_rtprio_thread 466
#define RTP_LOOKUP 0
#define RTP_SET 1
#define RTP_PRIO_REALTIME 2 /* round-robin */
#define RTP_PRIO_NORMAL 3 /* time-sharing */
#define PS5_PRIO_DEFAULT 700
#define PS5_PRIO_LOWEST 767
struct rtprio {
unsigned short type;
unsigned short prio;
};
static long
raw_syscall3(long n, long a, long b, long c) {
unsigned char failed;
__asm__ volatile("syscall; setc %1" : "+a"(n), "=q"(failed) : "D"(a), "S"(b), "d"(c) : "rcx", "r11", "memory");
return failed ? -n : n;
}
/* Payload threads start as FIFO threads at the PS5's default priority (class
* 10, priority 700; lower numbers run first, 767 is the lowest). A FIFO
* thread that never blocks is never descheduled, so a busy loop on every core
* freezes the whole console.
*
* Before any Go thread exists, move to the time-sharing class at the lowest
* priority, where nothing this process does can keep the system's own threads
* off the CPU. Threads created later inherit the setting. The kernel ignores
* priorities outside its own range without reporting an error, so the result
* is read back. Round-robin at the lowest priority is the fallback. */
static int
leave_realtime_class(void) {
static const struct rtprio choices[] = {
{RTP_PRIO_NORMAL, PS5_PRIO_LOWEST},
{RTP_PRIO_REALTIME, PS5_PRIO_LOWEST},
};
struct rtprio before = {0}, after = {0};
raw_syscall3(SYS_rtprio_thread, RTP_LOOKUP, 0, (long)&before);
for (size_t i = 0; i < sizeof(choices) / sizeof(choices[0]); i++) {
struct rtprio want = choices[i];
raw_syscall3(SYS_rtprio_thread, RTP_SET, 0, (long)&want);
raw_syscall3(SYS_rtprio_thread, RTP_LOOKUP, 0, (long)&after);
if (after.type == choices[i].type && after.prio == choices[i].prio) {
break;
}
}
#ifdef GOLOAD_DEBUG
fprintf(stderr, "launcher: scheduling class %u/%u -> %u/%u\n", before.type, before.prio, after.type, after.prio);
#else
(void)before;
#endif
return after.prio > PS5_PRIO_DEFAULT && after.type != before.type ? 0 : -1;
}
#ifdef GOLOAD_WATCHDOG
#include <pthread.h>
#include <signal.h>
/* Test builds only: kill the process after a fixed time, from a thread the Go
* scheduler knows nothing about, so that a hung test cannot outlive its
* welcome. */
static void *
watchdog_main(void *arg) {
sleep(GOLOAD_WATCHDOG);
static const char msg[] = "launcher: watchdog expired, killing the process\n";
write(2, msg, sizeof(msg) - 1);
kill(getpid(), SIGKILL);
return 0;
}
static void
start_watchdog(void) {
sigset_t all, old;
pthread_t thread;
/* The thread must never run a Go signal handler, so it starts with every
* signal blocked. */
sigfillset(&all);
pthread_sigmask(SIG_SETMASK, &all, &old);
pthread_create(&thread, 0, watchdog_main, 0);
pthread_sigmask(SIG_SETMASK, &old, 0);
}
#endif
int
main(int argc, char **argv) {
pid_t pid = getpid();
intptr_t rootvnode;
/* Run as root outside the sandbox so /data and the network are reachable. */
if ((rootvnode = kernel_get_root_vnode())) {
kernel_set_proc_rootdir(pid, rootvnode);
kernel_set_proc_jaildir(pid, 0);
}
kernel_set_ucred_uid(pid, 0);
kernel_set_ucred_ruid(pid, 0);
kernel_set_ucred_svuid(pid, 0);
kernel_set_ucred_rgid(pid, 0);
kernel_set_ucred_svgid(pid, 0);
if (leave_realtime_class()) {
/* Without this a runaway goroutine could hang the console, so do not
* take the chance. */
fprintf(stderr, "launcher: could not leave the real-time scheduling class; not starting\n");
return 1;
}
#ifdef GOLOAD_WATCHDOG
start_watchdog();
#endif
static char *envp[] = {
"HOME=/data/tailscale",
"TMPDIR=/data/tailscale/tmp",
"GOMAXPROCS=" GO_MAXPROCS,
#ifdef GO_DEBUG
"GODEBUG=" GO_DEBUG,
#endif
"PS5=1",
0,
};
return goload_run(go_image, (size_t)(go_image_end - go_image), argv, envp);
}
+926
View File
@@ -0,0 +1,926 @@
diff --git a/src/internal/platform/supported.go b/src/internal/platform/supported.go
index 6f37e36..f23af91 100644
--- a/src/internal/platform/supported.go
+++ b/src/internal/platform/supported.go
@@ -218,6 +218,7 @@ func InternalLinkPIESupported(goos, goarch string) bool {
switch goos + "/" + goarch {
case "android/arm64",
"darwin/amd64", "darwin/arm64",
+ "freebsd/amd64", // PS5: payloads are loaded at an arbitrary address by our own loader
"linux/amd64", "linux/arm64", "linux/loong64", "linux/ppc64", "linux/ppc64le", "linux/s390x",
"windows/386", "windows/amd64", "windows/arm64":
return true
diff --git a/src/runtime/defs_freebsd_amd64.go b/src/runtime/defs_freebsd_amd64.go
index 8f0b08d..9d3ac79 100644
--- a/src/runtime/defs_freebsd_amd64.go
+++ b/src/runtime/defs_freebsd_amd64.go
@@ -205,6 +205,7 @@ type mcontext struct {
type ucontext struct {
uc_sigmask sigset
+ ps5_pad [48]byte // PS5: the kernel has extra fields before the machine context
uc_mcontext mcontext
uc_link *ucontext
uc_stack stackt
@@ -251,7 +252,7 @@ type keventt struct {
fflags uint32
data int64
udata *byte
- ext [4]uint64
+ // PS5: no ext field. The kernel only has the 32 byte kevent of FreeBSD 11 and earlier.
}
type bintime struct {
diff --git a/src/runtime/sys_freebsd_amd64.s b/src/runtime/sys_freebsd_amd64.s
index 977ea09..9b675ba 100644
--- a/src/runtime/sys_freebsd_amd64.s
+++ b/src/runtime/sys_freebsd_amd64.s
@@ -43,8 +43,10 @@
#define SYS_thr_new 455
#define SYS_mmap 477
#define SYS_cpuset_getaffinity 487
-#define SYS_pipe2 542
-#define SYS_kevent 560
+// PS5: syscall numbers from 532 up belong to Sony, so pipe2 (542) and the
+// FreeBSD 12 kevent (560) are replaced by their older equivalents.
+#define SYS_pipe 42
+#define SYS_kevent 363
TEXT runtime·sys_umtx_op(SB),NOSPLIT,$0
MOVQ addr+0(FP), DI
@@ -137,15 +139,21 @@ TEXT runtime·read(SB),NOSPLIT,$-8
MOVL AX, ret+24(FP)
RET
-// func pipe2(flags int32) (r, w int32, errno int32)
-TEXT runtime·pipe2(SB),NOSPLIT,$0-20
- LEAQ r+8(FP), DI
- MOVL flags+0(FP), SI
- MOVL $SYS_pipe2, AX
+// func pipe() (r, w int32, errno int32)
+// The two descriptors come back in AX and DX.
+TEXT runtime·pipe(SB),NOSPLIT,$0-12
+ MOVL $SYS_pipe, AX
SYSCALL
- JCC 2(PC)
+ JCC pipeok
NEGQ AX
- MOVL AX, errno+16(FP)
+ MOVL $-1, r+0(FP)
+ MOVL $-1, w+4(FP)
+ MOVL AX, errno+8(FP)
+ RET
+pipeok:
+ MOVL AX, r+0(FP)
+ MOVL DX, w+4(FP)
+ MOVL $0, errno+8(FP)
RET
TEXT runtime·write1(SB),NOSPLIT,$-8
diff --git a/src/runtime/os_freebsd.go b/src/runtime/os_freebsd.go
index 68d895b..efa6b32 100644
--- a/src/runtime/os_freebsd.go
+++ b/src/runtime/os_freebsd.go
@@ -49,9 +49,33 @@ func kqueue() int32
//go:noescape
func kevent(kq int32, ch *keventt, nch int32, ev *keventt, nev int32, ts *timespec) int32
-func pipe2(flags int32) (r, w int32, errno int32)
+func pipe() (r, w int32, errno int32)
func fcntl(fd, cmd, arg int32) (ret int32, errno int32)
+// pipe2 is not implemented by the PS5 kernel, so build it from pipe and fcntl.
+func pipe2(flags int32) (r, w int32, errno int32) {
+ const (
+ _F_GETFL = 3
+ _F_SETFL = 4
+ )
+ r, w, errno = pipe()
+ if errno != 0 {
+ return -1, -1, errno
+ }
+ if flags&_O_CLOEXEC != 0 {
+ closeonexec(r)
+ closeonexec(w)
+ }
+ if flags&_O_NONBLOCK != 0 {
+ for _, fd := range [2]int32{r, w} {
+ if fl, e := fcntl(fd, _F_GETFL, 0); e == 0 {
+ fcntl(fd, _F_SETFL, fl|_O_NONBLOCK)
+ }
+ }
+ }
+ return r, w, 0
+}
+
func issetugid() int32
// From FreeBSD's <sys/sysctl.h>
@@ -108,14 +132,15 @@ func getCPUCount() int32 {
// with dynamically assigned sysctl entries.
miblen := sysctlnametomib([]byte("kern.smp.maxcpus"), &mib)
if miblen == 0 {
- return 1
+ // PS5: kern.smp.maxcpus does not exist.
+ return getCPUCountHW()
}
// Query kern.smp.maxcpus.
dstsize := uintptr(4)
maxcpus := uint32(0)
if sysctl(&mib[0], miblen, (*byte)(unsafe.Pointer(&maxcpus)), &dstsize, nil, 0) != 0 {
- return 1
+ return getCPUCountHW()
}
maskSize := int(maxcpus+7) / 8
@@ -128,7 +153,7 @@ func getCPUCount() int32 {
if cpuset_getaffinity(_CPU_LEVEL_WHICH, _CPU_WHICH_PID, _CPU_CURRENT_PID,
maskSize, (*byte)(unsafe.Pointer(&mask[0]))) != 0 {
- return 1
+ return getCPUCountHW()
}
n := int32(0)
for _, v := range mask[:maskSize] {
@@ -143,6 +168,18 @@ func getCPUCount() int32 {
return n
}
+// getCPUCountHW reads hw.ncpu. It is the PS5 fallback for getCPUCount.
+func getCPUCountHW() int32 {
+ const _HW_NCPU = 3
+ mib := [2]uint32{_CTL_HW, _HW_NCPU}
+ out := uint32(0)
+ nout := unsafe.Sizeof(out)
+ if sysctl(&mib[0], 2, (*byte)(unsafe.Pointer(&out)), &nout, nil, 0) >= 0 && out > 0 {
+ return int32(out)
+ }
+ return 1
+}
+
func getPageSize() uintptr {
mib := [2]uint32{_CTL_HW, _HW_PAGESIZE}
out := uint32(0)
diff --git a/src/syscall/asm_unix_amd64.s b/src/syscall/asm_unix_amd64.s
index 6d8da71..5f44736 100644
--- a/src/syscall/asm_unix_amd64.s
+++ b/src/syscall/asm_unix_amd64.s
@@ -11,11 +11,74 @@
// System call support for AMD64 unixes
//
+// PS5: the kernel implements the FreeBSD 9 syscall table. Numbers from 532 up
+// are Sony syscalls with unrelated meanings, so they must never reach the
+// kernel. The exported entry points below hand those numbers, plus socket (97)
+// and socketpair (135) whose SOCK_NONBLOCK/SOCK_CLOEXEC type flags need
+// translating, to the emulation in ps5_freebsd_amd64.go. Everything else goes
+// straight to the unchanged implementations, renamed to sysDirect*.
+
+#define PS5_FIRST_SONY_SYSCALL 532
+#define PS5_SYS_SOCKET 97
+#define PS5_SYS_SOCKETPAIR 135
+
// func Syscall(trap int64, a1, a2, a3 int64) (r1, r2, err int64)
+TEXT ·Syscall(SB),NOSPLIT,$0-56
+ MOVQ trap+0(FP), AX
+ CMPQ AX, $PS5_FIRST_SONY_SYSCALL
+ JAE emulate
+ CMPQ AX, $PS5_SYS_SOCKET
+ JEQ emulate
+ CMPQ AX, $PS5_SYS_SOCKETPAIR
+ JEQ emulate
+ JMP ·sysDirect(SB)
+emulate:
+ JMP ·ps5Syscall(SB)
+
// func Syscall6(trap int64, a1, a2, a3, a4, a5, a6 int64) (r1, r2, err int64)
+TEXT ·Syscall6(SB),NOSPLIT,$0-80
+ MOVQ trap+0(FP), AX
+ CMPQ AX, $PS5_FIRST_SONY_SYSCALL
+ JAE emulate6
+ CMPQ AX, $PS5_SYS_SOCKET
+ JEQ emulate6
+ CMPQ AX, $PS5_SYS_SOCKETPAIR
+ JEQ emulate6
+ JMP ·sysDirect6(SB)
+emulate6:
+ JMP ·ps5Syscall6(SB)
+
+// func RawSyscall(trap int64, a1, a2, a3 int64) (r1, r2, err int64)
+TEXT ·RawSyscall(SB),NOSPLIT,$0-56
+ MOVQ trap+0(FP), AX
+ CMPQ AX, $PS5_FIRST_SONY_SYSCALL
+ JAE emulateraw
+ CMPQ AX, $PS5_SYS_SOCKET
+ JEQ emulateraw
+ CMPQ AX, $PS5_SYS_SOCKETPAIR
+ JEQ emulateraw
+ JMP ·rawSysDirect(SB)
+emulateraw:
+ JMP ·ps5RawSyscall(SB)
+
+// func RawSyscall6(trap int64, a1, a2, a3, a4, a5, a6 int64) (r1, r2, err int64)
+TEXT ·RawSyscall6(SB),NOSPLIT,$0-80
+ MOVQ trap+0(FP), AX
+ CMPQ AX, $PS5_FIRST_SONY_SYSCALL
+ JAE emulateraw6
+ CMPQ AX, $PS5_SYS_SOCKET
+ JEQ emulateraw6
+ CMPQ AX, $PS5_SYS_SOCKETPAIR
+ JEQ emulateraw6
+ JMP ·rawSysDirect6(SB)
+emulateraw6:
+ JMP ·ps5RawSyscall6(SB)
+
+// func sysDirect(trap int64, a1, a2, a3 int64) (r1, r2, err int64)
+// func sysDirect6(trap int64, a1, a2, a3, a4, a5, a6 int64) (r1, r2, err int64)
// Trap # in AX, args in DI SI DX, return in AX DX
-TEXT ·Syscall(SB),NOSPLIT,$0-56
+TEXT ·sysDirect(SB),NOSPLIT,$0-56
CALL runtime·entersyscall<ABIInternal>(SB)
MOVQ trap+0(FP), AX // syscall entry
MOVQ a1+8(FP), DI
@@ -35,7 +98,7 @@ ok:
CALL runtime·exitsyscall<ABIInternal>(SB)
RET
-TEXT ·Syscall6(SB),NOSPLIT,$0-80
+TEXT ·sysDirect6(SB),NOSPLIT,$0-80
CALL runtime·entersyscall<ABIInternal>(SB)
MOVQ trap+0(FP), AX // syscall entry
MOVQ a1+8(FP), DI
@@ -58,7 +121,7 @@ ok6:
CALL runtime·exitsyscall<ABIInternal>(SB)
RET
-TEXT ·RawSyscall(SB),NOSPLIT,$0-56
+TEXT ·rawSysDirect(SB),NOSPLIT,$0-56
MOVQ a1+8(FP), DI
MOVQ a2+16(FP), SI
MOVQ a3+24(FP), DX
@@ -75,7 +138,7 @@ ok1:
MOVQ $0, err+48(FP) // errno
RET
-TEXT ·RawSyscall6(SB),NOSPLIT,$0-80
+TEXT ·rawSysDirect6(SB),NOSPLIT,$0-80
MOVQ a1+8(FP), DI
MOVQ a2+16(FP), SI
MOVQ a3+24(FP), DX
diff --git a/src/syscall/asm9_unix2_amd64.s b/src/syscall/asm9_unix2_amd64.s
index bb4e9db..5316f1f 100644
--- a/src/syscall/asm9_unix2_amd64.s
+++ b/src/syscall/asm9_unix2_amd64.s
@@ -11,8 +11,23 @@
// Syscall9 support for AMD64, DragonFly, FreeBSD and NetBSD
//
+// PS5: see asm_unix_amd64.s. Sony's syscall numbers are diverted to the
+// emulation, the rest go to the unchanged implementation.
// func Syscall9(trap int64, a1, a2, a3, a4, a5, a6, a7, a8, a9 int64) (r1, r2, err int64);
-TEXT ·Syscall9(SB),NOSPLIT,$32-104
+TEXT ·Syscall9(SB),NOSPLIT,$0-104
+ MOVQ num+0(FP), AX
+ CMPQ AX, $532
+ JAE emulate9
+ CMPQ AX, $97
+ JEQ emulate9
+ CMPQ AX, $135
+ JEQ emulate9
+ JMP ·sysDirect9(SB)
+emulate9:
+ JMP ·ps5Syscall9(SB)
+
+// func sysDirect9(trap int64, a1, a2, a3, a4, a5, a6, a7, a8, a9 int64) (r1, r2, err int64);
+TEXT ·sysDirect9(SB),NOSPLIT,$32-104
NO_LOCAL_POINTERS
CALL runtime·entersyscall<ABIInternal>(SB)
MOVQ num+0(FP), AX // syscall entry
diff --git a/src/syscall/ps5_freebsd_amd64.go b/src/syscall/ps5_freebsd_amd64.go
new file mode 100644
index 0000000..c369a88
--- /dev/null
+++ b/src/syscall/ps5_freebsd_amd64.go
@@ -0,0 +1,575 @@
+// This file is part of the PS5 patch for Go from the ps5-tailscale project.
+// Like the rest of the patch it is distributed under Go's BSD-style license,
+// which can be found in the LICENSE file.
+
+// PS5 support.
+//
+// The PS5 kernel implements the FreeBSD 9 system call table. Every number from
+// 532 up is a Sony system call with an unrelated meaning, so the calls FreeBSD
+// added in releases 10 to 13 (pipe2, accept4, the 64-bit inode stat family, the
+// extended kevent, getrandom, ...) do not exist. The assembly entry points
+// Syscall, Syscall6, RawSyscall, RawSyscall6 and Syscall9 divert those numbers
+// here, where they are rebuilt from the older calls. Because golang.org/x/sys
+// and internal/syscall/unix funnel through the same entry points, this covers
+// them too.
+//
+// socket and socketpair are diverted as well: the SOCK_NONBLOCK and
+// SOCK_CLOEXEC type flags postdate FreeBSD 9, so they are applied with fcntl.
+
+package syscall
+
+import (
+ "runtime"
+ "sync"
+ "unsafe"
+)
+
+func sysDirect(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
+func sysDirect6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
+func sysDirect9(trap, a1, a2, a3, a4, a5, a6, a7, a8, a9 uintptr) (r1, r2 uintptr, err Errno)
+func rawSysDirect(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
+func rawSysDirect6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
+
+func ps5Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno) {
+ return ps5Emulate(sysDirect6, trap, a1, a2, a3, 0, 0, 0)
+}
+
+func ps5Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno) {
+ return ps5Emulate(sysDirect6, trap, a1, a2, a3, a4, a5, a6)
+}
+
+func ps5Syscall9(trap, a1, a2, a3, a4, a5, a6, a7, a8, a9 uintptr) (r1, r2 uintptr, err Errno) {
+ return ps5Emulate(sysDirect6, trap, a1, a2, a3, a4, a5, a6)
+}
+
+func ps5RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno) {
+ return ps5Emulate(rawSysDirect6, trap, a1, a2, a3, 0, 0, 0)
+}
+
+func ps5RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno) {
+ return ps5Emulate(rawSysDirect6, trap, a1, a2, a3, a4, a5, a6)
+}
+
+// Syscall numbers of the FreeBSD 9 calls the emulation is built from.
+const (
+ ps5SysAccept = 30
+ ps5SysPipe = 42
+ ps5SysFsync = 95
+ ps5SysSocket = 97
+ ps5SysSocketpair = 135
+ ps5SysUtimes = 138
+ ps5SysStat = 188
+ ps5SysFstat = 189
+ ps5SysLstat = 190
+ ps5SysGetdirentries = 196
+ ps5SysSysctl = 202
+ ps5SysFutimes = 206
+ ps5SysPoll = 209
+ ps5SysClockGettime = 232
+ ps5SysLutimes = 276
+ ps5SysKevent = 363
+ ps5SysStatfs = 396
+ ps5SysFstatfs = 397
+ ps5SysLseek = 478
+ ps5SysFstatat = 493
+ ps5SysFutimesat = 494
+ ps5SysMknodat = 498
+)
+
+// Syscall numbers that do not exist on the PS5 and are emulated.
+const (
+ ps5NewAccept4 = 541
+ ps5NewPipe2 = 542
+ ps5NewPpoll = 545
+ ps5NewFutimens = 546
+ ps5NewUtimensat = 547
+ ps5NewFdatasync = 550
+ ps5NewFstat = 551
+ ps5NewFstatat = 552
+ ps5NewGetdirentries = 554
+ ps5NewStatfs = 555
+ ps5NewFstatfs = 556
+ ps5NewMknodat = 559
+ ps5NewKevent = 560
+ ps5NewGetrandom = 563
+)
+
+const (
+ ps5SockCloexec = 0x10000000
+ ps5SockNonblock = 0x20000000
+ ps5AtFdcwd = -100
+ ps5AtNofollow = 0x200
+ ps5UtimeNow = -1
+ ps5UtimeOmit = -2
+)
+
+type ps5Call func(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
+
+func ps5Emulate(call ps5Call, trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno) {
+ switch trap {
+ case ps5SysSocket:
+ flags := a2 & (ps5SockCloexec | ps5SockNonblock)
+ r1, r2, err = call(ps5SysSocket, a1, a2&^flags, a3, 0, 0, 0)
+ if err == 0 {
+ ps5SetFdFlags(r1, flags&ps5SockCloexec != 0, flags&ps5SockNonblock != 0)
+ }
+ return
+
+ case ps5SysSocketpair:
+ flags := a2 & (ps5SockCloexec | ps5SockNonblock)
+ r1, r2, err = call(ps5SysSocketpair, a1, a2&^flags, a3, a4, 0, 0)
+ if err == 0 && flags != 0 {
+ fds := (*[2]int32)(unsafe.Pointer(a4))
+ ps5SetFdFlags(uintptr(fds[0]), flags&ps5SockCloexec != 0, flags&ps5SockNonblock != 0)
+ ps5SetFdFlags(uintptr(fds[1]), flags&ps5SockCloexec != 0, flags&ps5SockNonblock != 0)
+ }
+ return
+
+ case ps5NewAccept4:
+ r1, r2, err = call(ps5SysAccept, a1, a2, a3, 0, 0, 0)
+ if err == 0 {
+ ps5SetFdFlags(r1, a4&ps5SockCloexec != 0, a4&ps5SockNonblock != 0)
+ }
+ return
+
+ case ps5NewPipe2:
+ // pipe returns the two descriptors in the result registers.
+ r1, r2, err = call(ps5SysPipe, 0, 0, 0, 0, 0, 0)
+ if err != 0 {
+ return
+ }
+ fds := (*[2]int32)(unsafe.Pointer(a1))
+ fds[0], fds[1] = int32(r1), int32(r2)
+ ps5SetFdFlags(r1, a2&O_CLOEXEC != 0, a2&O_NONBLOCK != 0)
+ ps5SetFdFlags(r2, a2&O_CLOEXEC != 0, a2&O_NONBLOCK != 0)
+ return 0, 0, 0
+
+ case ps5NewPpoll:
+ timeout := -1
+ if a3 != 0 {
+ ts := (*Timespec)(unsafe.Pointer(a3))
+ timeout = int(ts.Sec*1000 + (ts.Nsec+999999)/1000000)
+ }
+ return call(ps5SysPoll, a1, a2, uintptr(timeout), 0, 0, 0)
+
+ case ps5NewFutimens:
+ tv, omit, e := ps5Timevals(a2, func(st *ps5Stat) Errno {
+ _, _, e := rawSysDirect(ps5SysFstat, a1, uintptr(unsafe.Pointer(st)), 0)
+ return e
+ })
+ if e != 0 || omit {
+ return ps5Result(e)
+ }
+ r1, r2, err = call(ps5SysFutimes, a1, uintptr(unsafe.Pointer(tv)), 0, 0, 0, 0)
+ runtime.KeepAlive(tv)
+ return
+
+ case ps5NewUtimensat:
+ tv, omit, e := ps5Timevals(a3, func(st *ps5Stat) Errno {
+ _, _, e := rawSysDirect6(ps5SysFstatat, a1, a2, uintptr(unsafe.Pointer(st)), a4&ps5AtNofollow, 0, 0)
+ return e
+ })
+ if e != 0 || omit {
+ return ps5Result(e)
+ }
+ switch {
+ case a4&ps5AtNofollow != 0:
+ r1, r2, err = call(ps5SysLutimes, a2, uintptr(unsafe.Pointer(tv)), 0, 0, 0, 0)
+ case int32(a1) == ps5AtFdcwd:
+ r1, r2, err = call(ps5SysUtimes, a2, uintptr(unsafe.Pointer(tv)), 0, 0, 0, 0)
+ default:
+ r1, r2, err = call(ps5SysFutimesat, a1, a2, uintptr(unsafe.Pointer(tv)), 0, 0, 0)
+ }
+ runtime.KeepAlive(tv)
+ return
+
+ case ps5NewFdatasync:
+ return call(ps5SysFsync, a1, 0, 0, 0, 0, 0)
+
+ case ps5NewFstat:
+ var st ps5Stat
+ r1, r2, err = call(ps5SysFstat, a1, uintptr(unsafe.Pointer(&st)), 0, 0, 0, 0)
+ if err == 0 {
+ st.convert((*Stat_t)(unsafe.Pointer(a2)))
+ }
+ return
+
+ case ps5NewFstatat:
+ var st ps5Stat
+ r1, r2, err = call(ps5SysFstatat, a1, a2, uintptr(unsafe.Pointer(&st)), a4, 0, 0)
+ if err == 0 {
+ st.convert((*Stat_t)(unsafe.Pointer(a3)))
+ }
+ return
+
+ case ps5NewGetdirentries:
+ n, e := ps5Getdirentries(call, a1, unsafe.Slice((*byte)(unsafe.Pointer(a2)), int(a3)), (*int64)(unsafe.Pointer(a4)))
+ if e != 0 {
+ return ps5Result(e)
+ }
+ return uintptr(n), 0, 0
+
+ case ps5NewStatfs, ps5NewFstatfs:
+ old := uintptr(ps5SysStatfs)
+ if trap == ps5NewFstatfs {
+ old = ps5SysFstatfs
+ }
+ var st ps5Statfs
+ r1, r2, err = call(old, a1, uintptr(unsafe.Pointer(&st)), 0, 0, 0, 0)
+ if err == 0 {
+ st.convert((*Statfs_t)(unsafe.Pointer(a2)))
+ }
+ return
+
+ case ps5NewMknodat:
+ return call(ps5SysMknodat, a1, a2, a3, a4, 0, 0)
+
+ case ps5NewKevent:
+ return ps5Kevent(call, a1, a2, a3, a4, a5, a6)
+
+ case ps5NewGetrandom:
+ n, e := ps5Getrandom(unsafe.Slice((*byte)(unsafe.Pointer(a1)), int(a2)))
+ if e != 0 {
+ return ps5Result(e)
+ }
+ return uintptr(n), 0, 0
+ }
+
+ return ps5Result(ENOSYS)
+}
+
+func ps5Result(e Errno) (r1, r2 uintptr, err Errno) {
+ if e != 0 {
+ return ^uintptr(0), 0, e
+ }
+ return 0, 0, 0
+}
+
+func ps5SetFdFlags(fd uintptr, cloexec, nonblock bool) {
+ if cloexec {
+ rawSysDirect(SYS_FCNTL, fd, F_SETFD, FD_CLOEXEC)
+ }
+ if nonblock {
+ if fl, _, e := rawSysDirect(SYS_FCNTL, fd, F_GETFL, 0); e == 0 {
+ rawSysDirect(SYS_FCNTL, fd, F_SETFL, fl|O_NONBLOCK)
+ }
+ }
+}
+
+// ps5Stat is struct stat as the PS5 kernel fills it in (FreeBSD 9 to 11).
+type ps5Stat struct {
+ Dev uint32
+ Ino uint32
+ Mode uint16
+ Nlink uint16
+ Uid uint32
+ Gid uint32
+ Rdev uint32
+ Atim Timespec
+ Mtim Timespec
+ Ctim Timespec
+ Size int64
+ Blocks int64
+ Blksize uint32
+ Flags uint32
+ Gen uint32
+ Lspare int32
+ Birthtim Timespec
+}
+
+func (old *ps5Stat) convert(st *Stat_t) {
+ *st = Stat_t{
+ Dev: uint64(old.Dev),
+ Ino: uint64(old.Ino),
+ Nlink: uint64(old.Nlink),
+ Mode: old.Mode,
+ Uid: old.Uid,
+ Gid: old.Gid,
+ Rdev: uint64(old.Rdev),
+ Atimespec: old.Atim,
+ Mtimespec: old.Mtim,
+ Ctimespec: old.Ctim,
+ Birthtimespec: old.Birthtim,
+ Size: old.Size,
+ Blocks: old.Blocks,
+ Blksize: int32(old.Blksize),
+ Flags: old.Flags,
+ Gen: uint64(old.Gen),
+ }
+}
+
+// ps5Statfs is struct statfs as the PS5 kernel fills it in. It differs from
+// the current layout only in the length of the two mount names.
+type ps5Statfs struct {
+ Version uint32
+ Type uint32
+ Flags uint64
+ Bsize uint64
+ Iosize uint64
+ Blocks uint64
+ Bfree uint64
+ Bavail int64
+ Files uint64
+ Ffree int64
+ Syncwrites uint64
+ Asyncwrites uint64
+ Syncreads uint64
+ Asyncreads uint64
+ Spare [10]uint64
+ Namemax uint32
+ Owner uint32
+ Fsid Fsid
+ Charspare [80]int8
+ Fstypename [16]int8
+ Mntfromname [88]int8
+ Mntonname [88]int8
+}
+
+func (old *ps5Statfs) convert(st *Statfs_t) {
+ *st = Statfs_t{
+ Version: old.Version,
+ Type: old.Type,
+ Flags: old.Flags,
+ Bsize: old.Bsize,
+ Iosize: old.Iosize,
+ Blocks: old.Blocks,
+ Bfree: old.Bfree,
+ Bavail: old.Bavail,
+ Files: old.Files,
+ Ffree: old.Ffree,
+ Syncwrites: old.Syncwrites,
+ Asyncwrites: old.Asyncwrites,
+ Syncreads: old.Syncreads,
+ Asyncreads: old.Asyncreads,
+ Spare: old.Spare,
+ Namemax: old.Namemax,
+ Owner: old.Owner,
+ Fsid: old.Fsid,
+ Charspare: old.Charspare,
+ Fstypename: old.Fstypename,
+ }
+ copy(st.Mntfromname[:], old.Mntfromname[:])
+ copy(st.Mntonname[:], old.Mntonname[:])
+}
+
+// ps5Timevals converts the [2]Timespec argument of futimens/utimensat into the
+// [2]Timeval that futimes/utimes take. UTIME_NOW and UTIME_OMIT are resolved
+// here; stat is called only when a current timestamp has to be kept. omit
+// reports that both times are UTIME_OMIT, in which case there is nothing to do.
+func ps5Timevals(times uintptr, stat func(*ps5Stat) Errno) (tv *[2]Timeval, omit bool, err Errno) {
+ if times == 0 {
+ return nil, false, 0
+ }
+ ts := (*[2]Timespec)(unsafe.Pointer(times))
+ if ts[0].Nsec == ps5UtimeOmit && ts[1].Nsec == ps5UtimeOmit {
+ return nil, true, 0
+ }
+ var now Timespec
+ var st ps5Stat
+ haveNow, haveStat := false, false
+ tv = new([2]Timeval)
+ for i := range ts {
+ t := ts[i]
+ switch t.Nsec {
+ case ps5UtimeNow:
+ if !haveNow {
+ rawSysDirect(ps5SysClockGettime, 0 /* CLOCK_REALTIME */, uintptr(unsafe.Pointer(&now)), 0)
+ haveNow = true
+ }
+ t = now
+ case ps5UtimeOmit:
+ if !haveStat {
+ if e := stat(&st); e != 0 {
+ return nil, false, e
+ }
+ haveStat = true
+ }
+ if i == 0 {
+ t = st.Atim
+ } else {
+ t = st.Mtim
+ }
+ }
+ tv[i] = Timeval{Sec: t.Sec, Usec: t.Nsec / 1000}
+ }
+ return tv, false, 0
+}
+
+// Directory reading.
+//
+// The kernel returns the old directory entry (32-bit inode, 8 byte header)
+// and several PS5 filesystems, /data among them, reject any read that is not
+// exactly one 64 KiB block. A block can expand to more converted entries than
+// fit in the caller's buffer, so the remainder is kept per descriptor and
+// handed out by the following calls. The saved file offset detects a seek or a
+// reused descriptor, which invalidates the remainder.
+
+const ps5DirBlock = 65536
+
+type ps5DirState struct {
+ off int64 // file offset after the block that produced data
+ data []byte // converted entries not yet returned
+}
+
+var (
+ ps5DirMu sync.Mutex
+ ps5Dirs map[uintptr]*ps5DirState
+)
+
+func ps5Getdirentries(call ps5Call, fd uintptr, buf []byte, basep *int64) (int, Errno) {
+ cur, _, e := rawSysDirect(ps5SysLseek, fd, 0, 1 /* SEEK_CUR */)
+ if e != 0 {
+ return 0, e
+ }
+
+ ps5DirMu.Lock()
+ st := ps5Dirs[fd]
+ if st != nil && (st.off != int64(cur) || len(st.data) == 0) {
+ delete(ps5Dirs, fd)
+ st = nil
+ }
+ ps5DirMu.Unlock()
+
+ if st == nil {
+ block := make([]byte, ps5DirBlock)
+ for {
+ var base int64
+ n, _, e := call(ps5SysGetdirentries, fd, uintptr(unsafe.Pointer(&block[0])), ps5DirBlock, uintptr(unsafe.Pointer(&base)), 0, 0)
+ if e != 0 {
+ return 0, e
+ }
+ if n == 0 {
+ return 0, 0
+ }
+ if data := ps5ConvertDirents(block[:n]); len(data) > 0 {
+ off, _, _ := rawSysDirect(ps5SysLseek, fd, 0, 1)
+ st = &ps5DirState{off: int64(off), data: data}
+ break
+ }
+ // A block holding only unused entries: keep reading.
+ }
+ }
+
+ n := 0
+ for n < len(st.data) {
+ reclen := int(st.data[n+16]) | int(st.data[n+17])<<8
+ if n+reclen > len(buf) {
+ break
+ }
+ n += reclen
+ }
+ if n == 0 {
+ // Not even one entry fits.
+ ps5DirMu.Lock()
+ if ps5Dirs == nil {
+ ps5Dirs = make(map[uintptr]*ps5DirState)
+ }
+ ps5Dirs[fd] = st
+ ps5DirMu.Unlock()
+ return 0, EINVAL
+ }
+ copy(buf, st.data[:n])
+ st.data = st.data[n:]
+
+ ps5DirMu.Lock()
+ if len(st.data) > 0 {
+ if ps5Dirs == nil {
+ ps5Dirs = make(map[uintptr]*ps5DirState)
+ }
+ ps5Dirs[fd] = st
+ } else {
+ delete(ps5Dirs, fd)
+ }
+ ps5DirMu.Unlock()
+
+ if basep != nil {
+ *basep = st.off
+ }
+ return n, 0
+}
+
+// ps5ConvertDirents rewrites a block of old directory entries
+// (fileno uint32, reclen uint16, type uint8, namlen uint8, name) as Dirent
+// records. Unused entries (fileno 0) are dropped.
+func ps5ConvertDirents(old []byte) []byte {
+ const (
+ oldHeader = 8
+ newHeader = int(unsafe.Offsetof(Dirent{}.Name))
+ )
+ out := make([]byte, 0, len(old)*2)
+ for pos := 0; pos+oldHeader <= len(old); {
+ fileno := uint32(old[pos]) | uint32(old[pos+1])<<8 | uint32(old[pos+2])<<16 | uint32(old[pos+3])<<24
+ reclen := int(old[pos+4]) | int(old[pos+5])<<8
+ typ := old[pos+6]
+ namlen := int(old[pos+7])
+ if reclen < oldHeader || pos+reclen > len(old) {
+ break
+ }
+ if fileno != 0 && pos+oldHeader+namlen <= len(old) {
+ newlen := (newHeader + namlen + 1 + 7) &^ 7
+ start := len(out)
+ out = append(out, make([]byte, newlen)...)
+ rec := out[start:]
+ rec[0], rec[1], rec[2], rec[3] = byte(fileno), byte(fileno>>8), byte(fileno>>16), byte(fileno>>24)
+ rec[16], rec[17] = byte(newlen), byte(newlen>>8)
+ rec[18] = typ
+ rec[20], rec[21] = byte(namlen), byte(namlen>>8)
+ copy(rec[newHeader:], old[pos+oldHeader:pos+oldHeader+namlen])
+ }
+ pos += reclen
+ }
+ return out
+}
+
+// ps5Kevent implements the FreeBSD 12 kevent, whose events carry four extra
+// words, with the older call.
+func ps5Kevent(call ps5Call, kq, changes, nchanges, events, nevents, timeout uintptr) (r1, r2 uintptr, err Errno) {
+ type newKevent struct {
+ Kevent_t
+ Ext [4]uint64
+ }
+ var oldChanges, oldEvents []Kevent_t
+ var pc, pe unsafe.Pointer
+ if nchanges > 0 {
+ in := unsafe.Slice((*newKevent)(unsafe.Pointer(changes)), int(nchanges))
+ oldChanges = make([]Kevent_t, len(in))
+ for i := range in {
+ oldChanges[i] = in[i].Kevent_t
+ }
+ pc = unsafe.Pointer(&oldChanges[0])
+ }
+ if nevents > 0 {
+ oldEvents = make([]Kevent_t, int(nevents))
+ pe = unsafe.Pointer(&oldEvents[0])
+ }
+ r1, r2, err = call(ps5SysKevent, kq, uintptr(pc), nchanges, uintptr(pe), nevents, timeout)
+ runtime.KeepAlive(oldChanges)
+ if err == 0 && nevents > 0 {
+ out := unsafe.Slice((*newKevent)(unsafe.Pointer(events)), int(nevents))
+ for i := 0; i < int(r1) && i < len(out); i++ {
+ out[i] = newKevent{Kevent_t: oldEvents[i]}
+ }
+ }
+ return
+}
+
+// ps5Getrandom fills buf from the kernel's kern.arandom sysctl, which hands
+// out at most 256 bytes per call.
+func ps5Getrandom(buf []byte) (int, Errno) {
+ mib := [2]_C_int{1 /* CTL_KERN */, 37 /* KERN_ARND */}
+ for done := 0; done < len(buf); {
+ n := uintptr(len(buf) - done)
+ if n > 256 {
+ n = 256
+ }
+ _, _, e := rawSysDirect6(ps5SysSysctl, uintptr(unsafe.Pointer(&mib[0])), 2, uintptr(unsafe.Pointer(&buf[done])), uintptr(unsafe.Pointer(&n)), 0, 0)
+ if e != 0 {
+ return done, e
+ }
+ if n == 0 {
+ return done, EIO
+ }
+ done += int(n)
+ }
+ return len(buf), 0
+}
diff --git a/src/internal/routebsd/interface_freebsd.go b/src/internal/routebsd/interface_freebsd.go
index 7e10554..71f00ec 100644
--- a/src/internal/routebsd/interface_freebsd.go
+++ b/src/internal/routebsd/interface_freebsd.go
@@ -20,6 +20,11 @@ func (w *wireFormat) parseInterfaceMessage(b []byte) (Message, error) {
if attrs&syscall.RTA_IFP == 0 {
return nil, nil
}
+ // PS5: Sony's if_data is larger than FreeBSD's, so the link address is
+ // not at the usual offset. if_data records its own length; use it.
+ if dl := int(nativeEndian.Uint16(b[extOff+6 : extOff+8])); dl != 0 && extOff+dl+8 <= l {
+ bodyOff = extOff + dl
+ }
m := &InterfaceMessage{
Version: int(b[2]),
Type: int(b[3]),
diff --git a/src/os/executable_sysctl.go b/src/os/executable_sysctl.go
index 8b52e92..fc91280 100644
--- a/src/os/executable_sysctl.go
+++ b/src/os/executable_sysctl.go
@@ -12,6 +12,24 @@ import (
)
func executable() (string, error) {
+ p, err := executableSysctl()
+ if err == nil && p != "" {
+ return p, nil
+ }
+ // PS5: a payload is loaded from memory into a host process, so the
+ // kernel has no path to report. Programs mostly want a name to derive
+ // defaults from; give them one based on argv[0] rather than an error.
+ name := "payload.elf"
+ if len(Args) > 0 && Args[0] != "" {
+ name = Args[0]
+ }
+ if name[0] != '/' {
+ name = "/" + name
+ }
+ return name, nil
+}
+
+func executableSysctl() (string, error) {
n := uintptr(0)
// get length
_, _, err := syscall.Syscall6(syscall.SYS___SYSCTL, uintptr(unsafe.Pointer(&executableMIB[0])), 4, 0, uintptr(unsafe.Pointer(&n)), 0, 0)
+80
View File
@@ -0,0 +1,80 @@
/* Find out which buffer sizes getdirentries(196)/getdents(272) accept on the
* PS5's filesystems, and what the returned records look like. */
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mount.h>
#include <sys/param.h>
#include <sys/stat.h>
static long
raw(long n, long a, long b, long c, long d) {
long ret;
unsigned char cf;
register long r10 __asm__("r10") = d;
__asm__ volatile("syscall; setc %1" : "+a"(n), "=q"(cf) : "D"(a), "S"(b), "d"(c), "r"(r10) : "rcx", "r11", "r8", "r9", "memory");
ret = n;
return cf ? -ret : ret;
}
int
main(void) {
static const char *dirs[] = {"/", "/data", "/dev", "/system", "/user", "/system_data", "/mnt", 0};
static const int sizes[] = {256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536};
static char buf[65536];
setvbuf(stdout, 0, _IONBF, 0);
for (int d = 0; dirs[d]; d++) {
struct statfs sfs;
struct stat st;
memset(&sfs, 0, sizeof(sfs));
if (statfs(dirs[d], &sfs)) {
printf("%s: statfs failed\n", dirs[d]);
continue;
}
stat(dirs[d], &st);
printf("%s: fstype=%s bsize=%lu iosize=%lu st_blksize=%d sizeof(statfs)=%zu\n", dirs[d], sfs.f_fstypename,
(unsigned long)sfs.f_bsize, (unsigned long)sfs.f_iosize, (int)st.st_blksize, sizeof(sfs));
for (size_t s = 0; s < sizeof(sizes) / sizeof(sizes[0]); s++) {
int fd = open(dirs[d], O_RDONLY | O_DIRECTORY);
if (fd < 0) {
printf(" open failed\n");
break;
}
long base = 0;
long n1 = raw(196, fd, (long)buf, sizes[s], (long)&base);
long off1 = lseek(fd, 0, SEEK_CUR);
long n2 = raw(196, fd, (long)buf, sizes[s], (long)&base);
close(fd);
fd = open(dirs[d], O_RDONLY | O_DIRECTORY);
long n3 = raw(272, fd, (long)buf, sizes[s], 0);
close(fd);
printf(" size %-6d getdirentries=%ld (off after=%ld, next=%ld) getdents=%ld\n", sizes[s], n1, off1, n2, n3);
}
}
/* Dump the first records of /data to confirm the dirent layout. */
int fd = open("/data", O_RDONLY | O_DIRECTORY);
long base = 0;
long n = raw(196, fd, (long)buf, sizeof(buf), (long)&base);
printf("/data records (n=%ld):\n", n);
for (long pos = 0; pos + 8 <= n && pos < 400;) {
uint32_t fileno;
uint16_t reclen;
memcpy(&fileno, buf + pos, 4);
memcpy(&reclen, buf + pos + 4, 2);
printf(" +%ld fileno=%u reclen=%u type=%u namlen=%u name=%.*s\n", pos, fileno, reclen, (uint8_t)buf[pos + 6],
(uint8_t)buf[pos + 7], (uint8_t)buf[pos + 7], buf + pos + 8);
if (!reclen) {
break;
}
pos += reclen;
}
close(fd);
return 0;
}
+97
View File
@@ -0,0 +1,97 @@
/* Exercise the daemon's local forwards from the console: an HTTP GET through
* the TCP forward on 127.0.0.1:47989, and a UDP round trip through the
* forward on 127.0.0.1:47998. Build with -DHTTP_PATH="/serverinfo" etc. */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/time.h>
#ifndef HTTP_PATH
#define HTTP_PATH "/hello.txt"
#endif
static struct sockaddr_in
local(int port) {
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
return addr;
}
static double
now(void) {
struct timeval tv;
gettimeofday(&tv, 0);
return tv.tv_sec + tv.tv_usec / 1e6;
}
int
main(void) {
struct timeval tv = {10, 0};
struct sockaddr_in addr;
char buf[2048];
ssize_t n;
int fd;
setvbuf(stdout, 0, _IONBF, 0);
/* TCP */
addr = local(47989);
fd = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
double t0 = now();
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
printf("tcp 127.0.0.1:47989: cannot connect (no forward listening)\n");
} else {
const char *req = "GET " HTTP_PATH " HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
size_t total = 0;
write(fd, req, strlen(req));
while (total < sizeof(buf) - 1 && (n = read(fd, buf + total, sizeof(buf) - 1 - total)) > 0) {
total += n;
}
buf[total] = 0;
char *body = strstr(buf, "\r\n\r\n");
char *eol = strstr(buf, "\r\n");
if (!total) {
printf("tcp 127.0.0.1:47989: connected but no response\n");
} else {
printf("tcp 127.0.0.1:47989: %.*s (%.0f ms)\n", eol ? (int)(eol - buf) : 60, buf, (now() - t0) * 1000);
if (body) {
printf(" body: %.400s\n", body + 4);
}
}
}
close(fd);
#ifndef SKIP_UDP
/* UDP */
addr = local(47998);
fd = socket(AF_INET, SOCK_DGRAM, 0);
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
int ok = 0;
double worst = 0;
for (int i = 0; i < 20; i++) {
char msg[1300];
memset(msg, 'a' + i, sizeof(msg));
t0 = now();
sendto(fd, msg, sizeof(msg), 0, (struct sockaddr *)&addr, sizeof(addr));
n = recv(fd, buf, sizeof(buf), 0);
double ms = (now() - t0) * 1000;
if (n == sizeof(msg) && !memcmp(buf, msg, sizeof(msg))) {
ok++;
if (ms > worst) {
worst = ms;
}
}
}
printf("udp 127.0.0.1:47998: %d/20 datagrams of 1300 bytes echoed, worst round trip %.1f ms\n", ok, worst);
close(fd);
#endif
return 0;
}
+72
View File
@@ -0,0 +1,72 @@
/* List a few directories under /data (two levels) and print small text
* files, to learn how the console's payload autoloader is configured.
* Read-only. */
#include <dirent.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
static int
is_text_name(const char *name) {
const char *ext = strrchr(name, '.');
return ext && (!strcmp(ext, ".txt") || !strcmp(ext, ".json") || !strcmp(ext, ".ini") || !strcmp(ext, ".cfg") ||
!strcmp(ext, ".conf") || !strcmp(ext, ".lst"));
}
static void
print_file(const char *path, long size) {
char buf[3000];
FILE *f;
size_t n;
if (size > 20000 || !(f = fopen(path, "rb"))) {
return;
}
n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = 0;
printf("----- %s\n%s\n-----\n", path, buf);
}
static void
list(const char *dir, int depth) {
struct dirent *e;
DIR *d = opendir(dir);
if (!d) {
printf("%*s(cannot open %s)\n", depth * 2, "", dir);
return;
}
while ((e = readdir(d))) {
char path[1024];
struct stat st;
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) {
continue;
}
snprintf(path, sizeof(path), "%s/%s", dir, e->d_name);
if (stat(path, &st)) {
continue;
}
printf("%*s%s%s %ld\n", depth * 2, "", e->d_name, S_ISDIR(st.st_mode) ? "/" : "", (long)st.st_size);
if (S_ISDIR(st.st_mode) && depth < 2) {
list(path, depth + 1);
} else if (!S_ISDIR(st.st_mode) && is_text_name(e->d_name)) {
print_file(path, (long)st.st_size);
}
}
closedir(d);
}
int
main(void) {
static const char *dirs[] = {"/data/pldmgr", "/data/homebrew", "/data/ps5_autoloader", "/data/etaHEN", "/mnt/usb0", 0};
setvbuf(stdout, 0, _IONBF, 0);
for (int i = 0; dirs[i]; i++) {
printf("== %s\n", dirs[i]);
list(dirs[i], 1);
}
return 0;
}
+79
View File
@@ -0,0 +1,79 @@
/* Find out which scheduling class/priority changes the PS5 kernel accepts
* for a payload thread, and whether new threads inherit them. */
#include <pthread.h>
#include <sched.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
struct rtprio {
unsigned short type;
unsigned short prio;
};
static long
raw3(long n, long a, long b, long c) {
unsigned char failed;
__asm__ volatile("syscall; setc %1" : "+a"(n), "=q"(failed) : "D"(a), "S"(b), "d"(c) : "rcx", "r11", "memory");
return failed ? -n : n;
}
#define SYS_rtprio_thread 466
static struct rtprio
lookup(void) {
struct rtprio r = {0};
raw3(SYS_rtprio_thread, 0, 0, (long)&r);
return r;
}
static void *
child(void *arg) {
struct rtprio r = lookup();
printf(" new raw-inherited pthread: %u/%u\n", r.type, r.prio);
return 0;
}
int
main(void) {
static const struct rtprio tries[] = {
{3, 0}, {3, 20}, {4, 0}, {4, 31}, {2, 767}, {2, 31}, {10, 767}, {10, 760}, {10, 768}, {10, 700},
{2, 700}, {3, 767}, {6, 767},
};
struct sched_param sp;
int policy = -1;
struct rtprio r = lookup();
setvbuf(stdout, 0, _IONBF, 0);
printf("start: %u/%u\n", r.type, r.prio);
if (!pthread_getschedparam(pthread_self(), &policy, &sp)) {
printf("pthread policy=%d prio=%d (FIFO=%d RR=%d OTHER=%d)\n", policy, sp.sched_priority, SCHED_FIFO, SCHED_RR,
SCHED_OTHER);
}
for (size_t i = 0; i < sizeof(tries) / sizeof(tries[0]); i++) {
struct rtprio want = tries[i];
long err = raw3(SYS_rtprio_thread, 1, 0, (long)&want);
r = lookup();
printf("set %2u/%-3u -> ret=%ld now %u/%u\n", tries[i].type, tries[i].prio, err, r.type, r.prio);
}
/* Leave it at the lowest setting that stuck and see what a new thread gets. */
struct rtprio low = {10, 767};
raw3(SYS_rtprio_thread, 1, 0, (long)&low);
r = lookup();
printf("final: %u/%u\n", r.type, r.prio);
pthread_t t;
if (!pthread_create(&t, 0, child, 0)) {
pthread_join(t, 0);
}
for (int pol = 0; pol <= 3; pol++) {
sp.sched_priority = 767;
int e = pthread_setschedparam(pthread_self(), pol, &sp);
r = lookup();
printf("pthread_setschedparam(policy=%d, 767) -> %d, now %u/%u\n", pol, e, r.type, r.prio);
}
return 0;
}
+66
View File
@@ -0,0 +1,66 @@
/* Exercise the daemon's HTTP proxy from the console itself: one plain request
* to an Internet host, one to a tailnet address, and one CONNECT. */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/time.h>
#ifndef TAILNET_PEER
#define TAILNET_PEER "100.64.0.1:8000" /* a web server on one of your tailnet devices */
#endif
static void
request(const char *label, const char *req) {
struct sockaddr_in addr = {0};
struct timeval tv = {15, 0};
char buf[600];
int fd = socket(AF_INET, SOCK_STREAM, 0);
ssize_t n;
addr.sin_family = AF_INET;
addr.sin_port = htons(8118);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
printf("%s: cannot connect to the proxy\n", label);
close(fd);
return;
}
write(fd, req, strlen(req));
n = read(fd, buf, sizeof(buf) - 1);
if (n <= 0) {
printf("%s: no response\n", label);
} else {
buf[n] = 0;
char *eol = strstr(buf, "\r\n");
char *body = strstr(buf, "\r\n\r\n");
if (eol) {
*eol = 0;
}
printf("%s: %s", label, buf);
if (body && body[4]) {
body += 4;
body[strcspn(body, "\r\n")] = 0;
printf(" | body: %.80s", body);
}
printf("\n");
}
close(fd);
}
int
main(void) {
setvbuf(stdout, 0, _IONBF, 0);
request("internet GET ", "GET http://controlplane.tailscale.com/key?v=100 HTTP/1.1\r\n"
"Host: controlplane.tailscale.com\r\nConnection: close\r\n\r\n");
request("tailnet GET ", "GET http://" TAILNET_PEER "/hello.txt HTTP/1.1\r\n"
"Host: " TAILNET_PEER "\r\nConnection: close\r\n\r\n");
request("CONNECT ", "CONNECT controlplane.tailscale.com:443 HTTP/1.1\r\n"
"Host: controlplane.tailscale.com:443\r\n\r\n");
return 0;
}
+63
View File
@@ -0,0 +1,63 @@
/* List processes and kill stray test payloads.
*
* Only processes whose main thread is named "payload.elf" (what the ELF
* loader calls anything sent to it as raw bytes) and whose pid is at least
* MIN_PID are killed, so payloads the user started before this development
* session are left alone. */
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/sysctl.h>
#include <sys/types.h>
#include <sys/user.h>
#ifndef MIN_PID
#define MIN_PID 104
#endif
int
main(void) {
int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PROC, 0};
size_t size = 0;
pid_t self = getpid();
char *buf;
setvbuf(stdout, 0, _IONBF, 0);
if (sysctl(mib, 4, 0, &size, 0, 0) || !(buf = malloc(size)) || sysctl(mib, 4, buf, &size, 0, 0)) {
perror("sysctl");
return 1;
}
for (char *p = buf; p < buf + size;) {
struct kinfo_proc *ki = (struct kinfo_proc *)p;
const char *tdname = p + 447;
p += ki->ki_structsize;
#ifdef LIST_ONLY
int stray = 0;
{
#elif defined(KILL_PID)
/* Kill exactly one process, and only if it is a payload. */
int stray = ki->ki_pid == KILL_PID && ki->ki_pid != self && !strcmp(tdname, "payload.elf");
if (stray) {
#else
int stray = ki->ki_pid >= MIN_PID && ki->ki_pid != self && !strcmp(tdname, "payload.elf");
if (ki->ki_pid >= MIN_PID - 40 || stray) {
#endif
printf("%6d %-20s %-20s rss=%ldMB threads=%d cpu=%.2fs stat=%d wait=%.8s%s\n", ki->ki_pid, ki->ki_comm,
tdname, (long)(ki->ki_rssize * 16384L >> 20), ki->ki_numthreads, ki->ki_runtime / 1e6, (int)ki->ki_stat,
ki->ki_wmesg, stray ? " <- killing" : "");
}
if (stray) {
if (kill(ki->ki_pid, SIGKILL)) {
perror(" kill");
}
}
}
free(buf);
return 0;
}
+279
View File
@@ -0,0 +1,279 @@
/* Probe which raw FreeBSD syscalls the PS5 kernel accepts, and how the
* payload process looks (page size, fds, memory behaviour). Output goes to
* stdout, which the ELF loader pipes back over the socket. */
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/sysctl.h>
#include <sys/types.h>
#include <sys/utsname.h>
/* Raw syscall issued from payload memory (not from libkernel), the way the Go
* runtime will do it. Returns the kernel result, or -errno when carry is set. */
static long
raw(long n, long a, long b, long c, long d, long e, long f) {
long ret;
unsigned char cf;
register long r10 __asm__("r10") = d;
register long r8 __asm__("r8") = e;
register long r9 __asm__("r9") = f;
__asm__ volatile("syscall; setc %1"
: "+a"(n), "=q"(cf)
: "D"(a), "S"(b), "d"(c), "r"(r10), "r"(r8), "r"(r9)
: "rcx", "r11", "memory");
ret = n;
return cf ? -ret : ret;
}
#define R0(n) raw(n, 0, 0, 0, 0, 0, 0)
static void
report(const char *name, long ret) {
if (ret < 0) {
printf(" %-28s FAIL errno=%ld (%s)\n", name, -ret, strerror((int)-ret));
} else {
printf(" %-28s ok ret=%ld (0x%lx)\n", name, ret, ret);
}
}
static volatile int got_sig;
static void
on_sig(int sig, siginfo_t *info, void *ctx) {
got_sig = sig;
}
int
main(int argc, char **argv) {
setvbuf(stdout, 0, _IONBF, 0);
printf("== sysprobe ==\n");
printf("argc=%d argv0=%s pid=%d uid=%d\n", argc, argc ? argv[0] : "-", getpid(), getuid());
struct utsname un;
if (!uname(&un)) {
printf("uname: %s %s %s %s\n", un.sysname, un.release, un.version, un.machine);
}
/* sysctls */
{
int mib[2] = {CTL_HW, HW_PAGESIZE};
int v = 0;
size_t l = sizeof(v);
long r = raw(202, (long)mib, 2, (long)&v, (long)&l, 0, 0);
printf("sysctl hw.pagesize -> r=%ld v=%d\n", r, v);
mib[1] = HW_NCPU;
v = 0;
l = sizeof(v);
r = raw(202, (long)mib, 2, (long)&v, (long)&l, 0, 0);
printf("sysctl hw.ncpu -> r=%ld v=%d\n", r, v);
int mib2[2] = {CTL_KERN, 37 /* KERN_ARND */};
unsigned char rnd[16] = {0};
l = sizeof(rnd);
r = raw(202, (long)mib2, 2, (long)rnd, (long)&l, 0, 0);
printf("sysctl kern.arandom -> r=%ld len=%zu first=%02x%02x%02x%02x\n", r, l, rnd[0], rnd[1], rnd[2], rnd[3]);
int osrel = 0;
l = sizeof(osrel);
if (!sysctlbyname("kern.osreldate", &osrel, &l, 0, 0)) {
printf("kern.osreldate=%d\n", osrel);
} else {
printf("kern.osreldate: errno=%d\n", errno);
}
int maxcpus = 0;
l = sizeof(maxcpus);
if (!sysctlbyname("kern.smp.maxcpus", &maxcpus, &l, 0, 0)) {
printf("kern.smp.maxcpus=%d\n", maxcpus);
} else {
printf("kern.smp.maxcpus: errno=%d\n", errno);
}
}
printf("raw syscalls from payload text:\n");
report("getpid(20)", R0(20));
report("issetugid(253)", R0(253));
report("sched_yield(331)", R0(331));
{
long tid = 0;
report("thr_self(432)", raw(432, (long)&tid, 0, 0, 0, 0, 0));
printf(" tid=%ld\n", tid);
}
{
struct timespec ts = {0};
report("clock_gettime(232,MONO=4)", raw(232, 4, (long)&ts, 0, 0, 0, 0));
printf(" mono=%ld.%09ld\n", (long)ts.tv_sec, ts.tv_nsec);
report("clock_gettime(232,REAL=0)", raw(232, 0, (long)&ts, 0, 0, 0, 0));
printf(" real=%ld.%09ld\n", (long)ts.tv_sec, ts.tv_nsec);
}
{
/* Numbers >= 532 are Sony syscalls on this kernel (pipe2, accept4 and
* friends do not exist), so they are deliberately not probed. */
long r = raw(42, 0, 0, 0, 0, 0, 0); /* pipe: fds in rax/rdx */
report("pipe(42)", r);
if (r >= 0) {
close((int)r);
}
}
{
long kq = R0(362);
report("kqueue(362)", kq);
struct timespec zero = {0};
char evbuf[256];
report("kevent(363) poll", raw(363, kq, 0, 0, (long)evbuf, 1, (long)&zero));
close((int)kq);
}
{
unsigned char mask[64] = {0};
long r = raw(487, 1 /* CPU_LEVEL_WHICH */, 2 /* CPU_WHICH_PID */, -1, 8, (long)mask, 0);
report("cpuset_getaffinity(487)", r);
printf(" mask=%02x%02x\n", mask[1], mask[0]);
}
{
stack_t ss = {0};
report("sigaltstack(53) query", raw(53, 0, (long)&ss, 0, 0, 0, 0));
printf(" ss_sp=%p size=%zu flags=%d\n", ss.ss_sp, ss.ss_size, ss.ss_flags);
sigset_t set;
report("sigprocmask(340) query", raw(340, 1, 0, (long)&set, 0, 0, 0));
struct sigaction old;
report("sigaction(416) query URG", raw(416, SIGURG, 0, (long)&old, 0, 0, 0));
report("sigaction(416) query SEGV", raw(416, SIGSEGV, 0, (long)&old, 0, 0, 0));
printf(" SEGV handler=%p flags=%x\n", (void *)old.sa_sigaction, old.sa_flags);
}
{
char cwd[256] = {0};
report("__getcwd(326)", raw(326, (long)cwd, sizeof(cwd), 0, 0, 0, 0));
printf(" cwd=%s\n", cwd);
}
{
struct stat st;
report("stat(188) /data", raw(188, (long)"/data", (long)&st, 0, 0, 0, 0));
printf(" sizeof(struct stat)=%zu mode=%o\n", sizeof(st), st.st_mode);
report("lstat(190) /data", raw(190, (long)"/data", (long)&st, 0, 0, 0, 0));
report("fstatat(493) /data", raw(493, AT_FDCWD, (long)"/data", (long)&st, 0, 0, 0));
long fd = raw(499, AT_FDCWD, (long)"/data", O_RDONLY, 0, 0, 0);
report("openat(499) /data", fd);
if (fd >= 0) {
close((int)fd);
}
fd = raw(5, (long)"/data", O_RDONLY | 0x00020000 /* O_DIRECTORY */, 0, 0, 0, 0);
report("open(5) /data O_DIRECTORY", fd);
if (fd >= 0) {
char dents[1024];
long base = 0;
report("fstat(189)", raw(189, fd, (long)&st, 0, 0, 0, 0));
report("getdirentries(196)", raw(196, fd, (long)dents, sizeof(dents), (long)&base, 0, 0));
report("getdents(272)", raw(272, fd, (long)dents, sizeof(dents), 0, 0, 0));
report("fstatfs(397)", raw(397, fd, (long)dents, 0, 0, 0, 0));
close((int)fd);
}
char link[256] = {0};
report("readlink(58) /dev/stdout", raw(58, (long)"/dev/stdout", (long)link, sizeof(link), 0, 0, 0));
report("access(33) /dev/urandom", raw(33, (long)"/dev/urandom", 0, 0, 0, 0, 0));
report("access(33) /dev/random", raw(33, (long)"/dev/random", 0, 0, 0, 0, 0));
report("access(33) /etc/resolv.conf", raw(33, (long)"/etc/resolv.conf", 0, 0, 0, 0, 0));
report("access(33) /data/tailscale", raw(33, (long)"/data/tailscale", 0, 0, 0, 0, 0));
}
{
long s = raw(97, 2, 1, 0, 0, 0, 0);
report("socket(97) TCP", s);
if (s >= 0) {
close((int)s);
}
s = raw(97, 2, 1 | 0x10000000 | 0x20000000, 0, 0, 0, 0);
report("socket TCP|CLOEXEC|NONBLOCK", s);
if (s >= 0) {
close((int)s);
}
s = raw(97, 28, 2, 0, 0, 0, 0);
report("socket(97) UDP6", s);
if (s >= 0) {
close((int)s);
}
s = raw(97, 17 /* AF_ROUTE */, 3, 0, 0, 0, 0);
report("socket(97) AF_ROUTE", s);
if (s >= 0) {
close((int)s);
}
s = raw(97, 1, 1, 0, 0, 0, 0);
report("socket(97) AF_UNIX", s);
if (s >= 0) {
close((int)s);
}
int mib[6] = {CTL_NET, 17 /* AF_ROUTE */, 0, 0, 3 /* NET_RT_IFLIST */, 0};
size_t l = 0;
long r = raw(202, (long)mib, 6, 0, (long)&l, 0, 0);
report("sysctl NET_RT_IFLIST size", r);
printf(" need=%zu\n", l);
mib[4] = 1; /* NET_RT_DUMP */
l = 0;
r = raw(202, (long)mib, 6, 0, (long)&l, 0, 0);
report("sysctl NET_RT_DUMP size", r);
printf(" need=%zu\n", l);
}
printf("memory:\n");
{
long p = raw(477, 0, 64 << 20, PROT_NONE, MAP_ANON | MAP_PRIVATE, -1, 0);
report("mmap 64MB PROT_NONE", p);
if (p > 0) {
long q = raw(477, p, 1 << 20, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE | MAP_FIXED, -1, 0);
report(" remap 1MB RW MAP_FIXED", q);
if (q > 0) {
memset((void *)q, 0x5a, 1 << 20);
printf(" touched ok\n");
report(" madvise FREE(5)", raw(75, q, 1 << 20, 5, 0, 0, 0));
}
report(" munmap", raw(73, p, 64 << 20, 0, 0, 0, 0));
}
p = raw(477, 0x00c000000000, 64 << 20, PROT_NONE, MAP_ANON | MAP_PRIVATE, -1, 0);
report("mmap hint 0xc000000000", p);
if (p > 0) {
raw(73, p, 64 << 20, 0, 0, 0, 0);
}
p = raw(477, 0, 4L << 30, PROT_NONE, MAP_ANON | MAP_PRIVATE, -1, 0);
report("mmap 4GB PROT_NONE", p);
if (p > 0) {
raw(73, p, 4L << 30, 0, 0, 0, 0);
}
p = raw(477, 0, 512 << 20, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
report("mmap 512MB RW (untouched)", p);
if (p > 0) {
raw(73, p, 512 << 20, 0, 0, 0, 0);
}
p = raw(477, 0, 1 << 20, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANON | MAP_PRIVATE, -1, 0);
report("mmap 1MB RWX", p);
if (p > 0) {
raw(73, p, 1 << 20, 0, 0, 0, 0);
}
}
printf("signals:\n");
{
struct sigaction sa = {0};
sa.sa_sigaction = on_sig;
sa.sa_flags = SA_SIGINFO | SA_RESTART;
sigfillset(&sa.sa_mask);
report("sigaction(416) set USR1", raw(416, SIGUSR1, (long)&sa, 0, 0, 0, 0));
long tid = 0;
raw(432, (long)&tid, 0, 0, 0, 0, 0);
report("thr_kill(433) USR1", raw(433, tid, SIGUSR1, 0, 0, 0, 0));
printf(" handler ran: got_sig=%d\n", got_sig);
}
printf("fds:\n");
for (int fd = 0; fd < 16; fd++) {
struct stat st;
if (!fstat(fd, &st)) {
printf(" fd %d mode=%o\n", fd, st.st_mode);
}
}
printf("== done ==\n");
return 0;
}
+3
View File
@@ -0,0 +1,3 @@
module ps5probe
go 1.26
+391
View File
@@ -0,0 +1,391 @@
// Command probe exercises the parts of the Go runtime and standard library
// that Tailscale depends on, to find out what works on the PS5.
//
// Every test is bounded in time by construction: nothing here may spin
// forever, because a runaway thread can starve the whole console.
package main
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"sync"
"sync/atomic"
"syscall"
"time"
)
var failed int
func step(name string, fn func() (string, error)) {
done := make(chan struct{})
var res string
var err error
go func() {
defer close(done)
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
res, err = fn()
}()
select {
case <-done:
case <-time.After(30 * time.Second):
err = fmt.Errorf("timed out")
}
if err != nil {
failed++
fmt.Printf("FAIL %-22s %v\n", name, err)
return
}
fmt.Printf("ok %-22s %s\n", name, res)
}
// spin burns CPU without ever calling a function, so only asynchronous
// (signal based) preemption can interrupt it. It stops by itself after a
// fixed number of iterations, a few seconds at most.
//
//go:noinline
func spin(iterations uint64, stop *atomic.Bool) uint64 {
var x uint64 = 88172645463325252
for i := uint64(0); i < iterations; i++ {
x ^= x << 13
x ^= x >> 7
x ^= x << 17
if i&0xfffff == 0 && stop.Load() {
break
}
}
return x
}
func main() {
// The process must die on its own if anything goes badly wrong.
go func() {
time.Sleep(150 * time.Second)
fmt.Println("probe: watchdog expired, exiting")
os.Exit(3)
}()
fmt.Printf("== go probe: %s %s/%s cpus=%d gomaxprocs=%d pagesize=%d pid=%d GODEBUG=%q ==\n",
runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.NumCPU(), runtime.GOMAXPROCS(0),
os.Getpagesize(), os.Getpid(), os.Getenv("GODEBUG"))
step("goroutines+timers", func() (string, error) {
var wg sync.WaitGroup
var n atomic.Int64
start := time.Now()
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(20 * time.Millisecond)
n.Add(1)
}()
}
wg.Wait()
return fmt.Sprintf("%d goroutines in %v", n.Load(), time.Since(start).Round(time.Millisecond)), nil
})
step("os threads", func() (string, error) {
var wg sync.WaitGroup
for i := 0; i < 24; i++ {
wg.Add(1)
go func() {
defer wg.Done()
runtime.LockOSThread()
defer runtime.UnlockOSThread()
ts := syscall.Timespec{Nsec: 30e6}
syscall.Nanosleep(&ts, nil)
}()
}
wg.Wait()
return "24 locked threads slept in the kernel", nil
})
step("nil deref -> panic", func() (res string, err error) {
defer func() {
if r := recover(); r != nil {
res = fmt.Sprintf("recovered: %v", r)
}
}()
var p *int
*p = 1
return "", fmt.Errorf("no panic")
})
// One spinner per P plus garbage collections that have to stop them.
// With working async preemption each GC takes milliseconds; without it
// each GC waits for the spinners to finish.
step("async preemption+GC", func() (string, error) {
var stop atomic.Bool
var wg sync.WaitGroup
n := runtime.GOMAXPROCS(0)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
spin(3_000_000_000, &stop) // about 3 seconds
}()
}
time.Sleep(50 * time.Millisecond)
start := time.Now()
var worst time.Duration
for i := 0; i < 5; i++ {
t := time.Now()
runtime.GC()
if d := time.Since(t); d > worst {
worst = d
}
}
total := time.Since(start)
stop.Store(true)
wg.Wait()
res := fmt.Sprintf("%d spinners, 5 GCs in %v (worst %v)", n, total.Round(time.Millisecond), worst.Round(time.Millisecond))
if worst > time.Second {
return "", fmt.Errorf("preemption is not working: %s", res)
}
return res, nil
})
step("heap 256MB", func() (string, error) {
bufs := make([][]byte, 0, 64)
for i := 0; i < 64; i++ {
b := make([]byte, 4<<20)
for j := 0; j < len(b); j += 4096 {
b[j] = byte(i)
}
bufs = append(bufs, b)
}
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
bufs = nil
debug.FreeOSMemory()
return fmt.Sprintf("sys=%dMB heap=%dMB", ms.Sys>>20, ms.HeapAlloc>>20), nil
})
step("crypto/rand", func() (string, error) {
b := make([]byte, 600)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b[:8]) + "...", nil
})
step("time", func() (string, error) {
return time.Now().Format(time.RFC3339Nano), nil
})
step("hostname/cwd/exe", func() (string, error) {
h, herr := os.Hostname()
wd, werr := os.Getwd()
exe, eerr := os.Executable()
return fmt.Sprintf("host=%q(%v) cwd=%q(%v) exe=%q(%v)", h, herr, wd, werr, exe, eerr), nil
})
dir := "/data/tailscale/probe"
step("mkdir+write+read", func() (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
p := filepath.Join(dir, "a.txt")
if err := os.WriteFile(p, []byte("hello ps5\n"), 0o644); err != nil {
return "", err
}
b, err := os.ReadFile(p)
if err != nil {
return "", err
}
return fmt.Sprintf("%q", b), nil
})
step("stat+chtimes", func() (string, error) {
p := filepath.Join(dir, "a.txt")
when := time.Date(2024, 2, 3, 4, 5, 6, 0, time.UTC)
if err := os.Chtimes(p, when, when); err != nil {
return "", fmt.Errorf("chtimes: %w", err)
}
fi, err := os.Stat(p)
if err != nil {
return "", err
}
li, err := os.Lstat(dir)
if err != nil {
return "", err
}
if !fi.ModTime().Equal(when) {
return "", fmt.Errorf("mtime %v, want %v", fi.ModTime(), when)
}
return fmt.Sprintf("size=%d mode=%v mtime ok; dir=%v", fi.Size(), fi.Mode(), li.Mode()), nil
})
step("rename+remove", func() (string, error) {
a, b := filepath.Join(dir, "a.txt"), filepath.Join(dir, "b.txt")
if err := os.Rename(a, b); err != nil {
return "", err
}
f, err := os.OpenFile(b, os.O_RDWR|os.O_APPEND, 0)
if err != nil {
return "", err
}
if _, err := f.WriteString("more\n"); err != nil {
return "", err
}
if err := f.Sync(); err != nil {
return "", fmt.Errorf("sync: %w", err)
}
f.Close()
if err := os.Remove(b); err != nil {
return "", err
}
return "ok", nil
})
step("readdir", func() (string, error) {
for i := 0; i < 300; i++ {
if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("file-with-a-longish-name-%03d", i)), nil, 0o644); err != nil {
return "", err
}
}
ents, err := os.ReadDir(dir)
if err != nil {
return "", err
}
data, err := os.ReadDir("/data")
if err != nil {
return "", fmt.Errorf("/data: %w", err)
}
names := ""
for _, e := range data {
names += e.Name() + " "
}
if err := os.RemoveAll(dir); err != nil {
return "", fmt.Errorf("removeall: %w", err)
}
if len(ents) != 300 {
return "", fmt.Errorf("probe dir has %d entries, want 300", len(ents))
}
return fmt.Sprintf("probe=%d; /data: %s", len(ents), names), nil
})
step("autoloader dir", func() (string, error) {
b, err := os.ReadFile("/data/ps5_autoloader/autoload.txt")
if err != nil {
return fmt.Sprintf("no autoload.txt: %v", err), nil
}
ents, _ := os.ReadDir("/data/ps5_autoloader")
names := ""
for _, e := range ents {
names += e.Name() + " "
}
return fmt.Sprintf("files: %s\n%s", names, b), nil
})
step("notification", func() (string, error) {
var req [0xC30]byte
copy(req[0x2D:], "Tailscale probe: notification test")
fd, err := syscall.Open("/dev/notification0", syscall.O_WRONLY|syscall.O_NONBLOCK, 0)
if err != nil {
return "", fmt.Errorf("open: %w", err)
}
defer syscall.Close(fd)
n, err := syscall.Write(fd, req[:])
if err != nil {
return "", fmt.Errorf("write: %w", err)
}
return fmt.Sprintf("wrote %d bytes", n), nil
})
step("net.Interfaces", func() (string, error) {
ifs, err := net.Interfaces()
if err != nil {
return "", err
}
s := ""
for _, ifc := range ifs {
addrs, aerr := ifc.Addrs()
s += fmt.Sprintf("\n %d %s %v mtu=%d %v addrs=%v err=%v", ifc.Index, ifc.Name, ifc.HardwareAddr, ifc.MTU, ifc.Flags, addrs, aerr)
}
return s, nil
})
step("tcp listen+accept", func() (string, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
defer ln.Close()
go func() {
c, err := ln.Accept()
if err != nil {
return
}
io.Copy(c, c)
c.Close()
}()
c, err := net.DialTimeout("tcp", ln.Addr().String(), 5*time.Second)
if err != nil {
return "", err
}
defer c.Close()
c.Write([]byte("ping"))
c.(*net.TCPConn).CloseWrite()
b, err := io.ReadAll(c)
if err != nil {
return "", err
}
return fmt.Sprintf("echo %q via %v", b, ln.Addr()), nil
})
step("udp v4+v6 sockets", func() (string, error) {
c4, err := net.ListenPacket("udp4", ":0")
if err != nil {
return "", fmt.Errorf("udp4: %w", err)
}
defer c4.Close()
c6, err6 := net.ListenPacket("udp6", ":0")
if err6 == nil {
defer c6.Close()
}
return fmt.Sprintf("udp4=%v udp6 err=%v", c4.LocalAddr(), err6), nil
})
step("dns default resolver", func() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupHost(ctx, "controlplane.tailscale.com")
if err != nil {
return "", err
}
return fmt.Sprintf("%d addresses, first %s", len(ips), ips[0]), nil
})
step("https", func() (string, error) {
c := &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}}
resp, err := c.Get("https://controlplane.tailscale.com/key?v=100")
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 60))
return fmt.Sprintf("%s %s %q", resp.Proto, resp.Status, b), nil
})
fmt.Printf("== done, %d failed ==\n", failed)
if failed > 0 {
os.Exit(1)
}
}
+27
View File
@@ -0,0 +1,27 @@
# Build the installer payload around an already built daemon payload.
# .\tools\build-installer.ps1 [-Daemon out\tailscale.elf] [-Send]
param(
[string]$Daemon = 'out\tailscale.elf',
[string]$Out = 'out\tailscale-installer.elf',
[switch]$Send,
[int]$Seconds = 70
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'env.ps1')
$daemonPath = (Resolve-Path (Join-Path $DevRoot $Daemon)).Path -replace '\\', '/'
$assets = (Join-Path $DevRoot 'installer\assets') -replace '\\', '/'
$outPath = Join-Path $DevRoot $Out
# The app installer library only loads when these come with it, in this
# order (as in the SDK's install_app sample). With libSceAppInstUtil alone
# the payload never starts: the loader leaves it stopped.
Invoke-PS5CC -O2 -Wall "-DDAEMON_ELF=`"$daemonPath`"" "-DASSET_DIR=`"$assets`"" `
-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService `
-o $outPath (Join-Path $DevRoot 'installer\main.c')
Write-Host ("built {0} ({1:N1} MB)" -f $outPath, ((Get-Item $outPath).Length / 1MB))
if ($Send) {
& (Join-Path $PSScriptRoot 'ps5send.ps1') -File $outPath -Seconds $Seconds
}
+48
View File
@@ -0,0 +1,48 @@
# Build a Go program for the PS5 and wrap it in the launcher payload.
# .\tools\build-payload.ps1 -GoDir probe-go -Name probe [-DebugLoader] [-Watchdog 120] [-Send]
param(
[Parameter(Mandatory = $true)][string]$GoDir,
[Parameter(Mandatory = $true)][string]$Name,
[string]$Package = '.',
[string]$Tags = '',
[string]$Version = '', # sets main.version in the Go program
[string]$MaxProcs = '', # GOMAXPROCS for the Go program (launcher default: 4)
[string]$GoDebug = '', # GODEBUG value baked into the launcher
[int]$Watchdog = 0, # test builds: kill the process after this many seconds
[switch]$DebugLoader, # print loader details and early crash registers
[switch]$KeepSymbols,
[switch]$Send,
[int]$Seconds = 60
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'env.ps1')
$out = Join-Path $DevRoot 'out'
New-Item -ItemType Directory -Force $out | Out-Null
$bin = Join-Path $out "$Name.bin"
$elf = Join-Path $out "$Name.elf"
$ldflags = @()
if (-not $KeepSymbols) { $ldflags += '-s', '-w' }
if ($Version) { $ldflags += "-X main.version=$Version" }
$goArgs = @('build', '-buildmode=pie', '-trimpath', "-ldflags=$($ldflags -join ' ')", '-o', $bin)
if ($Tags) { $goArgs += "-tags=$Tags" }
$goArgs += $Package
Push-Location (Join-Path $DevRoot $GoDir)
try { Invoke-PS5Go @goArgs } finally { Pop-Location }
$img = $bin -replace '\\', '/'
$ccArgs = @('-O2', '-Wall', "-DGO_IMAGE=`"$img`"")
if ($DebugLoader) { $ccArgs += '-DGOLOAD_DEBUG' }
if ($Watchdog -gt 0) { $ccArgs += "-DGOLOAD_WATCHDOG=$Watchdog" }
if ($MaxProcs) { $ccArgs += "-DGO_MAXPROCS=`"$MaxProcs`"" }
if ($GoDebug) { $ccArgs += "-DGO_DEBUG=`"$GoDebug`"" }
$ccArgs += @('-o', $elf, (Join-Path $DevRoot 'launcher\main.c'), (Join-Path $DevRoot 'launcher\goload.c'))
Invoke-PS5CC @ccArgs
Write-Host ("built {0} ({1:N1} MB)" -f $elf, ((Get-Item $elf).Length / 1MB))
if ($Send) {
& (Join-Path $PSScriptRoot 'ps5send.ps1') -File $elf -Seconds $Seconds
}
+61
View File
@@ -0,0 +1,61 @@
# Dot-source this file to get the PS5 cross-build helpers.
# . .\tools\env.ps1
$script:DevRoot = Split-Path -Parent $PSScriptRoot
$script:SdkRoot = Join-Path $DevRoot 'toolchain\ps5-payload-sdk'
$script:LlvmBin = Join-Path $DevRoot 'toolchain\llvm\bin'
$script:GoRoot = Join-Path $DevRoot 'goroot'
$env:PS5_PAYLOAD_SDK = $SdkRoot
if ($env:PATH -notlike "*$LlvmBin*") {
$env:PATH = "$LlvmBin;$SdkRoot\win;$env:PATH"
}
# Compile and link C sources into a PS5 payload ELF (same flags as the SDK's
# prospero-clang wrapper).
function Invoke-PS5CC {
# Plain $args on purpose: a param() block would make PowerShell try to bind
# compiler flags such as -o to common parameters.
$CcArgs = $args
# clang >= 20 links the crt objects itself and finds them through this
# variable, so crt1.o must not be passed explicitly.
$env:SCE_PROSPERO_SDK_DIR = $SdkRoot
& "$LlvmBin\clang.exe" `
--start-no-unused-arguments `
-target x86_64-sie-ps5 `
-fvisibility-nodllstorageclass=default `
-isysroot $SdkRoot `
-isystem "$SdkRoot\target\include" `
-L "$SdkRoot\target\lib" `
-fno-stack-protector -fno-plt -femulated-tls `
-lc `
--end-no-unused-arguments `
@CcArgs `
--start-no-unused-arguments `
--sysroot $SdkRoot `
'-Wl,--hash-style=gnu' `
-lkernel_web -lSceLibcInternal -lSceNet `
--end-no-unused-arguments
if ($LASTEXITCODE -ne 0) { throw "clang failed ($LASTEXITCODE)" }
}
# Run the patched Go toolchain for the PS5 (FreeBSD/amd64 ABI).
function Invoke-PS5Go {
$GoArgs = $args
$old = @{ GOROOT = $env:GOROOT; GOOS = $env:GOOS; GOARCH = $env:GOARCH; CGO_ENABLED = $env:CGO_ENABLED
GOTOOLCHAIN = $env:GOTOOLCHAIN; GOTMPDIR = $env:GOTMPDIR; GOCACHE = $env:GOCACHE; GOFLAGS = $env:GOFLAGS }
try {
$env:GOROOT = $GoRoot
$env:GOOS = 'freebsd'
$env:GOARCH = 'amd64'
$env:CGO_ENABLED = '0'
$env:GOTOOLCHAIN = 'local'
$env:GOTMPDIR = Join-Path $DevRoot '.gotmp'
$env:GOCACHE = Join-Path $DevRoot '.gocache'
New-Item -ItemType Directory -Force $env:GOTMPDIR, $env:GOCACHE | Out-Null
& "$GoRoot\bin\go.exe" @GoArgs
if ($LASTEXITCODE -ne 0) { throw "go failed ($LASTEXITCODE)" }
} finally {
foreach ($k in $old.Keys) { Set-Item "env:$k" $old[$k] }
}
}
+39
View File
@@ -0,0 +1,39 @@
# Send an ELF payload to the PS5 ELF loader and print whatever it writes back.
# $env:PS5_HOST = '192.168.1.50'
# .\tools\ps5send.ps1 -File out\hello.elf [-Seconds 20]
param(
[Parameter(Mandatory = $true)][string]$File,
[string]$PS5Host = $env:PS5_HOST, # the console's address
[int]$Port = 9021,
[int]$Seconds = 20, # stop listening after this long
[int]$IdleSeconds = 0 # if > 0, also stop after this long without output
)
if (-not $PS5Host) { throw 'Set $env:PS5_HOST or pass -PS5Host with the console''s address.' }
$bytes = [IO.File]::ReadAllBytes((Resolve-Path $File))
$client = [Net.Sockets.TcpClient]::new()
$client.NoDelay = $true
$client.Connect($PS5Host, $Port)
$stream = $client.GetStream()
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
Write-Host ("[sent {0:N0} bytes to {1}:{2}]" -f $bytes.Length, $PS5Host, $Port)
$buf = [byte[]]::new(65536)
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
$lastData = [DateTime]::UtcNow
$closed = $false
while ([DateTime]::UtcNow -lt $deadline) {
if ($client.Client.Poll(200000, [Net.Sockets.SelectMode]::SelectRead)) {
$n = 0
try { $n = $stream.Read($buf, 0, $buf.Length) } catch { $closed = $true; break }
if ($n -le 0) { $closed = $true; break }
Write-Host -NoNewline ([Text.Encoding]::UTF8.GetString($buf, 0, $n))
$lastData = [DateTime]::UtcNow
} elseif ($IdleSeconds -gt 0 -and ([DateTime]::UtcNow - $lastData).TotalSeconds -gt $IdleSeconds) {
break
}
}
Write-Host ''
if ($closed) { Write-Host '[connection closed by PS5]' } else { Write-Host '[stopped listening]' }
$client.Close()
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
)
// The installer registers the daemon with whichever payload autoloader the
// console uses: it copies the payload where the autoloader looks for files
// and adds its file name to the autoloader's load order (one payload per
// line, "!N" lines are delays). This file is the daemon's side of that:
// reporting the registration and undoing it. Keep the table in sync with
// installer/main.c.
const daemonFileName = "tailscale.elf"
// An autoloader is one place a payload autoloader keeps its load order.
type autoloader struct {
name string // for messages
list string // the load order file
payloadDir string // where the daemon payload is stored for it
}
var autoloaders = func() []autoloader {
al := []autoloader{
// Payload Manager finds entries by file name anywhere below
// /data/pldmgr and keeps each payload in a folder of its own.
{"Payload Manager", "/data/pldmgr/autoload.txt", "/data/pldmgr/payloads/Tailscale"},
{"PS5 autoloader", "/data/ps5_autoloader/autoload.txt", "/data/ps5_autoloader"},
}
for _, usb := range []string{"usb0", "usb1", "usb2", "usb3"} {
dir := "/mnt/" + usb + "/ps5_autoloader"
al = append(al, autoloader{"PS5 autoloader (" + usb + ")", dir + "/autoload.txt", dir})
}
return al
}()
// autostartNames returns the autoloaders whose load order includes the daemon.
func autostartNames() []string {
names := []string{}
for _, al := range autoloaders {
b, err := os.ReadFile(al.list)
if err != nil {
continue
}
if _, found := removeAutoloadEntry(string(b), daemonFileName); found {
names = append(names, al.name)
}
}
return names
}
// removeAutoloadEntry returns text without the lines that name the payload,
// and whether there were any. Everything else, including line endings, is
// kept as it was.
func removeAutoloadEntry(text, name string) (string, bool) {
var out strings.Builder
found := false
for len(text) > 0 {
line := text
if i := strings.IndexByte(text, '\n'); i >= 0 {
line = text[:i+1]
}
text = text[len(line):]
if strings.TrimSpace(line) == name {
found = true
continue
}
out.WriteString(line)
}
return out.String(), found
}
// uninstall removes the daemon from every autoloader and deletes the
// installed payload. With purge it also deletes the Tailscale state and
// config, which forgets the login. The running process is not affected.
func uninstall(purge bool, logf func(string, ...any)) error {
var errs []error
remove := func(path string) {
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
errs = append(errs, err)
}
}
for _, al := range autoloaders {
b, err := os.ReadFile(al.list)
if err != nil {
continue
}
if text, found := removeAutoloadEntry(string(b), daemonFileName); found {
if err := os.WriteFile(al.list, []byte(text), 0o644); err != nil {
errs = append(errs, err)
continue
}
logf("removed %s from %s", daemonFileName, al.list)
}
remove(filepath.Join(al.payloadDir, daemonFileName))
if filepath.Base(al.payloadDir) == "Tailscale" {
// A folder the installer created just for this payload.
os.Remove(al.payloadDir)
}
}
remove(filepath.Join(dataDir, daemonFileName))
if purge {
for _, name := range []string{"state", "config.json", ".cache"} {
if err := os.RemoveAll(filepath.Join(dataDir, name)); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"io"
"net"
"testing"
)
func TestRemoveAutoloadEntry(t *testing.T) {
tests := []struct {
name, in, want string
found bool
}{
{"absent", "kstuff.elf\n!500\nftpsrv.elf\n", "kstuff.elf\n!500\nftpsrv.elf\n", false},
{"last line", "kstuff.elf\ntailscale.elf\n", "kstuff.elf\n", true},
{"no trailing newline", "kstuff.elf\ntailscale.elf", "kstuff.elf\n", true},
{"middle, CRLF kept", "a.elf\r\ntailscale.elf\r\nb.elf\r\n", "a.elf\r\nb.elf\r\n", true},
{"listed twice", "tailscale.elf\na.elf\n tailscale.elf \n", "a.elf\n", true},
{"similar names stay", "# tailscale.elf\nmy-tailscale.elf\ntailscale.elf.bak\n", "# tailscale.elf\nmy-tailscale.elf\ntailscale.elf.bak\n", false},
{"empty", "", "", false},
}
for _, tt := range tests {
got, found := removeAutoloadEntry(tt.in, daemonFileName)
if got != tt.want || found != tt.found {
t.Errorf("%s: got %q, %v; want %q, %v", tt.name, got, found, tt.want, tt.found)
}
}
}
// pipe must pass a half-close through: the ELF loader protocol and FTP data
// connections both rely on the reader seeing EOF while the other direction
// stays open.
func TestPipeHalfClose(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
// "Local service": reads everything, then answers.
go func() {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
b, _ := io.ReadAll(c)
c.Write(append([]byte("got "), b...))
}()
tcpClient, tcpProxy := tcpPair(t)
local, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
go func() {
pipe(tcpProxy, local)
tcpProxy.Close()
local.Close()
}()
tcpClient.Write([]byte("payload"))
tcpClient.(*net.TCPConn).CloseWrite()
b, err := io.ReadAll(tcpClient)
if err != nil {
t.Fatal(err)
}
if string(b) != "got payload" {
t.Fatalf("got %q", b)
}
}
func tcpPair(t *testing.T) (net.Conn, net.Conn) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
ch := make(chan net.Conn, 1)
go func() {
c, _ := ln.Accept()
ch <- c
}()
a, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
return a, <-ch
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"encoding/json"
"errors"
"io/fs"
"os"
)
// config is read from /data/tailscale/config.json. Every field is optional.
type config struct {
// Hostname is the name this console gets on the tailnet.
Hostname string `json:"hostname"`
// AuthKey, if set, logs the console in without the browser step.
AuthKey string `json:"authKey,omitempty"`
// WebAddr is where the status page listens.
WebAddr string `json:"webAddr"`
// HTTPProxyAddr is where the outbound HTTP proxy listens. Pointing the
// PS5's proxy setting at it lets the console reach tailnet hosts. Empty
// disables the proxy.
HTTPProxyAddr string `json:"httpProxyAddr"`
// ControlURL selects a coordination server other than Tailscale's.
ControlURL string `json:"controlURL,omitempty"`
// SunshineHost is a tailnet device running Sunshine. When set, its
// streaming ports are forwarded from 127.0.0.1, so a Moonlight client on
// the console can use 127.0.0.1 as the host.
SunshineHost string `json:"sunshineHost,omitempty"`
// Forwards are extra local forwards: a localhost port on the console
// relayed to a host on the tailnet.
Forwards []forwardRule `json:"forwards,omitempty"`
// BlockedPorts lists local TCP ports that are never exposed to the tailnet.
BlockedPorts []uint16 `json:"blockedPorts,omitempty"`
// Verbose turns on Tailscale's own (very chatty) logging.
Verbose bool `json:"verbose,omitempty"`
}
func defaultConfig() config {
return config{
Hostname: "ps5",
WebAddr: ":8090",
HTTPProxyAddr: "127.0.0.1:8118",
}
}
// loadConfig reads the config file, writing one with the defaults if it does
// not exist yet so that there is something to edit.
func loadConfig(path string) (config, error) {
cfg := defaultConfig()
b, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return cfg, saveConfig(path, cfg)
}
if err != nil {
return cfg, err
}
if err := json.Unmarshal(b, &cfg); err != nil {
return defaultConfig(), err
}
if cfg.Hostname == "" {
cfg.Hostname = "ps5"
}
if cfg.WebAddr == "" {
cfg.WebAddr = ":8090"
}
return cfg, nil
}
func saveConfig(path string, cfg config) error {
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o600)
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !freebsd
package main
import (
"fmt"
"os"
)
// On a development machine the console is just the terminal and PS5
// notifications are printed.
type console struct{}
func newConsole() *console { return &console{} }
func (c *console) Printf(format string, args ...any) { fmt.Printf(format, args...) }
func redirectStdio(f *os.File) {}
func notify(format string, args ...any) {
fmt.Printf("[notification] "+format+"\n", args...)
}
+80
View File
@@ -0,0 +1,80 @@
//go:build freebsd
package main
import (
"fmt"
"os"
"sync"
"syscall"
)
// console writes to whoever sent the payload. The ELF loader hands a payload
// its TCP connection as stdin/stdout/stderr, and that connection goes away as
// soon as the sender disconnects.
//
// Go kills the process when a write to fd 1 or 2 fails with EPIPE, so the
// connection is moved to another descriptor and the first failed write turns
// the console off for good.
type console struct {
mu sync.Mutex
fd int
}
func newConsole() *console {
fd, err := syscall.Dup(1)
if err != nil {
return &console{fd: -1}
}
syscall.CloseOnExec(fd)
return &console{fd: fd}
}
func (c *console) Printf(format string, args ...any) {
c.mu.Lock()
defer c.mu.Unlock()
if c.fd < 0 {
return
}
b := []byte(fmt.Sprintf(format, args...))
for len(b) > 0 {
n, err := syscall.Write(c.fd, b)
if err != nil || n <= 0 {
syscall.Close(c.fd)
c.fd = -1
return
}
b = b[n:]
}
}
// redirectStdio points stdout and stderr at the log file.
func redirectStdio(f *os.File) {
syscall.Dup2(int(f.Fd()), 1)
syscall.Dup2(int(f.Fd()), 2)
}
// notify shows a pop-up on the PS5 screen.
//
// It does what libkernel's sceKernelSendNotificationRequest does: write a
// fixed-size request to the notification device. The request is 0xC30 bytes
// with the UTF-8 message at offset 0x2D.
func notify(format string, args ...any) {
const (
requestSize = 0xC30
messageOffset = 0x2D
)
var req [requestSize]byte
msg := fmt.Sprintf(format, args...)
if max := requestSize - messageOffset - 1; len(msg) > max {
msg = msg[:max]
}
copy(req[messageOffset:], msg)
fd, err := syscall.Open("/dev/notification0", syscall.O_WRONLY|syscall.O_NONBLOCK, 0)
if err != nil {
return
}
defer syscall.Close(fd)
syscall.Write(fd, req[:])
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"fmt"
"os"
"sync"
"time"
)
// debugLog keeps Tailscale's own verbose log on disk, in a file of bounded
// size next to the main log.
//
// The main log only has the daemon's few messages, which is not enough to
// tell what Tailscale was doing when something goes wrong, and the things
// that go wrong on a console (a freeze, a crash) take the process down
// before anyone can ask it. So the detail is always recorded and synced to
// disk every couple of seconds.
type debugLog struct {
path string
max int64
mu sync.Mutex
f *os.File
size int64
dirty bool
}
func openDebugLog(path string, max int64) *debugLog {
l := &debugLog{path: path, max: max}
l.open()
go l.syncLoop()
return l
}
func (l *debugLog) open() {
f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
l.f = f
if fi, err := f.Stat(); err == nil {
l.size = fi.Size()
}
}
func (l *debugLog) Printf(format string, args ...any) {
line := time.Now().Format("2006-01-02 15:04:05.000 ") + fmt.Sprintf(format, args...) + "\n"
l.mu.Lock()
defer l.mu.Unlock()
if l.f == nil {
return
}
if l.size+int64(len(line)) > l.max {
// Keep one previous file, so there is always between one and two
// times max of history.
l.f.Close()
os.Rename(l.path, l.path+".old")
l.f, l.size = nil, 0
l.open()
if l.f == nil {
return
}
}
n, _ := l.f.WriteString(line)
l.size += int64(n)
l.dirty = true
}
func (l *debugLog) syncLoop() {
for range time.Tick(2 * time.Second) {
l.mu.Lock()
if l.dirty && l.f != nil {
l.f.Sync()
l.dirty = false
}
l.mu.Unlock()
}
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"io"
"net"
"net/netip"
"slices"
"strconv"
"time"
)
// forwardToLocalhost is tsnet's fallback TCP handler: it is asked about every
// tailnet connection to a port nothing in this process listens on. If a
// service on the console listens on that port, the connection is accepted and
// piped to it. That is what makes FTP, the payload loader and the like
// reachable over the tailnet.
//
// The local connection is made before answering, while the tailnet peer is
// still waiting for its SYN-ACK. If nothing listens on the port the
// connection is declined, so the peer sees an ordinary "connection refused"
// rather than a connection that opens and closes.
func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
port := dst.Port()
if slices.Contains(d.cfg.BlockedPorts, port) || port == d.proxyPort || d.fwd.listensOnTCP(port) {
// The outbound proxy and the local forwards are for the console's
// own apps. Exposing them would let any tailnet device use the
// console as a relay.
return nil, false
}
local, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port))), 2*time.Second)
if err != nil {
return nil, false
}
// If the tailnet side never completes its handshake the handler is not
// called; do not keep the local connection open for it forever.
abandoned := time.AfterFunc(30*time.Second, func() { local.Close() })
return func(c net.Conn) {
defer c.Close()
defer local.Close()
if !abandoned.Stop() {
return
}
if port != d.webPort {
// The status page polls every few seconds; logging its own
// requests would bury everything else.
d.logf("forward %v -> localhost:%d", src, port)
}
pipe(c, local)
}, true
}
// pipe copies in both directions until both sides are done.
func pipe(a, b net.Conn) {
done := make(chan struct{}, 2)
cp := func(dst, src net.Conn) {
io.Copy(dst, src)
// Pass the end of the stream on, so that protocols relying on a
// half-close (such as sending a payload to the ELF loader) work.
if cw, ok := dst.(interface{ CloseWrite() error }); ok {
cw.CloseWrite()
} else {
dst.Close()
}
done <- struct{}{}
}
go cp(a, b)
go cp(b, a)
<-done
<-done
}
+55
View File
@@ -0,0 +1,55 @@
module ps5tailscale
go 1.27.1
require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto/x509roots/fallback v0.0.0-20260929172509-b39ff6d641ec
tailscale.com v1.104.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/creachadair/msync v0.10.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/gaissmai/bart v0.29.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/mdlayher/netlink v1.11.2 // indirect
github.com/mdlayher/socket v0.7.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pires/go-proxyproto v0.15.0 // indirect
github.com/safchain/ethtool v0.7.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f // indirect
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20260917222731-e0ed2d0d0fea // indirect
github.com/tailscale/wireguard-go v0.0.0-20260928213032-417aef361226 // indirect
github.com/x448/float16 v0.8.4 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.16.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20260915211658-a6f909f08a72 // indirect
)
+243
View File
@@ -0,0 +1,243 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aws/aws-sdk-go-v2 v1.46.0 h1:1kt7m/EKcEHt5mlyyxx9cSlMddRPIKbjb6DIQsu4HPk=
github.com/aws/aws-sdk-go-v2 v1.46.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/config v1.33.3 h1:h090b3O5S17bF87/0ysHZuIT/7DCb4EBRFQX2PMVPCw=
github.com/aws/aws-sdk-go-v2/config v1.33.3/go.mod h1:YYDB1kTejxbfAbEVUqgCtkVp26xvNCHev9cLKABMGAk=
github.com/aws/aws-sdk-go-v2/credentials v1.20.3 h1:tToOYM/LXev4NpfWlIYGDvBvjHmJ3HXpRU9ppl+pM6k=
github.com/aws/aws-sdk-go-v2/credentials v1.20.3/go.mod h1:wfGneWyncO7p67wqXV2IQhPk14JqIc25woKlaArT3WI=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2 h1:Ldv7RPHs7qwwTscRjAl3YBud32f3BvdAGRmSvAx5L38=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2/go.mod h1:XyK6UV8xbo66ysVqLd2783C09pBYHOm8aKTRV5DVJ30=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.2 h1:q/PSLGuRWCChWg+dLnb9dWOnrCxJtnboXbBtFoqqRrI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.2/go.mod h1:TD1jvU2LvXkJexct5vBqcd8QlNXh5EmRUeL/Z32p0n4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.2 h1:6fl86IPqKEXoySqiOWdfgbEp9OVbn44zTfEICNEBDhY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.2/go.mod h1:63HDfhFkdzBpI8WGXTSKUHPKS6mqldj4u3LJW7RZtSU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2 h1:XMgIRS+uW9F3yFKnXGRrI9pkHi99CXTmoz2kz2/TGBA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2/go.mod h1:vorxDzK+n3jiv9a5ST/LG0Eu9cSv1CRdKTpG6pDMs+M=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.2 h1:ZtHYnumr6QyxhzEzNZwzQTFJEXOswrZqTTkRxthwvr4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.2/go.mod h1:a1NXrYpBd311gBzn1UI5UzJyyvXktM4xNh/ydPiPpqY=
github.com/aws/aws-sdk-go-v2/service/signin v1.9.0 h1:c3k+k/CS4L+sAIH6fxikL+g5g2LpeNczaoyjjw1iMKI=
github.com/aws/aws-sdk-go-v2/service/signin v1.9.0/go.mod h1:AGIoQg99fBrOIQnF78TLx4lj18mc4gZ0hJx1UaLIFM4=
github.com/aws/aws-sdk-go-v2/service/ssm v1.77.0 h1:JqKSBJlS68F4nxoPLwD7tB3lJ3gWEK9p+7eOiBE/lsM=
github.com/aws/aws-sdk-go-v2/service/ssm v1.77.0/go.mod h1:5TMMHmQGjCODMeIopSNfxkan11jG0SmFpyjcMvCLzHA=
github.com/aws/aws-sdk-go-v2/service/sso v1.37.0 h1:+rqBaOq7jzInjY8M12hr+zEe85JpRll9BjMx38r33Ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.37.0/go.mod h1:XFlVwUsw3sYh8Hw37umYVJnrcWrwXWRxbNw/JY0bblw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.42.0 h1:hzM3GslEAOBcLn3DHH6ENToFi+vXP+n02W+x6zejAIM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.42.0/go.mod h1:588e7skMkYIYkSUseT8E3WKFfbAfrA1bj3Zf+qxJtJY=
github.com/aws/aws-sdk-go-v2/service/sts v1.49.0 h1:N7Ey8obY3uSui+cxl0OUzFlFmkxSucoJnrniFhw+cLc=
github.com/aws/aws-sdk-go-v2/service/sts v1.49.0/go.mod h1:zMBwjSf4Pt8a1OHYiZ5rPD0PJRK1kQrUaxhS/Dbld8E=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZoRJw=
github.com/axiomhq/hyperloglog v0.2.6/go.mod h1:YjX/dQqCR/7QYX0g8mu8UZAjpIenz1FKM71UEsjFoTo=
github.com/benbjohnson/immutable v0.4.3 h1:GYHcksoJ9K6HyAUpGxwZURrbTkXA0Dh4otXGqbhdrjA=
github.com/benbjohnson/immutable v0.4.3/go.mod h1:qJIKKSmdqz1tVzNtst1DZzvaqOU1onk1rc03IeM3Owk=
github.com/bradfitz/reco v0.0.0-20260929154613-b883fbd17e3f h1:jdXGSp5Ge5/kzikuxGOw9+6HSStUjkNBJZjGNtpItek=
github.com/bradfitz/reco v0.0.0-20260929154613-b883fbd17e3f/go.mod h1:oiCPXxBoDKSkb/RGEZwAZk8y/fwDlQ1vYlBbNRxPysw=
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc=
github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/creachadair/mds v0.31.0 h1:h4SXXgallvZfD8CFQnQNAOtBkUGpZjPwNBRQpHpHM/g=
github.com/creachadair/mds v0.31.0/go.mod h1:IZ3oLffgVNZ4O5WXTPxx7EX5ilSl1ypdv5FekYyynyI=
github.com/creachadair/msync v0.10.1 h1:14jT5Ujob64Zp2D3ZcUidayDMoXikhmYN8xBxEuPKPk=
github.com/creachadair/msync v0.10.1/go.mod h1:dFf4Z0cxE2nibFmAdq5OqTuGn++GgEqA9JuKH+7ceSA=
github.com/creachadair/taskgroup v0.14.4 h1:ttR8StLWmYA1O6x96YlTpQLXjKyRpO4RBo+OcO6W6C0=
github.com/creachadair/taskgroup v0.14.4/go.mod h1:uhCtIEsa7zpeMAFixddhCYjbJi7e4JMyU7OXUygkSsg=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ=
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c=
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM=
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.29.0 h1:wO6HGE8g9YE0Wm0bCpYxwRzfQ4+fbJKOhL64e5ACGCI=
github.com/gaissmai/bart v0.29.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9 h1:J8S+Ms89nvHX1F9Ma2m3KhdTl41ZhbFKMyAJqvDA+iE=
github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
github.com/insomniacslk/dhcp v0.0.0-20260901064844-234b97448fae h1:nXGg65fXsylSUTNNWwvHuQsXev7mhIzQTnZREPTbWzs=
github.com/insomniacslk/dhcp v0.0.0-20260901064844-234b97448fae/go.mod h1:tGfUTcnFYGYvVNCaZZhwlJySU/fQQxh9TmpsFzWXnnY=
github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk=
github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.7.0 h1:qVREPVwtUMg17pwvveQxpurq0PVisMxi3FGgpsorMYQ=
github.com/mdlayher/socket v0.7.0/go.mod h1:f7iKql2EK/rfsWYDKofKjk2Ig7I+6HQWdMIdn1tO4A4=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/safchain/ethtool v0.7.0 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is=
github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 h1:Azz5ILxxVsHN/KjIu3wkJPAmmtiijucZw4Ax5Ye8n+s=
github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/golang-x-crypto v0.0.0-20260720160339-c86ca1284b96 h1:m9EAsCu/afUkg9YkknXaRqMPl/omkUNRol5yB7hbG0Y=
github.com/tailscale/golang-x-crypto v0.0.0-20260720160339-c86ca1284b96/go.mod h1:NC3xRCu4UR+m4n6ix8b6oLLbHa820Y0StbOQEdWTDo0=
github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys=
github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20260917222731-e0ed2d0d0fea h1:ah5jt6ZnxMvNclt6kBBoLcMpLLjMA44PTsnov2y7gpU=
github.com/tailscale/web-client-prebuilt v0.0.0-20260917222731-e0ed2d0d0fea/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20260928213032-417aef361226 h1:v3Lpj2iHPWQDqeCwemQPz4fWweIEMLqBkwJqCjRyJQc=
github.com/tailscale/wireguard-go v0.0.0-20260928213032-417aef361226/go.mod h1:rUelGmuK4UnSJYM5gl5Mknp6YbwwcL8+VAPMhNYe+jg=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/u-root/u-root v0.16.0 h1:wY40O83MBVks97+Is0WlFlOPSwKQMIrWP9R1IsrExg8=
github.com/u-root/u-root v0.16.0/go.mod h1:yL/XdSSW27PdGLgUh4MNRBy54mKM+TBLzpwiB4nwj90=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs=
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260929172509-b39ff6d641ec h1:6gLejUU4OPMnmDEz8DkDkWMoyDj2Ny5iUy1Ze3raJhs=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260929172509-b39ff6d641ec/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f h1:+lI8cDJ4uceLipg2f1ODay7fEuLkk0BIHXd6PB8icxo=
golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gvisor.dev/gvisor v0.0.0-20260915211658-a6f909f08a72 h1:EytKYr5WrVs+Aah/0xDO8ZAZZzS/iTEVC8ln4ipDbL0=
gvisor.dev/gvisor v0.0.0-20260915211658-a6f909f08a72/go.mod h1:8aLQqUBHDH8fY5y60lzmwDpMMbQCcT3EBfoSwhfaGCY=
honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.104.0 h1:7LgglawekeutAexqXUzSxP/Kqo3HHwF/SkcbX5HpiU4=
tailscale.com v1.104.0/go.mod h1:Cb1XjScRgSOeRD7NW6uCEznme782WiOU9IxqyTWoRf0=
+9
View File
@@ -0,0 +1,9 @@
//go:build !freebsd
package main
// On a development machine there is no stale-instance handling.
func stopRecordedInstance(pidFile string) int { return 0 }
func recordInstance(pidFile string) error { return nil }
+74
View File
@@ -0,0 +1,74 @@
//go:build freebsd
package main
import (
"encoding/hex"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"time"
)
// Only one daemon may run: two would share one Tailscale identity and fight
// over it. A new instance first asks the old one to stop through its status
// page, but that page can be unreachable, so the daemon also records its
// process id and a new instance stops whatever process holds it.
//
// Process ids start over after a reboot, and a stale file could then name an
// unrelated process. The file therefore also holds the kernel's boot time, and
// the pid is only trusted when that still matches.
// bootID identifies the current boot, or is empty if the kernel will not say.
func bootID() string {
raw, err := syscall.Sysctl("kern.boottime")
if err != nil || raw == "" {
return ""
}
return hex.EncodeToString([]byte(raw))
}
// stopRecordedInstance stops the daemon named in the pid file, if it is
// still running. It reports the pid it stopped, or 0.
func stopRecordedInstance(pidFile string) int {
b, err := os.ReadFile(pidFile)
if err != nil {
return 0
}
fields := strings.Fields(string(b))
if len(fields) != 2 {
return 0
}
pid, err := strconv.Atoi(fields[0])
boot := bootID()
if err != nil || pid <= 1 || pid == os.Getpid() || boot == "" || fields[1] != boot {
return 0
}
if syscall.Kill(pid, 0) != nil {
return 0 // already gone
}
syscall.Kill(pid, syscall.SIGTERM)
for i := 0; i < 40; i++ {
time.Sleep(250 * time.Millisecond)
if syscall.Kill(pid, 0) != nil {
return pid
}
}
syscall.Kill(pid, syscall.SIGKILL)
time.Sleep(500 * time.Millisecond)
return pid
}
// recordInstance writes this process to the pid file.
func recordInstance(pidFile string) error {
boot := bootID()
if boot == "" {
// Without a boot id the pid could not be trusted later.
os.Remove(pidFile)
return fmt.Errorf("kern.boottime is not available")
}
return os.WriteFile(pidFile, []byte(fmt.Sprintf("%d %s\n", os.Getpid(), boot)), 0o644)
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"net"
"sync"
"time"
)
// resilientListener is a TCP listener that reopens itself when accepting
// fails.
//
// On the PS5 a listening socket dies when the network is reconfigured, for
// example when the connection settings are changed: accept returns the
// Sony-specific errno 163 and never works again. Servers treat that as fatal
// and stop. This listener closes the dead socket, listens on the same address
// again and carries on, so the server on top of it never notices.
type resilientListener struct {
network string
addr string
logf func(format string, args ...any)
mu sync.Mutex
ln net.Listener
closed bool
}
func listenResilient(network, addr string, logf func(string, ...any)) (*resilientListener, error) {
ln, err := net.Listen(network, addr)
if err != nil {
return nil, err
}
// Remember the concrete address, so that ":0" reopens on the same port.
return &resilientListener{network: network, addr: ln.Addr().String(), logf: logf, ln: ln}, nil
}
func (l *resilientListener) current() (net.Listener, bool) {
l.mu.Lock()
defer l.mu.Unlock()
return l.ln, l.closed
}
func (l *resilientListener) Accept() (net.Conn, error) {
for {
ln, closed := l.current()
if closed {
return nil, net.ErrClosed
}
c, err := ln.Accept()
if err == nil {
return c, nil
}
if _, closed := l.current(); closed {
return nil, net.ErrClosed
}
l.logf("listener %s: %v; reopening", l.addr, err)
ln.Close()
if !l.reopen() {
return nil, net.ErrClosed
}
}
}
// reopen listens again, retrying until it works or the listener is closed.
func (l *resilientListener) reopen() bool {
for delay := 250 * time.Millisecond; ; delay = min(2*delay, 5*time.Second) {
time.Sleep(delay)
if _, closed := l.current(); closed {
return false
}
ln, err := net.Listen(l.network, l.addr)
if err != nil {
continue
}
l.mu.Lock()
if l.closed {
l.mu.Unlock()
ln.Close()
return false
}
l.ln = ln
l.mu.Unlock()
l.logf("listener %s: reopened", l.addr)
return true
}
}
func (l *resilientListener) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
l.closed = true
return l.ln.Close()
}
func (l *resilientListener) Addr() net.Addr {
ln, _ := l.current()
return ln.Addr()
}
// port returns the TCP port the listener is bound to.
func (l *resilientListener) port() uint16 {
if a, ok := l.Addr().(*net.TCPAddr); ok {
return uint16(a.Port)
}
return 0
}
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"context"
"errors"
"fmt"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
)
// Local forwards go the opposite way from forwardToLocalhost: they let apps
// on the console reach a host on the tailnet.
//
// Tailscale runs inside this process, not in the PS5's network stack, so
// other apps cannot open connections to tailnet addresses. A forward listens
// on a localhost port and relays to a tailnet host; the app connects to
// 127.0.0.1 instead. TCP and UDP are both supported, which is what game
// streaming (Moonlight to a Sunshine host) needs.
// forwardRule is one local forward.
type forwardRule struct {
Proto string `json:"proto"` // "tcp" or "udp"
Listen string `json:"listen"` // local address, e.g. "127.0.0.1:47989"
Target string `json:"target"` // tailnet host and port, e.g. "my-pc:47989"
}
func (r forwardRule) String() string {
return fmt.Sprintf("%s %s -> %s", r.Proto, r.Listen, r.Target)
}
// Ports a Sunshine host uses with its default base port (47989).
var (
sunshineTCPPorts = []int{47984, 47989, 48010} // HTTPS, HTTP, RTSP
sunshineUDPPorts = []int{47998, 47999, 48000, 48002} // video, control, audio, microphone
)
// sunshineRules returns the forwards that make the Sunshine host on the
// tailnet appear on 127.0.0.1 to a Moonlight client on the console.
func sunshineRules(host string) []forwardRule {
if host == "" {
return nil
}
var rules []forwardRule
for _, p := range sunshineTCPPorts {
port := strconv.Itoa(p)
rules = append(rules, forwardRule{"tcp", net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(host, port)})
}
for _, p := range sunshineUDPPorts {
port := strconv.Itoa(p)
rules = append(rules, forwardRule{"udp", net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(host, port)})
}
return rules
}
type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
// forwarder runs a set of local forwards.
type forwarder struct {
dial dialFunc
logf func(format string, args ...any)
mu sync.Mutex
active []forwardRule
closers []func()
tcpPorts map[uint16]bool
}
func newForwarder(dial dialFunc, logf func(string, ...any)) *forwarder {
return &forwarder{dial: dial, logf: logf, tcpPorts: map[uint16]bool{}}
}
// set replaces the running forwards with rules. Rules that cannot be started
// are skipped and reported.
func (f *forwarder) set(rules []forwardRule) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.closers {
c()
}
f.closers, f.active, f.tcpPorts = nil, nil, map[uint16]bool{}
var errs []error
for _, r := range rules {
var stop func()
var err error
switch r.Proto {
case "tcp":
stop, err = f.startTCP(r)
case "udp":
stop, err = f.startUDP(r)
default:
err = fmt.Errorf("unknown protocol %q", r.Proto)
}
if err != nil {
f.logf("forward %v: %v", r, err)
errs = append(errs, fmt.Errorf("%v: %w", r, err))
continue
}
f.closers = append(f.closers, stop)
f.active = append(f.active, r)
}
if len(f.active) > 0 {
f.logf("local forwards active: %d", len(f.active))
}
return errors.Join(errs...)
}
func (f *forwarder) rules() []forwardRule {
f.mu.Lock()
defer f.mu.Unlock()
return append([]forwardRule(nil), f.active...)
}
// listensOnTCP reports whether a local forward owns the TCP port.
func (f *forwarder) listensOnTCP(port uint16) bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.tcpPorts[port]
}
func (f *forwarder) startTCP(r forwardRule) (stop func(), err error) {
ln, err := listenResilient("tcp", r.Listen, f.logf)
if err != nil {
return nil, err
}
f.tcpPorts[ln.port()] = true
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go f.serveTCP(c, r)
}
}()
return func() { ln.Close() }, nil
}
func (f *forwarder) serveTCP(c net.Conn, r forwardRule) {
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
up, err := f.dial(ctx, "tcp", r.Target)
cancel()
if err != nil {
f.logf("forward %v: %v", r, err)
return
}
defer up.Close()
pipe(c, up)
}
// UDP flows that have been silent this long are forgotten.
const udpIdleTimeout = 2 * time.Minute
// udpFlow is the relay state for one local client address.
type udpFlow struct {
out chan []byte // datagrams from the client waiting to go upstream
lastSeen atomic.Int64
}
func (fl *udpFlow) touch() { fl.lastSeen.Store(time.Now().UnixNano()) }
func (fl *udpFlow) idle() bool {
return time.Since(time.Unix(0, fl.lastSeen.Load())) > udpIdleTimeout
}
func (f *forwarder) startUDP(r forwardRule) (stop func(), err error) {
pc, err := net.ListenPacket("udp", r.Listen)
if err != nil {
return nil, err
}
var (
mu sync.Mutex
current = pc
closed bool
flows = map[string]*udpFlow{}
)
socket := func() (net.PacketConn, bool) {
mu.Lock()
defer mu.Unlock()
return current, closed
}
go func() {
buf := make([]byte, 65535)
for {
sock, stopped := socket()
if stopped {
return
}
n, from, err := sock.ReadFrom(buf)
if err != nil {
if _, stopped := socket(); stopped {
return
}
// Like TCP listeners, a UDP socket can die when the
// PS5's network is reconfigured. Open a new one.
f.logf("forward %v: %v; reopening", r, err)
sock.Close()
time.Sleep(time.Second)
if reopened, err := net.ListenPacket("udp", r.Listen); err == nil {
mu.Lock()
if closed {
reopened.Close()
} else {
current = reopened
}
mu.Unlock()
}
continue
}
key := from.String()
mu.Lock()
fl := flows[key]
if fl == nil {
fl = &udpFlow{out: make(chan []byte, 256)}
flows[key] = fl
go f.serveUDPFlow(r, fl, from, socket, func() {
mu.Lock()
if flows[key] == fl {
delete(flows, key)
}
mu.Unlock()
})
}
mu.Unlock()
fl.touch()
select {
case fl.out <- append([]byte(nil), buf[:n]...):
default: // upstream is not keeping up; UDP may drop
}
}
}()
return func() {
mu.Lock()
closed = true
current.Close()
mu.Unlock()
}, nil
}
// serveUDPFlow relays one client's datagrams to the target and the replies
// back, until the flow goes quiet or the forward is stopped.
func (f *forwarder) serveUDPFlow(r forwardRule, fl *udpFlow, client net.Addr, socket func() (net.PacketConn, bool), done func()) {
defer done()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
up, err := f.dial(ctx, "udp", r.Target)
cancel()
if err != nil {
f.logf("forward %v: %v", r, err)
return
}
defer up.Close()
// Replies: target -> client.
go func() {
buf := make([]byte, 65535)
for {
up.SetReadDeadline(time.Now().Add(udpIdleTimeout))
n, err := up.Read(buf)
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() && !fl.idle() {
continue
}
return
}
fl.touch()
if pc, closed := socket(); !closed {
pc.WriteTo(buf[:n], client)
}
}
}()
idle := time.NewTicker(udpIdleTimeout / 4)
defer idle.Stop()
for {
select {
case b := <-fl.out:
if _, err := up.Write(b); err != nil {
return
}
case <-idle.C:
if _, closed := socket(); closed || fl.idle() {
return
}
}
}
}
+218
View File
@@ -0,0 +1,218 @@
package main
import (
"context"
"io"
"net"
"strings"
"testing"
"time"
)
// startEcho runs a TCP and a UDP echo server on the same port and returns it.
func startEcho(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() { io.Copy(c, c); c.Close() }()
}
}()
pc, err := net.ListenPacket("udp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pc.Close() })
go func() {
buf := make([]byte, 65535)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return
}
pc.WriteTo(append([]byte("echo:"), buf[:n]...), from)
}
}()
return ln.Addr().String()
}
// freePort returns a localhost address nothing listens on.
func freePort(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().String()
}
func TestLocalForward(t *testing.T) {
echo := startEcho(t)
listen := freePort(t)
// The "tailnet" is the loopback interface; record what gets dialed.
var dialed []string
dial := func(ctx context.Context, network, addr string) (net.Conn, error) {
dialed = append(dialed, network+" "+addr)
var d net.Dialer
return d.DialContext(ctx, network, echo)
}
f := newForwarder(dial, t.Logf)
err := f.set([]forwardRule{
{"tcp", listen, "sunshine-host:47989"},
{"udp", listen, "sunshine-host:47998"},
})
if err != nil {
t.Fatal(err)
}
defer f.set(nil)
_, port, _ := net.SplitHostPort(listen)
if got := f.rules(); len(got) != 2 {
t.Fatalf("active rules: %v", got)
}
var portNum uint16
for _, c := range port {
portNum = portNum*10 + uint16(c-'0')
}
if !f.listensOnTCP(portNum) {
t.Errorf("listensOnTCP(%d) = false", portNum)
}
// TCP: data and the half-close go through.
c, err := net.Dial("tcp", listen)
if err != nil {
t.Fatal(err)
}
c.Write([]byte("hello"))
c.(*net.TCPConn).CloseWrite()
c.SetReadDeadline(time.Now().Add(5 * time.Second))
b, err := io.ReadAll(c)
c.Close()
if err != nil || string(b) != "hello" {
t.Fatalf("tcp: got %q, %v", b, err)
}
// UDP: several datagrams from one client share a flow and come back to it.
u, err := net.Dial("udp", listen)
if err != nil {
t.Fatal(err)
}
defer u.Close()
for _, msg := range []string{"one", "two", "three"} {
u.Write([]byte(msg))
buf := make([]byte, 100)
u.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := u.Read(buf)
if err != nil || string(buf[:n]) != "echo:"+msg {
t.Fatalf("udp %q: got %q, %v", msg, buf[:n], err)
}
}
want := "tcp sunshine-host:47989,udp sunshine-host:47998"
if got := strings.Join(dialed, ","); got != want {
t.Errorf("dialed %q, want %q (one dial per TCP connection and per UDP client)", got, want)
}
// Replacing the rules frees the ports.
if err := f.set(nil); err != nil {
t.Fatal(err)
}
if c, err := net.DialTimeout("tcp", listen, time.Second); err == nil {
c.Close()
t.Errorf("TCP forward still accepting after it was removed")
}
}
func TestSunshineRules(t *testing.T) {
if got := sunshineRules(""); got != nil {
t.Errorf("no host: got %v", got)
}
rules := sunshineRules("gaming-pc")
if len(rules) != 7 {
t.Fatalf("got %d rules, want 7", len(rules))
}
if got, want := rules[0].String(), "tcp 127.0.0.1:47984 -> gaming-pc:47984"; got != want {
t.Errorf("first rule %q, want %q", got, want)
}
for _, r := range rules {
if !strings.HasPrefix(r.Listen, "127.0.0.1:") {
t.Errorf("%v does not listen on localhost only", r)
}
}
}
func TestIsLocalDestination(t *testing.T) {
for addr, want := range map[string]bool{
"127.0.0.1:8080": true,
"localhost:80": true,
"192.168.1.50:2121": true,
"10.0.0.5:443": true,
"[::1]:80": true,
"100.64.0.5:8090": false, // tailnet address
"my-pc.tailnet.ts.net:80": false,
"example.com:443": false,
} {
if got := isLocalDestination(addr); got != want {
t.Errorf("isLocalDestination(%q) = %v, want %v", addr, got, want)
}
}
}
// A listener that dies must be reopened without the server noticing.
func TestResilientListenerReopens(t *testing.T) {
ln, err := listenResilient("tcp", "127.0.0.1:0", t.Logf)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
addr := ln.Addr().String()
accepted := make(chan net.Conn, 4)
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
accepted <- c
}
}()
connect := func() {
t.Helper()
var c net.Conn
var err error
for i := 0; i < 50; i++ {
if c, err = net.DialTimeout("tcp", addr, time.Second); err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
if err != nil {
t.Fatalf("dial: %v", err)
}
c.Close()
select {
case s := <-accepted:
s.Close()
case <-time.After(5 * time.Second):
t.Fatal("connection was not accepted")
}
}
connect()
// Kill the socket underneath, as a network reconfiguration would.
inner, _ := ln.current()
inner.Close()
connect()
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"fmt"
"os"
"sync"
"time"
)
const maxLogSize = 2 << 20
// openLog opens the log file and points stdout and stderr at it, so that Go
// runtime crash output is kept too. A large log is rotated once at startup.
func openLog(path string) (*os.File, error) {
if fi, err := os.Stat(path); err == nil && fi.Size() > maxLogSize {
os.Rename(path, path+".old")
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, err
}
redirectStdio(f)
return f, nil
}
// newLogger returns a logf that writes timestamped lines to the log file and
// keeps the most recent ones for the status page.
func newLogger(f *os.File) func(format string, args ...any) {
var mu sync.Mutex
return func(format string, args ...any) {
line := time.Now().Format("2006-01-02 15:04:05 ") + fmt.Sprintf(format, args...)
mu.Lock()
fmt.Fprintln(f, line)
mu.Unlock()
recentLogs.add(line)
}
}
// logRing keeps the last lines of the log in memory.
type logRing struct {
mu sync.Mutex
lines []string
}
var recentLogs logRing
func (r *logRing) add(line string) {
r.mu.Lock()
defer r.mu.Unlock()
r.lines = append(r.lines, line)
if len(r.lines) > 200 {
r.lines = r.lines[len(r.lines)-200:]
}
}
func (r *logRing) snapshot() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.lines...)
}
+395
View File
@@ -0,0 +1,395 @@
// Command ps5tailscale runs Tailscale on a jailbroken PS5.
//
// The PS5 kernel has no tunnel device, so Tailscale runs entirely in
// userspace (tsnet + netstack). The console joins the tailnet as a node, and
// every TCP connection that arrives for it over the tailnet is handed to the
// matching port on localhost. That makes whatever is listening on the console
// (FTP, the payload loader, web servers, ...) reachable from the tailnet.
package main
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
_ "golang.org/x/crypto/x509roots/fallback" // the PS5 has no system CA bundle
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tsnet"
)
// version is shown on the status page. Set at build time with -ldflags -X.
var version = "dev"
// forceVerbose turns on Tailscale's own logging regardless of the config
// file. Set to "1" at build time with -ldflags -X for debugging builds.
var forceVerbose = ""
// dataDir holds the config, the log and Tailscale's state. PS5TS_DATA overrides
// it when testing on a development machine.
var dataDir = func() string {
if d := os.Getenv("PS5TS_DATA"); d != "" {
return d
}
return "/data/tailscale"
}()
func main() {
console := newConsole()
if err := os.MkdirAll(dataDir, 0o755); err != nil {
console.Printf("tailscale: cannot create %s: %v\n", dataDir, err)
os.Exit(1)
}
logFile, err := openLog(filepath.Join(dataDir, "tailscale.log"))
if err != nil {
console.Printf("tailscale: cannot open log: %v\n", err)
os.Exit(1)
}
logf := newLogger(logFile)
cfg, err := loadConfig(filepath.Join(dataDir, "config.json"))
if err != nil {
logf("config: %v (using defaults)", err)
}
logf("ps5-tailscale %s starting, hostname %q, web UI on %s", version, cfg.Hostname, cfg.WebAddr)
// A payload that is sent again replaces the running instance, which is
// how an upgrade or a restart is done.
if stopRunningInstance(cfg.WebAddr) {
logf("stopped the instance that was already running")
}
// The status page of the old instance may have been unreachable, so
// also go by the process it recorded.
pidFile := filepath.Join(dataDir, "tailscale.pid")
if pid := stopRecordedInstance(pidFile); pid != 0 {
logf("stopped a previous instance that was still running (pid %d)", pid)
}
if err := recordInstance(pidFile); err != nil {
logf("pid file: %v", err)
}
// Everything in the main log also goes to the debug log, so that it
// reads as one timeline with Tailscale's own messages.
debug := openDebugLog(filepath.Join(dataDir, "tailscale-debug.log"), 4<<20)
mainLogf := logf
logf = func(format string, args ...any) {
mainLogf(format, args...)
debug.Printf(format, args...)
}
d := &daemon{cfg: cfg, cfgPath: filepath.Join(dataDir, "config.json"), logf: logf, debug: debug, console: console, started: time.Now()}
if err := d.run(); err != nil {
logf("fatal: %v", err)
notify("Tailscale failed to start:\n%v", err)
os.Exit(1)
}
}
type daemon struct {
cfg config // guarded by mu once the web UI is up
cfgPath string
debug *debugLog
logf func(format string, args ...any)
console *console
started time.Time
srv *tsnet.Server
lc *local.Client
fwd *forwarder
mu sync.Mutex
state string // ipn backend state, e.g. "NeedsLogin", "Running"
authURL string
lastErr string
notified string // last state the user was notified about
lastTsnetMsg string
proxyPort uint16 // port of the outbound HTTP proxy, 0 if disabled
webPort uint16 // port of the status page
lastRelogin time.Time
quit chan struct{}
quitOnce sync.Once
}
func (d *daemon) run() error {
d.quit = make(chan struct{})
d.srv = &tsnet.Server{
Dir: filepath.Join(dataDir, "state"),
Hostname: d.cfg.Hostname,
AuthKey: d.cfg.AuthKey,
UserLogf: d.tsnetLogf,
}
if d.cfg.ControlURL != "" {
d.srv.ControlURL = d.cfg.ControlURL
}
// Tailscale's own log always goes to the debug log; the main log only
// gets it when asked.
if d.cfg.Verbose || forceVerbose == "1" {
d.srv.Logf = func(format string, args ...any) { d.logf("ts: "+format, args...) }
} else {
d.srv.Logf = func(format string, args ...any) { d.debug.Printf("ts: "+format, args...) }
}
// The web UI comes up first so that it can show progress and errors even
// if Tailscale itself has trouble starting.
d.fwd = newForwarder(d.dialTailnet, d.logf)
webLn, err := listenResilient("tcp", d.cfg.WebAddr, d.logf)
if err != nil {
return fmt.Errorf("web UI: %w", err)
}
d.webPort = webLn.port()
go d.serveWeb(webLn)
if err := d.srv.Start(); err != nil {
return fmt.Errorf("starting tailscale: %w", err)
}
d.lc, err = d.srv.LocalClient()
if err != nil {
return fmt.Errorf("local client: %w", err)
}
if d.cfg.HTTPProxyAddr != "" {
if ln, err := listenResilient("tcp", d.cfg.HTTPProxyAddr, d.logf); err != nil {
d.logf("http proxy: %v", err)
} else {
d.proxyPort = ln.port()
go d.serveProxy(ln)
}
}
d.fwd.set(d.localForwardRules())
d.srv.RegisterFallbackTCPHandler(d.forwardToLocalhost)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go d.watch(ctx)
go d.recoverLogin(ctx)
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
select {
case <-d.quit:
d.logf("stop requested")
case s := <-sigc:
d.logf("received %v", s)
}
cancel()
webLn.Close()
done := make(chan struct{})
go func() {
d.srv.Close()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
d.logf("shutdown timed out")
}
d.logf("stopped")
return nil
}
// dialTailnet opens a connection through Tailscale.
func (d *daemon) dialTailnet(ctx context.Context, network, addr string) (net.Conn, error) {
return d.srv.Dial(ctx, network, addr)
}
// localForwardRules returns the local forwards the config asks for.
func (d *daemon) localForwardRules() []forwardRule {
d.mu.Lock()
defer d.mu.Unlock()
return append(sunshineRules(d.cfg.SunshineHost), d.cfg.Forwards...)
}
// tsnetLogf receives tsnet's messages for the user. While it waits for a
// login it repeats the same line every few seconds, which would drown the
// log, so a message is only logged again when it changes.
func (d *daemon) tsnetLogf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
d.mu.Lock()
repeat := msg == d.lastTsnetMsg
d.lastTsnetMsg = msg
d.mu.Unlock()
if !repeat {
d.logf("tsnet: %s", msg)
}
}
// watch follows the backend state and tells the user, on screen, when
// something needs their attention.
func (d *daemon) watch(ctx context.Context) {
for ctx.Err() == nil {
err := d.watchOnce(ctx)
if ctx.Err() != nil {
return
}
d.logf("state watcher: %v; retrying", err)
select {
case <-ctx.Done():
case <-time.After(3 * time.Second):
}
}
}
func (d *daemon) watchOnce(ctx context.Context) error {
w, err := d.lc.WatchIPNBus(ctx, ipn.NotifyInitialState)
if err != nil {
return err
}
defer w.Close()
for {
n, err := w.Next()
if err != nil {
return err
}
if n.ErrMessage != nil {
d.logf("backend error: %s", *n.ErrMessage)
d.mu.Lock()
d.lastErr = *n.ErrMessage
d.mu.Unlock()
}
if n.BrowseToURL != nil && *n.BrowseToURL != "" {
d.setAuthURL(*n.BrowseToURL)
}
if n.State != nil {
d.setState(ctx, n.State.String())
}
}
}
// freshLogin abandons whatever login is pending and starts a new one, which
// produces a new login link.
func (d *daemon) freshLogin(ctx context.Context) error {
d.mu.Lock()
d.authURL = ""
d.lastRelogin = time.Now()
d.mu.Unlock()
if err := d.lc.Logout(ctx); err != nil {
d.logf("fresh login: logout: %v", err)
}
return d.lc.StartLoginInteractive(ctx)
}
// recoverLogin gets the daemon out of a dead-end seen on the console: the
// user completes the login in the browser, but the confirmation never
// arrives and Tailscale's retry is answered with "auth path not found". The
// backend then keeps offering the used-up link forever. When that error
// shows up, start over with a new link.
func (d *daemon) recoverLogin(ctx context.Context) {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
d.mu.Lock()
waiting := d.state == "NeedsLogin" && time.Since(d.lastRelogin) > time.Minute
d.mu.Unlock()
if !waiting {
continue
}
st, err := d.status(ctx)
if err != nil {
continue
}
for _, h := range st.Health {
if strings.Contains(h, "auth path not found") {
d.logf("the pending login link is no longer valid; requesting a new one")
if err := d.freshLogin(ctx); err != nil {
d.logf("fresh login: %v", err)
}
break
}
}
}
}
func (d *daemon) setAuthURL(u string) {
d.mu.Lock()
changed := d.authURL != u
d.authURL = u
d.mu.Unlock()
if !changed {
return
}
d.logf("login URL: %s", u)
d.console.Printf("\nTo connect this PS5 to your tailnet, open:\n\n %s\n\n(also shown at %s)\n", u, d.webURL())
notify("Tailscale needs you to log in.\nOpen %s on a phone or PC.", d.webURL())
}
func (d *daemon) setState(ctx context.Context, state string) {
d.mu.Lock()
prev := d.state
d.state = state
if state == "Running" {
d.authURL = ""
d.lastErr = ""
}
already := d.notified == state
d.notified = state
d.mu.Unlock()
if prev != state {
d.logf("state: %s -> %s", prev, state)
}
if already {
return
}
switch state {
case "Running":
name, ip := d.cfg.Hostname, ""
if st, err := d.status(ctx); err == nil && st.Self != nil {
if st.Self.DNSName != "" {
name = strings.TrimSuffix(st.Self.DNSName, ".")
}
if len(st.Self.TailscaleIPs) > 0 {
ip = st.Self.TailscaleIPs[0].String()
}
}
d.console.Printf("Tailscale is connected: %s %s\n", name, ip)
notify("Tailscale connected\n%s\n%s", name, ip)
case "NeedsMachineAuth":
notify("Tailscale: this PS5 is waiting for approval in the admin console.")
}
}
func (d *daemon) status(ctx context.Context) (*ipnstate.Status, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return d.lc.Status(ctx)
}
// webURL is the address of the status page as seen from the local network.
func (d *daemon) webURL() string {
_, port, _ := net.SplitHostPort(d.cfg.WebAddr)
return "http://" + net.JoinHostPort(lanIP(), port)
}
// lanIP returns the console's address on the local network.
func lanIP() string {
// No packet is sent; connecting a UDP socket only selects a route.
c, err := net.Dial("udp4", "192.0.2.1:9")
if err != nil {
return "127.0.0.1"
}
defer c.Close()
if a, ok := c.LocalAddr().(*net.UDPAddr); ok && !a.IP.IsUnspecified() {
return a.IP.String()
}
return "127.0.0.1"
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"context"
"io"
"net"
"net/http"
"net/netip"
"time"
)
// serveProxy runs a plain HTTP proxy whose outbound connections go through
// Tailscale's dialer: tailnet addresses and MagicDNS names are reached over
// the tailnet, everything else goes out directly. Setting it as the proxy
// server in the PS5's network settings lets the console's own apps, such as
// the browser, open tailnet hosts.
func (d *daemon) serveProxy(ln net.Listener) {
d.logf("http proxy listening on %s", ln.Addr())
transport := &http.Transport{
DialContext: d.proxyDial,
ForceAttemptHTTP2: false,
MaxIdleConns: 32,
IdleConnTimeout: 60 * time.Second,
ResponseHeaderTimeout: 60 * time.Second,
}
srv := &http.Server{
ReadHeaderTimeout: 15 * time.Second,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
d.proxyConnect(w, r)
return
}
if !r.URL.IsAbs() {
http.Error(w, "this is a proxy; requests must use an absolute URL", http.StatusBadRequest)
return
}
r.RequestURI = ""
r.Header.Del("Proxy-Connection")
r.Header.Del("Proxy-Authorization")
resp, err := transport.RoundTrip(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}),
}
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !d.stopping() {
d.logf("http proxy stopped: %v", err)
}
}
// proxyDial connects on behalf of a proxy client. Tailscale's dialer handles
// tailnet addresses and names but waits until Tailscale is connected, so it
// is only used once that is the case, and never for the console's own or
// LAN addresses: those must keep working whatever state Tailscale is in.
func (d *daemon) proxyDial(ctx context.Context, network, addr string) (net.Conn, error) {
d.mu.Lock()
running := d.state == "Running"
d.mu.Unlock()
if running && !isLocalDestination(addr) {
return d.srv.Dial(ctx, network, addr)
}
var direct net.Dialer
return direct.DialContext(ctx, network, addr)
}
// isLocalDestination reports whether addr is a literal loopback, private or
// link-local address, or "localhost".
func isLocalDestination(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
if host == "localhost" {
return true
}
ip, err := netip.ParseAddr(host)
if err != nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
}
func (d *daemon) proxyConnect(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
upstream, err := d.proxyDial(ctx, "tcp", r.Host)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer upstream.Close()
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
return
}
client, buf, err := hj.Hijack()
if err != nil {
return
}
defer client.Close()
io.WriteString(client, "HTTP/1.1 200 Connection established\r\n\r\n")
// Anything the client sent right after its CONNECT request is already
// sitting in the server's read buffer.
if n := buf.Reader.Buffered(); n > 0 {
io.CopyN(upstream, buf.Reader, int64(n))
}
pipe(client, upstream)
}
+253
View File
@@ -0,0 +1,253 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tailscale on PS5</title>
<style>
:root {
--bg: #f4f5f7; --panel: #ffffff; --text: #1c1e21; --muted: #646a73;
--line: #dfe2e6; --accent: #2f5fd0; --ok: #1f8a4c; --warn: #b26a00; --bad: #c0362c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14161a; --panel: #1e2126; --text: #e8eaed; --muted: #9aa1ab;
--line: #30353c; --accent: #7fa4ff; --ok: #56c488; --warn: #e0a84a; --bad: #f0796f;
}
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px 16px 48px; background: var(--bg); color: var(--text);
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main { max-width: 720px; margin: 0 auto; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 0 0 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; margin-bottom: 16px; }
.state { display: flex; align-items: center; gap: 10px; font-size: 20px; font-weight: 600; }
.dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted); flex: none; }
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); }
dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 14px 0 0; }
dt { color: var(--muted); } dd { margin: 0; overflow-wrap: anywhere; }
code { font: 14px ui-monospace, SFMono-Regular, Consolas, monospace; }
a { color: var(--accent); }
.login { text-align: center; }
.login img { width: 240px; height: 240px; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 8px; }
.login .url { display: block; margin: 12px 0 4px; font-size: 18px; overflow-wrap: anywhere; }
.msg { color: var(--bad); margin: 12px 0 0; overflow-wrap: anywhere; }
.health { color: var(--warn); margin: 12px 0 0; padding-left: 18px; }
table { width: 100%; border-collapse: collapse; font-size: 15px; }
th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 8px 8px 0; }
td { padding: 7px 8px 7px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
td.off { color: var(--muted); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; }
button {
font: inherit; padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line);
background: var(--panel); color: var(--text); cursor: pointer;
}
button:hover { border-color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.danger { color: var(--bad); }
pre { margin: 0; max-height: 320px; overflow: auto; font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
details summary { cursor: pointer; color: var(--muted); }
.note { color: var(--muted); font-size: 14px; margin: 0 0 12px; }
.field { display: flex; flex-direction: column; gap: 4px; font-size: 14px; color: var(--muted); }
select {
font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
background: var(--panel); color: var(--text); min-width: 220px;
}
.actions { align-items: flex-end; }
.hidden { display: none; }
</style>
</head>
<body>
<main>
<h1>Tailscale on PS5</h1>
<p class="sub" id="version"></p>
<section class="panel">
<div class="state"><span class="dot" id="dot"></span><span id="state">Loading…</span></div>
<dl id="facts"></dl>
<ul class="health hidden" id="health"></ul>
<p class="msg hidden" id="error"></p>
</section>
<section class="panel login hidden" id="login">
<h2>Log in</h2>
<p>Scan this with your phone, or open the link on any device, to add this PS5 to your tailnet.</p>
<img id="qr" alt="QR code for the login link">
<a class="url" id="authurl" target="_blank" rel="noopener"></a>
</section>
<section class="panel hidden" id="peerpanel">
<h2>Devices on your tailnet</h2>
<table>
<thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead>
<tbody id="peers"></tbody>
</table>
</section>
<section class="panel hidden" id="streampanel">
<h2>Game streaming (Moonlight to Sunshine)</h2>
<p class="note">Apps on the PS5 cannot reach tailnet addresses directly. Pick the device that runs Sunshine and its
streaming ports are made available on this console. Then, in your Moonlight client on the PS5 (for example
ProsperoLight), add the host <code>127.0.0.1</code>.</p>
<div class="actions">
<label class="field">Sunshine host
<select id="sunshine-select"></select>
</label>
<button id="btn-sunshine">Save</button>
</div>
<p class="note" id="sunshine-state"></p>
</section>
<section class="panel">
<h2>Actions</h2>
<div class="actions">
<button id="btn-login">Log in again</button>
<button id="btn-logout" class="danger">Log out</button>
<button id="btn-quit" class="danger">Stop Tailscale</button>
<button id="btn-uninstall" class="danger">Uninstall</button>
</div>
<p class="msg hidden" id="actionmsg"></p>
</section>
<section class="panel">
<details id="logbox">
<summary>Recent log</summary>
<pre id="logs"></pre>
</details>
</section>
</main>
<script>
const $ = id => document.getElementById(id);
const labels = {
Starting: ['Starting…', 'warn'],
NoState: ['Starting…', 'warn'],
NeedsLogin: ['Not logged in', 'warn'],
NeedsMachineAuth: ['Waiting for approval in the admin console', 'warn'],
Stopped: ['Stopped', 'bad'],
Running: ['Connected', 'ok'],
};
let shownQR = '';
function row(dl, name, value, mono) {
const dt = document.createElement('dt'); dt.textContent = name;
const dd = document.createElement('dd');
if (mono) { const c = document.createElement('code'); c.textContent = value; dd.append(c); }
else dd.textContent = value;
dl.append(dt, dd);
}
async function refresh() {
let s;
try {
s = await (await fetch('/api/status', {cache: 'no-store'})).json();
} catch (e) {
$('state').textContent = 'Not responding';
$('dot').className = 'dot bad';
return;
}
const [label, cls] = labels[s.state] || [s.state, 'warn'];
$('state').textContent = label;
$('dot').className = 'dot ' + cls;
$('version').textContent = 'ps5-tailscale ' + s.version;
const dl = $('facts');
dl.replaceChildren();
row(dl, 'Name', s.dnsName || s.hostname);
if (s.ips.length) row(dl, 'Tailnet address', s.ips.join(', '), true);
if (s.tailnet) row(dl, 'Tailnet', s.tailnet);
if (s.proxy) row(dl, 'HTTP proxy', s.proxy, true);
row(dl, 'Starts with the console', s.autostart.length ? 'Yes, via ' + s.autostart.join(', ') : 'No');
const health = $('health');
health.replaceChildren(...(s.health || []).map(h => { const li = document.createElement('li'); li.textContent = h; return li; }));
health.classList.toggle('hidden', !(s.health || []).length);
$('error').textContent = s.error || '';
$('error').classList.toggle('hidden', !s.error);
const needLogin = !!s.authURL;
$('login').classList.toggle('hidden', !needLogin);
if (needLogin) {
$('authurl').textContent = s.authURL;
$('authurl').href = s.authURL;
if (shownQR !== s.authURL) { shownQR = s.authURL; $('qr').src = '/qr.png?' + Date.now(); }
}
const tbody = $('peers');
tbody.replaceChildren(...s.peers.map(p => {
const tr = document.createElement('tr');
for (const [v, mono] of [[p.name], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
const td = document.createElement('td');
if (!p.online) td.className = 'off';
if (mono) { const c = document.createElement('code'); c.textContent = v; td.append(c); } else td.textContent = v;
tr.append(td);
}
return tr;
}));
$('peerpanel').classList.toggle('hidden', !s.peers.length);
// Game streaming: offer the tailnet's devices, keep the user's selection
// while they are choosing.
$('streampanel').classList.toggle('hidden', s.state !== 'Running' && !s.sunshineHost);
const sel = $('sunshine-select');
const options = ['', ...s.peers.map(p => p.name)];
if (s.sunshineHost && !options.includes(s.sunshineHost)) options.push(s.sunshineHost);
const signature = options.join('|') + '#' + s.sunshineHost;
if (sel.dataset.signature !== signature && document.activeElement !== sel) {
sel.replaceChildren(...options.map(name => {
const o = document.createElement('option');
o.value = name;
const peer = s.peers.find(p => p.name === name);
o.textContent = name === '' ? 'None (off)' : name + (peer && !peer.online ? ' (offline)' : '');
return o;
}));
sel.value = s.sunshineHost;
sel.dataset.signature = signature;
}
$('sunshine-state').textContent = s.sunshineHost
? 'Forwarding 127.0.0.1 to ' + s.sunshineHost + ' (' + s.forwards.length + ' ports).'
: 'Off.';
if ($('logbox').open) {
try { $('logs').textContent = await (await fetch('/api/logs', {cache: 'no-store'})).text(); } catch (e) {}
}
}
async function act(path, confirmText) {
if (confirmText && !confirm(confirmText)) return;
const msg = $('actionmsg');
msg.classList.add('hidden');
try {
const r = await fetch(path, {method: 'POST', headers: {'X-PS5-Tailscale': '1'}});
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
} catch (e) {
msg.textContent = e.message;
msg.classList.remove('hidden');
}
refresh();
}
$('btn-login').onclick = () => act('/api/login');
$('btn-logout').onclick = () => act('/api/logout', 'Log this PS5 out of your tailnet?');
$('btn-quit').onclick = () => act('/api/quit', 'Stop Tailscale on this PS5? Send the payload again to start it.');
$('btn-sunshine').onclick = async () => {
await act('/api/sunshine?host=' + encodeURIComponent($('sunshine-select').value));
$('sunshine-select').dataset.signature = '';
};
$('btn-uninstall').onclick = () => {
if (!confirm('Remove Tailscale from this PS5? It will stop now and no longer start with the console.')) return;
const purge = confirm('Also log out and delete the saved login?\n\nOK: delete everything.\nCancel: keep the login, so reinstalling reconnects without logging in again.');
act('/api/uninstall' + (purge ? '?purge=1' : ''));
};
$('logbox').addEventListener('toggle', refresh);
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
+358
View File
@@ -0,0 +1,358 @@
package main
import (
_ "embed"
"encoding/json"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
qrcode "github.com/skip2/go-qrcode"
)
//go:embed status.html
var statusHTML []byte
// The status page has no login: like the other services on a jailbroken
// console it trusts the local network. State-changing requests must carry
// this header, which a web page on another origin cannot send, so a stray
// link or image tag cannot log the console out.
const apiHeader = "X-PS5-Tailscale"
type peerInfo struct {
Name string `json:"name"`
IP string `json:"ip"`
OS string `json:"os"`
Online bool `json:"online"`
}
type statusInfo struct {
Version string `json:"version"`
State string `json:"state"`
AuthURL string `json:"authURL,omitempty"`
Error string `json:"error,omitempty"`
Hostname string `json:"hostname"`
DNSName string `json:"dnsName,omitempty"`
IPs []string `json:"ips"`
Tailnet string `json:"tailnet,omitempty"`
Health []string `json:"health,omitempty"`
Peers []peerInfo `json:"peers"`
Proxy string `json:"proxy,omitempty"`
// Autostart lists the autoloaders that start the daemon.
Autostart []string `json:"autostart"`
// SunshineHost and Forwards describe the local forwards.
SunshineHost string `json:"sunshineHost"`
Forwards []string `json:"forwards"`
Uptime int64 `json:"uptimeSeconds"`
}
func (d *daemon) serveWeb(ln net.Listener) {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Write(statusHTML)
})
mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "ps5-tailscale "+version+"\n")
})
mux.HandleFunc("GET /api/status", d.handleStatus)
mux.HandleFunc("GET /api/logs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
switch {
case r.URL.Query().Get("full") == "1":
// The end of the log file itself.
writeFileTail(w, filepath.Join(dataDir, "tailscale.log"), 512<<10)
return
case r.URL.Query().Get("debug") == "1":
// The end of the debug log, which includes Tailscale's own messages.
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log"), 1<<20)
return
case r.URL.Query().Get("debug") == "old":
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log.old"), 1<<20)
return
}
io.WriteString(w, strings.Join(recentLogs.snapshot(), "\n")+"\n")
})
mux.HandleFunc("GET /qr.png", d.handleQR)
mux.HandleFunc("POST /api/login", d.guard(d.handleLogin))
mux.HandleFunc("POST /api/logout", d.guard(d.handleLogout))
mux.HandleFunc("POST /api/quit", d.guard(d.handleQuit))
mux.HandleFunc("POST /api/uninstall", d.guard(d.handleUninstall))
mux.HandleFunc("POST /api/sunshine", d.guard(d.handleSunshine))
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !d.stopping() {
d.logf("web UI stopped: %v", err)
}
}
// stopping reports whether a shutdown has been requested.
func (d *daemon) stopping() bool {
select {
case <-d.quit:
return true
default:
return false
}
}
// writeFileTail copies the last max bytes of a file to w.
func writeFileTail(w io.Writer, path string, max int64) {
f, err := os.Open(path)
if err != nil {
io.WriteString(w, err.Error()+"\n")
return
}
defer f.Close()
if fi, err := f.Stat(); err == nil && fi.Size() > max {
f.Seek(fi.Size()-max, io.SeekStart)
}
io.Copy(w, f)
}
func (d *daemon) guard(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(apiHeader) == "" {
http.Error(w, "missing "+apiHeader+" header", http.StatusForbidden)
return
}
h(w, r)
}
}
func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
d.mu.Lock()
info := statusInfo{
Version: version,
State: d.state,
AuthURL: d.authURL,
Error: d.lastErr,
Hostname: d.cfg.Hostname,
Proxy: d.cfg.HTTPProxyAddr,
Uptime: int64(time.Since(d.started).Seconds()),
IPs: []string{},
Peers: []peerInfo{},
}
info.SunshineHost = d.cfg.SunshineHost
d.mu.Unlock()
info.Autostart = autostartNames()
info.Forwards = []string{}
for _, r := range d.fwd.rules() {
info.Forwards = append(info.Forwards, r.String())
}
if info.State == "" {
info.State = "Starting"
}
if d.lc != nil {
if st, err := d.status(r.Context()); err == nil {
info.State = st.BackendState
info.Health = st.Health
if info.AuthURL == "" {
info.AuthURL = st.AuthURL
}
if st.CurrentTailnet != nil {
info.Tailnet = st.CurrentTailnet.Name
}
if st.Self != nil {
info.DNSName = strings.TrimSuffix(st.Self.DNSName, ".")
for _, ip := range st.Self.TailscaleIPs {
info.IPs = append(info.IPs, ip.String())
}
}
for _, p := range st.Peer {
pi := peerInfo{Name: p.HostName, OS: p.OS, Online: p.Online}
if p.DNSName != "" {
pi.Name = strings.SplitN(p.DNSName, ".", 2)[0]
}
if len(p.TailscaleIPs) > 0 {
pi.IP = p.TailscaleIPs[0].String()
}
info.Peers = append(info.Peers, pi)
}
sort.Slice(info.Peers, func(i, j int) bool {
if info.Peers[i].Online != info.Peers[j].Online {
return info.Peers[i].Online
}
return info.Peers[i].Name < info.Peers[j].Name
})
}
}
if info.State == "Running" {
info.AuthURL = ""
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(info)
}
// handleQR renders the current login URL as a QR code. It only ever encodes
// the URL the daemon holds, never one supplied by the request.
func (d *daemon) handleQR(w http.ResponseWriter, r *http.Request) {
d.mu.Lock()
u := d.authURL
d.mu.Unlock()
if u == "" {
http.NotFound(w, r)
return
}
png, err := qrcode.Encode(u, qrcode.Medium, 320)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
w.Write(png)
}
func (d *daemon) handleLogin(w http.ResponseWriter, r *http.Request) {
if d.lc == nil {
http.Error(w, "tailscale is still starting", http.StatusServiceUnavailable)
return
}
d.mu.Lock()
loggedOut := d.state == "NeedsLogin"
d.mu.Unlock()
var err error
if loggedOut {
// The pending link may be used up or expired; get a new one.
err = d.freshLogin(r.Context())
} else {
err = d.lc.StartLoginInteractive(r.Context())
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.WriteString(w, "ok\n")
}
func (d *daemon) handleLogout(w http.ResponseWriter, r *http.Request) {
if d.lc == nil {
http.Error(w, "tailscale is still starting", http.StatusServiceUnavailable)
return
}
if err := d.lc.Logout(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
d.mu.Lock()
d.notified = ""
d.mu.Unlock()
d.logf("logged out via the status page")
io.WriteString(w, "ok\n")
}
// handleSunshine sets (or with an empty host, clears) the Sunshine host whose
// streaming ports are forwarded from 127.0.0.1, saves the config and applies
// it without a restart.
func (d *daemon) handleSunshine(w http.ResponseWriter, r *http.Request) {
host := strings.TrimSpace(r.URL.Query().Get("host"))
if !validHostName(host) {
http.Error(w, "that does not look like a host name or address", http.StatusBadRequest)
return
}
d.mu.Lock()
d.cfg.SunshineHost = host
cfg := d.cfg
d.mu.Unlock()
if err := saveConfig(d.cfgPath, cfg); err != nil {
d.logf("saving config: %v", err)
}
d.logf("sunshine host set to %q", host)
if err := d.fwd.set(d.localForwardRules()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.WriteString(w, "ok\n")
}
// validHostName accepts an empty string, a DNS name or an IP address.
func validHostName(s string) bool {
if len(s) > 253 {
return false
}
for _, c := range s {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '-', c == ':':
default:
return false
}
}
return true
}
func (d *daemon) handleQuit(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "stopping\n")
d.stop()
}
func (d *daemon) stop() {
d.quitOnce.Do(func() { close(d.quit) })
}
// handleUninstall removes the autostart entry and the installed payload, then
// stops. With ?purge=1 it first logs the console out of the tailnet and
// deletes the saved state as well.
func (d *daemon) handleUninstall(w http.ResponseWriter, r *http.Request) {
purge := r.URL.Query().Get("purge") == "1"
if purge && d.lc != nil {
if err := d.lc.Logout(r.Context()); err != nil {
d.logf("uninstall: logout: %v", err)
}
}
if err := uninstall(purge, d.logf); err != nil {
d.logf("uninstall: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
d.logf("uninstalled (purge=%v)", purge)
notify("Tailscale was removed from this PS5.")
io.WriteString(w, "uninstalled\n")
d.stop()
}
// stopRunningInstance asks an instance that is already serving the status
// page to exit and waits for the port to become free. It reports whether
// there was one.
func stopRunningInstance(webAddr string) bool {
_, port, err := net.SplitHostPort(webAddr)
if err != nil {
return false
}
base := "http://" + net.JoinHostPort("127.0.0.1", port)
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(base + "/api/ping")
if err != nil {
return false
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 100))
resp.Body.Close()
if !strings.HasPrefix(string(body), "ps5-tailscale") {
return false
}
req, _ := http.NewRequest("POST", base+"/api/quit", nil)
req.Header.Set(apiHeader, "1")
if resp, err := client.Do(req); err == nil {
resp.Body.Close()
}
for i := 0; i < 40; i++ {
c, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", port), time.Second)
if err != nil {
return true
}
c.Close()
time.Sleep(250 * time.Millisecond)
}
return true
}