From 455450259104282ccf59c8161595875fbdfa0c2a Mon Sep 17 00:00:00 2001 From: holdmysocks <1349908+holdmysocks@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:29:31 -0400 Subject: [PATCH] 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. --- .gitattributes | 4 + .gitignore | 14 + LICENSE | 674 ++++++++++++++++++++++++++ README.md | 278 +++++++++++ docs/BUILDING.md | 106 +++++ docs/TECHNICAL.md | 162 +++++++ docs/status-page.png | Bin 0 -> 59674 bytes installer/assets/icon0.png | Bin 0 -> 15359 bytes installer/assets/param.json | 11 + installer/main.c | 402 ++++++++++++++++ launcher/goload.c | 269 +++++++++++ launcher/goload.h | 8 + launcher/main.c | 160 +++++++ patches/go1.27.1-ps5.patch | 926 ++++++++++++++++++++++++++++++++++++ probe-c/dirprobe.c | 80 ++++ probe-c/fwdtest.c | 97 ++++ probe-c/lsprobe.c | 72 +++ probe-c/prioprobe.c | 79 +++ probe-c/proxytest.c | 66 +++ probe-c/pskill.c | 63 +++ probe-c/sysprobe.c | 279 +++++++++++ probe-go/go.mod | 3 + probe-go/main.go | 391 +++++++++++++++ tools/build-installer.ps1 | 27 ++ tools/build-payload.ps1 | 48 ++ tools/env.ps1 | 61 +++ tools/ps5send.ps1 | 39 ++ tsd/autostart.go | 114 +++++ tsd/autostart_test.go | 91 ++++ tsd/config.go | 74 +++ tsd/console_other.go | 23 + tsd/console_ps5.go | 80 ++++ tsd/debuglog.go | 78 +++ tsd/forward.go | 70 +++ tsd/go.mod | 55 +++ tsd/go.sum | 243 ++++++++++ tsd/instance_other.go | 9 + tsd/instance_ps5.go | 74 +++ tsd/listener.go | 105 ++++ tsd/localforward.go | 295 ++++++++++++ tsd/localforward_test.go | 218 +++++++++ tsd/logging.go | 60 +++ tsd/main.go | 395 +++++++++++++++ tsd/proxy.go | 119 +++++ tsd/status.html | 253 ++++++++++ tsd/web.go | 358 ++++++++++++++ 46 files changed, 7033 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/BUILDING.md create mode 100644 docs/TECHNICAL.md create mode 100644 docs/status-page.png create mode 100644 installer/assets/icon0.png create mode 100644 installer/assets/param.json create mode 100644 installer/main.c create mode 100644 launcher/goload.c create mode 100644 launcher/goload.h create mode 100644 launcher/main.c create mode 100644 patches/go1.27.1-ps5.patch create mode 100644 probe-c/dirprobe.c create mode 100644 probe-c/fwdtest.c create mode 100644 probe-c/lsprobe.c create mode 100644 probe-c/prioprobe.c create mode 100644 probe-c/proxytest.c create mode 100644 probe-c/pskill.c create mode 100644 probe-c/sysprobe.c create mode 100644 probe-go/go.mod create mode 100644 probe-go/main.go create mode 100644 tools/build-installer.ps1 create mode 100644 tools/build-payload.ps1 create mode 100644 tools/env.ps1 create mode 100644 tools/ps5send.ps1 create mode 100644 tsd/autostart.go create mode 100644 tsd/autostart_test.go create mode 100644 tsd/config.go create mode 100644 tsd/console_other.go create mode 100644 tsd/console_ps5.go create mode 100644 tsd/debuglog.go create mode 100644 tsd/forward.go create mode 100644 tsd/go.mod create mode 100644 tsd/go.sum create mode 100644 tsd/instance_other.go create mode 100644 tsd/instance_ps5.go create mode 100644 tsd/listener.go create mode 100644 tsd/localforward.go create mode 100644 tsd/localforward_test.go create mode 100644 tsd/logging.go create mode 100644 tsd/main.go create mode 100644 tsd/proxy.go create mode 100644 tsd/status.html create mode 100644 tsd/web.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8c7e6c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.ps1 text eol=crlf +*.png binary +*.patch text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92478ec --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7f9a16 --- /dev/null +++ b/README.md @@ -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::9021 < tailscale-installer.elf + ``` + + ```powershell + # Windows (script from this repository) + .\tools\ps5send.ps1 -File tailscale-installer.elf -PS5Host -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://: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://: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://: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. diff --git a/docs/BUILDING.md b/docs/BUILDING.md new file mode 100644 index 0000000..7336af8 --- /dev/null +++ b/docs/BUILDING.md @@ -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@` 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. diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md new file mode 100644 index 0000000..27449ce --- /dev/null +++ b/docs/TECHNICAL.md @@ -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:`. 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. diff --git a/docs/status-page.png b/docs/status-page.png new file mode 100644 index 0000000000000000000000000000000000000000..79e400c8ca768617008bc6400eb53cd6013b4ae1 GIT binary patch literal 59674 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 literal 0 HcmV?d00001 diff --git a/installer/assets/icon0.png b/installer/assets/icon0.png new file mode 100644 index 0000000000000000000000000000000000000000..3193c55379eb1784f94ec244ab32b4118ef6ca40 GIT binary patch literal 15359 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 +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +#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; +} diff --git a/launcher/goload.c b/launcher/goload.c new file mode 100644 index 0000000..ec312c8 --- /dev/null +++ b/launcher/goload.c @@ -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 +#include +#include +#include +#include + +#include + +#include + +#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 +#include +#include + +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); +} diff --git a/launcher/goload.h b/launcher/goload.h new file mode 100644 index 0000000..825a881 --- /dev/null +++ b/launcher/goload.h @@ -0,0 +1,8 @@ +#pragma once + +#include +#include + +/* 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[]); diff --git a/launcher/main.c b/launcher/main.c new file mode 100644 index 0000000..e2c2402 --- /dev/null +++ b/launcher/main.c @@ -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 +#include +#include + +#include + +#include + +#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 +#include + +/* 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); +} diff --git a/patches/go1.27.1-ps5.patch b/patches/go1.27.1-ps5.patch new file mode 100644 index 0000000..03244a1 --- /dev/null +++ b/patches/go1.27.1-ps5.patch @@ -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 +@@ -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(SB) + MOVQ trap+0(FP), AX // syscall entry + MOVQ a1+8(FP), DI +@@ -35,7 +98,7 @@ ok: + CALL runtime·exitsyscall(SB) + RET + +-TEXT ·Syscall6(SB),NOSPLIT,$0-80 ++TEXT ·sysDirect6(SB),NOSPLIT,$0-80 + CALL runtime·entersyscall(SB) + MOVQ trap+0(FP), AX // syscall entry + MOVQ a1+8(FP), DI +@@ -58,7 +121,7 @@ ok6: + CALL runtime·exitsyscall(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(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) diff --git a/probe-c/dirprobe.c b/probe-c/dirprobe.c new file mode 100644 index 0000000..49947ee --- /dev/null +++ b/probe-c/dirprobe.c @@ -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 +#include +#include +#include +#include +#include + +#include +#include +#include + +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; +} diff --git a/probe-c/fwdtest.c b/probe-c/fwdtest.c new file mode 100644 index 0000000..3732914 --- /dev/null +++ b/probe-c/fwdtest.c @@ -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 +#include +#include + +#include +#include +#include +#include + +#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; +} diff --git a/probe-c/lsprobe.c b/probe-c/lsprobe.c new file mode 100644 index 0000000..cdc88eb --- /dev/null +++ b/probe-c/lsprobe.c @@ -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 +#include +#include +#include + +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; +} diff --git a/probe-c/prioprobe.c b/probe-c/prioprobe.c new file mode 100644 index 0000000..28e27a7 --- /dev/null +++ b/probe-c/prioprobe.c @@ -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 +#include +#include +#include +#include + +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; +} diff --git a/probe-c/proxytest.c b/probe-c/proxytest.c new file mode 100644 index 0000000..bad037a --- /dev/null +++ b/probe-c/proxytest.c @@ -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 +#include +#include + +#include +#include +#include +#include + +#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; +} diff --git a/probe-c/pskill.c b/probe-c/pskill.c new file mode 100644 index 0000000..f3b4aa4 --- /dev/null +++ b/probe-c/pskill.c @@ -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 +#include +#include +#include +#include + +#include +#include +#include + +#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; +} diff --git a/probe-c/sysprobe.c b/probe-c/sysprobe.c new file mode 100644 index 0000000..865e6c7 --- /dev/null +++ b/probe-c/sysprobe.c @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +/* 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; +} diff --git a/probe-go/go.mod b/probe-go/go.mod new file mode 100644 index 0000000..1a2db51 --- /dev/null +++ b/probe-go/go.mod @@ -0,0 +1,3 @@ +module ps5probe + +go 1.26 diff --git a/probe-go/main.go b/probe-go/main.go new file mode 100644 index 0000000..4ade64a --- /dev/null +++ b/probe-go/main.go @@ -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) + } +} diff --git a/tools/build-installer.ps1 b/tools/build-installer.ps1 new file mode 100644 index 0000000..4a9c978 --- /dev/null +++ b/tools/build-installer.ps1 @@ -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 +} diff --git a/tools/build-payload.ps1 b/tools/build-payload.ps1 new file mode 100644 index 0000000..52c534a --- /dev/null +++ b/tools/build-payload.ps1 @@ -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 +} diff --git a/tools/env.ps1 b/tools/env.ps1 new file mode 100644 index 0000000..a2eee3f --- /dev/null +++ b/tools/env.ps1 @@ -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] } + } +} diff --git a/tools/ps5send.ps1 b/tools/ps5send.ps1 new file mode 100644 index 0000000..d19b886 --- /dev/null +++ b/tools/ps5send.ps1 @@ -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() diff --git a/tsd/autostart.go b/tsd/autostart.go new file mode 100644 index 0000000..888047b --- /dev/null +++ b/tsd/autostart.go @@ -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...) +} diff --git a/tsd/autostart_test.go b/tsd/autostart_test.go new file mode 100644 index 0000000..8a65253 --- /dev/null +++ b/tsd/autostart_test.go @@ -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 +} diff --git a/tsd/config.go b/tsd/config.go new file mode 100644 index 0000000..a6b9321 --- /dev/null +++ b/tsd/config.go @@ -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) +} diff --git a/tsd/console_other.go b/tsd/console_other.go new file mode 100644 index 0000000..3eda870 --- /dev/null +++ b/tsd/console_other.go @@ -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...) +} diff --git a/tsd/console_ps5.go b/tsd/console_ps5.go new file mode 100644 index 0000000..7850fba --- /dev/null +++ b/tsd/console_ps5.go @@ -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[:]) +} diff --git a/tsd/debuglog.go b/tsd/debuglog.go new file mode 100644 index 0000000..9a527d2 --- /dev/null +++ b/tsd/debuglog.go @@ -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() + } +} diff --git a/tsd/forward.go b/tsd/forward.go new file mode 100644 index 0000000..428e470 --- /dev/null +++ b/tsd/forward.go @@ -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 +} diff --git a/tsd/go.mod b/tsd/go.mod new file mode 100644 index 0000000..379c8f6 --- /dev/null +++ b/tsd/go.mod @@ -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 +) diff --git a/tsd/go.sum b/tsd/go.sum new file mode 100644 index 0000000..7b7f00e --- /dev/null +++ b/tsd/go.sum @@ -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= diff --git a/tsd/instance_other.go b/tsd/instance_other.go new file mode 100644 index 0000000..336584b --- /dev/null +++ b/tsd/instance_other.go @@ -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 } diff --git a/tsd/instance_ps5.go b/tsd/instance_ps5.go new file mode 100644 index 0000000..6f8b215 --- /dev/null +++ b/tsd/instance_ps5.go @@ -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) +} diff --git a/tsd/listener.go b/tsd/listener.go new file mode 100644 index 0000000..addea11 --- /dev/null +++ b/tsd/listener.go @@ -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 +} diff --git a/tsd/localforward.go b/tsd/localforward.go new file mode 100644 index 0000000..771b8ce --- /dev/null +++ b/tsd/localforward.go @@ -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 + } + } + } +} diff --git a/tsd/localforward_test.go b/tsd/localforward_test.go new file mode 100644 index 0000000..1337b4b --- /dev/null +++ b/tsd/localforward_test.go @@ -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() +} diff --git a/tsd/logging.go b/tsd/logging.go new file mode 100644 index 0000000..33d51e3 --- /dev/null +++ b/tsd/logging.go @@ -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...) +} diff --git a/tsd/main.go b/tsd/main.go new file mode 100644 index 0000000..7f5f49a --- /dev/null +++ b/tsd/main.go @@ -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" +} diff --git a/tsd/proxy.go b/tsd/proxy.go new file mode 100644 index 0000000..600c5a9 --- /dev/null +++ b/tsd/proxy.go @@ -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) +} diff --git a/tsd/status.html b/tsd/status.html new file mode 100644 index 0000000..f8dbf09 --- /dev/null +++ b/tsd/status.html @@ -0,0 +1,253 @@ + + + + + +Tailscale on PS5 + + + +

+

Tailscale on PS5

+

+ +
+
Loading…
+
+ + +
+ +
+ + + + + +
+

Actions

+
+ + + + +
+ +
+ +
+
+ Recent log +

+    
+
+
+ + + + diff --git a/tsd/web.go b/tsd/web.go new file mode 100644 index 0000000..40471da --- /dev/null +++ b/tsd/web.go @@ -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 +}