Compare commits
49 Commits
v1.0.0-rc.
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b94eadc68 | ||
|
|
3a496af9f1 | ||
|
|
7f4771068f | ||
|
|
3463c223e8 | ||
|
|
6c291d9b0e | ||
|
|
e29789114c | ||
|
|
4c9aaeb769 | ||
|
|
37e59e6928 | ||
|
|
e02c197585 | ||
|
|
d9109cbf5a | ||
|
|
6bc8b1a567 | ||
|
|
244640d0c1 | ||
|
|
e3e89a2ecc | ||
|
|
90ae815b1d | ||
|
|
1fc15e82f1 | ||
|
|
3a83f65f27 | ||
|
|
37b09255d5 | ||
|
|
b92fab83c8 | ||
|
|
367fdfad54 | ||
|
|
ee460686d6 | ||
|
|
9b7c0fb7f3 | ||
|
|
0f7bb1a371 | ||
|
|
de30e2fcc9 | ||
|
|
a0fa5a94b6 | ||
|
|
3655b172f3 | ||
|
|
4748d7cb29 | ||
|
|
a20861fa8e | ||
|
|
62d0c7fa24 | ||
|
|
0b9ad3de05 | ||
|
|
3391d5803e | ||
|
|
a3cb1b5ae0 | ||
|
|
fb24ad58b4 | ||
|
|
ae5005baa3 | ||
|
|
f79868ff6c | ||
|
|
60211271b2 | ||
|
|
2cc06e0528 | ||
|
|
6cbcfece8e | ||
|
|
7646c27c5a | ||
|
|
7624502ec4 | ||
|
|
bf1233def4 | ||
|
|
1a3ba9efca | ||
|
|
897c4869de | ||
|
|
f0a4efb242 | ||
|
|
342c3ea565 | ||
|
|
522c6c17e4 | ||
|
|
d8cd8ae36a | ||
|
|
62d5248b0c | ||
|
|
ff597672db | ||
|
|
aa39cefcf1 |
269
CHANGELOG.md
269
CHANGELOG.md
@@ -1,5 +1,241 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.0.0
|
||||||
|
This is the first official stable release of Wings! Please be aware that while this specific version changelog is very short,
|
||||||
|
it technically includes all of the previous beta and alpha releases within it. For the sake of version history and following
|
||||||
|
along though, I've only included the differences between this version and the previous RC build.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixes file parser not properly appending newline character to modified lines.
|
||||||
|
* Fixes server disk usage not being properly reported back to the API and websocket.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Changes diagnostics endpoint URL to be `ptero.co` rather than `hastebin.com`.
|
||||||
|
* Diagnostics report now includes system time for easier debugging of logs and container issues.
|
||||||
|
|
||||||
|
## v1.0.0-rc.7
|
||||||
|
### Fixed
|
||||||
|
* **[Security]** Prevents the `allowed_mounts` configuration value from being set by a remote API call.
|
||||||
|
* Fixes an unexpected error when attempting to make a copy of an archive file.
|
||||||
|
* Fixes certain expected filesystems errors being returned to the API as a 500 error rather than a 4XX series error.
|
||||||
|
* Fixes a panic crash when there is no text on a line and the parser is attempting to determine if the line is a comment.
|
||||||
|
* Fixes multiple filesystem operations to correctly check, increment, and decrement disk usage for a server.
|
||||||
|
* Fixes a negative disk space usage issue when deleting a file due to a bad int swap.
|
||||||
|
* Errors from a websocket connection being closed will no longer spam the console.
|
||||||
|
* Fixes an extraneous `.` in the install script for servers causing errors in some scenarios.
|
||||||
|
* Fixes unexpected error spam due to a change in how `os.ErrNotExist` is returned from some functions.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Stacktrace is no longer emitted when warning that an image exists locally.
|
||||||
|
* Configuration parser now attempts to create the directory structure leading to a configuration file if it is missing.
|
||||||
|
* If a file name is too long for the system a nice error is returned to the caller.
|
||||||
|
* Enables client version negotiation for Docker to support more versions.
|
||||||
|
* Disk space errors are no longer logged into the wings logs.
|
||||||
|
* Servers can no longer be reinstalled while another power action is currently running. This avoids data collisions and wings running into issues with the container state.
|
||||||
|
* Wings now uses `1024` rather than `1000` bytes when calculating the disk space usage for a server to match how the Panel reports usage.
|
||||||
|
* JWT errors in the websocket are now sent back to the connection as a specific event type allowing them to be handled even if the timer fails to execute or executes but is not being listened for.
|
||||||
|
* A server struct is no longer embedded in the virtual filesystem allowing for easier testing and modularization of the codebase.
|
||||||
|
* Server websockets are now closed when a server is deleted, disconnecting any currently connected clients.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* **[Security]** Removes the `SafeJoin` function which could wrongly assume the state of a file and allow a user to escape the root if the developer implementing the call calls `Stat` rather than `Lstat`.
|
||||||
|
|
||||||
|
## v1.0.0-rc.6
|
||||||
|
### Fixed
|
||||||
|
* Fixes race condition when checking if the running state of a server has changed.
|
||||||
|
* Fixes files mistakenly unpacking themselves to the root directory rather than the directory they are told to unpack to.
|
||||||
|
* Fixes console output not being sent to the websocket in the same order that it was received.
|
||||||
|
* Fixes a file busy error causing a 500 error response when unpacking an archive rather than a 400 error with a message indicating what is wrong to the user.
|
||||||
|
* Fixes docker image not properly updating when a server is started.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Replaces fragile event bus logic with a more robust and easier to understand system. This fixes all remaining console and stats output issues that have been reported.
|
||||||
|
* Cleans up API response error messaging to avoid empty errors being logged which cannot be acted upon.
|
||||||
|
* Adds support for retrying a file write a few times with a back-off if the file is busy when the write occurs.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Docker image pull data is shown in the console when an admin is connected to the websocket.
|
||||||
|
* Adds console throttling to stop a server if too much data is being piped out of the console. This logic mimics the logic present in the old Nodejs daemon, but with a 2x line count limit (1000 -> 2000) per period.
|
||||||
|
|
||||||
|
## v1.0.0-rc.5
|
||||||
|
### Fixed
|
||||||
|
* Fixes long standing bug with console output not properly sending back to the client with server stats in certain edge case scenarios. This was "fixed" in `rc.4` but ended up breaking many servers in significantly more painful manners, but did allow us to better track down the issue.
|
||||||
|
* Fixes build flags during release process to correctly strip unnessary code allowing the final binary size to be reduced back down to `~5MB`
|
||||||
|
* Fixes Wings returning the last `16384` lines of the log file when connecting to the websocket.
|
||||||
|
* Fixes pre-boot actions always running for a server start event even if the server is already running.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Adds support for configuring the amount of time that can elapse between server disk size checking before the value is considered stale.
|
||||||
|
|
||||||
|
## v1.0.0-rc.4
|
||||||
|
### Fixed
|
||||||
|
* Fixes server files being inaccessible if the root data path is a symlink to another location on the machine.
|
||||||
|
* Fixes some console output being written to the logs accidentally truncating other lines due to special ANSI sequences being output.
|
||||||
|
* Fixes `server.properties` files getting mangled by the automatic configuration editor when booting a server.
|
||||||
|
* Fixes a missed stream close when stopping resource polling that would lead to memory leaks.
|
||||||
|
* Fixes port bindings being incorrectly re-assigned when using `127.0.0.1` with Docker. These are now properly re-mapped to the `pterodactyl0` interface so that networking operates as expected for the server.
|
||||||
|
* Fixes handling of values within arrays in `yaml` and `json` configuration files.
|
||||||
|
* Fixes a 304 error being returned rather than a 400 error when a file upload is invalid.
|
||||||
|
* Fixes deadlocks and response delays when processing server console and stat events. This was causing server consoles to become completely unresponsive after random periods of time and was only resolvable via a Wings restart.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Allows a stale value to be present when starting a server if the server is allowed an unlimited amount of disk space.
|
||||||
|
* Removed all remaining traces of the `zap` logging library from the codebase.
|
||||||
|
* Servers no longer auto-restart as if they crashed when a stop command is manually sent via the server console to the instance.
|
||||||
|
* Changes the CORS checks to allow `*` as a remote origin.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added an auto-generated logrotate file which is written to the normal logrotate directory when wings is first started.
|
||||||
|
* Added additional debug logging within the internals of the resource polling for servers to better trace unexpected behavior.
|
||||||
|
* Adds additional logic check to avoid even trying to stop a suspended server if it is already stopped.
|
||||||
|
|
||||||
|
## v1.0.0-rc.3
|
||||||
|
### Fixed
|
||||||
|
* Errors during the backup process are now correctly reported to the Panel and logged into the output correctly.
|
||||||
|
* Empty directories are no longer pushed into the backup file list (which was causing errors to occur previously).
|
||||||
|
* Covered an edge case to prevent errors if a file gets deleted while a backup is in progress.
|
||||||
|
* Fixed a bug causing Wings to panic and crash if an invalid environment variable value was passed through. These invalid values are now logged to the output to better detect and an empty string is returned in their place.
|
||||||
|
* Fixed startup variables and other server information not properly being updated when a server is restarted.
|
||||||
|
* Suspension state of a server is now properly returned by the API.
|
||||||
|
* Fixes an error being thrown if a backup does not exist on the local machine. A 404 is now properly returned and handled by the Panel when this occurs.
|
||||||
|
* Fixes an error when attempting to move or rename a folder due to the target being created accidentally before the rename occurred.
|
||||||
|
* Fixes install scripts running even when the checkbox to not run them on installation was selected in the Panel.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Disk space checking modified to not block in as many parts of the codebase and allow returning a stale cache value where that is appropriate. Thanks @cyberkitsune!
|
||||||
|
* SFTP package code is now merged into the codebase to make keeping up with all of the code simpler, and reduce the complexity of the SFTP server.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added the ability for a node to be configured to skip file permissions checking when starting a server.
|
||||||
|
* Added console output message to indicate that server disk space checking is occurring rather than silently sitting there.
|
||||||
|
|
||||||
|
## v1.0.0-rc.2
|
||||||
|
### Fixed
|
||||||
|
* Fixes significant performance degradation due to excessive `syscall` actions when determining directory sizes on large servers. This was previously causing CPU & I/O lockups on servers and should be significantly more performant and less impactful on the system now.
|
||||||
|
* Fixes panic crash when booting wings with no log directory created.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Changed default interval for disk space calculation from every 60 seconds to every 2.5 minutes.
|
||||||
|
|
||||||
|
## v1.0.0-rc.1
|
||||||
|
### Fixed
|
||||||
|
* Servers are no longer incorrectly marked as stopping when they are, in fact, offline.
|
||||||
|
* Release build version is now correctly output when starting wings.
|
||||||
|
* Termination signals can now always be sent to a server instance even if the instance is currently starting/stopping.
|
||||||
|
* Removed the file chown on wings boot to avoid slowing down the boot process unnecessarily when working with hundreds of servers on a node.
|
||||||
|
* Fixed a multitude of race conditions throughout the code that cropped up during testing and made power handling even more robust for server instances.
|
||||||
|
* In general multiple fragile areas of the codebase have been improved and are more likely to return useful errors should they break.
|
||||||
|
* Addressed serious CPU usage issues when generating backups, and additionally decreased the amount of time they take to generate.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Internal server now uses more secure and recommended TLS settings.
|
||||||
|
* Environment handling is now completely separate from the server package itself, allowing the environments to no longer be tightly coupled to the server.
|
||||||
|
* `/tmp` directory mounted into containers can now be programmatically managed and uses better defaults to avoid people even needing to edit it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Wings logs are now properly persisted to the disk.
|
||||||
|
* Adds the ability for an egg to use ANSI-stripped matching when determining if a server is done booting.
|
||||||
|
|
||||||
|
## v1.0.0-beta.9
|
||||||
|
### Fixed
|
||||||
|
* Fixes server resource usage stats not being returned correctly by the API endpoint.
|
||||||
|
* Fixes an exception thrown when attempting to write server installation logs.
|
||||||
|
* Fixes error handling to provide a more accurate stack-trace in more scenarios where one is missing initially.
|
||||||
|
* Fixes a memory leak and zombie event listeners when disconnecting from a server's websocket.
|
||||||
|
* Fixes a race condition when wings is attempting to register/de-register event subscribers.
|
||||||
|
* Server data directories now correctly have their permissions set recursively when booting Wings.
|
||||||
|
* Fixes a race condition when a server's console stream was not fully closed before the next power action was started.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Server power handling is now handled in a synchronous manner. This avoids endless bugs and race conditions that would crop up if someone triggered two restart processes back to back. The new logic prevents performing any additional power actions until the currently executing action is completed.
|
||||||
|
* Server disk usage is now correctly calculated when restarting the daemon as long as the server data directory exists.
|
||||||
|
* Multiple code pathways within the boot process were cleaned up and modified to be less IO intensive and overall easier to reason about as a developer.
|
||||||
|
* Additional timezone data is now mounted into containers to better improve the ability for instances running to be using the correct timezone.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Adds basic internal file upload endpoints (these are currently not exposed anywhere in the Panel).
|
||||||
|
* Added additional process events for installation start and completion.
|
||||||
|
* Additional CORS allowed origins for the websocket can now be defined in the configuration file.
|
||||||
|
* Adds the ability to authenticate with a docker registry when pulling images.
|
||||||
|
|
||||||
|
## v1.0.0-beta.8
|
||||||
|
### Fixed
|
||||||
|
* Server state is synced with the Panel before performing a reinstall to ensure that the latest information is used.
|
||||||
|
* Wings no longer crashes when a non-string environment variable value is passed through.
|
||||||
|
* SFTP server authentication no longer attempts to contact the Panel to validate credentials if the format is known to already be incorrect.
|
||||||
|
* Some previously missing error stacks are now properly returned when encountered.
|
||||||
|
* Renaming a file no longer triggers an error if the base path does not exist.
|
||||||
|
* Disk space is now properly calculated for a server even if it is assigned unlimited space.
|
||||||
|
* **[Security]** Prevent symlink files from unintentionally chmodding their target file when a server is booted when the target file resides outside the server data directory.
|
||||||
|
* **[Security]** Cleans up multiple code pathways that may unintentionally allow a malicious user to impact files outside their home directory.
|
||||||
|
* Fixes numerous race conditions during a server's boot process that may lead to unintended data states.
|
||||||
|
* Fixes an error thrown when attempting to delete a file that points to a symlink outside the server data directory.
|
||||||
|
* Deleting a symlink will no longer attempt to remove the source file, only the symlink itself.
|
||||||
|
* Websocket no longer blocks when handling a long running process for a user.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Install log for servers is now more detailed and more useful for debugging what might be going wrong.
|
||||||
|
* Certain file management API endpoints now support passing through multiple paths at once to make mass actions easier to perform.
|
||||||
|
* Re-worked file walking implementation to not cause a server crash when working with very large directories, and avoid race conditions when recursively walking directories.
|
||||||
|
* Server configuration structure re-worked to be more manageable in the code base and avoid additional race conditions and complexity while making on-the-fly changes to it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Support for configuring additional file mount points in a container via the Panel.
|
||||||
|
* Support for automated SSL certificate generation when booting the Daemon.
|
||||||
|
* Added wings diagnostics command.
|
||||||
|
* New API endpoints to compress and decompress files on a server.
|
||||||
|
|
||||||
|
## v1.0.0-beta.7
|
||||||
|
### Fixed
|
||||||
|
* Stacktraces are now displayed once in the error output, rather than twice when certain errors are encountered.
|
||||||
|
* Additional errors that did not previously output a stack trace have been fixed to output.
|
||||||
|
* Fixes a bug where server space available would be triggered when creating a new server from a remote configuration before that file location existed on the disk, leading to an error.
|
||||||
|
* Fixes context timeouts while pulling server docker images. Time was upped from 10 seconds to 15 minutes.
|
||||||
|
* Configuration file replacement values are now properly de-escaped when writing to the disk. `\/no\/more\/slashes`
|
||||||
|
* `.properties` files are now saved correctly to the disk with newlines, rather than shoved on a single line.
|
||||||
|
* `./wings configure` command can now properly save the configuration to the disk.
|
||||||
|
* Custom SSL locations are no longer obliterated when making changes to the Node's configuration via the Panel.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Exclusive lock is now acquired when performing a server installation to avoid two install processes being triggered at the same time. This also allows an install to be properly canceled if the server is deleted before it is completed.
|
||||||
|
|
||||||
|
## v1.0.0-beta.6
|
||||||
|
### Fixed
|
||||||
|
* Server status is no longer sent to all connected websocket clients when a new client connects to the socket.
|
||||||
|
* Server disk usage is sent back over the socket when connecting.
|
||||||
|
* Default configuration for the SFTP server is now properly returned to `on` rather than `off`
|
||||||
|
* Server boot is no longer blocked if there is an error fetching the Docker image as long as that image exists on the host.
|
||||||
|
* The websocket no longer gets locked up when Wings attempts to send an error to the client.
|
||||||
|
* Fixed a crash loop when an error is thrown during pre-boot phase of server startup.
|
||||||
|
* Errors with `BindJSON` in API endpoints are now properly handled and returned.
|
||||||
|
* Fixed warning about Gin running in non-release mode, even when the binary is running in release mode.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Switched logging libraries to output data in a clearer format more suited to the CLI where this application runs.
|
||||||
|
* Cleaner debug messaging in debug mode from the router.
|
||||||
|
|
||||||
|
## v1.0.0-beta.5
|
||||||
|
### Fixed
|
||||||
|
* Default config location settled on `/etc/pterodactyl/config.yml`; wings will now check all of the previous locations for the configuration and move it automatically to the new location.
|
||||||
|
* Deleting a server no longer fails the process if the container cannot be found.
|
||||||
|
* Fixes permissions checking for subusers connecting to the SFTP instance.
|
||||||
|
* S3 backups now properly send back hash data to the panel.
|
||||||
|
* Server installation containers are now always deleted, even if the installer process fails for the instance.
|
||||||
|
* Files and folders with special characters and spaces in them no longer return a 404 error.
|
||||||
|
* Servers using eggs with bad configurations will no longer cause the daemon to fail booting; these bad configurations are simply skipped over and a warning is emitted into the logs.
|
||||||
|
* Environment variables passed to containers no longer contain improper quotes around them.
|
||||||
|
* Matching on array indexes in configurations now works correctly; `foo[0]` is quietly transformed into the proper `foo.0` syntax.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* New banner error message when the daemon is unable to locate the configuration file. This should better clarify what the user needs to do in order to resolve the issue.
|
||||||
|
* Adds ability to configure the default networking driver used by docker.
|
||||||
|
|
||||||
|
## v1.0.0-beta.4
|
||||||
|
### Fixed
|
||||||
|
* Fixes unexpected nil-pointer panic when attempting to start some newly created servers, or any server that was missing a container on the system.
|
||||||
|
* Fixes memory usage of process being reported differently than the `docker stats` output leading to some confusion. These numbers should be more correct now.
|
||||||
|
* Fixes possible nil-pointer panic when detecting a deleted container as being crashed.
|
||||||
|
|
||||||
## v1.0.0-beta.3
|
## v1.0.0-beta.3
|
||||||
### Fixed
|
### Fixed
|
||||||
* Daemon will no longer crash if someone requests a websocket for a deleted server.
|
* Daemon will no longer crash if someone requests a websocket for a deleted server.
|
||||||
@@ -11,6 +247,39 @@
|
|||||||
### Changed
|
### Changed
|
||||||
* Memory overhead for containers is now 5/10/15% higher than the passed limit to account for JVM heap and prevent crashing.
|
* Memory overhead for containers is now 5/10/15% higher than the passed limit to account for JVM heap and prevent crashing.
|
||||||
|
|
||||||
|
## v1.0.0-beta.2
|
||||||
|
### Changed
|
||||||
|
* Backup functionality made significantly more modular to ease adding additional methods in the future.
|
||||||
|
* Websocket permissions changed to use same name as in panel.
|
||||||
|
* Container memory hard-limits are now adjusted up by 15% (< 2G memory), 10% (< 4G memory), or 5% to avoid unexpected OOM crashes for memory heavy games.
|
||||||
|
* Wings executable is now 80% smaller thanks to better compilation arguments.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Adds support for ignoring files and directories when generating a new backup.
|
||||||
|
* Added internal directory walker with callback-continue support.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed linux distro matching when booting the daemon.
|
||||||
|
* Fixes DNS to be configurable for docker containers that are made for servers.
|
||||||
|
* Fixes incorrect file truncation when making modifications to a server's configuration files.
|
||||||
|
|
||||||
|
## v1.0.0-beta.1
|
||||||
|
### Added
|
||||||
|
* Added support for passing specific threads to the docker environment when running a server.
|
||||||
|
* Added support for reinstalling an existing server.
|
||||||
|
* Added support for restarting a server.
|
||||||
|
* Adds support for transferring servers between daemon instances.
|
||||||
|
* Added auto-deploy command to fetch configuration from Panel automatically.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Server file and backup downloads now hit a direct endpoint with a one-time JWT to avoid proxying large downloads through the panel.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixes a go routine causing a memory and CPU leak.
|
||||||
|
* Fixed improper chown of server directories on boot.
|
||||||
|
* Miscellaneous fixes to CPU usage, abandoned go-routines, and race conditions.
|
||||||
|
* Fixes support for Alpine based systems.
|
||||||
|
|
||||||
## v1.0.0-alpha.2
|
## v1.0.0-alpha.2
|
||||||
### Added
|
### Added
|
||||||
* Ability to run an installation process for a server and notify the panel when completed.
|
* Ability to run an installation process for a server and notify the panel when completed.
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ I would like to extend my sincere thanks to the following sponsors for helping f
|
|||||||
|
|
||||||
| Company | About |
|
| Company | About |
|
||||||
| ------- | ----- |
|
| ------- | ----- |
|
||||||
| [**BloomVPS**](https://bloomvps.com) | BloomVPS offers dedicated core VPS and Minecraft hosting with Ryzen 9 processors. With owned-hardware, we offer truly unbeatable prices on high-performance hosting. |
|
| [**Bloom.host**](https://bloom.host) | Bloom.host offers dedicated core VPS and Minecraft hosting with Ryzen 9 processors. With owned-hardware, we offer truly unbeatable prices on high-performance hosting. |
|
||||||
| [**VersatileNode**](https://versatilenode.com/) | Looking to host a minecraft server, vps, or a website? VersatileNode is one of the most affordable hosting providers to provide quality yet cheap services with incredible support. |
|
| [**VersatileNode**](https://versatilenode.com/) | Looking to host a minecraft server, vps, or a website? VersatileNode is one of the most affordable hosting providers to provide quality yet cheap services with incredible support. |
|
||||||
| [**MineStrator**](https://minestrator.com/) | Looking for a French highend hosting company for you minecraft server? More than 14,000 members on our discord, trust us. |
|
| [**MineStrator**](https://minestrator.com/) | Looking for a French highend hosting company for you minecraft server? More than 14,000 members on our discord, trust us. |
|
||||||
| [**DedicatedMC**](https://dedicatedmc.io/) | DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance and giving you the best performance money can buy. |
|
| [**DedicatedMC**](https://dedicatedmc.io/) | DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance and giving you the best performance money can buy. |
|
||||||
| [**Skynode**](https://www.skynode.pro/) | Skynode provides blazing fast game servers along with a top-notch user experience. Whatever our clients are looking for, we're able to provide it! |
|
| [**Skynode**](https://www.skynode.pro/) | Skynode provides blazing fast game servers along with a top-notch user experience. Whatever our clients are looking for, we're able to provide it! |
|
||||||
| [**XCORE-SERVER.de**](https://xcore-server.de/) | XCORE-SERVER.de offers High-End Servers for hosting and gaming since 2012. Fast, excellent and well-known for eSports Gaming. |
|
| [**XCORE-SERVER.de**](https://xcore-server.de/) | XCORE-SERVER.de offers High-End Servers for hosting and gaming since 2012. Fast, excellent and well-known for eSports Gaming. |
|
||||||
|
| [**RoyaleHosting**](https://royalehosting.net/) | Build your dreams and deploy them with RoyaleHosting’s reliable servers and network. Easy to use, provisioned in a couple of minutes. |
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
* [Panel Documentation](https://pterodactyl.io/panel/1.0/getting_started.html)
|
* [Panel Documentation](https://pterodactyl.io/panel/1.0/getting_started.html)
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ func (r *PanelRequest) ValidateSftpCredentials(request SftpAuthRequest) (*SftpAu
|
|||||||
if r.HttpResponseCode() >= 400 && r.HttpResponseCode() < 500 {
|
if r.HttpResponseCode() >= 400 && r.HttpResponseCode() < 500 {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"subsystem": "sftp",
|
"subsystem": "sftp",
|
||||||
"username": request.User,
|
"username": request.User,
|
||||||
"ip": request.IP,
|
"ip": request.IP,
|
||||||
}).Warn(r.Error().String())
|
}).Warn(r.Error().String())
|
||||||
|
|
||||||
return nil, new(sftpInvalidCredentialsError)
|
return nil, new(sftpInvalidCredentialsError)
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/AlecAivazis/survey/v2/terminal"
|
"github.com/AlecAivazis/survey/v2/terminal"
|
||||||
@@ -24,7 +26,8 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultHastebinUrl = "https://hastebin.com"
|
const DefaultHastebinUrl = "https://ptero.co"
|
||||||
|
const DefaultLogLines = 200
|
||||||
|
|
||||||
var (
|
var (
|
||||||
diagnosticsArgs struct {
|
diagnosticsArgs struct {
|
||||||
@@ -32,6 +35,7 @@ var (
|
|||||||
IncludeLogs bool
|
IncludeLogs bool
|
||||||
ReviewBeforeUpload bool
|
ReviewBeforeUpload bool
|
||||||
HastebinURL string
|
HastebinURL string
|
||||||
|
LogLines int
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ var diagnosticsCmd = &cobra.Command{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
diagnosticsCmd.PersistentFlags().StringVar(&diagnosticsArgs.HastebinURL, "hastebin-url", DefaultHastebinUrl, "The url of the hastebin instance to use.")
|
diagnosticsCmd.PersistentFlags().StringVar(&diagnosticsArgs.HastebinURL, "hastebin-url", DefaultHastebinUrl, "The url of the hastebin instance to use.")
|
||||||
|
diagnosticsCmd.PersistentFlags().IntVar(&diagnosticsArgs.LogLines, "log-lines", DefaultLogLines, "The number of log lines to include in the report")
|
||||||
}
|
}
|
||||||
|
|
||||||
// diagnosticsCmdRun collects diagnostics about wings, it's configuration and the node.
|
// diagnosticsCmdRun collects diagnostics about wings, it's configuration and the node.
|
||||||
@@ -96,24 +101,27 @@ func diagnosticsCmdRun(cmd *cobra.Command, args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
printHeader(output, "Wings Configuration")
|
printHeader(output, "Wings Configuration")
|
||||||
if cfg, err := config.ReadConfiguration(config.DefaultLocation); cfg != nil {
|
cfg, err := config.ReadConfiguration(config.DefaultLocation)
|
||||||
fmt.Fprintln(output, "Panel Location:", redact(cfg.PanelLocation))
|
if cfg != nil {
|
||||||
fmt.Fprintln(output, "Api Host:", redact(cfg.Api.Host))
|
fmt.Fprintln(output, " Panel Location:", redact(cfg.PanelLocation))
|
||||||
fmt.Fprintln(output, "Api Port:", cfg.Api.Port)
|
fmt.Fprintln(output, "")
|
||||||
fmt.Fprintln(output, "Api Ssl Enabled:", cfg.Api.Ssl.Enabled)
|
fmt.Fprintln(output, "Internal Webserver:", redact(cfg.Api.Host) + ":", cfg.Api.Port)
|
||||||
fmt.Fprintln(output, "Api Ssl Certificate:", redact(cfg.Api.Ssl.CertificateFile))
|
fmt.Fprintln(output, " SSL Enabled:", cfg.Api.Ssl.Enabled)
|
||||||
fmt.Fprintln(output, "Api Ssl Key:", redact(cfg.Api.Ssl.KeyFile))
|
fmt.Fprintln(output, " SSL Certificate:", redact(cfg.Api.Ssl.CertificateFile))
|
||||||
fmt.Fprintln(output, "Sftp Address:", redact(cfg.System.Sftp.Address))
|
fmt.Fprintln(output, " SSL Key:", redact(cfg.Api.Ssl.KeyFile))
|
||||||
fmt.Fprintln(output, "Sftp Port:", cfg.System.Sftp.Port)
|
fmt.Fprintln(output, "")
|
||||||
fmt.Fprintln(output, "Sftp Read Only:", cfg.System.Sftp.ReadOnly)
|
fmt.Fprintln(output, " SFTP Server:", redact(cfg.System.Sftp.Address), ":", cfg.System.Sftp.Port)
|
||||||
fmt.Fprintln(output, "Sftp Diskchecking Disabled:", cfg.System.Sftp.DisableDiskChecking)
|
fmt.Fprintln(output, " SFTP Read-Only:", cfg.System.Sftp.ReadOnly)
|
||||||
fmt.Fprintln(output, "System Root Directory:", cfg.System.RootDirectory)
|
fmt.Fprintln(output, "")
|
||||||
fmt.Fprintln(output, "System Logs Directory:", cfg.System.LogDirectory)
|
fmt.Fprintln(output, " Root Directory:", cfg.System.RootDirectory)
|
||||||
fmt.Fprintln(output, "System Data Directory:", cfg.System.Data)
|
fmt.Fprintln(output, " Logs Directory:", cfg.System.LogDirectory)
|
||||||
fmt.Fprintln(output, "System Archive Directory:", cfg.System.ArchiveDirectory)
|
fmt.Fprintln(output, " Data Directory:", cfg.System.Data)
|
||||||
fmt.Fprintln(output, "System Backup Directory:", cfg.System.BackupDirectory)
|
fmt.Fprintln(output, " Archive Directory:", cfg.System.ArchiveDirectory)
|
||||||
fmt.Fprintln(output, "System Username:", cfg.System.Username)
|
fmt.Fprintln(output, " Backup Directory:", cfg.System.BackupDirectory)
|
||||||
fmt.Fprintln(output, "Debug Enabled:", cfg.Debug)
|
fmt.Fprintln(output, "")
|
||||||
|
fmt.Fprintln(output, " Username:", cfg.System.Username)
|
||||||
|
fmt.Fprintln(output, " Server Time:", time.Now().Format(time.RFC1123Z))
|
||||||
|
fmt.Fprintln(output, " Debug Mode:", cfg.Debug)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Failed to load configuration.", err)
|
fmt.Println("Failed to load configuration.", err)
|
||||||
}
|
}
|
||||||
@@ -149,7 +157,15 @@ func diagnosticsCmdRun(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
printHeader(output, "Latest Wings Logs")
|
printHeader(output, "Latest Wings Logs")
|
||||||
if diagnosticsArgs.IncludeLogs {
|
if diagnosticsArgs.IncludeLogs {
|
||||||
fmt.Fprintln(output, "No logs found. Probably because nobody implemented logging to files yet :(")
|
p := "/var/log/pterodactyl/wings.log"
|
||||||
|
if cfg != nil {
|
||||||
|
p = path.Join(cfg.System.LogDirectory, "wings.log")
|
||||||
|
}
|
||||||
|
if c, err := exec.Command("tail", "-n", strconv.Itoa(diagnosticsArgs.LogLines), p).Output(); err != nil {
|
||||||
|
fmt.Fprintln(output, "No logs found or an error occurred.")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(output, "%s\n", string(c))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(output, "Logs redacted.")
|
fmt.Fprintln(output, "Logs redacted.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ type Configuration struct {
|
|||||||
|
|
||||||
// AllowedMounts is a list of allowed host-system mount points.
|
// AllowedMounts is a list of allowed host-system mount points.
|
||||||
// This is required to have the "Server Mounts" feature work properly.
|
// This is required to have the "Server Mounts" feature work properly.
|
||||||
AllowedMounts []string `json:"allowed_mounts" yaml:"allowed_mounts"`
|
AllowedMounts []string `json:"-" yaml:"allowed_mounts"`
|
||||||
|
|
||||||
// AllowedOrigins is a list of allowed request origins.
|
// AllowedOrigins is a list of allowed request origins.
|
||||||
// The Panel URL is automatically allowed, this is only needed for adding
|
// The Panel URL is automatically allowed, this is only needed for adding
|
||||||
@@ -73,9 +73,6 @@ type Configuration struct {
|
|||||||
|
|
||||||
// Defines the configuration of the internal SFTP server.
|
// Defines the configuration of the internal SFTP server.
|
||||||
type SftpConfiguration struct {
|
type SftpConfiguration struct {
|
||||||
// If set to true disk checking will not be performed. This will prevent the SFTP
|
|
||||||
// server from checking the total size of a directory when uploading files.
|
|
||||||
DisableDiskChecking bool `default:"false" yaml:"disable_disk_checking"`
|
|
||||||
// The bind address of the SFTP server.
|
// The bind address of the SFTP server.
|
||||||
Address string `default:"0.0.0.0" json:"bind_address" yaml:"bind_address"`
|
Address string `default:"0.0.0.0" json:"bind_address" yaml:"bind_address"`
|
||||||
// The bind port of the SFTP server.
|
// The bind port of the SFTP server.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
// Configures the required network for the docker environment.
|
// Configures the required network for the docker environment.
|
||||||
func ConfigureDocker(c *config.DockerConfiguration) error {
|
func ConfigureDocker(c *config.DockerConfiguration) error {
|
||||||
// Ensure the required docker network exists on the system.
|
// Ensure the required docker network exists on the system.
|
||||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ func (e *Environment) ensureImageExists(image string) error {
|
|||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"image": image,
|
"image": image,
|
||||||
"container_id": e.Id,
|
"container_id": e.Id,
|
||||||
"error": errors.New(err.Error()),
|
"err": err.Error(),
|
||||||
}).Warn("unable to pull requested image from remote source, however the image exists locally")
|
}).Warn("unable to pull requested image from remote source, however the image exists locally")
|
||||||
|
|
||||||
// Okay, we found a matching container image, in that case just go ahead and return
|
// Okay, we found a matching container image, in that case just go ahead and return
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ type Environment struct {
|
|||||||
// reference the container from here on out. This should be unique per-server (we use the UUID
|
// reference the container from here on out. This should be unique per-server (we use the UUID
|
||||||
// by default). The container does not need to exist at this point.
|
// by default). The container does not need to exist at this point.
|
||||||
func New(id string, m *Metadata, c *environment.Configuration) (*Environment, error) {
|
func New(id string, m *Metadata, c *environment.Configuration) (*Environment, error) {
|
||||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type dockerLogLine struct {
|
|||||||
Log string `json:"log"`
|
Log string `json:"log"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ErrNotAttached = errors.New("not attached to instance")
|
||||||
|
|
||||||
func (e *Environment) setStream(s *types.HijackedResponse) {
|
func (e *Environment) setStream(s *types.HijackedResponse) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
e.stream = s
|
e.stream = s
|
||||||
@@ -24,13 +26,13 @@ func (e *Environment) setStream(s *types.HijackedResponse) {
|
|||||||
// Sends the specified command to the stdin of the running container instance. There is no
|
// Sends the specified command to the stdin of the running container instance. There is no
|
||||||
// confirmation that this data is sent successfully, only that it gets pushed into the stdin.
|
// confirmation that this data is sent successfully, only that it gets pushed into the stdin.
|
||||||
func (e *Environment) SendCommand(c string) error {
|
func (e *Environment) SendCommand(c string) error {
|
||||||
|
if !e.IsAttached() {
|
||||||
|
return ErrNotAttached
|
||||||
|
}
|
||||||
|
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
defer e.mu.RUnlock()
|
defer e.mu.RUnlock()
|
||||||
|
|
||||||
if !e.IsAttached() {
|
|
||||||
return errors.New("attempting to send command to non-attached instance")
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.meta.Stop != nil {
|
if e.meta.Stop != nil {
|
||||||
// If the command being processed is the same as the process stop command then we want to mark
|
// If the command being processed is the same as the process stop command then we want to mark
|
||||||
// the server as entering the stopping state otherwise the process will stop and Wings will think
|
// the server as entering the stopping state otherwise the process will stop and Wings will think
|
||||||
|
|||||||
@@ -46,4 +46,4 @@ func (cp *CallbackPool) index(v reflect.Value) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -24,6 +24,7 @@ require (
|
|||||||
github.com/docker/go-metrics v0.0.1 // indirect
|
github.com/docker/go-metrics v0.0.1 // indirect
|
||||||
github.com/docker/go-units v0.4.0 // indirect
|
github.com/docker/go-units v0.4.0 // indirect
|
||||||
github.com/fatih/color v1.9.0
|
github.com/fatih/color v1.9.0
|
||||||
|
github.com/franela/goblin v0.0.0-20200825194134-80c0062ed6cd
|
||||||
github.com/frankban/quicktest v1.10.2 // indirect
|
github.com/frankban/quicktest v1.10.2 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.4.9 // indirect
|
github.com/fsnotify/fsnotify v1.4.9 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.1.1
|
github.com/gabriel-vasile/mimetype v1.1.1
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -123,6 +123,8 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
|
|||||||
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
|
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
|
||||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||||
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
|
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
|
||||||
|
github.com/franela/goblin v0.0.0-20200825194134-80c0062ed6cd h1:b/30UOB56Rhfe185ZfgvZT0/HOql0OzxuiNOxRKXRXc=
|
||||||
|
github.com/franela/goblin v0.0.0-20200825194134-80c0062ed6cd/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo=
|
||||||
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
||||||
github.com/frankban/quicktest v1.10.2 h1:19ARM85nVi4xH7xPXuc5eM/udya5ieh7b/Sv+d844Tk=
|
github.com/frankban/quicktest v1.10.2 h1:19ARM85nVi4xH7xPXuc5eM/udya5ieh7b/Sv+d844Tk=
|
||||||
github.com/frankban/quicktest v1.10.2/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s=
|
github.com/frankban/quicktest v1.10.2/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s=
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -169,8 +170,13 @@ func (f *ConfigurationFile) Parse(path string, internal bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Create(path); err != nil {
|
b := strings.TrimSuffix(path, filepath.Base(path))
|
||||||
return errors.WithStack(err)
|
if err := os.MkdirAll(b, 0755); err != nil {
|
||||||
|
return errors.Wrap(err, "failed to create base directory for missing configuration file")
|
||||||
|
} else {
|
||||||
|
if _, err := os.Create(path); err != nil {
|
||||||
|
return errors.Wrap(err, "failed to create missing configuration file")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.Parse(path, true)
|
return f.Parse(path, true)
|
||||||
@@ -384,39 +390,26 @@ func (f *ConfigurationFile) parseYamlFile(path string) error {
|
|||||||
// scanning a file and performing a replacement. You should attempt to use anything other
|
// scanning a file and performing a replacement. You should attempt to use anything other
|
||||||
// than this function where possible.
|
// than this function where possible.
|
||||||
func (f *ConfigurationFile) parseTextFile(path string) error {
|
func (f *ConfigurationFile) parseTextFile(path string) error {
|
||||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
|
input, err := ioutil.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
lines := strings.Split(string(input), "\n")
|
||||||
for scanner.Scan() {
|
for i, line := range lines {
|
||||||
hasReplaced := false
|
|
||||||
t := scanner.Text()
|
|
||||||
|
|
||||||
// Iterate over the potential replacements for the line and check if there are
|
|
||||||
// any matches.
|
|
||||||
for _, replace := range f.Replace {
|
for _, replace := range f.Replace {
|
||||||
if !strings.HasPrefix(t, replace.Match) {
|
// If this line doesn't match what we expect for the replacement, move on to the next
|
||||||
|
// line. Otherwise, update the line to have the replacement value.
|
||||||
|
if !strings.HasPrefix(line, replace.Match) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
hasReplaced = true
|
lines[i] = replace.ReplaceWith.String()
|
||||||
t = strings.Replace(t, replace.Match, replace.ReplaceWith.String(), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there was a replacement that occurred on this specific line, do a write to the file
|
|
||||||
// immediately to write that modified content to the disk.
|
|
||||||
if hasReplaced {
|
|
||||||
if _, err := file.WriteAt([]byte(t), int64(len(scanner.Bytes()))); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
if err := ioutil.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644); err != nil {
|
||||||
return err
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -437,8 +430,7 @@ func (f *ConfigurationFile) parsePropertiesFile(path string) error {
|
|||||||
scanner := bufio.NewScanner(f2)
|
scanner := bufio.NewScanner(f2)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
text := scanner.Text()
|
text := scanner.Text()
|
||||||
|
if len(text) > 0 && text[0] != '#' {
|
||||||
if text[0] != '#' {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/pterodactyl/wings/server"
|
"github.com/pterodactyl/wings/server"
|
||||||
|
"github.com/pterodactyl/wings/server/filesystem"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RequestError struct {
|
type RequestError struct {
|
||||||
@@ -33,7 +35,7 @@ func TrackedError(err error) *RequestError {
|
|||||||
// generated this server for the purposes of logging.
|
// generated this server for the purposes of logging.
|
||||||
func TrackedServerError(err error, s *server.Server) *RequestError {
|
func TrackedServerError(err error, s *server.Server) *RequestError {
|
||||||
return &RequestError{
|
return &RequestError{
|
||||||
Err: errors.WithStack(err),
|
Err: err,
|
||||||
Uuid: uuid.Must(uuid.NewRandom()).String(),
|
Uuid: uuid.Must(uuid.NewRandom()).String(),
|
||||||
Message: "",
|
Message: "",
|
||||||
server: s,
|
server: s,
|
||||||
@@ -95,6 +97,46 @@ func (e *RequestError) AbortWithServerError(c *gin.Context) {
|
|||||||
e.AbortWithStatus(http.StatusInternalServerError, c)
|
e.AbortWithStatus(http.StatusInternalServerError, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle specific filesystem errors for a server.
|
||||||
|
func (e *RequestError) AbortFilesystemError(c *gin.Context) {
|
||||||
|
if errors.Is(e.Err, os.ErrNotExist) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "The requested resource was not found.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(e.Err, filesystem.ErrNotEnoughDiskSpace) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||||
|
"error": "There is not enough disk space available to perform that action.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(e.Err.Error(), "file name too long") {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "File name is too long.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if e, ok := e.Err.(*os.SyscallError); ok && e.Syscall == "readdirent" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "The requested directory does not exist.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(e.Err.Error(), "file name too long") {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "Cannot perform that action: file name is too long.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.AbortWithServerError(c)
|
||||||
|
}
|
||||||
|
|
||||||
// Format the error to a string and include the UUID.
|
// Format the error to a string and include the UUID.
|
||||||
func (e *RequestError) Error() string {
|
func (e *RequestError) Error() string {
|
||||||
return fmt.Sprintf("%v (uuid: %s)", e.Err, e.Uuid)
|
return fmt.Sprintf("%v (uuid: %s)", e.Err, e.Uuid)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func getDownloadFile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
p, _ := s.Filesystem.SafePath(token.FilePath)
|
p, _ := s.Filesystem().SafePath(token.FilePath)
|
||||||
st, err := os.Stat(p)
|
st, err := os.Stat(p)
|
||||||
// If there is an error or we're somehow trying to download a directory, just
|
// If there is an error or we're somehow trying to download a directory, just
|
||||||
// respond with the appropriate error.
|
// respond with the appropriate error.
|
||||||
|
|||||||
@@ -169,9 +169,16 @@ func postServerInstall(c *gin.Context) {
|
|||||||
func postServerReinstall(c *gin.Context) {
|
func postServerReinstall(c *gin.Context) {
|
||||||
s := GetServer(c.Param("server"))
|
s := GetServer(c.Param("server"))
|
||||||
|
|
||||||
go func(serv *server.Server) {
|
if s.ExecutingPowerAction() {
|
||||||
if err := serv.Reinstall(); err != nil {
|
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||||
serv.Log().WithField("error", err).Error("failed to complete server re-install process")
|
"error": "Cannot execute server reinstall event while another power action is running.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(s *server.Server) {
|
||||||
|
if err := s.Reinstall(); err != nil {
|
||||||
|
s.Log().WithField("error", err).Error("failed to complete server re-install process")
|
||||||
}
|
}
|
||||||
}(s)
|
}(s)
|
||||||
|
|
||||||
@@ -200,6 +207,7 @@ func deleteServer(c *gin.Context) {
|
|||||||
// Unsubscribe all of the event listeners.
|
// Unsubscribe all of the event listeners.
|
||||||
s.Events().Destroy()
|
s.Events().Destroy()
|
||||||
s.Throttler().StopTimer()
|
s.Throttler().StopTimer()
|
||||||
|
s.Websockets().CancelAll()
|
||||||
|
|
||||||
// Destroy the environment; in Docker this will handle a running container and
|
// Destroy the environment; in Docker this will handle a running container and
|
||||||
// forcibly terminate it before removing the container, so we do not need to handle
|
// forcibly terminate it before removing the container, so we do not need to handle
|
||||||
@@ -221,7 +229,7 @@ func deleteServer(c *gin.Context) {
|
|||||||
"error": errors.WithStack(err),
|
"error": errors.WithStack(err),
|
||||||
}).Warn("failed to remove server files during deletion process")
|
}).Warn("failed to remove server files during deletion process")
|
||||||
}
|
}
|
||||||
}(s.Filesystem.Path())
|
}(s.Filesystem().Path())
|
||||||
|
|
||||||
var uuid = s.Id()
|
var uuid = s.Id()
|
||||||
server.GetServers().Remove(func(s2 *server.Server) bool {
|
server.GetServers().Remove(func(s2 *server.Server) bool {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/pterodactyl/wings/router/tokens"
|
"github.com/pterodactyl/wings/router/tokens"
|
||||||
"github.com/pterodactyl/wings/server"
|
"github.com/pterodactyl/wings/server"
|
||||||
|
"github.com/pterodactyl/wings/server/filesystem"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -29,34 +29,12 @@ func getServerFileContents(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
p = "/" + strings.TrimLeft(p, "/")
|
p = "/" + strings.TrimLeft(p, "/")
|
||||||
|
|
||||||
cleaned, err := s.Filesystem.SafePath(p)
|
st, err := s.Filesystem().Stat(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||||
"error": "The file requested could not be found.",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
st, err := s.Filesystem.Stat(cleaned)
|
|
||||||
if err != nil {
|
|
||||||
TrackedServerError(err, s).AbortWithServerError(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if st.Info.IsDir() {
|
|
||||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
|
||||||
"error": "The requested resource was not found on the system.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Open(cleaned)
|
|
||||||
if err != nil {
|
|
||||||
TrackedServerError(err, s).AbortWithServerError(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
c.Header("X-Mime-Type", st.Mimetype)
|
c.Header("X-Mime-Type", st.Mimetype)
|
||||||
c.Header("Content-Length", strconv.Itoa(int(st.Info.Size())))
|
c.Header("Content-Length", strconv.Itoa(int(st.Info.Size())))
|
||||||
|
|
||||||
@@ -67,7 +45,10 @@ func getServerFileContents(c *gin.Context) {
|
|||||||
c.Header("Content-Type", "application/octet-stream")
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
}
|
}
|
||||||
|
|
||||||
bufio.NewReader(f).WriteTo(c.Writer)
|
if err := s.Filesystem().Readfile(p, c.Writer); err != nil {
|
||||||
|
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the contents of a directory for a server.
|
// Returns the contents of a directory for a server.
|
||||||
@@ -80,16 +61,9 @@ func getServerListDirectory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := s.Filesystem.ListDirectory(d)
|
stats, err := s.Filesystem().ListDirectory(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "readdirent: not a directory" {
|
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
|
||||||
"error": "The requested directory does not exist.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
TrackedServerError(err, s).AbortWithServerError(c)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +107,7 @@ func putServerRenameFiles(c *gin.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
default:
|
default:
|
||||||
if err := s.Filesystem.Rename(pf, pt); err != nil {
|
if err := s.Filesystem().Rename(pf, pt); err != nil {
|
||||||
// Return nil if the error is an is not exists.
|
// Return nil if the error is an is not exists.
|
||||||
// NOTE: os.IsNotExist() does not work if the error is wrapped.
|
// NOTE: os.IsNotExist() does not work if the error is wrapped.
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -156,7 +130,7 @@ func putServerRenameFiles(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
TrackedServerError(err, s).AbortWithServerError(c)
|
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,15 +149,8 @@ func postServerCopyFile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Filesystem.Copy(data.Location); err != nil {
|
if err := s.Filesystem().Copy(data.Location); err != nil {
|
||||||
// Check if the file does not exist.
|
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||||
// NOTE: os.IsNotExist() does not work if the error is wrapped.
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
c.Status(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
TrackedServerError(err, s).AbortWithServerError(c)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +189,7 @@ func postServerDeleteFiles(c *gin.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
default:
|
default:
|
||||||
return s.Filesystem.Delete(pi)
|
return s.Filesystem().Delete(pi)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -246,8 +213,15 @@ func postServerWriteFile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
f = "/" + strings.TrimLeft(f, "/")
|
f = "/" + strings.TrimLeft(f, "/")
|
||||||
|
|
||||||
if err := s.Filesystem.Writefile(f, c.Request.Body); err != nil {
|
if err := s.Filesystem().Writefile(f, c.Request.Body); err != nil {
|
||||||
TrackedServerError(err, s).AbortWithServerError(c)
|
if errors.Is(err, filesystem.ErrIsDirectory) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "Cannot write file, name conflicts with an existing directory by the same name.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +241,14 @@ func postServerCreateDirectory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Filesystem.CreateDirectory(data.Name, data.Path); err != nil {
|
if err := s.Filesystem().CreateDirectory(data.Name, data.Path); err != nil {
|
||||||
|
if err.Error() == "not a directory" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "Part of the path being created is not a directory (ENOTDIR).",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
TrackedServerError(err, s).AbortWithServerError(c)
|
TrackedServerError(err, s).AbortWithServerError(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -294,20 +275,20 @@ func postServerCompressFiles(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.Filesystem.HasSpaceAvailable(true) {
|
if !s.Filesystem().HasSpaceAvailable(true) {
|
||||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||||
"error": "This server does not have enough available disk space to generate a compressed archive.",
|
"error": "This server does not have enough available disk space to generate a compressed archive.",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := s.Filesystem.CompressFiles(data.RootPath, data.Files)
|
f, err := s.Filesystem().CompressFiles(data.RootPath, data.Files)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
TrackedServerError(err, s).AbortWithServerError(c)
|
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, &server.Stat{
|
c.JSON(http.StatusOK, &filesystem.Stat{
|
||||||
Info: f,
|
Info: f,
|
||||||
Mimetype: "application/tar+gzip",
|
Mimetype: "application/tar+gzip",
|
||||||
})
|
})
|
||||||
@@ -325,8 +306,18 @@ func postServerDecompressFiles(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hasSpace, err := s.Filesystem.SpaceAvailableForDecompression(data.RootPath, data.File)
|
hasSpace, err := s.Filesystem().SpaceAvailableForDecompression(data.RootPath, data.File)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Handle an unknown format error.
|
||||||
|
if errors.Is(err, filesystem.ErrUnknownArchiveFormat) {
|
||||||
|
s.Log().WithField("error", err).Warn("failed to decompress file due to unknown format")
|
||||||
|
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "unknown archive format",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
TrackedServerError(err, s).AbortWithServerError(c)
|
TrackedServerError(err, s).AbortWithServerError(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -338,7 +329,7 @@ func postServerDecompressFiles(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Filesystem.DecompressFile(data.RootPath, data.File); err != nil {
|
if err := s.Filesystem().DecompressFile(data.RootPath, data.File); err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||||
"error": "The requested archive was not found.",
|
"error": "The requested archive was not found.",
|
||||||
@@ -358,7 +349,7 @@ func postServerDecompressFiles(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
TrackedServerError(err, s).AbortWithServerError(c)
|
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,13 +371,6 @@ func postServerUploadFiles(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.Filesystem.HasSpaceAvailable(true) {
|
|
||||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
|
||||||
"error": "This server does not have enough available disk space to accept any file uploads.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
form, err := c.MultipartForm()
|
form, err := c.MultipartForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||||
@@ -405,8 +389,13 @@ func postServerUploadFiles(c *gin.Context) {
|
|||||||
|
|
||||||
directory := c.Query("directory")
|
directory := c.Query("directory")
|
||||||
|
|
||||||
|
var totalSize int64
|
||||||
for _, header := range headers {
|
for _, header := range headers {
|
||||||
p, err := s.Filesystem.SafePath(filepath.Join(directory, header.Filename))
|
totalSize += header.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, header := range headers {
|
||||||
|
p, err := s.Filesystem().SafePath(filepath.Join(directory, header.Filename))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
@@ -428,7 +417,7 @@ func handleFileUpload(p string, s *server.Server, header *multipart.FileHeader)
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
if err := s.Filesystem.Writefile(p, file); err != nil {
|
if err := s.Filesystem().Writefile(p, file); err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
ws "github.com/gorilla/websocket"
|
ws "github.com/gorilla/websocket"
|
||||||
"github.com/pterodactyl/wings/router/websocket"
|
"github.com/pterodactyl/wings/router/websocket"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Upgrades a connection to a websocket and passes events along between.
|
// Upgrades a connection to a websocket and passes events along between.
|
||||||
@@ -23,6 +24,28 @@ func getServerWebsocket(c *gin.Context) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Track this open connection on the server so that we can close them all programtically
|
||||||
|
// if the server is deleted.
|
||||||
|
s.Websockets().Push(handler.Uuid(), &cancel)
|
||||||
|
defer s.Websockets().Remove(handler.Uuid())
|
||||||
|
|
||||||
|
// Listen for the context being canceled and then close the websocket connection. This normally
|
||||||
|
// just happens because you're disconnecting from the socket in the browser, however in some
|
||||||
|
// cases we close the connections programatically (e.g. deleting the server) and need to send
|
||||||
|
// a close message to the websocket so it disconnects.
|
||||||
|
go func(ctx context.Context, c *ws.Conn) {
|
||||||
|
ListenerLoop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
handler.Connection.WriteControl(ws.CloseMessage, ws.FormatCloseMessage(ws.CloseGoingAway, "server deleted"), time.Now().Add(time.Second*5))
|
||||||
|
// A break right here without defining the specific loop would only break the select
|
||||||
|
// and not actually break the for loop, thus causing this routine to stick around forever.
|
||||||
|
break ListenerLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(ctx, handler.Connection)
|
||||||
|
|
||||||
go handler.ListenForServerEvents(ctx)
|
go handler.ListenForServerEvents(ctx)
|
||||||
go handler.ListenForExpiration(ctx)
|
go handler.ListenForExpiration(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func getServerArchive(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open(s.Archiver.ArchivePath())
|
file, err := os.Open(s.Archiver.Path())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tserr := TrackedServerError(err, s)
|
tserr := TrackedServerError(err, s)
|
||||||
if !os.IsNotExist(err) {
|
if !os.IsNotExist(err) {
|
||||||
@@ -84,7 +84,7 @@ func getServerArchive(c *gin.Context) {
|
|||||||
c.Header("X-Checksum", checksum)
|
c.Header("X-Checksum", checksum)
|
||||||
c.Header("X-Mime-Type", st.Mimetype)
|
c.Header("X-Mime-Type", st.Mimetype)
|
||||||
c.Header("Content-Length", strconv.Itoa(int(st.Info.Size())))
|
c.Header("Content-Length", strconv.Itoa(int(st.Info.Size())))
|
||||||
c.Header("Content-Disposition", "attachment; filename="+s.Archiver.ArchiveName())
|
c.Header("Content-Disposition", "attachment; filename="+s.Archiver.Name())
|
||||||
c.Header("Content-Type", "application/octet-stream")
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
|
|
||||||
bufio.NewReader(file).WriteTo(c.Writer)
|
bufio.NewReader(file).WriteTo(c.Writer)
|
||||||
@@ -283,7 +283,7 @@ func postTransfer(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Un-archive the archive. That sounds weird..
|
// Un-archive the archive. That sounds weird..
|
||||||
if err := archiver.NewTarGz().Unarchive(archivePath, i.Server().Filesystem.Path()); err != nil {
|
if err := archiver.NewTarGz().Unarchive(archivePath, i.Server().Filesystem().Path()); err != nil {
|
||||||
l.WithField("error", errors.WithStack(err)).Error("failed to extract server archive")
|
l.WithField("error", errors.WithStack(err)).Error("failed to extract server archive")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ const (
|
|||||||
SetStateEvent = "set state"
|
SetStateEvent = "set state"
|
||||||
SendServerLogsEvent = "send logs"
|
SendServerLogsEvent = "send logs"
|
||||||
SendCommandEvent = "send command"
|
SendCommandEvent = "send command"
|
||||||
|
SendStatsEvent = "send stats"
|
||||||
ErrorEvent = "daemon error"
|
ErrorEvent = "daemon error"
|
||||||
|
JwtErrorEvent = "jwt error"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/pterodactyl/wings/config"
|
"github.com/pterodactyl/wings/config"
|
||||||
"github.com/pterodactyl/wings/environment"
|
"github.com/pterodactyl/wings/environment"
|
||||||
|
"github.com/pterodactyl/wings/environment/docker"
|
||||||
"github.com/pterodactyl/wings/router/tokens"
|
"github.com/pterodactyl/wings/router/tokens"
|
||||||
"github.com/pterodactyl/wings/server"
|
"github.com/pterodactyl/wings/server"
|
||||||
|
"github.com/pterodactyl/wings/server/filesystem"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -32,15 +34,17 @@ const (
|
|||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
sync.RWMutex
|
sync.RWMutex
|
||||||
|
|
||||||
Connection *websocket.Conn
|
Connection *websocket.Conn
|
||||||
jwt *tokens.WebsocketPayload `json:"-"`
|
jwt *tokens.WebsocketPayload `json:"-"`
|
||||||
server *server.Server
|
server *server.Server
|
||||||
|
uuid uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrJwtNotPresent = errors.New("jwt: no jwt present")
|
ErrJwtNotPresent = errors.New("jwt: no jwt present")
|
||||||
ErrJwtNoConnectPerm = errors.New("jwt: missing connect permission")
|
ErrJwtNoConnectPerm = errors.New("jwt: missing connect permission")
|
||||||
ErrJwtUuidMismatch = errors.New("jwt: server uuid mismatch")
|
ErrJwtUuidMismatch = errors.New("jwt: server uuid mismatch")
|
||||||
)
|
)
|
||||||
|
|
||||||
func IsJwtError(err error) bool {
|
func IsJwtError(err error) bool {
|
||||||
@@ -97,19 +101,29 @@ func GetHandler(s *server.Server, w http.ResponseWriter, r *http.Request) (*Hand
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u, err := uuid.NewRandom()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
return &Handler{
|
return &Handler{
|
||||||
Connection: conn,
|
Connection: conn,
|
||||||
jwt: nil,
|
jwt: nil,
|
||||||
server: s,
|
server: s,
|
||||||
|
uuid: u,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Uuid() uuid.UUID {
|
||||||
|
return h.uuid
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) SendJson(v *Message) error {
|
func (h *Handler) SendJson(v *Message) error {
|
||||||
// Do not send JSON down the line if the JWT on the connection is not valid!
|
// Do not send JSON down the line if the JWT on the connection is not valid!
|
||||||
if err := h.TokenValid(); err != nil {
|
if err := h.TokenValid(); err != nil {
|
||||||
h.unsafeSendJson(Message{
|
h.unsafeSendJson(Message{
|
||||||
Event: ErrorEvent,
|
Event: JwtErrorEvent,
|
||||||
Args: []string{"could not authenticate client: " + err.Error()},
|
Args: []string{err.Error()},
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -134,7 +148,23 @@ func (h *Handler) SendJson(v *Message) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.unsafeSendJson(v)
|
if err := h.unsafeSendJson(v); err != nil {
|
||||||
|
// Not entirely sure how this happens (likely just when there is a ton of console spam)
|
||||||
|
// but I don't care to fix it right now, so just mask the error and throw a warning into
|
||||||
|
// the logs for us to look into later.
|
||||||
|
if errors.Is(err, websocket.ErrCloseSent) {
|
||||||
|
if h.server != nil {
|
||||||
|
h.server.Log().WithField("subsystem", "websocket").
|
||||||
|
WithField("event", v.Event).
|
||||||
|
Warn("failed to send event to websocket: close already sent")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sends JSON over the websocket connection, ignoring the authentication state of the
|
// Sends JSON over the websocket connection, ignoring the authentication state of the
|
||||||
@@ -174,7 +204,9 @@ func (h *Handler) TokenValid() error {
|
|||||||
// error message, otherwise we just send back a standard error message.
|
// error message, otherwise we just send back a standard error message.
|
||||||
func (h *Handler) SendErrorJson(msg Message, err error, shouldLog ...bool) error {
|
func (h *Handler) SendErrorJson(msg Message, err error, shouldLog ...bool) error {
|
||||||
j := h.GetJwt()
|
j := h.GetJwt()
|
||||||
expected := errors.Is(err, server.ErrSuspended) || errors.Is(err, server.ErrIsRunning)
|
expected := errors.Is(err, server.ErrSuspended) ||
|
||||||
|
errors.Is(err, server.ErrIsRunning) ||
|
||||||
|
errors.Is(err, filesystem.ErrNotEnoughDiskSpace)
|
||||||
|
|
||||||
message := "an unexpected error was encountered while handling this request"
|
message := "an unexpected error was encountered while handling this request"
|
||||||
if expected || (j != nil && j.HasPermission(PermissionReceiveErrors)) {
|
if expected || (j != nil && j.HasPermission(PermissionReceiveErrors)) {
|
||||||
@@ -225,8 +257,8 @@ func (h *Handler) HandleInbound(m Message) error {
|
|||||||
if m.Event != AuthenticationEvent {
|
if m.Event != AuthenticationEvent {
|
||||||
if err := h.TokenValid(); err != nil {
|
if err := h.TokenValid(); err != nil {
|
||||||
h.unsafeSendJson(Message{
|
h.unsafeSendJson(Message{
|
||||||
Event: ErrorEvent,
|
Event: JwtErrorEvent,
|
||||||
Args: []string{"could not authenticate client: " + err.Error()},
|
Args: []string{err.Error()},
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -281,7 +313,7 @@ func (h *Handler) HandleInbound(m Message) error {
|
|||||||
// Only send the current disk usage if the server is offline, if docker container is running,
|
// Only send the current disk usage if the server is offline, if docker container is running,
|
||||||
// Environment#EnableResourcePolling() will send this data to all clients.
|
// Environment#EnableResourcePolling() will send this data to all clients.
|
||||||
if state == environment.ProcessOfflineState {
|
if state == environment.ProcessOfflineState {
|
||||||
_ = h.server.Filesystem.HasSpaceAvailable(false)
|
_ = h.server.Filesystem().HasSpaceAvailable(false)
|
||||||
|
|
||||||
b, _ := json.Marshal(h.server.Proc())
|
b, _ := json.Marshal(h.server.Proc())
|
||||||
h.SendJson(&Message{
|
h.SendJson(&Message{
|
||||||
@@ -341,6 +373,16 @@ func (h *Handler) HandleInbound(m Message) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case SendStatsEvent:
|
||||||
|
{
|
||||||
|
b, _ := json.Marshal(h.server.Proc())
|
||||||
|
h.SendJson(&Message{
|
||||||
|
Event: server.StatsEvent,
|
||||||
|
Args: []string{string(b)},
|
||||||
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
case SendCommandEvent:
|
case SendCommandEvent:
|
||||||
@@ -353,6 +395,18 @@ func (h *Handler) HandleInbound(m Message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(dane): should probably add a new process state that is "booting environment" or something
|
||||||
|
// so that we can better handle this and only set the environment to booted once we're attached.
|
||||||
|
//
|
||||||
|
// Or maybe just an IsBooted function?
|
||||||
|
if h.server.GetState() == environment.ProcessStartingState {
|
||||||
|
if e, ok := h.server.Environment.(*docker.Environment); ok {
|
||||||
|
if !e.IsAttached() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return h.server.Environment.SendCommand(strings.Join(m.Args, ""))
|
return h.server.Environment.SendCommand(strings.Join(m.Args, ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"github.com/mholt/archiver/v3"
|
"github.com/mholt/archiver/v3"
|
||||||
|
"github.com/pkg/errors"
|
||||||
"github.com/pterodactyl/wings/config"
|
"github.com/pterodactyl/wings/config"
|
||||||
|
"github.com/pterodactyl/wings/server/filesystem"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
@@ -16,19 +18,19 @@ type Archiver struct {
|
|||||||
Server *Server
|
Server *Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchivePath returns the path to the server's archive.
|
// Path returns the path to the server's archive.
|
||||||
func (a *Archiver) ArchivePath() string {
|
func (a *Archiver) Path() string {
|
||||||
return filepath.Join(config.Get().System.ArchiveDirectory, a.ArchiveName())
|
return filepath.Join(config.Get().System.ArchiveDirectory, a.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveName returns the name of the server's archive.
|
// Name returns the name of the server's archive.
|
||||||
func (a *Archiver) ArchiveName() string {
|
func (a *Archiver) Name() string {
|
||||||
return a.Server.Id() + ".tar.gz"
|
return a.Server.Id() + ".tar.gz"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exists returns a boolean based off if the archive exists.
|
// Exists returns a boolean based off if the archive exists.
|
||||||
func (a *Archiver) Exists() bool {
|
func (a *Archiver) Exists() bool {
|
||||||
if _, err := os.Stat(a.ArchivePath()); os.IsNotExist(err) {
|
if _, err := os.Stat(a.Path()); os.IsNotExist(err) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,13 +38,21 @@ func (a *Archiver) Exists() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stat stats the archive file.
|
// Stat stats the archive file.
|
||||||
func (a *Archiver) Stat() (*Stat, error) {
|
func (a *Archiver) Stat() (*filesystem.Stat, error) {
|
||||||
return a.Server.Filesystem.unsafeStat(a.ArchivePath())
|
s, err := os.Stat(a.Path())
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &filesystem.Stat{
|
||||||
|
Info: s,
|
||||||
|
Mimetype: "application/tar+gzip",
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive creates an archive of the server and deletes the previous one.
|
// Archive creates an archive of the server and deletes the previous one.
|
||||||
func (a *Archiver) Archive() error {
|
func (a *Archiver) Archive() error {
|
||||||
path := a.Server.Filesystem.Path()
|
path := a.Server.Filesystem().Path()
|
||||||
|
|
||||||
// Get the list of root files and directories to archive.
|
// Get the list of root files and directories to archive.
|
||||||
var files []string
|
var files []string
|
||||||
@@ -52,41 +62,44 @@ func (a *Archiver) Archive() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range fileInfo {
|
for _, file := range fileInfo {
|
||||||
f, err := a.Server.Filesystem.SafeJoin(path, file)
|
f := filepath.Join(path, file.Name())
|
||||||
if err != nil {
|
// If the file is a symlink we cannot safely assume that the result of a filepath.Join() will be
|
||||||
return err
|
// a safe destination. We need to check if the file is a symlink, and if so pass off to the SafePath
|
||||||
|
// function to resolve it to the final destination.
|
||||||
|
//
|
||||||
|
// ioutil.ReadDir() calls Lstat, so this will work correctly. If it did not call Lstat, but rather
|
||||||
|
// just did a normal Stat call, this would fail since that would be looking at the symlink destination
|
||||||
|
// and not the actual file in this listing.
|
||||||
|
if file.Mode()&os.ModeSymlink != 0 {
|
||||||
|
f, err = a.Server.Filesystem().SafePath(filepath.Join(path, file.Name()))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
files = append(files, f)
|
files = append(files, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
stat, err := a.Stat()
|
if err := a.DeleteIfExists(); err != nil {
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file exists.
|
return archiver.NewTarGz().Archive(files, a.Path())
|
||||||
if stat != nil {
|
|
||||||
if err := os.Remove(a.ArchivePath()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return archiver.NewTarGz().Archive(files, a.ArchivePath())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteIfExists deletes the archive if it exists.
|
// DeleteIfExists deletes the archive if it exists.
|
||||||
func (a *Archiver) DeleteIfExists() error {
|
func (a *Archiver) DeleteIfExists() error {
|
||||||
stat, err := a.Stat()
|
if _, err := a.Stat(); err != nil {
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file exists.
|
if err := os.Remove(a.Path()); err != nil {
|
||||||
if stat != nil {
|
return errors.WithStack(err)
|
||||||
if err := os.Remove(a.ArchivePath()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -94,7 +107,7 @@ func (a *Archiver) DeleteIfExists() error {
|
|||||||
|
|
||||||
// Checksum computes a SHA256 checksum of the server's archive.
|
// Checksum computes a SHA256 checksum of the server's archive.
|
||||||
func (a *Archiver) Checksum() (string, error) {
|
func (a *Archiver) Checksum() (string, error) {
|
||||||
file, err := os.Open(a.ArchivePath())
|
file, err := os.Open(a.Path())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func (s *Server) notifyPanelOfBackup(uuid string, ad *backup.ArchiveDetails, suc
|
|||||||
func (s *Server) getServerwideIgnoredFiles() ([]string, error) {
|
func (s *Server) getServerwideIgnoredFiles() ([]string, error) {
|
||||||
var ignored []string
|
var ignored []string
|
||||||
|
|
||||||
f, err := os.Open(path.Join(s.Filesystem.Path(), ".pteroignore"))
|
f, err := os.Open(path.Join(s.Filesystem().Path(), ".pteroignore"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !os.IsNotExist(err) {
|
if !os.IsNotExist(err) {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -70,7 +70,7 @@ func (s *Server) GetIncludedBackupFiles(ignored []string) (*backup.IncludedFiles
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the included files based on the root path and the ignored files provided.
|
// Get the included files based on the root path and the ignored files provided.
|
||||||
return s.Filesystem.GetIncludedFiles(s.Filesystem.Path(), ignored)
|
return s.Filesystem().GetIncludedFiles(s.Filesystem().Path(), ignored)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Performs a server backup and then emits the event over the server websocket. We
|
// Performs a server backup and then emits the event over the server websocket. We
|
||||||
@@ -83,7 +83,7 @@ func (s *Server) Backup(b backup.BackupInterface) error {
|
|||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ad, err := b.Generate(inc, s.Filesystem.Path())
|
ad, err := b.Generate(inc, s.Filesystem().Path())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if notifyError := s.notifyPanelOfBackup(b.Identifier(), &backup.ArchiveDetails{}, false); notifyError != nil {
|
if notifyError := s.notifyPanelOfBackup(b.Identifier(), &backup.ArchiveDetails{}, false); notifyError != nil {
|
||||||
s.Log().WithFields(log.Fields{
|
s.Log().WithFields(log.Fields{
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ type Archive struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Creates an archive at dst with all of the files defined in the included files struct.
|
// Creates an archive at dst with all of the files defined in the included files struct.
|
||||||
func (a *Archive) Create(dst string, ctx context.Context) (os.FileInfo, error) {
|
func (a *Archive) Create(dst string, ctx context.Context) error {
|
||||||
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
@@ -75,15 +75,10 @@ func (a *Archive) Create(dst string, ctx context.Context) (os.FileInfo, error) {
|
|||||||
log.WithField("location", dst).Warn("failed to delete corrupted backup archive")
|
log.WithField("location", dst).Warn("failed to delete corrupted backup archive")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
st, err := f.Stat()
|
return nil
|
||||||
if err != nil {
|
|
||||||
return nil, errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return st, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds a single file to the existing tar archive writer.
|
// Adds a single file to the existing tar archive writer.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func (b *LocalBackup) Generate(included *IncludedFiles, prefix string) (*Archive
|
|||||||
Files: included,
|
Files: included,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := a.Create(b.Path(), context.Background()); err != nil {
|
if err := a.Create(b.Path(), context.Background()); err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (s *S3Backup) Generate(included *IncludedFiles, prefix string) (*ArchiveDet
|
|||||||
Files: included,
|
Files: included,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := a.Create(s.Path(), context.Background()); err != nil {
|
if err := a.Create(s.Path(), context.Background()); err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func (s *Server) UpdateConfigurationFiles() {
|
|||||||
f := cf
|
f := cf
|
||||||
|
|
||||||
pool.Submit(func() {
|
pool.Submit(func() {
|
||||||
p, err := s.Filesystem.SafePath(f.FileName)
|
p, err := s.Filesystem().SafePath(f.FileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Log().WithField("error", err).Error("failed to generate safe path for configuration file")
|
s.Log().WithField("error", err).Error("failed to generate safe path for configuration file")
|
||||||
|
|
||||||
|
|||||||
@@ -47,11 +47,12 @@ func (s *Server) Config() *Configuration {
|
|||||||
return &s.cfg
|
return &s.cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the amount of disk space available to a server in bytes.
|
||||||
func (s *Server) DiskSpace() int64 {
|
func (s *Server) DiskSpace() int64 {
|
||||||
s.cfg.mu.RLock()
|
s.cfg.mu.RLock()
|
||||||
defer s.cfg.mu.RUnlock()
|
defer s.cfg.mu.RUnlock()
|
||||||
|
|
||||||
return s.cfg.Build.DiskSpace
|
return s.cfg.Build.DiskSpace * 1024.0 * 1024.0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) MemoryLimit() int64 {
|
func (s *Server) MemoryLimit() int64 {
|
||||||
|
|||||||
@@ -1,968 +1,30 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
|
||||||
"github.com/karrick/godirwalk"
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/pterodactyl/wings/config"
|
"github.com/pterodactyl/wings/server/filesystem"
|
||||||
"github.com/pterodactyl/wings/server/backup"
|
|
||||||
ignore "github.com/sabhiram/go-gitignore"
|
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error returned when there is a bad path provided to one of the FS calls.
|
func (s *Server) Filesystem() *filesystem.Filesystem {
|
||||||
type PathResolutionError struct{}
|
return s.fs
|
||||||
|
|
||||||
var ErrNotEnoughDiskSpace = errors.New("not enough disk space is available to perform this operation")
|
|
||||||
|
|
||||||
// Returns the error response in a string form that can be more easily consumed.
|
|
||||||
func (pre PathResolutionError) Error() string {
|
|
||||||
return "invalid path resolution"
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsPathResolutionError(err error) bool {
|
|
||||||
_, ok := err.(PathResolutionError)
|
|
||||||
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
type Filesystem struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
lookupTimeMu sync.RWMutex
|
|
||||||
|
|
||||||
lastLookupTime time.Time
|
|
||||||
lookupInProgress int32
|
|
||||||
disk int64
|
|
||||||
|
|
||||||
Server *Server
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the root path that contains all of a server's data.
|
|
||||||
func (fs *Filesystem) Path() string {
|
|
||||||
return filepath.Join(config.Get().System.Data, fs.Server.Id())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalizes a directory being passed in to ensure the user is not able to escape
|
|
||||||
// from their data directory. After normalization if the directory is still within their home
|
|
||||||
// path it is returned. If they managed to "escape" an error will be returned.
|
|
||||||
//
|
|
||||||
// This logic is actually copied over from the SFTP server code. Ideally that eventually
|
|
||||||
// either gets ported into this application, or is able to make use of this package.
|
|
||||||
func (fs *Filesystem) SafePath(p string) (string, error) {
|
|
||||||
var nonExistentPathResolution string
|
|
||||||
|
|
||||||
// Start with a cleaned up path before checking the more complex bits.
|
|
||||||
r := fs.unsafeFilePath(p)
|
|
||||||
|
|
||||||
// At the same time, evaluate the symlink status and determine where this file or folder
|
|
||||||
// is truly pointing to.
|
|
||||||
p, err := filepath.EvalSymlinks(r)
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
return "", err
|
|
||||||
} else if os.IsNotExist(err) {
|
|
||||||
// The requested directory doesn't exist, so at this point we need to iterate up the
|
|
||||||
// path chain until we hit a directory that _does_ exist and can be validated.
|
|
||||||
parts := strings.Split(filepath.Dir(r), "/")
|
|
||||||
|
|
||||||
var try string
|
|
||||||
// Range over all of the path parts and form directory pathings from the end
|
|
||||||
// moving up until we have a valid resolution or we run out of paths to try.
|
|
||||||
for k := range parts {
|
|
||||||
try = strings.Join(parts[:(len(parts)-k)], "/")
|
|
||||||
|
|
||||||
if !fs.unsafeIsInDataDirectory(try) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := filepath.EvalSymlinks(try)
|
|
||||||
if err == nil {
|
|
||||||
nonExistentPathResolution = t
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the new path doesn't start with their root directory there is clearly an escape
|
|
||||||
// attempt going on, and we should NOT resolve this path for them.
|
|
||||||
if nonExistentPathResolution != "" {
|
|
||||||
if !fs.unsafeIsInDataDirectory(nonExistentPathResolution) {
|
|
||||||
return "", PathResolutionError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the nonExistentPathResolution variable is not empty then the initial path requested
|
|
||||||
// did not exist and we looped through the pathway until we found a match. At this point
|
|
||||||
// we've confirmed the first matched pathway exists in the root server directory, so we
|
|
||||||
// can go ahead and just return the path that was requested initially.
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the requested directory from EvalSymlinks begins with the server root directory go
|
|
||||||
// ahead and return it. If not we'll return an error which will block any further action
|
|
||||||
// on the file.
|
|
||||||
if fs.unsafeIsInDataDirectory(p) {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", PathResolutionError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a path to the file by cleaning it up and appending the root server path to it. This
|
|
||||||
// DOES NOT guarantee that the file resolves within the server data directory. You'll want to use
|
|
||||||
// the fs.unsafeIsInDataDirectory(p) function to confirm.
|
|
||||||
func (fs *Filesystem) unsafeFilePath(p string) string {
|
|
||||||
// Calling filepath.Clean on the joined directory will resolve it to the absolute path,
|
|
||||||
// removing any ../ type of resolution arguments, and leaving us with a direct path link.
|
|
||||||
//
|
|
||||||
// This will also trim the existing root path off the beginning of the path passed to
|
|
||||||
// the function since that can get a bit messy.
|
|
||||||
return filepath.Clean(filepath.Join(fs.Path(), strings.TrimPrefix(p, fs.Path())))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that that path string starts with the server data directory path. This function DOES NOT
|
|
||||||
// validate that the rest of the path does not end up resolving out of this directory, or that the
|
|
||||||
// targeted file or folder is not a symlink doing the same thing.
|
|
||||||
func (fs *Filesystem) unsafeIsInDataDirectory(p string) bool {
|
|
||||||
return strings.HasPrefix(strings.TrimSuffix(p, "/")+"/", strings.TrimSuffix(fs.Path(), "/")+"/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to keep some of the codebase a little cleaner. Returns a "safe" version of the path
|
|
||||||
// joined with a file. This is important because you cannot just assume that appending a file to a cleaned
|
|
||||||
// path will result in a cleaned path to that file. For example, imagine you have the following scenario:
|
|
||||||
//
|
|
||||||
// my_bad_file -> symlink:/etc/passwd
|
|
||||||
//
|
|
||||||
// cleaned := SafePath("../../etc") -> "/"
|
|
||||||
// filepath.Join(cleaned, my_bad_file) -> "/my_bad_file"
|
|
||||||
//
|
|
||||||
// You might think that "/my_bad_file" is fine since it isn't pointing to the original "../../etc/my_bad_file".
|
|
||||||
// However, this doesn't account for symlinks where the file might be pointing outside of the directory, so
|
|
||||||
// calling a function such as Chown against it would chown the symlinked location, and not the file within the
|
|
||||||
// Wings daemon.
|
|
||||||
func (fs *Filesystem) SafeJoin(dir string, f os.FileInfo) (string, error) {
|
|
||||||
if f.Mode()&os.ModeSymlink != 0 {
|
|
||||||
return fs.SafePath(filepath.Join(dir, f.Name()))
|
|
||||||
}
|
|
||||||
|
|
||||||
return filepath.Join(dir, f.Name()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Executes the fs.SafePath function in parallel against an array of paths. If any of the calls
|
|
||||||
// fails an error will be returned.
|
|
||||||
func (fs *Filesystem) ParallelSafePath(paths []string) ([]string, error) {
|
|
||||||
var cleaned []string
|
|
||||||
|
|
||||||
// Simple locker function to avoid racy appends to the array of cleaned paths.
|
|
||||||
var m = new(sync.Mutex)
|
|
||||||
var push = func(c string) {
|
|
||||||
m.Lock()
|
|
||||||
cleaned = append(cleaned, c)
|
|
||||||
m.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an error group that we can use to run processes in parallel while retaining
|
|
||||||
// the ability to cancel the entire process immediately should any of it fail.
|
|
||||||
g, ctx := errgroup.WithContext(context.Background())
|
|
||||||
|
|
||||||
// Iterate over all of the paths and generate a cleaned path, if there is an error for any
|
|
||||||
// of the files, abort the process.
|
|
||||||
for _, p := range paths {
|
|
||||||
// Create copy so we can use it within the goroutine correctly.
|
|
||||||
pi := p
|
|
||||||
|
|
||||||
// Recursively call this function to continue digging through the directory tree within
|
|
||||||
// a separate goroutine. If the context is canceled abort this process.
|
|
||||||
g.Go(func() error {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
// If the callback returns true, go ahead and keep walking deeper. This allows
|
|
||||||
// us to programmatically continue deeper into directories, or stop digging
|
|
||||||
// if that pathway knows it needs nothing else.
|
|
||||||
if c, err := fs.SafePath(pi); err != nil {
|
|
||||||
return err
|
|
||||||
} else {
|
|
||||||
push(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block until all of the routines finish and have returned a value.
|
|
||||||
return cleaned, g.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
type SpaceCheckingOpts struct {
|
|
||||||
AllowStaleResponse bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determines if the directory a file is trying to be added to has enough space available
|
|
||||||
// for the file to be written to.
|
|
||||||
//
|
|
||||||
// Because determining the amount of space being used by a server is a taxing operation we
|
|
||||||
// will load it all up into a cache and pull from that as long as the key is not expired.
|
|
||||||
//
|
|
||||||
// This operation will potentially block unless allowStaleValue is set to true. See the
|
|
||||||
// documentation on DiskUsage for how this affects the call.
|
|
||||||
func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool {
|
|
||||||
size, err := fs.DiskUsage(allowStaleValue)
|
|
||||||
if err != nil {
|
|
||||||
fs.Server.Log().WithField("error", err).Warn("failed to determine root server directory size")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if their folder size, in bytes, is smaller than the amount of space they've
|
|
||||||
// been allocated.
|
|
||||||
fs.Server.Proc().SetDisk(size)
|
|
||||||
|
|
||||||
space := fs.Server.DiskSpace()
|
|
||||||
// If space is -1 or 0 just return true, means they're allowed unlimited.
|
|
||||||
//
|
|
||||||
// Technically we could skip disk space calculation because we don't need to check if the server exceeds it's limit
|
|
||||||
// but because this method caches the disk usage it would be best to calculate the disk usage and always
|
|
||||||
// return true.
|
|
||||||
if space <= 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return (size / 1000.0 / 1000.0) <= space
|
|
||||||
}
|
|
||||||
|
|
||||||
// Internal helper function to allow other parts of the codebase to check the total used disk space
|
|
||||||
// as needed without overly taxing the system. This will prioritize the value from the cache to avoid
|
|
||||||
// excessive IO usage. We will only walk the filesystem and determine the size of the directory if there
|
|
||||||
// is no longer a cached value.
|
|
||||||
//
|
|
||||||
// If "allowStaleValue" is set to true, a stale value MAY be returned to the caller if there is an
|
|
||||||
// expired cache value AND there is currently another lookup in progress. If there is no cached value but
|
|
||||||
// no other lookup is in progress, a fresh disk space response will be returned to the caller.
|
|
||||||
//
|
|
||||||
// This is primarily to avoid a bunch of I/O operations from piling up on the server, especially on servers
|
|
||||||
// with a large amount of files.
|
|
||||||
func (fs *Filesystem) DiskUsage(allowStaleValue bool) (int64, error) {
|
|
||||||
// Check if cache is expired.
|
|
||||||
fs.lookupTimeMu.RLock()
|
|
||||||
isValidInCache := fs.lastLookupTime.After(time.Now().Add(time.Second * time.Duration(-1*config.Get().System.DiskCheckInterval)))
|
|
||||||
fs.lookupTimeMu.RUnlock()
|
|
||||||
|
|
||||||
if !isValidInCache {
|
|
||||||
// If we are now allowing a stale response go ahead and perform the lookup and return the fresh
|
|
||||||
// value. This is a blocking operation to the calling process.
|
|
||||||
if !allowStaleValue {
|
|
||||||
return fs.updateCachedDiskUsage()
|
|
||||||
} else if atomic.LoadInt32(&fs.lookupInProgress) == 0 {
|
|
||||||
// Otherwise, if we allow a stale value and there isn't a valid item in the cache and we aren't
|
|
||||||
// currently performing a lookup, just do the disk usage calculation in the background.
|
|
||||||
go func(fs *Filesystem) {
|
|
||||||
if _, err := fs.updateCachedDiskUsage(); err != nil {
|
|
||||||
fs.Server.Log().WithField("error", errors.WithStack(err)).Warn("failed to determine disk usage in go-routine")
|
|
||||||
}
|
|
||||||
}(fs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the currently cached value back to the calling function.
|
|
||||||
return atomic.LoadInt64(&fs.disk), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates the currently used disk space for a server.
|
|
||||||
func (fs *Filesystem) updateCachedDiskUsage() (int64, error) {
|
|
||||||
// Obtain an exclusive lock on this process so that we don't unintentionally run it at the same
|
|
||||||
// time as another running process. Once the lock is available it'll read from the cache for the
|
|
||||||
// second call rather than hitting the disk in parallel.
|
|
||||||
fs.mu.Lock()
|
|
||||||
defer fs.mu.Unlock()
|
|
||||||
|
|
||||||
// Signal that we're currently updating the disk size so that other calls to the disk checking
|
|
||||||
// functions can determine if they should queue up additional calls to this function. Ensure that
|
|
||||||
// we always set this back to 0 when this process is done executing.
|
|
||||||
atomic.StoreInt32(&fs.lookupInProgress, 1)
|
|
||||||
defer atomic.StoreInt32(&fs.lookupInProgress, 0)
|
|
||||||
|
|
||||||
// If there is no size its either because there is no data (in which case running this function
|
|
||||||
// will have effectively no impact), or there is nothing in the cache, in which case we need to
|
|
||||||
// grab the size of their data directory. This is a taxing operation, so we want to store it in
|
|
||||||
// the cache once we've gotten it.
|
|
||||||
size, err := fs.DirectorySize("/")
|
|
||||||
|
|
||||||
// Always cache the size, even if there is an error. We want to always return that value
|
|
||||||
// so that we don't cause an endless loop of determining the disk size if there is a temporary
|
|
||||||
// error encountered.
|
|
||||||
fs.lookupTimeMu.Lock()
|
|
||||||
fs.lastLookupTime = time.Now()
|
|
||||||
fs.lookupTimeMu.Unlock()
|
|
||||||
|
|
||||||
atomic.StoreInt64(&fs.disk, size)
|
|
||||||
|
|
||||||
return size, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determines the directory size of a given location by running parallel tasks to iterate
|
|
||||||
// through all of the folders. Returns the size in bytes. This can be a fairly taxing operation
|
|
||||||
// on locations with tons of files, so it is recommended that you cache the output.
|
|
||||||
func (fs *Filesystem) DirectorySize(dir string) (int64, error) {
|
|
||||||
d, err := fs.SafePath(dir)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var size int64
|
|
||||||
var st syscall.Stat_t
|
|
||||||
|
|
||||||
err = godirwalk.Walk(d, &godirwalk.Options{
|
|
||||||
Unsorted: true,
|
|
||||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
|
||||||
// If this is a symlink then resolve the final destination of it before trying to continue walking
|
|
||||||
// over its contents. If it resolves outside the server data directory just skip everything else for
|
|
||||||
// it. Otherwise, allow it to continue.
|
|
||||||
if e.IsSymlink() {
|
|
||||||
if _, err := fs.SafePath(p); err != nil {
|
|
||||||
if IsPathResolutionError(err) {
|
|
||||||
return godirwalk.SkipThis
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !e.IsDir() {
|
|
||||||
syscall.Lstat(p, &st)
|
|
||||||
atomic.AddInt64(&size, st.Size)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return size, errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads a file on the system and returns it as a byte representation in a file
|
|
||||||
// reader. This is not the most memory efficient usage since it will be reading the
|
|
||||||
// entirety of the file into memory.
|
|
||||||
func (fs *Filesystem) Readfile(p string) (io.Reader, error) {
|
|
||||||
cleaned, err := fs.SafePath(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := ioutil.ReadFile(cleaned)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes.NewReader(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Writes a file to the system. If the file does not already exist one will be created.
|
|
||||||
func (fs *Filesystem) Writefile(p string, r io.Reader) error {
|
|
||||||
cleaned, err := fs.SafePath(p)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentSize int64
|
|
||||||
|
|
||||||
// If the file does not exist on the system already go ahead and create the pathway
|
|
||||||
// to it and an empty file. We'll then write to it later on after this completes.
|
|
||||||
if stat, err := os.Stat(cleaned); err != nil {
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(cleaned), 0755); err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := fs.Chown(filepath.Dir(cleaned)); err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if stat.IsDir() {
|
|
||||||
return errors.New("cannot write file contents to a directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSize = stat.Size()
|
|
||||||
}
|
|
||||||
|
|
||||||
o := &fileOpener{}
|
|
||||||
// This will either create the file if it does not already exist, or open and
|
|
||||||
// truncate the existing file.
|
|
||||||
file, err := o.open(cleaned, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
buf := make([]byte, 1024*4)
|
|
||||||
sz, err := io.CopyBuffer(file, r, buf)
|
|
||||||
|
|
||||||
// Adjust the disk usage to account for the old size and the new size of the file.
|
|
||||||
atomic.AddInt64(&fs.disk, sz-currentSize)
|
|
||||||
|
|
||||||
// Finally, chown the file to ensure the permissions don't end up out-of-whack
|
|
||||||
// if we had just created it.
|
|
||||||
return fs.Chown(cleaned)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defines the stat struct object.
|
|
||||||
type Stat struct {
|
|
||||||
Info os.FileInfo
|
|
||||||
Mimetype string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Stat) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Created string `json:"created"`
|
|
||||||
Modified string `json:"modified"`
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Directory bool `json:"directory"`
|
|
||||||
File bool `json:"file"`
|
|
||||||
Symlink bool `json:"symlink"`
|
|
||||||
Mime string `json:"mime"`
|
|
||||||
}{
|
|
||||||
Name: s.Info.Name(),
|
|
||||||
Created: s.CTime().Format(time.RFC3339),
|
|
||||||
Modified: s.Info.ModTime().Format(time.RFC3339),
|
|
||||||
Mode: s.Info.Mode().String(),
|
|
||||||
Size: s.Info.Size(),
|
|
||||||
Directory: s.Info.IsDir(),
|
|
||||||
File: !s.Info.IsDir(),
|
|
||||||
Symlink: s.Info.Mode().Perm()&os.ModeSymlink != 0,
|
|
||||||
Mime: s.Mimetype,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats a file or folder and returns the base stat object from go along with the
|
|
||||||
// MIME data that can be used for editing files.
|
|
||||||
func (fs *Filesystem) Stat(p string) (*Stat, error) {
|
|
||||||
cleaned, err := fs.SafePath(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return fs.unsafeStat(cleaned)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *Filesystem) unsafeStat(p string) (*Stat, error) {
|
|
||||||
s, err := os.Stat(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var m *mimetype.MIME
|
|
||||||
if !s.IsDir() {
|
|
||||||
m, err = mimetype.DetectFile(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
st := &Stat{
|
|
||||||
Info: s,
|
|
||||||
Mimetype: "inode/directory",
|
|
||||||
}
|
|
||||||
|
|
||||||
if m != nil {
|
|
||||||
st.Mimetype = m.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
return st, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creates a new directory (name) at a specified path (p) for the server.
|
|
||||||
func (fs *Filesystem) CreateDirectory(name string, p string) error {
|
|
||||||
cleaned, err := fs.SafePath(path.Join(p, name))
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.MkdirAll(cleaned, 0755)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moves (or renames) a file or directory.
|
|
||||||
func (fs *Filesystem) Rename(from string, to string) error {
|
|
||||||
cleanedFrom, err := fs.SafePath(from)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanedTo, err := fs.SafePath(to)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the target file or directory already exists the rename function will fail, so just
|
|
||||||
// bail out now.
|
|
||||||
if _, err := os.Stat(cleanedTo); err == nil {
|
|
||||||
return os.ErrExist
|
|
||||||
}
|
|
||||||
|
|
||||||
if cleanedTo == fs.Path() {
|
|
||||||
return errors.New("attempting to rename into an invalid directory space")
|
|
||||||
}
|
|
||||||
|
|
||||||
d := strings.TrimSuffix(cleanedTo, path.Base(cleanedTo))
|
|
||||||
// Ensure that the directory we're moving into exists correctly on the system. Only do this if
|
|
||||||
// we're not at the root directory level.
|
|
||||||
if d != fs.Path() {
|
|
||||||
if mkerr := os.MkdirAll(d, 0644); mkerr != nil {
|
|
||||||
return errors.Wrap(mkerr, "failed to create directory structure for file rename")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.Rename(cleanedFrom, cleanedTo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively iterates over a file or directory and sets the permissions on all of the
|
|
||||||
// underlying files. Iterate over all of the files and directories. If it is a file just
|
|
||||||
// go ahead and perform the chown operation. Otherwise dig deeper into the directory until
|
|
||||||
// we've run out of directories to dig into.
|
|
||||||
func (fs *Filesystem) Chown(path string) error {
|
|
||||||
cleaned, err := fs.SafePath(path)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
uid := config.Get().System.User.Uid
|
|
||||||
gid := config.Get().System.User.Gid
|
|
||||||
|
|
||||||
// Start by just chowning the initial path that we received.
|
|
||||||
if err := os.Chown(cleaned, uid, gid); err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this is not a directory we can now return from the function, there is nothing
|
|
||||||
// left that we need to do.
|
|
||||||
if st, _ := os.Stat(cleaned); !st.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this was a directory, begin walking over its contents recursively and ensure that all
|
|
||||||
// of the subfiles and directories get their permissions updated as well.
|
|
||||||
return godirwalk.Walk(cleaned, &godirwalk.Options{
|
|
||||||
Unsorted: true,
|
|
||||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
|
||||||
// Do not attempt to chmod a symlink. Go's os.Chown function will affect the symlink
|
|
||||||
// so if it points to a location outside the data directory the user would be able to
|
|
||||||
// (un)intentionally modify that files permissions.
|
|
||||||
if e.IsSymlink() {
|
|
||||||
if e.IsDir() {
|
|
||||||
return godirwalk.SkipThis
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.Chown(p, uid, gid)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copies a given file to the same location and appends a suffix to the file to indicate that
|
|
||||||
// it has been copied.
|
|
||||||
//
|
|
||||||
// @todo need to get an exclusive lock on the file.
|
|
||||||
func (fs *Filesystem) Copy(p string) error {
|
|
||||||
cleaned, err := fs.SafePath(p)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s, err := os.Stat(cleaned); err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
} else if s.IsDir() || !s.Mode().IsRegular() {
|
|
||||||
// If this is a directory or not a regular file, just throw a not-exist error
|
|
||||||
// since anything calling this function should understand what that means.
|
|
||||||
return os.ErrNotExist
|
|
||||||
}
|
|
||||||
|
|
||||||
base := filepath.Base(cleaned)
|
|
||||||
relative := strings.TrimSuffix(strings.TrimPrefix(cleaned, fs.Path()), base)
|
|
||||||
extension := filepath.Ext(base)
|
|
||||||
name := strings.TrimSuffix(base, filepath.Ext(base))
|
|
||||||
|
|
||||||
// Begin looping up to 50 times to try and create a unique copy file name. This will take
|
|
||||||
// an input of "file.txt" and generate "file copy.txt". If that name is already taken, it will
|
|
||||||
// then try to write "file copy 2.txt" and so on, until reaching 50 loops. At that point we
|
|
||||||
// won't waste anymore time, just use the current timestamp and make that copy.
|
|
||||||
//
|
|
||||||
// Could probably make this more efficient by checking if there are any files matching the copy
|
|
||||||
// pattern, and trying to find the highest number and then incrementing it by one rather than
|
|
||||||
// looping endlessly.
|
|
||||||
var i int
|
|
||||||
copySuffix := " copy"
|
|
||||||
for i = 0; i < 51; i++ {
|
|
||||||
if i > 0 {
|
|
||||||
copySuffix = " copy " + strconv.Itoa(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
tryName := fmt.Sprintf("%s%s%s", name, copySuffix, extension)
|
|
||||||
tryLocation, err := fs.SafePath(path.Join(relative, tryName))
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the file exists, continue to the next loop, otherwise we're good to start a copy.
|
|
||||||
if _, err := os.Stat(tryLocation); err != nil && !os.IsNotExist(err) {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
} else if os.IsNotExist(err) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if i == 50 {
|
|
||||||
copySuffix = "." + time.Now().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
finalPath, err := fs.SafePath(path.Join(relative, fmt.Sprintf("%s%s%s", name, copySuffix, extension)))
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
source, err := os.Open(cleaned)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
defer source.Close()
|
|
||||||
|
|
||||||
dest, err := os.Create(finalPath)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
defer dest.Close()
|
|
||||||
|
|
||||||
buf := make([]byte, 1024*4)
|
|
||||||
if _, err := io.CopyBuffer(dest, source, buf); err != nil {
|
|
||||||
return errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deletes a file or folder from the system. Prevents the user from accidentally
|
|
||||||
// (or maliciously) removing their root server data directory.
|
|
||||||
func (fs *Filesystem) Delete(p string) error {
|
|
||||||
// This is one of the few (only?) places in the codebase where we're explicitly not using
|
|
||||||
// the SafePath functionality when working with user provided input. If we did, you would
|
|
||||||
// not be able to delete a file that is a symlink pointing to a location outside of the data
|
|
||||||
// directory.
|
|
||||||
//
|
|
||||||
// We also want to avoid resolving a symlink that points _within_ the data directory and thus
|
|
||||||
// deleting the actual source file for the symlink rather than the symlink itself. For these
|
|
||||||
// purposes just resolve the actual file path using filepath.Join() and confirm that the path
|
|
||||||
// exists within the data directory.
|
|
||||||
resolved := fs.unsafeFilePath(p)
|
|
||||||
if !fs.unsafeIsInDataDirectory(resolved) {
|
|
||||||
return PathResolutionError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block any whoopsies.
|
|
||||||
if resolved == fs.Path() {
|
|
||||||
return errors.New("cannot delete root server directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
if st, err := os.Stat(resolved); err != nil {
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
fs.Server.Log().WithField("error", err).WithField("path", resolved).Warn("error while attempting to stat file before deletion")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !st.IsDir() {
|
|
||||||
atomic.SwapInt64(&fs.disk, -st.Size())
|
|
||||||
} else {
|
|
||||||
go func(st os.FileInfo, resolved string) {
|
|
||||||
if s, err := fs.DirectorySize(resolved); err == nil {
|
|
||||||
atomic.AddInt64(&fs.disk, -s)
|
|
||||||
}
|
|
||||||
}(st, resolved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.RemoveAll(resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lists the contents of a given directory and returns stat information about each
|
|
||||||
// file and folder within it.
|
|
||||||
func (fs *Filesystem) ListDirectory(p string) ([]*Stat, error) {
|
|
||||||
cleaned, err := fs.SafePath(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := ioutil.ReadDir(cleaned)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
// You must initialize the output of this directory as a non-nil value otherwise
|
|
||||||
// when it is marshaled into a JSON object you'll just get 'null' back, which will
|
|
||||||
// break the panel badly.
|
|
||||||
out := make([]*Stat, len(files))
|
|
||||||
|
|
||||||
// Iterate over all of the files and directories returned and perform an async process
|
|
||||||
// to get the mime-type for them all.
|
|
||||||
for i, file := range files {
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
go func(idx int, f os.FileInfo) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
var m *mimetype.MIME
|
|
||||||
var d = "inode/directory"
|
|
||||||
if !f.IsDir() {
|
|
||||||
cleanedp, _ := fs.SafeJoin(cleaned, f)
|
|
||||||
if cleanedp != "" {
|
|
||||||
m, _ = mimetype.DetectFile(filepath.Join(cleaned, f.Name()))
|
|
||||||
} else {
|
|
||||||
// Just pass this for an unknown type because the file could not safely be resolved within
|
|
||||||
// the server data path.
|
|
||||||
d = "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
st := &Stat{
|
|
||||||
Info: f,
|
|
||||||
Mimetype: d,
|
|
||||||
}
|
|
||||||
|
|
||||||
if m != nil {
|
|
||||||
st.Mimetype = m.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
out[idx] = st
|
|
||||||
}(i, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
// Sort the output alphabetically to begin with since we've run the output
|
|
||||||
// through an asynchronous process and the order is gonna be very random.
|
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
|
||||||
if out[i].Info.Name() == out[j].Info.Name() || out[i].Info.Name() > out[j].Info.Name() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
// Then, sort it so that directories are listed first in the output. Everything
|
|
||||||
// will continue to be alphabetized at this point.
|
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
|
||||||
return out[i].Info.IsDir()
|
|
||||||
})
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensures that the data directory for the server instance exists.
|
// Ensures that the data directory for the server instance exists.
|
||||||
func (fs *Filesystem) EnsureDataDirectory() error {
|
func (s *Server) EnsureDataDirectoryExists() error {
|
||||||
if _, err := os.Stat(fs.Path()); err != nil && !os.IsNotExist(err) {
|
if _, err := os.Stat(s.fs.Path()); err != nil && !os.IsNotExist(err) {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
// Create the server data directory because it does not currently exist
|
// Create the server data directory because it does not currently exist
|
||||||
// on the system.
|
// on the system.
|
||||||
if err := os.MkdirAll(fs.Path(), 0700); err != nil {
|
if err := os.MkdirAll(s.fs.Path(), 0700); err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := fs.Chown("/"); err != nil {
|
if err := s.fs.Chown("/"); err != nil {
|
||||||
fs.Server.Log().WithField("error", err).Warn("failed to chown server data directory")
|
s.Log().WithField("error", err).Warn("failed to chown server data directory")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Given a directory, iterate through all of the files and folders within it and determine
|
|
||||||
// if they should be included in the output based on an array of ignored matches. This uses
|
|
||||||
// standard .gitignore formatting to make that determination.
|
|
||||||
//
|
|
||||||
// If no ignored files are passed through you'll get the entire directory listing.
|
|
||||||
func (fs *Filesystem) GetIncludedFiles(dir string, ignored []string) (*backup.IncludedFiles, error) {
|
|
||||||
cleaned, err := fs.SafePath(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
i, err := ignore.CompileIgnoreLines(ignored...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk through all of the files and directories on a server. This callback only returns
|
|
||||||
// files found, and will keep walking deeper and deeper into directories.
|
|
||||||
inc := new(backup.IncludedFiles)
|
|
||||||
|
|
||||||
err = godirwalk.Walk(cleaned, &godirwalk.Options{
|
|
||||||
Unsorted: true,
|
|
||||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
|
||||||
sp := p
|
|
||||||
if e.IsSymlink() {
|
|
||||||
sp, err = fs.SafePath(p)
|
|
||||||
if err != nil {
|
|
||||||
if IsPathResolutionError(err) {
|
|
||||||
return godirwalk.SkipThis
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only push files into the result array since archives can't create an empty directory within them.
|
|
||||||
if !e.IsDir() {
|
|
||||||
// Avoid unnecessary parsing if there are no ignored files, nothing will match anyways
|
|
||||||
// so no reason to call the function.
|
|
||||||
if len(ignored) == 0 || !i.MatchesPath(strings.TrimPrefix(sp, fs.Path()+"/")) {
|
|
||||||
inc.Push(sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can't just abort if the path is technically ignored. It is possible there is a nested
|
|
||||||
// file or folder that should not be excluded, so in this case we need to just keep going
|
|
||||||
// until we get to a final state.
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return inc, errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compresses all of the files matching the given paths in the specified directory. This function
|
|
||||||
// also supports passing nested paths to only compress certain files and folders when working in
|
|
||||||
// a larger directory. This effectively creates a local backup, but rather than ignoring specific
|
|
||||||
// files and folders, it takes an allow-list of files and folders.
|
|
||||||
//
|
|
||||||
// All paths are relative to the dir that is passed in as the first argument, and the compressed
|
|
||||||
// file will be placed at that location named `archive-{date}.tar.gz`.
|
|
||||||
func (fs *Filesystem) CompressFiles(dir string, paths []string) (os.FileInfo, error) {
|
|
||||||
cleanedRootDir, err := fs.SafePath(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Take all of the paths passed in and merge them together with the root directory we've gotten.
|
|
||||||
for i, p := range paths {
|
|
||||||
paths[i] = filepath.Join(cleanedRootDir, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned, err := fs.ParallelSafePath(paths)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
inc := new(backup.IncludedFiles)
|
|
||||||
// Iterate over all of the cleaned paths and merge them into a large object of final file
|
|
||||||
// paths to pass into the archiver. As directories are encountered this will drop into them
|
|
||||||
// and look for all of the files.
|
|
||||||
for _, p := range cleaned {
|
|
||||||
f, err := os.Stat(p)
|
|
||||||
if err != nil {
|
|
||||||
fs.Server.Log().WithField("error", err).WithField("path", p).Debug("failed to stat file or directory for compression")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !f.IsDir() {
|
|
||||||
inc.Push(p)
|
|
||||||
} else {
|
|
||||||
err := godirwalk.Walk(p, &godirwalk.Options{
|
|
||||||
Unsorted: true,
|
|
||||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
|
||||||
sp := p
|
|
||||||
if e.IsSymlink() {
|
|
||||||
// Ensure that any symlinks are properly resolved to their final destination. If
|
|
||||||
// that destination is outside the server directory skip over this entire item, otherwise
|
|
||||||
// use the resolved location for the rest of this function.
|
|
||||||
sp, err = fs.SafePath(p)
|
|
||||||
if err != nil {
|
|
||||||
if IsPathResolutionError(err) {
|
|
||||||
return godirwalk.SkipThis
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !e.IsDir() {
|
|
||||||
inc.Push(sp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a := &backup.Archive{TrimPrefix: fs.Path(), Files: inc}
|
|
||||||
|
|
||||||
d := path.Join(cleanedRootDir, fmt.Sprintf("archive-%s.tar.gz", strings.ReplaceAll(time.Now().Format(time.RFC3339), ":", "")))
|
|
||||||
|
|
||||||
return a.Create(d, context.Background())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle errors encountered when walking through directories.
|
|
||||||
//
|
|
||||||
// If there is a path resolution error just skip the item entirely. Only return this for a
|
|
||||||
// directory, otherwise return nil. Returning this error for a file will stop the walking
|
|
||||||
// for the remainder of the directory. This is assuming an os.FileInfo struct was even returned.
|
|
||||||
func (fs *Filesystem) handleWalkerError(err error, f os.FileInfo) error {
|
|
||||||
if !IsPathResolutionError(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if f != nil && f.IsDir() {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fileOpener struct {
|
|
||||||
busy uint
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempts to open a given file up to "attempts" number of times, using a backoff. If the file
|
|
||||||
// cannot be opened because of a "text file busy" error, we will attempt until the number of attempts
|
|
||||||
// has been exhaused, at which point we will abort with an error.
|
|
||||||
func (fo *fileOpener) open(path string, flags int, perm os.FileMode) (*os.File, error) {
|
|
||||||
for {
|
|
||||||
f, err := os.OpenFile(path, flags, perm)
|
|
||||||
|
|
||||||
// If there is an error because the text file is busy, go ahead and sleep for a few
|
|
||||||
// hundred milliseconds and then try again up to three times before just returning the
|
|
||||||
// error back to the caller.
|
|
||||||
//
|
|
||||||
// Based on code from: https://github.com/golang/go/issues/22220#issuecomment-336458122
|
|
||||||
if err != nil && fo.busy < 3 && strings.Contains(err.Error(), "text file busy") {
|
|
||||||
time.Sleep(100 * time.Millisecond << fo.busy)
|
|
||||||
fo.busy++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return f, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
163
server/filesystem/compress.go
Normal file
163
server/filesystem/compress.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/karrick/godirwalk"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/pterodactyl/wings/server/backup"
|
||||||
|
ignore "github.com/sabhiram/go-gitignore"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Given a directory, iterate through all of the files and folders within it and determine
|
||||||
|
// if they should be included in the output based on an array of ignored matches. This uses
|
||||||
|
// standard .gitignore formatting to make that determination.
|
||||||
|
//
|
||||||
|
// If no ignored files are passed through you'll get the entire directory listing.
|
||||||
|
func (fs *Filesystem) GetIncludedFiles(dir string, ignored []string) (*backup.IncludedFiles, error) {
|
||||||
|
cleaned, err := fs.SafePath(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
i, err := ignore.CompileIgnoreLines(ignored...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk through all of the files and directories on a server. This callback only returns
|
||||||
|
// files found, and will keep walking deeper and deeper into directories.
|
||||||
|
inc := new(backup.IncludedFiles)
|
||||||
|
|
||||||
|
err = godirwalk.Walk(cleaned, &godirwalk.Options{
|
||||||
|
Unsorted: true,
|
||||||
|
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||||
|
sp := p
|
||||||
|
if e.IsSymlink() {
|
||||||
|
sp, err = fs.SafePath(p)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrBadPathResolution) {
|
||||||
|
return godirwalk.SkipThis
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only push files into the result array since archives can't create an empty directory within them.
|
||||||
|
if !e.IsDir() {
|
||||||
|
// Avoid unnecessary parsing if there are no ignored files, nothing will match anyways
|
||||||
|
// so no reason to call the function.
|
||||||
|
if len(ignored) == 0 || !i.MatchesPath(strings.TrimPrefix(sp, fs.Path()+"/")) {
|
||||||
|
inc.Push(sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can't just abort if the path is technically ignored. It is possible there is a nested
|
||||||
|
// file or folder that should not be excluded, so in this case we need to just keep going
|
||||||
|
// until we get to a final state.
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return inc, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compresses all of the files matching the given paths in the specified directory. This function
|
||||||
|
// also supports passing nested paths to only compress certain files and folders when working in
|
||||||
|
// a larger directory. This effectively creates a local backup, but rather than ignoring specific
|
||||||
|
// files and folders, it takes an allow-list of files and folders.
|
||||||
|
//
|
||||||
|
// All paths are relative to the dir that is passed in as the first argument, and the compressed
|
||||||
|
// file will be placed at that location named `archive-{date}.tar.gz`.
|
||||||
|
func (fs *Filesystem) CompressFiles(dir string, paths []string) (os.FileInfo, error) {
|
||||||
|
cleanedRootDir, err := fs.SafePath(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take all of the paths passed in and merge them together with the root directory we've gotten.
|
||||||
|
for i, p := range paths {
|
||||||
|
paths[i] = filepath.Join(cleanedRootDir, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned, err := fs.ParallelSafePath(paths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
inc := new(backup.IncludedFiles)
|
||||||
|
// Iterate over all of the cleaned paths and merge them into a large object of final file
|
||||||
|
// paths to pass into the archiver. As directories are encountered this will drop into them
|
||||||
|
// and look for all of the files.
|
||||||
|
for _, p := range cleaned {
|
||||||
|
f, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
fs.error(err).WithField("path", p).Debug("failed to stat file or directory for compression")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !f.IsDir() {
|
||||||
|
inc.Push(p)
|
||||||
|
} else {
|
||||||
|
err := godirwalk.Walk(p, &godirwalk.Options{
|
||||||
|
Unsorted: true,
|
||||||
|
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||||
|
sp := p
|
||||||
|
if e.IsSymlink() {
|
||||||
|
// Ensure that any symlinks are properly resolved to their final destination. If
|
||||||
|
// that destination is outside the server directory skip over this entire item, otherwise
|
||||||
|
// use the resolved location for the rest of this function.
|
||||||
|
sp, err = fs.SafePath(p)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrBadPathResolution) {
|
||||||
|
return godirwalk.SkipThis
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !e.IsDir() {
|
||||||
|
inc.Push(sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a := &backup.Archive{TrimPrefix: fs.Path(), Files: inc}
|
||||||
|
d := path.Join(cleanedRootDir, fmt.Sprintf("archive-%s.tar.gz", strings.ReplaceAll(time.Now().Format(time.RFC3339), ":", "")))
|
||||||
|
|
||||||
|
if err := a.Create(d, context.Background()); err != nil {
|
||||||
|
return nil, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Stat(d)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(d)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fs.hasSpaceFor(f.Size()); err != nil {
|
||||||
|
_ = os.Remove(d)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.addDisk(f.Size())
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package server
|
package filesystem
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ import (
|
|||||||
func (fs *Filesystem) SpaceAvailableForDecompression(dir string, file string) (bool, error) {
|
func (fs *Filesystem) SpaceAvailableForDecompression(dir string, file string) (bool, error) {
|
||||||
// Don't waste time trying to determine this if we know the server will have the space for
|
// Don't waste time trying to determine this if we know the server will have the space for
|
||||||
// it since there is no limit.
|
// it since there is no limit.
|
||||||
if fs.Server.DiskSpace() <= 0 {
|
if fs.MaxDisk() <= 0 {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,17 +33,24 @@ func (fs *Filesystem) SpaceAvailableForDecompression(dir string, file string) (b
|
|||||||
dirSize, err := fs.DiskUsage(false)
|
dirSize, err := fs.DiskUsage(false)
|
||||||
|
|
||||||
var size int64
|
var size int64
|
||||||
var max = fs.Server.DiskSpace() * 1000.0 * 1000.0
|
|
||||||
// Walk over the archive and figure out just how large the final output would be from unarchiving it.
|
// Walk over the archive and figure out just how large the final output would be from unarchiving it.
|
||||||
err = archiver.Walk(source, func(f archiver.File) error {
|
err = archiver.Walk(source, func(f archiver.File) error {
|
||||||
if atomic.AddInt64(&size, f.Size()) + dirSize > max {
|
if atomic.AddInt64(&size, f.Size())+dirSize > fs.MaxDisk() {
|
||||||
return errors.WithStack(ErrNotEnoughDiskSpace)
|
return ErrNotEnoughDiskSpace
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
return err == nil, errors.WithStack(err)
|
if err != nil {
|
||||||
|
if strings.HasPrefix(err.Error(), "format ") {
|
||||||
|
return false, ErrUnknownArchiveFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decompress a file in a given directory by using the archiver tool to infer the file
|
// Decompress a file in a given directory by using the archiver tool to infer the file
|
||||||
@@ -63,7 +71,7 @@ func (fs *Filesystem) DecompressFile(dir string, file string) error {
|
|||||||
// Walk over all of the files spinning up an additional go-routine for each file we've encountered
|
// Walk over all of the files spinning up an additional go-routine for each file we've encountered
|
||||||
// and then extract that file from the archive and write it to the disk. If any part of this process
|
// and then extract that file from the archive and write it to the disk. If any part of this process
|
||||||
// encounters an error the entire process will be stopped.
|
// encounters an error the entire process will be stopped.
|
||||||
return archiver.Walk(source, func(f archiver.File) error {
|
err = archiver.Walk(source, func(f archiver.File) error {
|
||||||
// Don't waste time with directories, we don't need to create them if they have no contents, and
|
// Don't waste time with directories, we don't need to create them if they have no contents, and
|
||||||
// we will ensure the directory exists when opening the file for writing anyways.
|
// we will ensure the directory exists when opening the file for writing anyways.
|
||||||
if f.IsDir() {
|
if f.IsDir() {
|
||||||
@@ -90,4 +98,13 @@ func (fs *Filesystem) DecompressFile(dir string, file string) error {
|
|||||||
|
|
||||||
return errors.Wrap(fs.Writefile(p, f), "could not extract file from archive")
|
return errors.Wrap(fs.Writefile(p, f), "could not extract file from archive")
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.HasPrefix(err.Error(), "format ") {
|
||||||
|
return errors.WithStack(ErrUnknownArchiveFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
231
server/filesystem/disk_space.go
Normal file
231
server/filesystem/disk_space.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/apex/log"
|
||||||
|
"github.com/karrick/godirwalk"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SpaceCheckingOpts struct {
|
||||||
|
AllowStaleResponse bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type usageLookupTime struct {
|
||||||
|
sync.RWMutex
|
||||||
|
value time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the last time that a disk space lookup was performed.
|
||||||
|
func (ult *usageLookupTime) Set(t time.Time) {
|
||||||
|
ult.Lock()
|
||||||
|
ult.value = t
|
||||||
|
ult.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the last time that we performed a disk space usage lookup.
|
||||||
|
func (ult *usageLookupTime) Get() time.Time {
|
||||||
|
ult.RLock()
|
||||||
|
defer ult.RUnlock()
|
||||||
|
|
||||||
|
return ult.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the maximum amount of disk space that this Filesystem instance is allowed to use.
|
||||||
|
func (fs *Filesystem) MaxDisk() int64 {
|
||||||
|
fs.mu.RLock()
|
||||||
|
defer fs.mu.RUnlock()
|
||||||
|
|
||||||
|
return fs.diskLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets the disk space limit for this Filesystem instance.
|
||||||
|
func (fs *Filesystem) SetDiskLimit(i int64) {
|
||||||
|
fs.mu.Lock()
|
||||||
|
fs.diskLimit = i
|
||||||
|
fs.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determines if the directory a file is trying to be added to has enough space available
|
||||||
|
// for the file to be written to.
|
||||||
|
//
|
||||||
|
// Because determining the amount of space being used by a server is a taxing operation we
|
||||||
|
// will load it all up into a cache and pull from that as long as the key is not expired.
|
||||||
|
//
|
||||||
|
// This operation will potentially block unless allowStaleValue is set to true. See the
|
||||||
|
// documentation on DiskUsage for how this affects the call.
|
||||||
|
func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool {
|
||||||
|
size, err := fs.DiskUsage(allowStaleValue)
|
||||||
|
if err != nil {
|
||||||
|
log.WithField("root", fs.root).WithField("error", err).Warn("failed to determine root fs directory size")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If space is -1 or 0 just return true, means they're allowed unlimited.
|
||||||
|
//
|
||||||
|
// Technically we could skip disk space calculation because we don't need to check if the
|
||||||
|
// server exceeds it's limit but because this method caches the disk usage it would be best
|
||||||
|
// to calculate the disk usage and always return true.
|
||||||
|
if fs.MaxDisk() == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return size <= fs.MaxDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the cached value for the amount of disk space used by the filesystem. Do not rely on this
|
||||||
|
// function for critical logical checks. It should only be used in areas where the actual disk usage
|
||||||
|
// does not need to be perfect, e.g. API responses for server resource usage.
|
||||||
|
func (fs *Filesystem) CachedUsage() int64 {
|
||||||
|
fs.mu.RLock()
|
||||||
|
defer fs.mu.RUnlock()
|
||||||
|
|
||||||
|
return fs.diskUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal helper function to allow other parts of the codebase to check the total used disk space
|
||||||
|
// as needed without overly taxing the system. This will prioritize the value from the cache to avoid
|
||||||
|
// excessive IO usage. We will only walk the filesystem and determine the size of the directory if there
|
||||||
|
// is no longer a cached value.
|
||||||
|
//
|
||||||
|
// If "allowStaleValue" is set to true, a stale value MAY be returned to the caller if there is an
|
||||||
|
// expired cache value AND there is currently another lookup in progress. If there is no cached value but
|
||||||
|
// no other lookup is in progress, a fresh disk space response will be returned to the caller.
|
||||||
|
//
|
||||||
|
// This is primarily to avoid a bunch of I/O operations from piling up on the server, especially on servers
|
||||||
|
// with a large amount of files.
|
||||||
|
func (fs *Filesystem) DiskUsage(allowStaleValue bool) (int64, error) {
|
||||||
|
if !fs.lastLookupTime.Get().After(time.Now().Add(time.Second * fs.diskCheckInterval * -1)) {
|
||||||
|
// If we are now allowing a stale response go ahead and perform the lookup and return the fresh
|
||||||
|
// value. This is a blocking operation to the calling process.
|
||||||
|
if !allowStaleValue {
|
||||||
|
return fs.updateCachedDiskUsage()
|
||||||
|
} else if !fs.lookupInProgress.Get() {
|
||||||
|
// Otherwise, if we allow a stale value and there isn't a valid item in the cache and we aren't
|
||||||
|
// currently performing a lookup, just do the disk usage calculation in the background.
|
||||||
|
go func(fs *Filesystem) {
|
||||||
|
if _, err := fs.updateCachedDiskUsage(); err != nil {
|
||||||
|
log.WithField("root", fs.root).WithField("error", err).Warn("failed to update fs disk usage from within routine")
|
||||||
|
}
|
||||||
|
}(fs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the currently cached value back to the calling function.
|
||||||
|
return atomic.LoadInt64(&fs.diskUsed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates the currently used disk space for a server.
|
||||||
|
func (fs *Filesystem) updateCachedDiskUsage() (int64, error) {
|
||||||
|
// Obtain an exclusive lock on this process so that we don't unintentionally run it at the same
|
||||||
|
// time as another running process. Once the lock is available it'll read from the cache for the
|
||||||
|
// second call rather than hitting the disk in parallel.
|
||||||
|
fs.mu.Lock()
|
||||||
|
defer fs.mu.Unlock()
|
||||||
|
|
||||||
|
// Signal that we're currently updating the disk size so that other calls to the disk checking
|
||||||
|
// functions can determine if they should queue up additional calls to this function. Ensure that
|
||||||
|
// we always set this back to "false" when this process is done executing.
|
||||||
|
fs.lookupInProgress.Set(true)
|
||||||
|
defer fs.lookupInProgress.Set(false)
|
||||||
|
|
||||||
|
// If there is no size its either because there is no data (in which case running this function
|
||||||
|
// will have effectively no impact), or there is nothing in the cache, in which case we need to
|
||||||
|
// grab the size of their data directory. This is a taxing operation, so we want to store it in
|
||||||
|
// the cache once we've gotten it.
|
||||||
|
size, err := fs.DirectorySize("/")
|
||||||
|
|
||||||
|
// Always cache the size, even if there is an error. We want to always return that value
|
||||||
|
// so that we don't cause an endless loop of determining the disk size if there is a temporary
|
||||||
|
// error encountered.
|
||||||
|
fs.lastLookupTime.Set(time.Now())
|
||||||
|
|
||||||
|
atomic.StoreInt64(&fs.diskUsed, size)
|
||||||
|
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determines the directory size of a given location by running parallel tasks to iterate
|
||||||
|
// through all of the folders. Returns the size in bytes. This can be a fairly taxing operation
|
||||||
|
// on locations with tons of files, so it is recommended that you cache the output.
|
||||||
|
func (fs *Filesystem) DirectorySize(dir string) (int64, error) {
|
||||||
|
d, err := fs.SafePath(dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var size int64
|
||||||
|
var st syscall.Stat_t
|
||||||
|
|
||||||
|
err = godirwalk.Walk(d, &godirwalk.Options{
|
||||||
|
Unsorted: true,
|
||||||
|
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||||
|
// If this is a symlink then resolve the final destination of it before trying to continue walking
|
||||||
|
// over its contents. If it resolves outside the server data directory just skip everything else for
|
||||||
|
// it. Otherwise, allow it to continue.
|
||||||
|
if e.IsSymlink() {
|
||||||
|
if _, err := fs.SafePath(p); err != nil {
|
||||||
|
if errors.Is(err, ErrBadPathResolution) {
|
||||||
|
return godirwalk.SkipThis
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !e.IsDir() {
|
||||||
|
syscall.Lstat(p, &st)
|
||||||
|
atomic.AddInt64(&size, st.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return size, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to determine if a server has space available for a file of a given size.
|
||||||
|
// If space is available, no error will be returned, otherwise an ErrNotEnoughSpace error
|
||||||
|
// will be raised.
|
||||||
|
func (fs *Filesystem) hasSpaceFor(size int64) error {
|
||||||
|
if fs.MaxDisk() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := fs.DiskUsage(true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s + size) > fs.MaxDisk() {
|
||||||
|
return ErrNotEnoughDiskSpace
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates the disk usage for the Filesystem instance.
|
||||||
|
func (fs *Filesystem) addDisk(i int64) int64 {
|
||||||
|
var size = atomic.LoadInt64(&fs.diskUsed)
|
||||||
|
// Sorry go gods. This is ugly but the best approach I can come up with for right
|
||||||
|
// now without completely re-evaluating the logic we use for determining disk space.
|
||||||
|
//
|
||||||
|
// Normally I would just be using the atomic load right below, but I'm not sure about
|
||||||
|
// the scenarios where it is 0 because nothing has run that would trigger a disk size
|
||||||
|
// calculation?
|
||||||
|
//
|
||||||
|
// Perhaps that isn't even a concern for the sake of this?
|
||||||
|
if !fs.isTest {
|
||||||
|
size, _ = fs.DiskUsage(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're dropping below 0 somehow just cap it to 0.
|
||||||
|
if (size + i) < 0 {
|
||||||
|
return atomic.SwapInt64(&fs.diskUsed, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return atomic.AddInt64(&fs.diskUsed, i)
|
||||||
|
}
|
||||||
35
server/filesystem/errors.go
Normal file
35
server/filesystem/errors.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/apex/log"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrIsDirectory = errors.New("filesystem: is a directory")
|
||||||
|
var ErrNotEnoughDiskSpace = errors.New("filesystem: not enough disk space")
|
||||||
|
var ErrBadPathResolution = errors.New("filesystem: invalid path resolution")
|
||||||
|
var ErrUnknownArchiveFormat = errors.New("filesystem: unknown archive format")
|
||||||
|
|
||||||
|
// Generates an error logger instance with some basic information.
|
||||||
|
func (fs *Filesystem) error(err error) *log.Entry {
|
||||||
|
return log.WithField("subsystem", "filesystem").WithField("root", fs.root).WithField("error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle errors encountered when walking through directories.
|
||||||
|
//
|
||||||
|
// If there is a path resolution error just skip the item entirely. Only return this for a
|
||||||
|
// directory, otherwise return nil. Returning this error for a file will stop the walking
|
||||||
|
// for the remainder of the directory. This is assuming an os.FileInfo struct was even returned.
|
||||||
|
func (fs *Filesystem) handleWalkerError(err error, f os.FileInfo) error {
|
||||||
|
if !errors.Is(err, ErrBadPathResolution) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if f != nil && f.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
461
server/filesystem/filesystem.go
Normal file
461
server/filesystem/filesystem.go
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/karrick/godirwalk"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/pterodactyl/wings/config"
|
||||||
|
"github.com/pterodactyl/wings/system"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Filesystem struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lastLookupTime *usageLookupTime
|
||||||
|
lookupInProgress system.AtomicBool
|
||||||
|
diskUsed int64
|
||||||
|
diskCheckInterval time.Duration
|
||||||
|
|
||||||
|
// The maximum amount of disk space (in bytes) that this Filesystem instance can use.
|
||||||
|
diskLimit int64
|
||||||
|
|
||||||
|
// The root data directory path for this Filesystem instance.
|
||||||
|
root string
|
||||||
|
|
||||||
|
isTest bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new Filesystem instance for a given server.
|
||||||
|
func New(root string, size int64) *Filesystem {
|
||||||
|
return &Filesystem{
|
||||||
|
root: root,
|
||||||
|
diskLimit: size,
|
||||||
|
diskCheckInterval: time.Duration(config.Get().System.DiskCheckInterval),
|
||||||
|
lastLookupTime: &usageLookupTime{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the root path for the Filesystem instance.
|
||||||
|
func (fs *Filesystem) Path() string {
|
||||||
|
return fs.root
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads a file on the system and returns it as a byte representation in a file
|
||||||
|
// reader. This is not the most memory efficient usage since it will be reading the
|
||||||
|
// entirety of the file into memory.
|
||||||
|
func (fs *Filesystem) Readfile(p string, w io.Writer) error {
|
||||||
|
cleaned, err := fs.SafePath(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if st, err := os.Stat(cleaned); err != nil {
|
||||||
|
return err
|
||||||
|
} else if st.IsDir() {
|
||||||
|
return ErrIsDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = bufio.NewReader(f).WriteTo(w)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes a file to the system. If the file does not already exist one will be created.
|
||||||
|
func (fs *Filesystem) Writefile(p string, r io.Reader) error {
|
||||||
|
cleaned, err := fs.SafePath(p)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSize int64
|
||||||
|
// If the file does not exist on the system already go ahead and create the pathway
|
||||||
|
// to it and an empty file. We'll then write to it later on after this completes.
|
||||||
|
if stat, err := os.Stat(cleaned); err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cleaned), 0755); err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fs.Chown(filepath.Dir(cleaned)); err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if stat.IsDir() {
|
||||||
|
return ErrIsDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSize = stat.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
br := bufio.NewReader(r)
|
||||||
|
// Check that the new size we're writing to the disk can fit. If there is currently a file
|
||||||
|
// we'll subtract that current file size from the size of the buffer to determine the amount
|
||||||
|
// of new data we're writing (or amount we're removing if smaller).
|
||||||
|
if err := fs.hasSpaceFor(int64(br.Size()) - currentSize); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
o := &fileOpener{}
|
||||||
|
// This will either create the file if it does not already exist, or open and
|
||||||
|
// truncate the existing file.
|
||||||
|
file, err := o.open(cleaned, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 1024*4)
|
||||||
|
sz, err := io.CopyBuffer(file, r, buf)
|
||||||
|
|
||||||
|
// Adjust the disk usage to account for the old size and the new size of the file.
|
||||||
|
fs.addDisk(sz - currentSize)
|
||||||
|
|
||||||
|
// Finally, chown the file to ensure the permissions don't end up out-of-whack
|
||||||
|
// if we had just created it.
|
||||||
|
return fs.Chown(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new directory (name) at a specified path (p) for the server.
|
||||||
|
func (fs *Filesystem) CreateDirectory(name string, p string) error {
|
||||||
|
cleaned, err := fs.SafePath(path.Join(p, name))
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.MkdirAll(cleaned, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moves (or renames) a file or directory.
|
||||||
|
func (fs *Filesystem) Rename(from string, to string) error {
|
||||||
|
cleanedFrom, err := fs.SafePath(from)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanedTo, err := fs.SafePath(to)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the target file or directory already exists the rename function will fail, so just
|
||||||
|
// bail out now.
|
||||||
|
if _, err := os.Stat(cleanedTo); err == nil {
|
||||||
|
return os.ErrExist
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleanedTo == fs.Path() {
|
||||||
|
return errors.New("attempting to rename into an invalid directory space")
|
||||||
|
}
|
||||||
|
|
||||||
|
d := strings.TrimSuffix(cleanedTo, path.Base(cleanedTo))
|
||||||
|
// Ensure that the directory we're moving into exists correctly on the system. Only do this if
|
||||||
|
// we're not at the root directory level.
|
||||||
|
if d != fs.Path() {
|
||||||
|
if mkerr := os.MkdirAll(d, 0755); mkerr != nil {
|
||||||
|
return errors.Wrap(mkerr, "failed to create directory structure for file rename")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Rename(cleanedFrom, cleanedTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively iterates over a file or directory and sets the permissions on all of the
|
||||||
|
// underlying files. Iterate over all of the files and directories. If it is a file just
|
||||||
|
// go ahead and perform the chown operation. Otherwise dig deeper into the directory until
|
||||||
|
// we've run out of directories to dig into.
|
||||||
|
func (fs *Filesystem) Chown(path string) error {
|
||||||
|
cleaned, err := fs.SafePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fs.isTest {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := config.Get().System.User.Uid
|
||||||
|
gid := config.Get().System.User.Gid
|
||||||
|
|
||||||
|
// Start by just chowning the initial path that we received.
|
||||||
|
if err := os.Chown(cleaned, uid, gid); err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is not a directory we can now return from the function, there is nothing
|
||||||
|
// left that we need to do.
|
||||||
|
if st, _ := os.Stat(cleaned); !st.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this was a directory, begin walking over its contents recursively and ensure that all
|
||||||
|
// of the subfiles and directories get their permissions updated as well.
|
||||||
|
return godirwalk.Walk(cleaned, &godirwalk.Options{
|
||||||
|
Unsorted: true,
|
||||||
|
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||||
|
// Do not attempt to chmod a symlink. Go's os.Chown function will affect the symlink
|
||||||
|
// so if it points to a location outside the data directory the user would be able to
|
||||||
|
// (un)intentionally modify that files permissions.
|
||||||
|
if e.IsSymlink() {
|
||||||
|
if e.IsDir() {
|
||||||
|
return godirwalk.SkipThis
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Chown(p, uid, gid)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin looping up to 50 times to try and create a unique copy file name. This will take
|
||||||
|
// an input of "file.txt" and generate "file copy.txt". If that name is already taken, it will
|
||||||
|
// then try to write "file copy 2.txt" and so on, until reaching 50 loops. At that point we
|
||||||
|
// won't waste anymore time, just use the current timestamp and make that copy.
|
||||||
|
//
|
||||||
|
// Could probably make this more efficient by checking if there are any files matching the copy
|
||||||
|
// pattern, and trying to find the highest number and then incrementing it by one rather than
|
||||||
|
// looping endlessly.
|
||||||
|
func (fs *Filesystem) findCopySuffix(dir string, name string, extension string) (string, error) {
|
||||||
|
var i int
|
||||||
|
var suffix = " copy"
|
||||||
|
|
||||||
|
for i = 0; i < 51; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
suffix = " copy " + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := name + suffix + extension
|
||||||
|
// If we stat the file and it does not exist that means we're good to create the copy. If it
|
||||||
|
// does exist, we'll just continue to the next loop and try again.
|
||||||
|
if _, err := fs.Stat(path.Join(dir, n)); err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 50 {
|
||||||
|
suffix = "copy." + time.Now().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return name + suffix + extension, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copies a given file to the same location and appends a suffix to the file to indicate that
|
||||||
|
// it has been copied.
|
||||||
|
func (fs *Filesystem) Copy(p string) error {
|
||||||
|
cleaned, err := fs.SafePath(p)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := os.Stat(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
} else if s.IsDir() || !s.Mode().IsRegular() {
|
||||||
|
// If this is a directory or not a regular file, just throw a not-exist error
|
||||||
|
// since anything calling this function should understand what that means.
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that copying this file wouldn't put the server over its limit.
|
||||||
|
if err := fs.hasSpaceFor(s.Size()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Base(cleaned)
|
||||||
|
relative := strings.TrimSuffix(strings.TrimPrefix(cleaned, fs.Path()), base)
|
||||||
|
extension := filepath.Ext(base)
|
||||||
|
name := strings.TrimSuffix(base, extension)
|
||||||
|
|
||||||
|
// Ensure that ".tar" is also counted as apart of the file extension.
|
||||||
|
// There might be a better way to handle this for other double file extensions,
|
||||||
|
// but this is a good workaround for now.
|
||||||
|
if strings.HasSuffix(name, ".tar") {
|
||||||
|
extension = ".tar" + extension
|
||||||
|
name = strings.TrimSuffix(name, ".tar")
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := os.Open(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
n, err := fs.findCopySuffix(relative, name, extension)
|
||||||
|
|
||||||
|
return fs.Writefile(path.Join(relative, n), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes a file or folder from the system. Prevents the user from accidentally
|
||||||
|
// (or maliciously) removing their root server data directory.
|
||||||
|
func (fs *Filesystem) Delete(p string) error {
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
// This is one of the few (only?) places in the codebase where we're explicitly not using
|
||||||
|
// the SafePath functionality when working with user provided input. If we did, you would
|
||||||
|
// not be able to delete a file that is a symlink pointing to a location outside of the data
|
||||||
|
// directory.
|
||||||
|
//
|
||||||
|
// We also want to avoid resolving a symlink that points _within_ the data directory and thus
|
||||||
|
// deleting the actual source file for the symlink rather than the symlink itself. For these
|
||||||
|
// purposes just resolve the actual file path using filepath.Join() and confirm that the path
|
||||||
|
// exists within the data directory.
|
||||||
|
resolved := fs.unsafeFilePath(p)
|
||||||
|
if !fs.unsafeIsInDataDirectory(resolved) {
|
||||||
|
return ErrBadPathResolution
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block any whoopsies.
|
||||||
|
if resolved == fs.Path() {
|
||||||
|
return errors.New("cannot delete root server directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
if st, err := os.Lstat(resolved); err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
fs.error(err).Warn("error while attempting to stat file before deletion")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !st.IsDir() {
|
||||||
|
fs.addDisk(-st.Size())
|
||||||
|
} else {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(wg *sync.WaitGroup, st os.FileInfo, resolved string) {
|
||||||
|
defer wg.Done()
|
||||||
|
if s, err := fs.DirectorySize(resolved); err == nil {
|
||||||
|
fs.addDisk(-s)
|
||||||
|
}
|
||||||
|
}(&wg, st, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
return os.RemoveAll(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileOpener struct {
|
||||||
|
busy uint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempts to open a given file up to "attempts" number of times, using a backoff. If the file
|
||||||
|
// cannot be opened because of a "text file busy" error, we will attempt until the number of attempts
|
||||||
|
// has been exhaused, at which point we will abort with an error.
|
||||||
|
func (fo *fileOpener) open(path string, flags int, perm os.FileMode) (*os.File, error) {
|
||||||
|
for {
|
||||||
|
f, err := os.OpenFile(path, flags, perm)
|
||||||
|
|
||||||
|
// If there is an error because the text file is busy, go ahead and sleep for a few
|
||||||
|
// hundred milliseconds and then try again up to three times before just returning the
|
||||||
|
// error back to the caller.
|
||||||
|
//
|
||||||
|
// Based on code from: https://github.com/golang/go/issues/22220#issuecomment-336458122
|
||||||
|
if err != nil && fo.busy < 3 && strings.Contains(err.Error(), "text file busy") {
|
||||||
|
time.Sleep(100 * time.Millisecond << fo.busy)
|
||||||
|
fo.busy++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lists the contents of a given directory and returns stat information about each
|
||||||
|
// file and folder within it.
|
||||||
|
func (fs *Filesystem) ListDirectory(p string) ([]*Stat, error) {
|
||||||
|
cleaned, err := fs.SafePath(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := ioutil.ReadDir(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// You must initialize the output of this directory as a non-nil value otherwise
|
||||||
|
// when it is marshaled into a JSON object you'll just get 'null' back, which will
|
||||||
|
// break the panel badly.
|
||||||
|
out := make([]*Stat, len(files))
|
||||||
|
|
||||||
|
// Iterate over all of the files and directories returned and perform an async process
|
||||||
|
// to get the mime-type for them all.
|
||||||
|
for i, file := range files {
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func(idx int, f os.FileInfo) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
var m *mimetype.MIME
|
||||||
|
var d = "inode/directory"
|
||||||
|
if !f.IsDir() {
|
||||||
|
cleanedp := filepath.Join(cleaned, f.Name())
|
||||||
|
if f.Mode()&os.ModeSymlink != 0 {
|
||||||
|
cleanedp, _ = fs.SafePath(filepath.Join(cleaned, f.Name()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleanedp != "" {
|
||||||
|
m, _ = mimetype.DetectFile(filepath.Join(cleaned, f.Name()))
|
||||||
|
} else {
|
||||||
|
// Just pass this for an unknown type because the file could not safely be resolved within
|
||||||
|
// the server data path.
|
||||||
|
d = "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st := &Stat{
|
||||||
|
Info: f,
|
||||||
|
Mimetype: d,
|
||||||
|
}
|
||||||
|
|
||||||
|
if m != nil {
|
||||||
|
st.Mimetype = m.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
out[idx] = st
|
||||||
|
}(i, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Sort the output alphabetically to begin with since we've run the output
|
||||||
|
// through an asynchronous process and the order is gonna be very random.
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Info.Name() == out[j].Info.Name() || out[i].Info.Name() > out[j].Info.Name() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Then, sort it so that directories are listed first in the output. Everything
|
||||||
|
// will continue to be alphabetized at this point.
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
return out[i].Info.IsDir()
|
||||||
|
})
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
784
server/filesystem/filesystem_test.go
Normal file
784
server/filesystem/filesystem_test.go
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
. "github.com/franela/goblin"
|
||||||
|
"github.com/pterodactyl/wings/config"
|
||||||
|
"io/ioutil"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewFs() (*Filesystem, *rootFs) {
|
||||||
|
config.Set(&config.Configuration{
|
||||||
|
AuthenticationToken: "abc",
|
||||||
|
System: config.SystemConfiguration{
|
||||||
|
RootDirectory: "/server",
|
||||||
|
DiskCheckInterval: 150,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
tmpDir, err := ioutil.TempDir(os.TempDir(), "pterodactyl")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
rfs := rootFs{root: tmpDir}
|
||||||
|
|
||||||
|
rfs.reset()
|
||||||
|
|
||||||
|
fs := New(filepath.Join(tmpDir, "/server"), 0)
|
||||||
|
fs.isTest = true
|
||||||
|
|
||||||
|
return fs, &rfs
|
||||||
|
}
|
||||||
|
|
||||||
|
type rootFs struct {
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rfs *rootFs) CreateServerFile(p string, c string) error {
|
||||||
|
f, err := os.Create(filepath.Join(rfs.root, "/server", p))
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
f.Write([]byte(c))
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rfs *rootFs) StatServerFile(p string) (os.FileInfo, error) {
|
||||||
|
return os.Stat(filepath.Join(rfs.root, "/server", p))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rfs *rootFs) reset() {
|
||||||
|
if err := os.RemoveAll(filepath.Join(rfs.root, "/server")); err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Mkdir(filepath.Join(rfs.root, "/server"), 0755); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystem_Path(t *testing.T) {
|
||||||
|
g := Goblin(t)
|
||||||
|
fs, rfs := NewFs()
|
||||||
|
|
||||||
|
g.Describe("Path", func() {
|
||||||
|
g.It("returns the root path for the instance", func() {
|
||||||
|
g.Assert(fs.Path()).Equal(filepath.Join(rfs.root, "/server"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystem_SafePath(t *testing.T) {
|
||||||
|
g := Goblin(t)
|
||||||
|
fs, rfs := NewFs()
|
||||||
|
prefix := filepath.Join(rfs.root, "/server")
|
||||||
|
|
||||||
|
g.Describe("SafePath", func() {
|
||||||
|
g.It("returns a cleaned path to a given file", func() {
|
||||||
|
p, err := fs.SafePath("test.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(p).Equal(prefix + "/test.txt")
|
||||||
|
|
||||||
|
p, err = fs.SafePath("/test.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(p).Equal(prefix + "/test.txt")
|
||||||
|
|
||||||
|
p, err = fs.SafePath("./test.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(p).Equal(prefix + "/test.txt")
|
||||||
|
|
||||||
|
p, err = fs.SafePath("/foo/../test.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(p).Equal(prefix + "/test.txt")
|
||||||
|
|
||||||
|
p, err = fs.SafePath("/foo/bar")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(p).Equal(prefix + "/foo/bar")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("handles root directory access", func() {
|
||||||
|
p, err := fs.SafePath("/")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(p).Equal(prefix)
|
||||||
|
|
||||||
|
p, err = fs.SafePath("")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(p).Equal(prefix)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("removes trailing slashes from paths", func() {
|
||||||
|
p, err := fs.SafePath("/foo/bar/")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(p).Equal(prefix + "/foo/bar")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("handles deeply nested directories that do not exist", func() {
|
||||||
|
p, err := fs.SafePath("/foo/bar/baz/quaz/../../ducks/testing.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(p).Equal(prefix + "/foo/bar/ducks/testing.txt")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("blocks access to files outside the root directory", func() {
|
||||||
|
p, err := fs.SafePath("../test.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
g.Assert(p).Equal("")
|
||||||
|
|
||||||
|
p, err = fs.SafePath("/../test.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
g.Assert(p).Equal("")
|
||||||
|
|
||||||
|
p, err = fs.SafePath("./foo/../../test.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
g.Assert(p).Equal("")
|
||||||
|
|
||||||
|
p, err = fs.SafePath("..")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
g.Assert(p).Equal("")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// We test against accessing files outside the root directory in the tests, however it
|
||||||
|
// is still possible for someone to mess up and not properly use this safe path call. In
|
||||||
|
// order to truly confirm this, we'll try to pass in a symlinked malicious file to all of
|
||||||
|
// the calls and ensure they all fail with the same reason.
|
||||||
|
func TestFilesystem_Blocks_Symlinks(t *testing.T) {
|
||||||
|
g := Goblin(t)
|
||||||
|
fs, rfs := NewFs()
|
||||||
|
|
||||||
|
if err := rfs.CreateServerFile("/../malicious.txt", "external content"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Mkdir(filepath.Join(rfs.root, "/malicious_dir"), 0777); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Symlink(filepath.Join(rfs.root, "malicious.txt"), filepath.Join(rfs.root, "/server/symlinked.txt")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Symlink(filepath.Join(rfs.root, "/malicious_dir"), filepath.Join(rfs.root, "/server/external_dir")); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Describe("Readfile", func() {
|
||||||
|
g.It("cannot read a file symlinked outside the root", func() {
|
||||||
|
b := bytes.Buffer{}
|
||||||
|
|
||||||
|
err := fs.Readfile("symlinked.txt", &b)
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Describe("Writefile", func() {
|
||||||
|
g.It("cannot write to a file symlinked outside the root", func() {
|
||||||
|
r := bytes.NewReader([]byte("testing"))
|
||||||
|
|
||||||
|
err := fs.Writefile("symlinked.txt", r)
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("cannot write a file to a directory symlinked outside the root", func() {
|
||||||
|
r := bytes.NewReader([]byte("testing"))
|
||||||
|
|
||||||
|
err := fs.Writefile("external_dir/foo.txt", r)
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Describe("CreateDirectory", func() {
|
||||||
|
g.It("cannot create a directory outside the root", func() {
|
||||||
|
err := fs.CreateDirectory("my_dir", "external_dir")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("cannot create a nested directory outside the root", func() {
|
||||||
|
err := fs.CreateDirectory("my/nested/dir", "external_dir/foo/bar")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("cannot create a nested directory outside the root", func() {
|
||||||
|
err := fs.CreateDirectory("my/nested/dir", "external_dir/server")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Describe("Rename", func() {
|
||||||
|
g.It("cannot rename a file symlinked outside the directory root", func() {
|
||||||
|
err := fs.Rename("symlinked.txt", "foo.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("cannot rename a symlinked directory outside the root", func() {
|
||||||
|
err := fs.Rename("external_dir", "foo")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("cannot rename a file to a location outside the directory root", func() {
|
||||||
|
rfs.CreateServerFile("my_file.txt", "internal content")
|
||||||
|
|
||||||
|
err := fs.Rename("my_file.txt", "external_dir/my_file.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Describe("Chown", func() {
|
||||||
|
g.It("cannot chown a file symlinked outside the directory root", func() {
|
||||||
|
err := fs.Chown("symlinked.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("cannot chown a directory symlinked outside the directory root", func() {
|
||||||
|
err := fs.Chown("external_dir")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Describe("Copy", func() {
|
||||||
|
g.It("cannot copy a file symlinked outside the directory root", func() {
|
||||||
|
err := fs.Copy("symlinked.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Describe("Delete", func() {
|
||||||
|
g.It("deletes the symlinked file but leaves the source", func() {
|
||||||
|
err := fs.Delete("symlinked.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(rfs.root, "malicious.txt"))
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
_, err = rfs.StatServerFile("symlinked.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
rfs.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystem_Readfile(t *testing.T) {
|
||||||
|
g := Goblin(t)
|
||||||
|
fs, rfs := NewFs()
|
||||||
|
|
||||||
|
g.Describe("Readfile", func() {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
g.It("opens a file if it exists on the system", func() {
|
||||||
|
err := rfs.CreateServerFile("test.txt", "testing")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Readfile("test.txt", buf)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(buf.String()).Equal("testing")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("returns an error if the file does not exist", func() {
|
||||||
|
err := fs.Readfile("test.txt", buf)
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("returns an error if the \"file\" is a directory", func() {
|
||||||
|
err := os.Mkdir(filepath.Join(rfs.root, "/server/test.txt"), 0755)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Readfile("test.txt", buf)
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrIsDirectory)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("cannot open a file outside the root directory", func() {
|
||||||
|
err := rfs.CreateServerFile("/../test.txt", "testing")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Readfile("/../test.txt", buf)
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.AfterEach(func() {
|
||||||
|
buf.Truncate(0)
|
||||||
|
fs.diskUsed = 0
|
||||||
|
rfs.reset()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystem_Writefile(t *testing.T) {
|
||||||
|
g := Goblin(t)
|
||||||
|
fs, rfs := NewFs()
|
||||||
|
|
||||||
|
g.Describe("Open and WriteFile", func() {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
// Test that a file can be written to the disk and that the disk space used as a result
|
||||||
|
// is updated correctly in the end.
|
||||||
|
g.It("can create a new file", func() {
|
||||||
|
r := bytes.NewReader([]byte("test file content"))
|
||||||
|
|
||||||
|
g.Assert(fs.diskUsed).Equal(int64(0))
|
||||||
|
|
||||||
|
err := fs.Writefile("test.txt", r)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Readfile("test.txt", buf)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(buf.String()).Equal("test file content")
|
||||||
|
g.Assert(fs.diskUsed).Equal(r.Size())
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("can create a new file inside a nested directory with leading slash", func() {
|
||||||
|
r := bytes.NewReader([]byte("test file content"))
|
||||||
|
|
||||||
|
err := fs.Writefile("/some/nested/test.txt", r)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Readfile("/some/nested/test.txt", buf)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(buf.String()).Equal("test file content")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("can create a new file inside a nested directory without a trailing slash", func() {
|
||||||
|
r := bytes.NewReader([]byte("test file content"))
|
||||||
|
|
||||||
|
err := fs.Writefile("some/../foo/bar/test.txt", r)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Readfile("foo/bar/test.txt", buf)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(buf.String()).Equal("test file content")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("cannot create a file outside the root directory", func() {
|
||||||
|
r := bytes.NewReader([]byte("test file content"))
|
||||||
|
|
||||||
|
err := fs.Writefile("/some/../foo/../../test.txt", r)
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("cannot write a file that exceedes the disk limits", func() {
|
||||||
|
fs.diskLimit = 1024
|
||||||
|
|
||||||
|
b := make([]byte, 1025)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(len(b)).Equal(1025)
|
||||||
|
|
||||||
|
r := bytes.NewReader(b)
|
||||||
|
err = fs.Writefile("test.txt", r)
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrNotEnoughDiskSpace)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("updates the total space used when a file is appended to", func() {
|
||||||
|
fs.diskUsed = 100
|
||||||
|
|
||||||
|
b := make([]byte, 100)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
|
||||||
|
r := bytes.NewReader(b)
|
||||||
|
err := fs.Writefile("test.txt", r)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(fs.diskUsed).Equal(int64(200))
|
||||||
|
|
||||||
|
// If we write less data than already exists, we should expect the total
|
||||||
|
// disk used to be decremented.
|
||||||
|
b = make([]byte, 50)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
|
||||||
|
r = bytes.NewReader(b)
|
||||||
|
err = fs.Writefile("test.txt", r)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(fs.diskUsed).Equal(int64(150))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("truncates the file when writing new contents", func() {
|
||||||
|
r := bytes.NewReader([]byte("original data"))
|
||||||
|
err := fs.Writefile("test.txt", r)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
r = bytes.NewReader([]byte("new data"))
|
||||||
|
err = fs.Writefile("test.txt", r)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Readfile("test.txt", buf)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(buf.String()).Equal("new data")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.AfterEach(func() {
|
||||||
|
buf.Truncate(0)
|
||||||
|
rfs.reset()
|
||||||
|
fs.diskUsed = 0
|
||||||
|
fs.diskLimit = 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystem_CreateDirectory(t *testing.T) {
|
||||||
|
g := Goblin(t)
|
||||||
|
fs, rfs := NewFs()
|
||||||
|
|
||||||
|
g.Describe("CreateDirectory", func() {
|
||||||
|
g.It("should create missing directories automatically", func() {
|
||||||
|
err := fs.CreateDirectory("test", "foo/bar/baz")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
st, err := rfs.StatServerFile("foo/bar/baz/test")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(st.IsDir()).IsTrue()
|
||||||
|
g.Assert(st.Name()).Equal("test")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should work with leading and trailing slashes", func() {
|
||||||
|
err := fs.CreateDirectory("test", "/foozie/barzie/bazzy/")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
st, err := rfs.StatServerFile("foozie/barzie/bazzy/test")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(st.IsDir()).IsTrue()
|
||||||
|
g.Assert(st.Name()).Equal("test")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should not allow the creation of directories outside the root", func() {
|
||||||
|
err := fs.CreateDirectory("test", "e/../../something")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should not increment the disk usage", func() {
|
||||||
|
err := fs.CreateDirectory("test", "/")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(fs.diskUsed).Equal(int64(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.AfterEach(func() {
|
||||||
|
rfs.reset()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystem_Rename(t *testing.T) {
|
||||||
|
g := Goblin(t)
|
||||||
|
fs, rfs := NewFs()
|
||||||
|
|
||||||
|
g.Describe("Rename", func() {
|
||||||
|
g.BeforeEach(func() {
|
||||||
|
if err := rfs.CreateServerFile("source.txt", "text content"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("returns an error if the target already exists", func() {
|
||||||
|
err := rfs.CreateServerFile("target.txt", "taget content")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Rename("source.txt", "target.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("returns an error if the final destination is the root directory", func() {
|
||||||
|
err := fs.Rename("source.txt", "/")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("returns an error if the source destination is the root directory", func() {
|
||||||
|
err := fs.Rename("source.txt", "/")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("does not allow renaming to a location outside the root", func() {
|
||||||
|
err := fs.Rename("source.txt", "../target.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("does not allow renaming from a location outside the root", func() {
|
||||||
|
err := rfs.CreateServerFile("/../ext-source.txt", "taget content")
|
||||||
|
|
||||||
|
err = fs.Rename("/../ext-source.txt", "target.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("allows a file to be renamed", func() {
|
||||||
|
err := fs.Rename("source.txt", "target.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
_, err = rfs.StatServerFile("source.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||||
|
|
||||||
|
st, err := rfs.StatServerFile("target.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(st.Name()).Equal("target.txt")
|
||||||
|
g.Assert(st.Size()).IsNotZero()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("allows a folder to be renamed", func() {
|
||||||
|
err := os.Mkdir(filepath.Join(rfs.root, "/server/source_dir"), 0755)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Rename("source_dir", "target_dir")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
_, err = rfs.StatServerFile("source_dir")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||||
|
|
||||||
|
st, err := rfs.StatServerFile("target_dir")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(st.IsDir()).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("returns an error if the source does not exist", func() {
|
||||||
|
err := fs.Rename("missing.txt", "target.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("creates directories if they are missing", func() {
|
||||||
|
err := fs.Rename("source.txt", "nested/folder/target.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
st, err := rfs.StatServerFile("nested/folder/target.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(st.Name()).Equal("target.txt")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.AfterEach(func() {
|
||||||
|
rfs.reset()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystem_Copy(t *testing.T) {
|
||||||
|
g := Goblin(t)
|
||||||
|
fs, rfs := NewFs()
|
||||||
|
|
||||||
|
g.Describe("Copy", func() {
|
||||||
|
g.BeforeEach(func() {
|
||||||
|
if err := rfs.CreateServerFile("source.txt", "text content"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.diskUsed = int64(utf8.RuneCountInString("test content"))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should return an error if the source does not exist", func() {
|
||||||
|
err := fs.Copy("foo.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should return an error if the source is outside the root", func() {
|
||||||
|
err := rfs.CreateServerFile("/../ext-source.txt", "text content")
|
||||||
|
|
||||||
|
err = fs.Copy("../ext-source.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should return an error if the source directory is outside the root", func() {
|
||||||
|
err := os.MkdirAll(filepath.Join(rfs.root, "/nested/in/dir"), 0755)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = rfs.CreateServerFile("/../nested/in/dir/ext-source.txt", "external content")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Copy("../nested/in/dir/ext-source.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
|
||||||
|
err = fs.Copy("nested/in/../../../nested/in/dir/ext-source.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should return an error if the source is a directory", func() {
|
||||||
|
err := os.Mkdir(filepath.Join(rfs.root, "/server/dir"), 0755)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Copy("dir")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should return an error if there is not space to copy the file", func() {
|
||||||
|
fs.diskLimit = 2
|
||||||
|
|
||||||
|
err := fs.Copy("source.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrNotEnoughDiskSpace)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should create a copy of the file and increment the disk used", func() {
|
||||||
|
err := fs.Copy("source.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
_, err = rfs.StatServerFile("source.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
_, err = rfs.StatServerFile("source copy.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should create a copy of the file with a suffix if a copy already exists", func() {
|
||||||
|
err := fs.Copy("source.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Copy("source.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
r := []string{"source.txt", "source copy.txt", "source copy 1.txt"}
|
||||||
|
|
||||||
|
for _, name := range r {
|
||||||
|
_, err = rfs.StatServerFile(name)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Assert(fs.diskUsed).Equal(int64(utf8.RuneCountInString("test content")) * 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("should create a copy inside of a directory", func() {
|
||||||
|
err := os.MkdirAll(filepath.Join(rfs.root, "/server/nested/in/dir"), 0755)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = rfs.CreateServerFile("nested/in/dir/source.txt", "test content")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
err = fs.Copy("nested/in/dir/source.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
_, err = rfs.StatServerFile("nested/in/dir/source.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
_, err = rfs.StatServerFile("nested/in/dir/source copy.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.AfterEach(func() {
|
||||||
|
rfs.reset()
|
||||||
|
fs.diskUsed = 0
|
||||||
|
fs.diskLimit = 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystem_Delete(t *testing.T) {
|
||||||
|
g := Goblin(t)
|
||||||
|
fs, rfs := NewFs()
|
||||||
|
|
||||||
|
g.Describe("Delete", func() {
|
||||||
|
g.BeforeEach(func() {
|
||||||
|
if err := rfs.CreateServerFile("source.txt", "test content"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.diskUsed = int64(utf8.RuneCountInString("test content"))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("does not delete files outside the root directory", func() {
|
||||||
|
err := rfs.CreateServerFile("/../ext-source.txt", "external content")
|
||||||
|
|
||||||
|
err = fs.Delete("../ext-source.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("does not allow the deletion of the root directory", func() {
|
||||||
|
err := fs.Delete("/")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(err.Error()).Equal("cannot delete root server directory")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("does not return an error if the target does not exist", func() {
|
||||||
|
err := fs.Delete("missing.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
st, err := rfs.StatServerFile("source.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(st.Name()).Equal("source.txt")
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("deletes files and subtracts their size from the disk usage", func() {
|
||||||
|
err := fs.Delete("source.txt")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
_, err = rfs.StatServerFile("source.txt")
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||||
|
|
||||||
|
g.Assert(fs.diskUsed).Equal(int64(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.It("deletes all items inside a directory if the directory is deleted", func() {
|
||||||
|
sources := []string{
|
||||||
|
"foo/source.txt",
|
||||||
|
"foo/bar/source.txt",
|
||||||
|
"foo/bar/baz/source.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.MkdirAll(filepath.Join(rfs.root, "/server/foo/bar/baz"), 0755)
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
|
||||||
|
for _, s := range sources {
|
||||||
|
err = rfs.CreateServerFile(s, "test content")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.diskUsed = int64(utf8.RuneCountInString("test content") * 3)
|
||||||
|
|
||||||
|
err = fs.Delete("foo")
|
||||||
|
g.Assert(err).IsNil()
|
||||||
|
g.Assert(fs.diskUsed).Equal(int64(0))
|
||||||
|
|
||||||
|
for _, s := range sources {
|
||||||
|
_, err = rfs.StatServerFile(s)
|
||||||
|
g.Assert(err).IsNotNil()
|
||||||
|
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
g.AfterEach(func() {
|
||||||
|
rfs.reset()
|
||||||
|
fs.diskUsed = 0
|
||||||
|
fs.diskLimit = 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
141
server/filesystem/path.go
Normal file
141
server/filesystem/path.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Normalizes a directory being passed in to ensure the user is not able to escape
|
||||||
|
// from their data directory. After normalization if the directory is still within their home
|
||||||
|
// path it is returned. If they managed to "escape" an error will be returned.
|
||||||
|
//
|
||||||
|
// This logic is actually copied over from the SFTP server code. Ideally that eventually
|
||||||
|
// either gets ported into this application, or is able to make use of this package.
|
||||||
|
func (fs *Filesystem) SafePath(p string) (string, error) {
|
||||||
|
var nonExistentPathResolution string
|
||||||
|
|
||||||
|
// Start with a cleaned up path before checking the more complex bits.
|
||||||
|
r := fs.unsafeFilePath(p)
|
||||||
|
|
||||||
|
// At the same time, evaluate the symlink status and determine where this file or folder
|
||||||
|
// is truly pointing to.
|
||||||
|
p, err := filepath.EvalSymlinks(r)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return "", err
|
||||||
|
} else if os.IsNotExist(err) {
|
||||||
|
// The requested directory doesn't exist, so at this point we need to iterate up the
|
||||||
|
// path chain until we hit a directory that _does_ exist and can be validated.
|
||||||
|
parts := strings.Split(filepath.Dir(r), "/")
|
||||||
|
|
||||||
|
var try string
|
||||||
|
// Range over all of the path parts and form directory pathings from the end
|
||||||
|
// moving up until we have a valid resolution or we run out of paths to try.
|
||||||
|
for k := range parts {
|
||||||
|
try = strings.Join(parts[:(len(parts)-k)], "/")
|
||||||
|
|
||||||
|
if !fs.unsafeIsInDataDirectory(try) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := filepath.EvalSymlinks(try)
|
||||||
|
if err == nil {
|
||||||
|
nonExistentPathResolution = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the new path doesn't start with their root directory there is clearly an escape
|
||||||
|
// attempt going on, and we should NOT resolve this path for them.
|
||||||
|
if nonExistentPathResolution != "" {
|
||||||
|
if !fs.unsafeIsInDataDirectory(nonExistentPathResolution) {
|
||||||
|
return "", ErrBadPathResolution
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the nonExistentPathResolution variable is not empty then the initial path requested
|
||||||
|
// did not exist and we looped through the pathway until we found a match. At this point
|
||||||
|
// we've confirmed the first matched pathway exists in the root server directory, so we
|
||||||
|
// can go ahead and just return the path that was requested initially.
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the requested directory from EvalSymlinks begins with the server root directory go
|
||||||
|
// ahead and return it. If not we'll return an error which will block any further action
|
||||||
|
// on the file.
|
||||||
|
if fs.unsafeIsInDataDirectory(p) {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", ErrBadPathResolution
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a path to the file by cleaning it up and appending the root server path to it. This
|
||||||
|
// DOES NOT guarantee that the file resolves within the server data directory. You'll want to use
|
||||||
|
// the fs.unsafeIsInDataDirectory(p) function to confirm.
|
||||||
|
func (fs *Filesystem) unsafeFilePath(p string) string {
|
||||||
|
// Calling filepath.Clean on the joined directory will resolve it to the absolute path,
|
||||||
|
// removing any ../ type of resolution arguments, and leaving us with a direct path link.
|
||||||
|
//
|
||||||
|
// This will also trim the existing root path off the beginning of the path passed to
|
||||||
|
// the function since that can get a bit messy.
|
||||||
|
return filepath.Clean(filepath.Join(fs.Path(), strings.TrimPrefix(p, fs.Path())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that that path string starts with the server data directory path. This function DOES NOT
|
||||||
|
// validate that the rest of the path does not end up resolving out of this directory, or that the
|
||||||
|
// targeted file or folder is not a symlink doing the same thing.
|
||||||
|
func (fs *Filesystem) unsafeIsInDataDirectory(p string) bool {
|
||||||
|
return strings.HasPrefix(strings.TrimSuffix(p, "/")+"/", strings.TrimSuffix(fs.Path(), "/")+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executes the fs.SafePath function in parallel against an array of paths. If any of the calls
|
||||||
|
// fails an error will be returned.
|
||||||
|
func (fs *Filesystem) ParallelSafePath(paths []string) ([]string, error) {
|
||||||
|
var cleaned []string
|
||||||
|
|
||||||
|
// Simple locker function to avoid racy appends to the array of cleaned paths.
|
||||||
|
var m = new(sync.Mutex)
|
||||||
|
var push = func(c string) {
|
||||||
|
m.Lock()
|
||||||
|
cleaned = append(cleaned, c)
|
||||||
|
m.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an error group that we can use to run processes in parallel while retaining
|
||||||
|
// the ability to cancel the entire process immediately should any of it fail.
|
||||||
|
g, ctx := errgroup.WithContext(context.Background())
|
||||||
|
|
||||||
|
// Iterate over all of the paths and generate a cleaned path, if there is an error for any
|
||||||
|
// of the files, abort the process.
|
||||||
|
for _, p := range paths {
|
||||||
|
// Create copy so we can use it within the goroutine correctly.
|
||||||
|
pi := p
|
||||||
|
|
||||||
|
// Recursively call this function to continue digging through the directory tree within
|
||||||
|
// a separate goroutine. If the context is canceled abort this process.
|
||||||
|
g.Go(func() error {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
// If the callback returns true, go ahead and keep walking deeper. This allows
|
||||||
|
// us to programmatically continue deeper into directories, or stop digging
|
||||||
|
// if that pathway knows it needs nothing else.
|
||||||
|
if c, err := fs.SafePath(pi); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
push(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block until all of the routines finish and have returned a value.
|
||||||
|
return cleaned, g.Wait()
|
||||||
|
}
|
||||||
74
server/filesystem/stat.go
Normal file
74
server/filesystem/stat.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Stat struct {
|
||||||
|
Info os.FileInfo
|
||||||
|
Mimetype string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stat) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Created string `json:"created"`
|
||||||
|
Modified string `json:"modified"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Directory bool `json:"directory"`
|
||||||
|
File bool `json:"file"`
|
||||||
|
Symlink bool `json:"symlink"`
|
||||||
|
Mime string `json:"mime"`
|
||||||
|
}{
|
||||||
|
Name: s.Info.Name(),
|
||||||
|
Created: s.CTime().Format(time.RFC3339),
|
||||||
|
Modified: s.Info.ModTime().Format(time.RFC3339),
|
||||||
|
Mode: s.Info.Mode().String(),
|
||||||
|
Size: s.Info.Size(),
|
||||||
|
Directory: s.Info.IsDir(),
|
||||||
|
File: !s.Info.IsDir(),
|
||||||
|
Symlink: s.Info.Mode().Perm()&os.ModeSymlink != 0,
|
||||||
|
Mime: s.Mimetype,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats a file or folder and returns the base stat object from go along with the
|
||||||
|
// MIME data that can be used for editing files.
|
||||||
|
func (fs *Filesystem) Stat(p string) (*Stat, error) {
|
||||||
|
cleaned, err := fs.SafePath(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.unsafeStat(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *Filesystem) unsafeStat(p string) (*Stat, error) {
|
||||||
|
s, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var m *mimetype.MIME
|
||||||
|
if !s.IsDir() {
|
||||||
|
m, err = mimetype.DetectFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st := &Stat{
|
||||||
|
Info: s,
|
||||||
|
Mimetype: "inode/directory",
|
||||||
|
}
|
||||||
|
|
||||||
|
if m != nil {
|
||||||
|
st.Mimetype = m.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package server
|
package filesystem
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package server
|
package filesystem
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package server
|
package filesystem
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
@@ -395,7 +395,7 @@ func (ip *InstallationProcess) Execute() (string, error) {
|
|||||||
AttachStdin: true,
|
AttachStdin: true,
|
||||||
OpenStdin: true,
|
OpenStdin: true,
|
||||||
Tty: true,
|
Tty: true,
|
||||||
Cmd: []string{ip.Script.Entrypoint, "./mnt/install/install.sh"},
|
Cmd: []string{ip.Script.Entrypoint, "/mnt/install/install.sh"},
|
||||||
Image: ip.Script.ContainerImage,
|
Image: ip.Script.ContainerImage,
|
||||||
Env: ip.Server.GetEnvironmentVariables(),
|
Env: ip.Server.GetEnvironmentVariables(),
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
@@ -409,7 +409,7 @@ func (ip *InstallationProcess) Execute() (string, error) {
|
|||||||
Mounts: []mount.Mount{
|
Mounts: []mount.Mount{
|
||||||
{
|
{
|
||||||
Target: "/mnt/server",
|
Target: "/mnt/server",
|
||||||
Source: ip.Server.Filesystem.Path(),
|
Source: ip.Server.Filesystem().Path(),
|
||||||
Type: mount.TypeBind,
|
Type: mount.TypeBind,
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ var dockerEvents = []string{
|
|||||||
func (s *Server) StartEventListeners() {
|
func (s *Server) StartEventListeners() {
|
||||||
console := func(e events.Event) {
|
console := func(e events.Event) {
|
||||||
t := s.Throttler()
|
t := s.Throttler()
|
||||||
err := t.Increment(func () {
|
err := t.Increment(func() {
|
||||||
s.PublishConsoleOutputFromDaemon("Your server is outputting too much data and is being throttled.")
|
s.PublishConsoleOutputFromDaemon("Your server is outputting too much data and is being throttled.")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ func (s *Server) StartEventListeners() {
|
|||||||
s.resources.Stats = *st
|
s.resources.Stats = *st
|
||||||
s.resources.mu.Unlock()
|
s.resources.mu.Unlock()
|
||||||
|
|
||||||
s.Filesystem.HasSpaceAvailable(true)
|
s.Filesystem().HasSpaceAvailable(true)
|
||||||
|
|
||||||
s.emitProcUsage()
|
s.emitProcUsage()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import (
|
|||||||
"github.com/gammazero/workerpool"
|
"github.com/gammazero/workerpool"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/pterodactyl/wings/api"
|
"github.com/pterodactyl/wings/api"
|
||||||
|
"github.com/pterodactyl/wings/config"
|
||||||
"github.com/pterodactyl/wings/environment"
|
"github.com/pterodactyl/wings/environment"
|
||||||
"github.com/pterodactyl/wings/environment/docker"
|
"github.com/pterodactyl/wings/environment/docker"
|
||||||
|
"github.com/pterodactyl/wings/server/filesystem"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -90,7 +93,7 @@ func FromConfiguration(data *api.ServerConfigurationResponse) (*Server, error) {
|
|||||||
defaults.Set(&s.resources)
|
defaults.Set(&s.resources)
|
||||||
|
|
||||||
s.Archiver = Archiver{Server: s}
|
s.Archiver = Archiver{Server: s}
|
||||||
s.Filesystem = Filesystem{Server: s}
|
s.fs = filesystem.New(filepath.Join(config.Get().System.Data, s.Id()), s.DiskSpace())
|
||||||
|
|
||||||
// Right now we only support a Docker based environment, so I'm going to hard code
|
// Right now we only support a Docker based environment, so I'm going to hard code
|
||||||
// this logic in. When we're ready to support other environment we'll need to make
|
// this logic in. When we're ready to support other environment we'll need to make
|
||||||
@@ -120,8 +123,8 @@ func FromConfiguration(data *api.ServerConfigurationResponse) (*Server, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the server's data directory exists, force disk usage calculation.
|
// If the server's data directory exists, force disk usage calculation.
|
||||||
if _, err := os.Stat(s.Filesystem.Path()); err == nil {
|
if _, err := os.Stat(s.Filesystem().Path()); err == nil {
|
||||||
s.Filesystem.HasSpaceAvailable(true)
|
s.Filesystem().HasSpaceAvailable(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (s *Server) Mounts() []environment.Mount {
|
|||||||
m = append(m, environment.Mount{
|
m = append(m, environment.Mount{
|
||||||
Default: true,
|
Default: true,
|
||||||
Target: "/home/container",
|
Target: "/home/container",
|
||||||
Source: s.Filesystem.Path(),
|
Source: s.Filesystem().Path(),
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/pterodactyl/wings/config"
|
"github.com/pterodactyl/wings/config"
|
||||||
"github.com/pterodactyl/wings/environment"
|
"github.com/pterodactyl/wings/environment"
|
||||||
|
"github.com/pterodactyl/wings/server/filesystem"
|
||||||
"golang.org/x/sync/semaphore"
|
"golang.org/x/sync/semaphore"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -38,6 +39,21 @@ func (pa PowerAction) IsStart() bool {
|
|||||||
return pa == PowerActionStart || pa == PowerActionRestart
|
return pa == PowerActionStart || pa == PowerActionRestart
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if there is currently a power action being processed for the server.
|
||||||
|
func (s *Server) ExecutingPowerAction() bool {
|
||||||
|
if s.powerLock == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ok := s.powerLock.TryAcquire(1)
|
||||||
|
if ok {
|
||||||
|
s.powerLock.Release(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember, if we acquired a lock it means nothing was running.
|
||||||
|
return !ok
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function that can receive a power action and then process the actions that need
|
// Helper function that can receive a power action and then process the actions that need
|
||||||
// to occur for it. This guards against someone calling Start() twice at the same time, or
|
// to occur for it. This guards against someone calling Start() twice at the same time, or
|
||||||
// trying to restart while another restart process is currently running.
|
// trying to restart while another restart process is currently running.
|
||||||
@@ -149,11 +165,11 @@ func (s *Server) onBeforeStart() error {
|
|||||||
// If a server has unlimited disk space, we don't care enough to block the startup to check remaining.
|
// If a server has unlimited disk space, we don't care enough to block the startup to check remaining.
|
||||||
// However, we should trigger a size anyway, as it'd be good to kick it off for other processes.
|
// However, we should trigger a size anyway, as it'd be good to kick it off for other processes.
|
||||||
if s.DiskSpace() <= 0 {
|
if s.DiskSpace() <= 0 {
|
||||||
s.Filesystem.HasSpaceAvailable(true)
|
s.Filesystem().HasSpaceAvailable(true)
|
||||||
} else {
|
} else {
|
||||||
s.PublishConsoleOutputFromDaemon("Checking server disk space usage, this could take a few seconds...")
|
s.PublishConsoleOutputFromDaemon("Checking server disk space usage, this could take a few seconds...")
|
||||||
if !s.Filesystem.HasSpaceAvailable(false) {
|
if !s.Filesystem().HasSpaceAvailable(false) {
|
||||||
return errors.New("cannot start server, not enough disk space available")
|
return filesystem.ErrNotEnoughDiskSpace
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +184,7 @@ func (s *Server) onBeforeStart() error {
|
|||||||
if config.Get().System.CheckPermissionsOnBoot {
|
if config.Get().System.CheckPermissionsOnBoot {
|
||||||
s.PublishConsoleOutputFromDaemon("Ensuring file permissions are set correctly, this could take a few seconds...")
|
s.PublishConsoleOutputFromDaemon("Ensuring file permissions are set correctly, this could take a few seconds...")
|
||||||
// Ensure all of the server file permissions are set correctly before booting the process.
|
// Ensure all of the server file permissions are set correctly before booting the process.
|
||||||
if err := s.Filesystem.Chown("/"); err != nil {
|
if err := s.Filesystem().Chown("/"); err != nil {
|
||||||
return errors.Wrap(err, "failed to chown root server directory during pre-boot process")
|
return errors.Wrap(err, "failed to chown root server directory during pre-boot process")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"github.com/pterodactyl/wings/environment"
|
"github.com/pterodactyl/wings/environment"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -17,17 +18,34 @@ type ResourceUsage struct {
|
|||||||
// The current server status.
|
// The current server status.
|
||||||
State string `json:"state" default:"offline"`
|
State string `json:"state" default:"offline"`
|
||||||
|
|
||||||
// The current disk space being used by the server. This is cached to prevent slow lookup
|
// The current disk space being used by the server. This value is not guaranteed to be accurate
|
||||||
// issues on frequent refreshes.
|
// at all times. It is "manually" set whenever server.Proc() is called. This is kind of just a
|
||||||
|
// hacky solution for now to avoid passing events all over the place.
|
||||||
Disk int64 `json:"disk_bytes"`
|
Disk int64 `json:"disk_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alias the resource usage so that we don't infinitely recurse when marshaling the struct.
|
||||||
|
type IResourceUsage ResourceUsage
|
||||||
|
|
||||||
|
// Custom marshaler to ensure that the object is locked when we're converting it to JSON in
|
||||||
|
// order to avoid race conditions.
|
||||||
|
func (ru *ResourceUsage) MarshalJSON() ([]byte, error) {
|
||||||
|
ru.mu.Lock()
|
||||||
|
defer ru.mu.Unlock()
|
||||||
|
|
||||||
|
return json.Marshal(IResourceUsage(*ru))
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the resource usage stats for the server instance. If the server is not running, only the
|
// Returns the resource usage stats for the server instance. If the server is not running, only the
|
||||||
// disk space currently used will be returned. When the server is running all of the other stats will
|
// disk space currently used will be returned. When the server is running all of the other stats will
|
||||||
// be returned.
|
// be returned.
|
||||||
//
|
//
|
||||||
// When a process is stopped all of the stats are zeroed out except for the disk.
|
// When a process is stopped all of the stats are zeroed out except for the disk.
|
||||||
func (s *Server) Proc() *ResourceUsage {
|
func (s *Server) Proc() *ResourceUsage {
|
||||||
|
s.resources.SetDisk(s.Filesystem().CachedUsage())
|
||||||
|
|
||||||
|
// Get a read lock on the resources at this point. Don't do this before setting
|
||||||
|
// the disk, otherwise you'll cause a deadlock.
|
||||||
s.resources.mu.RLock()
|
s.resources.mu.RLock()
|
||||||
defer s.resources.mu.RUnlock()
|
defer s.resources.mu.RUnlock()
|
||||||
|
|
||||||
@@ -35,11 +53,9 @@ func (s *Server) Proc() *ResourceUsage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) emitProcUsage() {
|
func (s *Server) emitProcUsage() {
|
||||||
s.resources.mu.RLock()
|
if err := s.Events().PublishJson(StatsEvent, s.Proc()); err != nil {
|
||||||
if err := s.Events().PublishJson(StatsEvent, s.resources); err != nil {
|
|
||||||
s.Log().WithField("error", err).Warn("error while emitting server resource usage to listeners")
|
s.Log().WithField("error", err).Warn("error while emitting server resource usage to listeners")
|
||||||
}
|
}
|
||||||
s.resources.mu.RUnlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the servers current state.
|
// Returns the servers current state.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/pterodactyl/wings/environment"
|
"github.com/pterodactyl/wings/environment"
|
||||||
"github.com/pterodactyl/wings/environment/docker"
|
"github.com/pterodactyl/wings/environment/docker"
|
||||||
"github.com/pterodactyl/wings/events"
|
"github.com/pterodactyl/wings/events"
|
||||||
|
"github.com/pterodactyl/wings/server/filesystem"
|
||||||
"golang.org/x/sync/semaphore"
|
"golang.org/x/sync/semaphore"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -34,7 +35,8 @@ type Server struct {
|
|||||||
resources ResourceUsage
|
resources ResourceUsage
|
||||||
Archiver Archiver `json:"-"`
|
Archiver Archiver `json:"-"`
|
||||||
Environment environment.ProcessEnvironment `json:"-"`
|
Environment environment.ProcessEnvironment `json:"-"`
|
||||||
Filesystem Filesystem `json:"-"`
|
|
||||||
|
fs *filesystem.Filesystem
|
||||||
|
|
||||||
// Events emitted by the server instance.
|
// Events emitted by the server instance.
|
||||||
emitter *events.EventBus
|
emitter *events.EventBus
|
||||||
@@ -52,6 +54,10 @@ type Server struct {
|
|||||||
|
|
||||||
// The console throttler instance used to control outputs.
|
// The console throttler instance used to control outputs.
|
||||||
throttler *ConsoleThrottler
|
throttler *ConsoleThrottler
|
||||||
|
|
||||||
|
// Tracks open websocket connections for the server.
|
||||||
|
wsBag *WebsocketBag
|
||||||
|
wsBagLocker sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type InstallerDetails struct {
|
type InstallerDetails struct {
|
||||||
@@ -133,6 +139,10 @@ func (s *Server) SyncWithConfiguration(cfg *api.ServerConfigurationResponse) err
|
|||||||
s.procConfig = cfg.ProcessConfiguration
|
s.procConfig = cfg.ProcessConfiguration
|
||||||
s.Unlock()
|
s.Unlock()
|
||||||
|
|
||||||
|
// Update the disk space limits for the server whenever the configuration
|
||||||
|
// for it changes.
|
||||||
|
s.fs.SetDiskLimit(s.DiskSpace())
|
||||||
|
|
||||||
// If this is a Docker environment we need to sync the stop configuration with it so that
|
// If this is a Docker environment we need to sync the stop configuration with it so that
|
||||||
// the process isn't just terminated when a user requests it be stopped.
|
// the process isn't just terminated when a user requests it be stopped.
|
||||||
if e, ok := s.Environment.(*docker.Environment); ok {
|
if e, ok := s.Environment.(*docker.Environment); ok {
|
||||||
@@ -161,7 +171,7 @@ func (s *Server) IsBootable() bool {
|
|||||||
// for the server is setup, and that all of the necessary files are created.
|
// for the server is setup, and that all of the necessary files are created.
|
||||||
func (s *Server) CreateEnvironment() error {
|
func (s *Server) CreateEnvironment() error {
|
||||||
// Ensure the data directory exists before getting too far through this process.
|
// Ensure the data directory exists before getting too far through this process.
|
||||||
if err := s.Filesystem.EnsureDataDirectory(); err != nil {
|
if err := s.EnsureDataDirectoryExists(); err != nil {
|
||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
server/websockets.go
Normal file
61
server/websockets.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebsocketBag struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
conns map[uuid.UUID]*context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the websocket bag which contains all of the currently open websocket connections
|
||||||
|
// for the server instance.
|
||||||
|
func (s *Server) Websockets() *WebsocketBag {
|
||||||
|
s.wsBagLocker.Lock()
|
||||||
|
defer s.wsBagLocker.Unlock()
|
||||||
|
|
||||||
|
if s.wsBag == nil {
|
||||||
|
s.wsBag = &WebsocketBag{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.wsBag
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a new websocket connection to the stack.
|
||||||
|
func (w *WebsocketBag) Push(u uuid.UUID, cancel *context.CancelFunc) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.conns == nil {
|
||||||
|
w.conns = make(map[uuid.UUID]*context.CancelFunc)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.conns[u] = cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes a connection from the stack.
|
||||||
|
func (w *WebsocketBag) Remove(u uuid.UUID) {
|
||||||
|
w.mu.Lock()
|
||||||
|
delete(w.conns, u)
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancels all of the stored cancel functions which has the effect of disconnecting
|
||||||
|
// every listening websocket for the server.
|
||||||
|
func (w *WebsocketBag) CancelAll() {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.conns != nil {
|
||||||
|
for _, cancel := range w.conns {
|
||||||
|
c := *cancel
|
||||||
|
c()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the connections.
|
||||||
|
w.conns = make(map[uuid.UUID]*context.CancelFunc)
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ func validatePath(fs FileSystem, p string) (string, error) {
|
|||||||
return "", noMatchingServerError
|
return "", noMatchingServerError
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.Filesystem.SafePath(p)
|
return s.Filesystem().SafePath(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateDiskSpace(fs FileSystem) bool {
|
func validateDiskSpace(fs FileSystem) bool {
|
||||||
@@ -63,7 +63,7 @@ func validateDiskSpace(fs FileSystem) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.Filesystem.HasSpaceAvailable(true)
|
return s.Filesystem().HasSpaceAvailable(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validates a set of credentials for a SFTP login against Pterodactyl Panel and returns
|
// Validates a set of credentials for a SFTP login against Pterodactyl Panel and returns
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ func (ab *AtomicBool) Set(v bool) {
|
|||||||
|
|
||||||
func (ab *AtomicBool) Get() bool {
|
func (ab *AtomicBool) Get() bool {
|
||||||
return atomic.LoadUint32(&ab.flag) == 1
|
return atomic.LoadUint32(&ab.flag) == 1
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user