mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2024-11-26 01:42:53 +01:00
Compare commits
1 Commits
340b236282
...
55d8c6bcfc
Author | SHA1 | Date | |
---|---|---|---|
55d8c6bcfc |
@ -9,10 +9,10 @@
|
|||||||
<env name="AGENT_NAME" value="Agent 1" />
|
<env name="AGENT_NAME" value="Agent 1" />
|
||||||
<env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" />
|
<env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" />
|
||||||
<env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" />
|
<env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" />
|
||||||
<env name="CONTROLLER_HOST" value="localhost" />
|
|
||||||
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
||||||
<env name="MAX_INSTANCES" value="3" />
|
<env name="MAX_INSTANCES" value="3" />
|
||||||
<env name="MAX_MEMORY" value="12G" />
|
<env name="MAX_MEMORY" value="12G" />
|
||||||
|
<env name="SERVER_HOST" value="localhost" />
|
||||||
</envs>
|
</envs>
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
<option name="USE_MONO" value="0" />
|
<option name="USE_MONO" value="0" />
|
||||||
|
@ -9,10 +9,10 @@
|
|||||||
<env name="AGENT_NAME" value="Agent 2" />
|
<env name="AGENT_NAME" value="Agent 2" />
|
||||||
<env name="ALLOWED_RCON_PORTS" value="27002-27006" />
|
<env name="ALLOWED_RCON_PORTS" value="27002-27006" />
|
||||||
<env name="ALLOWED_SERVER_PORTS" value="26002-26006" />
|
<env name="ALLOWED_SERVER_PORTS" value="26002-26006" />
|
||||||
<env name="CONTROLLER_HOST" value="localhost" />
|
|
||||||
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
||||||
<env name="MAX_INSTANCES" value="5" />
|
<env name="MAX_INSTANCES" value="5" />
|
||||||
<env name="MAX_MEMORY" value="10G" />
|
<env name="MAX_MEMORY" value="10G" />
|
||||||
|
<env name="SERVER_HOST" value="localhost" />
|
||||||
</envs>
|
</envs>
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
<option name="USE_MONO" value="0" />
|
<option name="USE_MONO" value="0" />
|
||||||
|
@ -9,10 +9,10 @@
|
|||||||
<env name="AGENT_NAME" value="Agent 3" />
|
<env name="AGENT_NAME" value="Agent 3" />
|
||||||
<env name="ALLOWED_RCON_PORTS" value="27007" />
|
<env name="ALLOWED_RCON_PORTS" value="27007" />
|
||||||
<env name="ALLOWED_SERVER_PORTS" value="26007" />
|
<env name="ALLOWED_SERVER_PORTS" value="26007" />
|
||||||
<env name="CONTROLLER_HOST" value="localhost" />
|
|
||||||
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
||||||
<env name="MAX_INSTANCES" value="1" />
|
<env name="MAX_INSTANCES" value="1" />
|
||||||
<env name="MAX_MEMORY" value="2560M" />
|
<env name="MAX_MEMORY" value="2560M" />
|
||||||
|
<env name="SERVER_HOST" value="localhost" />
|
||||||
</envs>
|
</envs>
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
<option name="USE_MONO" value="0" />
|
<option name="USE_MONO" value="0" />
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="Web" type="DotNetProject" factoryName=".NET Project">
|
|
||||||
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Web/debug/Phantom.Web.exe" />
|
|
||||||
<option name="PROGRAM_PARAMETERS" value="" />
|
|
||||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Web" />
|
|
||||||
<option name="PASS_PARENT_ENVS" value="1" />
|
|
||||||
<envs>
|
|
||||||
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
|
|
||||||
<env name="WEB_SERVER_HOST" value="localhost" />
|
|
||||||
</envs>
|
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
|
||||||
<option name="USE_MONO" value="0" />
|
|
||||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
|
||||||
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" />
|
|
||||||
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
|
|
||||||
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
|
|
||||||
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
|
|
||||||
<option name="PROJECT_KIND" value="DotNetCore" />
|
|
||||||
<option name="PROJECT_TFM" value="net8.0" />
|
|
||||||
<method v="2">
|
|
||||||
<option name="Build" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
@ -82,10 +82,10 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.Length > 0 && MessageRegistries.ToAgent.TryGetType(data, out var type)) {
|
if (data.Length > 0 && MessageRegistries.ToAgent.TryGetType(data, out var type)) {
|
||||||
logger.Verbose("Received {MessageType} ({Bytes} B) from controller.", type.Name, data.Length);
|
logger.Verbose("Received {MessageType} ({Bytes} B) from server.", type.Name, data.Length);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
logger.Verbose("Received {Bytes} B message from controller.", data.Length);
|
logger.Verbose("Received {Bytes} B message from server.", data.Length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +93,7 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
|
|||||||
var unregisterTimeoutTask = Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None);
|
var unregisterTimeoutTask = Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||||
var finishedTask = await Task.WhenAny(ServerMessaging.Send(new UnregisterAgentMessage(agentGuid)), unregisterTimeoutTask);
|
var finishedTask = await Task.WhenAny(ServerMessaging.Send(new UnregisterAgentMessage(agentGuid)), unregisterTimeoutTask);
|
||||||
if (finishedTask == unregisterTimeoutTask) {
|
if (finishedTask == unregisterTimeoutTask) {
|
||||||
config.RuntimeLogger.Error("Timed out communicating agent shutdown with the controller.");
|
config.RuntimeLogger.Error("Timed out communicating agent shutdown with the server.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,9 +52,9 @@ static class AgentKey {
|
|||||||
|
|
||||||
private static (NetMQCertificate, AgentAuthToken)? LoadFromBytes(byte[] agentKey) {
|
private static (NetMQCertificate, AgentAuthToken)? LoadFromBytes(byte[] agentKey) {
|
||||||
var (publicKey, agentToken) = AgentKeyData.FromBytes(agentKey);
|
var (publicKey, agentToken) = AgentKeyData.FromBytes(agentKey);
|
||||||
var controllerCertificate = NetMQCertificate.FromPublicKey(publicKey);
|
var serverCertificate = NetMQCertificate.FromPublicKey(publicKey);
|
||||||
|
|
||||||
Logger.Information("Loaded agent key.");
|
Logger.Information("Loaded agent key.");
|
||||||
return (controllerCertificate, agentToken);
|
return (serverCertificate, agentToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@ try {
|
|||||||
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent...");
|
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent...");
|
||||||
PhantomLogger.Root.Information("Agent version: {Version}", fullVersion);
|
PhantomLogger.Root.Information("Agent version: {Version}", fullVersion);
|
||||||
|
|
||||||
var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop();
|
var (serverHost, serverPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop();
|
||||||
|
|
||||||
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
|
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
|
||||||
if (agentKey == null) {
|
if (agentKey == null) {
|
||||||
@ -43,7 +43,7 @@ try {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (controllerCertificate, agentToken) = agentKey.Value;
|
var (serverCertificate, agentToken) = agentKey.Value;
|
||||||
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
|
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
|
||||||
var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks));
|
var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks));
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ try {
|
|||||||
await agentServices.Initialize();
|
await agentServices.Initialize();
|
||||||
|
|
||||||
var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1);
|
var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1);
|
||||||
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), controllerHost, controllerPort, controllerCertificate);
|
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), serverHost, serverPort, serverCertificate);
|
||||||
var rpcTask = RpcLauncher.Launch(rpcConfiguration, agentToken, agentInfo, MessageListenerFactory, rpcDisconnectSemaphore, shutdownCancellationToken);
|
var rpcTask = RpcLauncher.Launch(rpcConfiguration, agentToken, agentInfo, MessageListenerFactory, rpcDisconnectSemaphore, shutdownCancellationToken);
|
||||||
try {
|
try {
|
||||||
await rpcTask.WaitAsync(shutdownCancellationToken);
|
await rpcTask.WaitAsync(shutdownCancellationToken);
|
||||||
|
@ -6,8 +6,8 @@ using Phantom.Utils.Runtime;
|
|||||||
namespace Phantom.Agent;
|
namespace Phantom.Agent;
|
||||||
|
|
||||||
sealed record Variables(
|
sealed record Variables(
|
||||||
string ControllerHost,
|
string ServerHost,
|
||||||
ushort ControllerPort,
|
ushort ServerPort,
|
||||||
string JavaSearchPath,
|
string JavaSearchPath,
|
||||||
string? AgentKeyToken,
|
string? AgentKeyToken,
|
||||||
string? AgentKeyFilePath,
|
string? AgentKeyFilePath,
|
||||||
@ -23,8 +23,8 @@ sealed record Variables(
|
|||||||
var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").WithDefaultGetter(GetDefaultJavaSearchPath);
|
var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").WithDefaultGetter(GetDefaultJavaSearchPath);
|
||||||
|
|
||||||
return new Variables(
|
return new Variables(
|
||||||
EnvironmentVariables.GetString("CONTROLLER_HOST").Require,
|
EnvironmentVariables.GetString("SERVER_HOST").Require,
|
||||||
EnvironmentVariables.GetPortNumber("CONTROLLER_PORT").WithDefault(9401),
|
EnvironmentVariables.GetPortNumber("SERVER_PORT").WithDefault(9401),
|
||||||
javaSearchPath,
|
javaSearchPath,
|
||||||
agentKeyToken,
|
agentKeyToken,
|
||||||
agentKeyFilePath,
|
agentKeyFilePath,
|
||||||
|
82
README.md
82
README.md
@ -4,21 +4,20 @@ Phantom Panel is a **work-in-progress** web interface for managing Minecraft ser
|
|||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
Phantom Panel has 3 types of services:
|
Phantom Panel is built on what I'm calling a **Server-Agent architecture**:
|
||||||
|
|
||||||
* The **Web** provides a web interface for the **Controller**.
|
* The **Server** is provides a web interface, persists data in a database, and sends commands to the **Agents**.
|
||||||
* The **Controller** manages all state and persists it in a database, and communicates with **Agents**.
|
* One or more **Agents** receive commands from the **Server**, manage the Minecraft server processes, and report on their status.
|
||||||
* One or more **Agents** receive commands from the **Controller**, manage the Minecraft server processes, and report on their status.
|
|
||||||
|
|
||||||
This architecture has several goals and benefits:
|
This architecture has several goals and benefits:
|
||||||
|
|
||||||
1. The services can run on separate computers, in separate containers, or a mixture of both.
|
1. The Server and Agents can run on separate computers, in separate containers, or a mixture of both.
|
||||||
2. The services can be updated independently.
|
2. The Server and Agents can be updated independently.
|
||||||
- The Controller or Web can receive new features, bug fixes, and security updates without the need to shutdown every Minecraft server.
|
- The Server can receive new features, bug fixes, and security updates without the need to shutdown every Minecraft server.
|
||||||
- Agent updates can be staggered or delayed. For example, if you have Agents in different geographical locations, you could schedule around timezones and update them at times when people are unlikely to be online.
|
- Agent updates can be staggered or delayed. For example, if you have Agents in different geographical locations, you could schedule around timezones and update them at times when people are unlikely to be online.
|
||||||
3. Agents are lightweight processes which should have minimal impact on the performance of Minecraft servers.
|
3. Agents are lightweight processes which should have minimal impact on the performance of Minecraft servers.
|
||||||
|
|
||||||
When an official Controller update is released, it will work with older versions of Agents. There is no guarantee it will also work in reverse (updated Agents and an older Controller), but if there is an Agent update that is compatible with an older Controller, it will be mentioned in the release notes.
|
When an official Server update is released, it will work with older versions of Agents. There is no guarantee it will also work in reverse (updated Agents and an older Server), but if there is an Agent update that is compatible with older Servers, it will be mentioned in the release notes.
|
||||||
|
|
||||||
Note that compatibility is only guaranteed when using official releases. If you build the project from a version of the source between two official releases, you have to understand which changes break compatibility.
|
Note that compatibility is only guaranteed when using official releases. If you build the project from a version of the source between two official releases, you have to understand which changes break compatibility.
|
||||||
|
|
||||||
@ -26,30 +25,30 @@ Note that compatibility is only guaranteed when using official releases. If you
|
|||||||
|
|
||||||
This project is **work-in-progress**, and currently has no official releases. Feel free to try it and experiment, but there will be missing features, bugs, and breaking changes.
|
This project is **work-in-progress**, and currently has no official releases. Feel free to try it and experiment, but there will be missing features, bugs, and breaking changes.
|
||||||
|
|
||||||
For a quick start, I recommend using [Docker](https://www.docker.com/) or another containerization platform. The `Dockerfile` in the root of the repository can build three target images: `phantom-web`, `phantom-controller`, and `phantom-agent`.
|
For a quick start, I recommend using [Docker](https://www.docker.com/) or another containerization platform. The `Dockerfile` in the root of the repository can build two target images: `phantom-server` and `phantom-agent`.
|
||||||
|
|
||||||
All images put the built application into the `/app` folder. The Agent image also installs Java 8, 16, 17, and 18.
|
Both images put the built application into the `/app` folder. The Agent image also installs Java 8, 16, 17, and 18.
|
||||||
|
|
||||||
Files are stored relative to the working directory. In the provided images, the working directory is set to `/data`.
|
Files are stored relative to the working directory. In the provided images, the working directory is set to `/data`.
|
||||||
|
|
||||||
## Controller
|
## Server
|
||||||
|
|
||||||
The Controller comprises 3 key areas:
|
The Server comprises 3 key areas:
|
||||||
|
|
||||||
* **Agent RPC server** that Agents connect to.
|
* **Web server** that provides the web interface.
|
||||||
* **Web RPC server** that Web connects to.
|
* **RPC server** that Agents connect to.
|
||||||
* **PostgreSQL database connection** to persist data.
|
* **Database connection** that requires a PostgreSQL database server in order to persist data.
|
||||||
|
|
||||||
The configuration for these is set via environment variables.
|
The configuration for these is set via environment variables.
|
||||||
|
|
||||||
### Agent & Web Keys
|
### Agent Key
|
||||||
|
|
||||||
When the Controller starts for the first time, it will generate an **Agent Key** and **Web Key**. These contain encryption certificates and authorization tokens, which are needed for the Agents and Web to connect to the Controller.
|
When the Server starts for the first time, it will generate and an **Agent Key**. The Agent Key contains an encryption certificate and an authorization token, which are needed for the Agents to connect to the Server.
|
||||||
|
|
||||||
Each key has two forms:
|
The Agent Key has two forms:
|
||||||
|
|
||||||
* A binary file stored in `/data/secrets/agent.key` or `/data/secrets/web.key` that can be distributed to the other services.
|
* A binary file stored in `/data/secrets/agent.key` that the Agents can read.
|
||||||
* A plaintext-encoded version printed into the logs on every startup, that can be passed to the other services in an environment variable.
|
* A plaintext-encoded version the Server outputs into the logs on every startup, that can be passed to the Agents in an environemnt variable.
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
@ -57,13 +56,14 @@ Use volumes to persist the whole `/data` folder.
|
|||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
* **Agent RPC Server**
|
* **Web Server**
|
||||||
- `AGENT_RPC_SERVER_HOST` is the host. Default: `0.0.0.0`
|
- `WEB_SERVER_HOST` is the host. Default: `0.0.0.0`
|
||||||
- `AGENT_RPC_SERVER_PORT` is the port. Default: `9401`
|
- `WEB_SERVER_PORT` is the port. Default: `9400`
|
||||||
* **Web RPC Server**
|
- `WEB_BASE_PATH` is the base path of every URL. Must begin with a slash. Default: `/`
|
||||||
- `WEB_RPC_SERVER_HOST` is the host. Default: `0.0.0.0`
|
* **RPC Server**
|
||||||
- `WEB_RPC_SERVER_PORT` is the port. Default: `9402`
|
- `RPC_SERVER_HOST` is the host. Default: `0.0.0.0`
|
||||||
* **PostgreSQL Database Connection**
|
- `RPC_SERVER_PORT` is the port. Default: `9401`
|
||||||
|
* **PostgreSQL Database Server**
|
||||||
- `PG_HOST` is the hostname.
|
- `PG_HOST` is the hostname.
|
||||||
- `PG_PORT` is the port.
|
- `PG_PORT` is the port.
|
||||||
- `PG_USER` is the username.
|
- `PG_USER` is the username.
|
||||||
@ -81,11 +81,11 @@ The `/data` folder will contain two folders:
|
|||||||
|
|
||||||
Use volumes to persist either the whole `/data` folder, or just `/data/data` if you don't want to persist the volatile files.
|
Use volumes to persist either the whole `/data` folder, or just `/data/data` if you don't want to persist the volatile files.
|
||||||
|
|
||||||
### Environment variables
|
### Environment variables:
|
||||||
|
|
||||||
* **Controller Communication**
|
* **Server Communication**
|
||||||
- `CONTROLLER_HOST` is the hostname of the Controller.
|
- `SERVER_HOST` is the hostname of the Server.
|
||||||
- `CONTROLLER_PORT` is the Agent RPC port of the Controller. Default: `9401`
|
- `SERVER_PORT` is the RPC port of the Server. Default: `9401`
|
||||||
- `AGENT_NAME` is the display name of the Agent. Emoji are allowed.
|
- `AGENT_NAME` is the display name of the Agent. Emoji are allowed.
|
||||||
- `AGENT_KEY` is the plaintext-encoded version of [Agent Key](#agent-key).
|
- `AGENT_KEY` is the plaintext-encoded version of [Agent Key](#agent-key).
|
||||||
- `AGENT_KEY_FILE` is a path to the [Agent Key](#agent-key) binary file.
|
- `AGENT_KEY_FILE` is a path to the [Agent Key](#agent-key) binary file.
|
||||||
@ -98,24 +98,6 @@ Use volumes to persist either the whole `/data` folder, or just `/data/data` if
|
|||||||
- `ALLOWED_SERVER_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft Server ports. Example: `25565,25900,26000-27000`
|
- `ALLOWED_SERVER_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft Server ports. Example: `25565,25900,26000-27000`
|
||||||
- `ALLOWED_RCON_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft RCON ports. Example: `25575,25901,36000-37000`
|
- `ALLOWED_RCON_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft RCON ports. Example: `25575,25901,36000-37000`
|
||||||
|
|
||||||
## Web
|
|
||||||
|
|
||||||
### Storage
|
|
||||||
|
|
||||||
Use volumes to persist the whole `/data` folder.
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
* **Controller Communication**
|
|
||||||
- `CONTROLLER_HOST` is the hostname of the Controller.
|
|
||||||
- `CONTROLLER_PORT` is the Web RPC port of the Controller.
|
|
||||||
- `WEB_KEY` is the plaintext-encoded version of [Web Key](#agent--web-keys).
|
|
||||||
- `WEB_KEY_FILE` is a path to the [Web Key](#agent--web-keys) binary file.
|
|
||||||
* **Web Server**
|
|
||||||
- `WEB_SERVER_HOST` is the host. Default: `0.0.0.0`
|
|
||||||
- `WEB_SERVER_PORT` is the port. Default: `9400`
|
|
||||||
- `WEB_BASE_PATH` is the base path of every URL. Must begin with a slash. Default: `/`
|
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
Both the Server and Agent support a `LOG_LEVEL` environment variable to set the minimum log level. Possible values:
|
Both the Server and Agent support a `LOG_LEVEL` environment variable to set the minimum log level. Possible values:
|
||||||
@ -134,7 +116,7 @@ The repository includes a [Rider](https://www.jetbrains.com/rider/) projects wit
|
|||||||
|
|
||||||
1. You will need a local PostgreSQL instance. If you have [Docker](https://www.docker.com/), you can enter the `Docker` folder in this repository, and run `docker compose up`. Otherwise, you will need to set it up manually with the following configuration:
|
1. You will need a local PostgreSQL instance. If you have [Docker](https://www.docker.com/), you can enter the `Docker` folder in this repository, and run `docker compose up`. Otherwise, you will need to set it up manually with the following configuration:
|
||||||
- Host: `localhost`
|
- Host: `localhost`
|
||||||
- Port: `9403`
|
- Port: `9402`
|
||||||
- User: `postgres`
|
- User: `postgres`
|
||||||
- Password: `development`
|
- Password: `development`
|
||||||
- Database: `postgres`
|
- Database: `postgres`
|
||||||
|
112
Web/Phantom.Web.Identity/PhantomIdentityConfigurator.cs
Normal file
112
Web/Phantom.Web.Identity/PhantomIdentityConfigurator.cs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Controller.Database;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
using Phantom.Controller.Services.Users;
|
||||||
|
using Phantom.Utils.Collections;
|
||||||
|
using Phantom.Utils.Runtime;
|
||||||
|
using Phantom.Utils.Tasks;
|
||||||
|
using Phantom.Web.Identity.Data;
|
||||||
|
using ILogger = Serilog.ILogger;
|
||||||
|
|
||||||
|
namespace Phantom.Web.Identity;
|
||||||
|
|
||||||
|
public sealed class PhantomIdentityConfigurator {
|
||||||
|
private static readonly ILogger Logger = PhantomLogger.Create<PhantomIdentityConfigurator>();
|
||||||
|
|
||||||
|
public static async Task MigrateDatabase(IServiceProvider serviceProvider) {
|
||||||
|
await using var scope = serviceProvider.CreateAsyncScope();
|
||||||
|
await scope.ServiceProvider.GetRequiredService<PhantomIdentityConfigurator>().Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ApplicationDbContext db;
|
||||||
|
private readonly RoleManager roleManager;
|
||||||
|
|
||||||
|
public PhantomIdentityConfigurator(ApplicationDbContext db, RoleManager roleManager) {
|
||||||
|
this.db = db;
|
||||||
|
this.roleManager = roleManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Initialize() {
|
||||||
|
await CreatePermissions();
|
||||||
|
await CreateDefaultRoles();
|
||||||
|
await AssignDefaultRolePermissions();
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreatePermissions() {
|
||||||
|
var existingPermissionIds = await db.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync();
|
||||||
|
var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds);
|
||||||
|
|
||||||
|
if (!missingPermissionIds.IsEmpty) {
|
||||||
|
Logger.Information("Adding permissions: {Permissions}", string.Join(", ", missingPermissionIds));
|
||||||
|
foreach (var permissionId in missingPermissionIds) {
|
||||||
|
db.Permissions.Add(new PermissionEntity(permissionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateDefaultRoles() {
|
||||||
|
Logger.Information("Creating default roles.");
|
||||||
|
|
||||||
|
var allRoleNames = await roleManager.GetAllNames();
|
||||||
|
|
||||||
|
foreach (var (guid, name, _) in Role.All) {
|
||||||
|
if (allRoleNames.Contains(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await roleManager.Create(guid, name);
|
||||||
|
if (result is Result<RoleEntity, AddRoleError>.Fail fail) {
|
||||||
|
switch (fail.Error) {
|
||||||
|
case AddRoleError.NameIsEmpty:
|
||||||
|
Logger.Fatal("Error creating default role \"{Name}\", name is empty!", name);
|
||||||
|
throw StopProcedureException.Instance;
|
||||||
|
|
||||||
|
case AddRoleError.NameIsTooLong:
|
||||||
|
Logger.Fatal("Error creating default role \"{Name}\", name is too long!", name);
|
||||||
|
throw StopProcedureException.Instance;
|
||||||
|
|
||||||
|
case AddRoleError.NameAlreadyExists:
|
||||||
|
Logger.Warning("Error creating default role \"{Name}\", a role with this name already exists!", name);
|
||||||
|
throw StopProcedureException.Instance;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Logger.Fatal("Error creating default role \"{Name}\", unknown error!", name);
|
||||||
|
throw StopProcedureException.Instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AssignDefaultRolePermissions() {
|
||||||
|
Logger.Information("Assigning default role permissions.");
|
||||||
|
|
||||||
|
foreach (var role in Role.All) {
|
||||||
|
var roleEntity = await roleManager.GetByGuid(role.Guid);
|
||||||
|
if (roleEntity == null) {
|
||||||
|
Logger.Fatal("Error assigning default role permissions, role \"{Name}\" with GUID {Guid} not found.", role.Name, role.Guid);
|
||||||
|
throw StopProcedureException.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingPermissionIds = await db.RolePermissions
|
||||||
|
.Where(rp => rp.RoleGuid == roleEntity.RoleGuid)
|
||||||
|
.Select(static rp => rp.PermissionId)
|
||||||
|
.AsAsyncEnumerable()
|
||||||
|
.ToImmutableSetAsync();
|
||||||
|
|
||||||
|
var missingPermissionIds = GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds);
|
||||||
|
if (!missingPermissionIds.IsEmpty) {
|
||||||
|
Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds));
|
||||||
|
foreach (var permissionId in missingPermissionIds) {
|
||||||
|
db.RolePermissions.Add(new RolePermissionEntity(roleEntity.RoleGuid, permissionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
|
||||||
|
return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
|
||||||
|
}
|
||||||
|
}
|
@ -2,8 +2,10 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Server;
|
using Microsoft.AspNetCore.Components.Server;
|
||||||
|
using Phantom.Controller.Services.Users;
|
||||||
using Phantom.Web.Identity.Authentication;
|
using Phantom.Web.Identity.Authentication;
|
||||||
using Phantom.Web.Identity.Authorization;
|
using Phantom.Web.Identity.Authorization;
|
||||||
|
using Phantom.Web.Identity.Data;
|
||||||
|
|
||||||
namespace Phantom.Web.Identity;
|
namespace Phantom.Web.Identity;
|
||||||
|
|
||||||
@ -15,8 +17,14 @@ public static class PhantomIdentityExtensions {
|
|||||||
services.AddSingleton(PhantomLoginStore.Create(cancellationToken));
|
services.AddSingleton(PhantomLoginStore.Create(cancellationToken));
|
||||||
services.AddScoped<PhantomLoginManager>();
|
services.AddScoped<PhantomLoginManager>();
|
||||||
|
|
||||||
|
services.AddScoped<PhantomIdentityConfigurator>();
|
||||||
services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>();
|
services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>();
|
||||||
services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
|
services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
|
||||||
|
|
||||||
|
services.AddScoped<UserManager>();
|
||||||
|
services.AddScoped<RoleManager>();
|
||||||
|
services.AddScoped<UserRoleManager>();
|
||||||
|
services.AddTransient<PermissionManager>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void UsePhantomIdentity(this IApplicationBuilder application) {
|
public static void UsePhantomIdentity(this IApplicationBuilder application) {
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Phantom.Controller.Services;
|
|
||||||
using Phantom.Utils.Tasks;
|
|
||||||
using Phantom.Web.Base;
|
using Phantom.Web.Base;
|
||||||
using Phantom.Web.Identity;
|
using Phantom.Web.Identity;
|
||||||
using Phantom.Web.Identity.Interfaces;
|
using Phantom.Web.Identity.Interfaces;
|
||||||
@ -9,7 +7,7 @@ using Serilog;
|
|||||||
namespace Phantom.Web;
|
namespace Phantom.Web;
|
||||||
|
|
||||||
public static class Launcher {
|
public static class Launcher {
|
||||||
public static WebApplication CreateApplication(Configuration config, ServiceConfiguration serviceConfiguration, TaskManager taskManager) {
|
public static async Task<WebApplication> CreateApplication(Configuration config, IConfigurator configurator, Action<DbContextOptionsBuilder> dbOptionsBuilder) {
|
||||||
var assembly = typeof(Launcher).Assembly;
|
var assembly = typeof(Launcher).Assembly;
|
||||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions {
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions {
|
||||||
ApplicationName = assembly.GetName().Name,
|
ApplicationName = assembly.GetName().Name,
|
||||||
@ -25,24 +23,32 @@ public static class Launcher {
|
|||||||
builder.WebHost.UseStaticWebAssets();
|
builder.WebHost.UseStaticWebAssets();
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.Services.AddSingleton(serviceConfiguration);
|
configurator.ConfigureServices(builder.Services);
|
||||||
builder.Services.AddSingleton(taskManager);
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<IHostLifetime>(new NullLifetime());
|
builder.Services.AddSingleton<IHostLifetime>(new NullLifetime());
|
||||||
builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath));
|
builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath));
|
||||||
|
|
||||||
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath));
|
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath));
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<ApplicationDbContext>(dbOptionsBuilder, ServiceLifetime.Transient);
|
||||||
|
builder.Services.AddSingleton<DatabaseProvider>();
|
||||||
|
|
||||||
builder.Services.AddPhantomIdentity(config.CancellationToken);
|
builder.Services.AddPhantomIdentity(config.CancellationToken);
|
||||||
builder.Services.AddScoped<ILoginEvents, LoginEvents>();
|
builder.Services.AddScoped<ILoginEvents, LoginEvents>();
|
||||||
|
|
||||||
builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout");
|
builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout");
|
||||||
builder.Services.AddServerSideBlazor();
|
builder.Services.AddServerSideBlazor();
|
||||||
|
|
||||||
return builder.Build();
|
var application = builder.Build();
|
||||||
|
|
||||||
|
await MigrateDatabase(config, application.Services.GetRequiredService<DatabaseProvider>());
|
||||||
|
await PhantomIdentityConfigurator.MigrateDatabase(application.Services);
|
||||||
|
await configurator.LoadFromDatabase(application.Services);
|
||||||
|
|
||||||
|
return application;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Task Launch(Configuration config, WebApplication application) {
|
public static async Task Launch(Configuration config, WebApplication application) {
|
||||||
var logger = config.Logger;
|
var logger = config.Logger;
|
||||||
|
|
||||||
application.UseSerilogRequestLogging();
|
application.UseSerilogRequestLogging();
|
||||||
@ -61,7 +67,7 @@ public static class Launcher {
|
|||||||
application.MapFallbackToPage("/_Host");
|
application.MapFallbackToPage("/_Host");
|
||||||
|
|
||||||
logger.Information("Starting Web server on port {Port}...", config.Port);
|
logger.Information("Starting Web server on port {Port}...", config.Port);
|
||||||
return application.RunAsync(config.CancellationToken);
|
await application.RunAsync(config.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class NullLifetime : IHostLifetime {
|
private sealed class NullLifetime : IHostLifetime {
|
||||||
@ -73,4 +79,9 @@ public static class Launcher {
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IConfigurator {
|
||||||
|
void ConfigureServices(IServiceCollection services);
|
||||||
|
Task LoadFromDatabase(IServiceProvider serviceProvider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Library</OutputType>
|
||||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@ -20,6 +19,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Controller\Phantom.Controller.Database\Phantom.Controller.Database.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Controller\Phantom.Controller.Services\Phantom.Controller.Services.csproj" />
|
||||||
<ProjectReference Include="..\Phantom.Web.Components\Phantom.Web.Components.csproj" />
|
<ProjectReference Include="..\Phantom.Web.Components\Phantom.Web.Components.csproj" />
|
||||||
<ProjectReference Include="..\Phantom.Web.Identity\Phantom.Web.Identity.csproj" />
|
<ProjectReference Include="..\Phantom.Web.Identity\Phantom.Web.Identity.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
@ -1,69 +0,0 @@
|
|||||||
using System.Reflection;
|
|
||||||
using Phantom.Common.Logging;
|
|
||||||
using Phantom.Controller.Services;
|
|
||||||
using Phantom.Utils.Cryptography;
|
|
||||||
using Phantom.Utils.IO;
|
|
||||||
using Phantom.Utils.Runtime;
|
|
||||||
using Phantom.Utils.Tasks;
|
|
||||||
using Phantom.Web;
|
|
||||||
|
|
||||||
var cancellationTokenSource = new CancellationTokenSource();
|
|
||||||
|
|
||||||
PosixSignals.RegisterCancellation(cancellationTokenSource, static () => {
|
|
||||||
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel web...");
|
|
||||||
});
|
|
||||||
|
|
||||||
static void CreateFolderOrStop(string path, UnixFileMode chmod) {
|
|
||||||
if (!Directory.Exists(path)) {
|
|
||||||
try {
|
|
||||||
Directories.Create(path, chmod);
|
|
||||||
} catch (Exception e) {
|
|
||||||
PhantomLogger.Root.Fatal(e, "Error creating folder: {FolderName}", path);
|
|
||||||
throw StopProcedureException.Instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly());
|
|
||||||
|
|
||||||
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel web...");
|
|
||||||
PhantomLogger.Root.Information("Web version: {Version}", fullVersion);
|
|
||||||
|
|
||||||
var (webServerHost, webServerPort, webBasePath) = Variables.LoadOrStop();
|
|
||||||
|
|
||||||
string webKeysPath = Path.GetFullPath("./keys");
|
|
||||||
CreateFolderOrStop(webKeysPath, Chmod.URWX);
|
|
||||||
|
|
||||||
PhantomLogger.Root.InformationHeading("Launching Phantom Panel web...");
|
|
||||||
|
|
||||||
var taskManager = new TaskManager(PhantomLogger.Create<TaskManager>("Web"));
|
|
||||||
try {
|
|
||||||
var configuration = new Configuration(PhantomLogger.Create("Web"), webServerHost, webServerPort, webBasePath, webKeysPath, cancellationTokenSource.Token);
|
|
||||||
|
|
||||||
var administratorToken = TokenGenerator.Create(60);
|
|
||||||
PhantomLogger.Root.Information("Your administrator token is: {AdministratorToken}", administratorToken);
|
|
||||||
PhantomLogger.Root.Information("For administrator setup, visit: {HttpUrl}{SetupPath}", configuration.HttpUrl, configuration.BasePath + "setup");
|
|
||||||
|
|
||||||
var serviceConfiguration = new ServiceConfiguration(fullVersion, TokenGenerator.GetBytesOrThrow(administratorToken), cancellationTokenSource.Token);
|
|
||||||
var webApplication = Launcher.CreateApplication(configuration, serviceConfiguration, taskManager);
|
|
||||||
|
|
||||||
await Launcher.Launch(configuration, webApplication);
|
|
||||||
} finally {
|
|
||||||
cancellationTokenSource.Cancel();
|
|
||||||
await taskManager.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
} catch (OperationCanceledException) {
|
|
||||||
return 0;
|
|
||||||
} catch (StopProcedureException) {
|
|
||||||
return 1;
|
|
||||||
} catch (Exception e) {
|
|
||||||
PhantomLogger.Root.Fatal(e, "Caught exception in entry point.");
|
|
||||||
return 1;
|
|
||||||
} finally {
|
|
||||||
cancellationTokenSource.Dispose();
|
|
||||||
PhantomLogger.Root.Information("Bye!");
|
|
||||||
PhantomLogger.Dispose();
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
using Phantom.Common.Logging;
|
|
||||||
using Phantom.Utils.Runtime;
|
|
||||||
|
|
||||||
namespace Phantom.Web;
|
|
||||||
|
|
||||||
sealed record Variables(
|
|
||||||
string WebServerHost,
|
|
||||||
ushort WebServerPort,
|
|
||||||
string WebBasePath
|
|
||||||
) {
|
|
||||||
private static Variables LoadOrThrow() {
|
|
||||||
return new Variables(
|
|
||||||
EnvironmentVariables.GetString("WEB_SERVER_HOST").WithDefault("0.0.0.0"),
|
|
||||||
EnvironmentVariables.GetPortNumber("WEB_SERVER_PORT").WithDefault(9400),
|
|
||||||
EnvironmentVariables.GetString("WEB_BASE_PATH").Validate(static value => value.StartsWith('/') && value.EndsWith('/'), "Environment variable must begin and end with '/'").WithDefault("/")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Variables LoadOrStop() {
|
|
||||||
try {
|
|
||||||
return LoadOrThrow();
|
|
||||||
} catch (Exception e) {
|
|
||||||
PhantomLogger.Root.Fatal(e.Message);
|
|
||||||
throw StopProcedureException.Instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user