using System.Net;
using System.Net.Sockets;
using Sunfish.Auth;
using Sunfish.Config;
using Sunfish.Ftp;
using Sunfish.Http;
using Sunfish.Log;
using Sunfish.Net;
using Sunfish.Web;
namespace Sunfish.Host;
/// <summary>
/// Punto de entrada para las aplicaciones (GUI, TUI, consola): arranca y para los servicios
/// (HTTP y FTP), aplica y guarda la configuracion, vigila el fichero por si otro programa lo
/// cambia y expone el estado. Las aplicaciones no contienen logica de servidor.
/// </summary>
public sealed class SunfishHost : IAsyncDisposable
{
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Authenticator _auth = new();
private volatile ServerState _state;
private HttpServer? _http;
private FtpServer? _ftp;
private MdnsAnnouncer? _mdns;
private bool _running;
private FileSystemWatcher? _watcher;
private string? _lastWritten;
private CancellationTokenSource? _reloadDelay;
public SunfishHost(SunfishConfig config, ServerLog? log = null)
{
_state = new ServerState(config);
Log = log ?? new ServerLog();
}
public ServerLog Log { get; }
public SunfishConfig Config => _state.Config;
/// <summary>
/// Ajustes que mandan sobre el fichero (p. ej. -p en la linea de comandos); se vuelven a
/// aplicar a cada configuracion recargada del disco.
/// </summary>
public Action<SunfishConfig>? Overrides { get; set; }
/// <summary>Cambio de estado (arranque, parada, configuracion nueva). Llega en cualquier hilo.</summary>
public event Action? Changed;
/// <summary>El servidor esta en marcha (con los servicios que la configuracion active).</summary>
public bool Running => _running;
public bool HttpRunning => _http is not null;
public bool FtpRunning => _ftp is not null;
/// <summary>Donde escucha HTTP (puerto real si se pidio el 0).</summary>
public IReadOnlyList<IPEndPoint> HttpEndpoints => _http?.Endpoints ?? [];
public IReadOnlyList<IPEndPoint> FtpEndpoints => _ftp?.Endpoints ?? [];
/// <summary>Puerto HTTP en uso (o el configurado si esta parado).</summary>
public int HttpPort => HttpEndpoints.Count > 0 ? HttpEndpoints[0].Port : Config.Server.HttpPort;
public int FtpPort => FtpEndpoints.Count > 0 ? FtpEndpoints[0].Port : Config.Server.FtpPort;
/// <summary>
/// Arranca los servicios activados en la configuracion. Lanza <see cref="SocketException"/>
/// si un puerto esta ocupado (los que ya arrancaron siguen en marcha).
/// </summary>
public void Start()
{
_gate.Wait();
try
{
_running = true;
try
{
StartServicesLocked();
}
finally
{
UpdateAnnouncerLocked().GetAwaiter().GetResult();
}
}
finally
{
_gate.Release();
Changed?.Invoke();
}
}
public async Task StopAsync()
{
await _gate.WaitAsync();
try
{
_running = false;
await StopHttpLocked();
await StopFtpLocked();
await UpdateAnnouncerLocked();
}
finally
{
_gate.Release();
}
Changed?.Invoke();
}
/// <summary>Anuncio DNS-SD acorde al estado: en marcha con Announce, o parado.</summary>
private async Task UpdateAnnouncerLocked()
{
bool wanted = _running && Config.Server.Announce && (_http is not null || _ftp is not null);
if (!wanted)
{
if (_mdns is { } old)
{
_mdns = null;
await old.DisposeAsync();
}
return;
}
var services = new AnnouncedServices(Config.Server.Name,
_http is not null ? HttpPort : 0,
Config.Shares.Any(s => s.Enabled && s.WebDav),
_ftp is not null ? FtpPort : 0);
if (_mdns is null)
_mdns = MdnsAnnouncer.Start(services, Log);
else
_mdns.Update(services);
}
private void StartServicesLocked()
{
SocketException? failure = null;
try
{
StartHttpLocked();
}
catch (SocketException e)
{
failure = e;
Log.Error(Strings.CannotListen(Config.Server.HttpPort, e.Message));
}
try
{
StartFtpLocked();
}
catch (SocketException e)
{
failure ??= e;
Log.Error(Strings.CannotListen(Config.Server.FtpPort, e.Message));
}
if (failure is not null)
throw failure;
}
private void StartHttpLocked()
{
if (Config.Server.Http && _http is null)
{
var web = new WebApp(() => _state, Log, _auth, this);
var server = new HttpServer(Config.Server.Bind, Config.Server.HttpPort, web.HandleAsync, Log);
server.Start();
_http = server;
}
}
private void StartFtpLocked()
{
if (Config.Server.Ftp && _ftp is null)
{
var server = new FtpServer(() => _state, Log, _auth);
server.Start();
_ftp = server;
}
}
private async Task StopHttpLocked()
{
if (_http is { } http)
{
_http = null;
await http.DisposeAsync();
}
}
private async Task StopFtpLocked()
{
if (_ftp is { } ftp)
{
_ftp = null;
await ftp.DisposeAsync();
}
}
/// <summary>
/// Aplica una configuracion nueva sin cortar conexiones. Si el servidor esta en marcha y
/// cambian puertos, direcciones o los interruptores HTTP/FTP, se reinicia el servicio afectado.
/// Devuelve false (y lo anota en el registro) si no se pudo volver a arrancar.
/// </summary>
public async Task<bool> ApplyAsync(SunfishConfig config)
{
bool ok = true;
await _gate.WaitAsync();
try
{
var old = _state.Config.Server;
_state = new ServerState(config);
var now = config.Server;
if (_running)
{
bool bind = !old.Bind.SequenceEqual(now.Bind);
if (bind || old.Http != now.Http || old.HttpPort != now.HttpPort)
{
await StopHttpLocked();
ok &= TryStart(StartHttpLocked, now.HttpPort);
}
if (bind || old.Ftp != now.Ftp || old.FtpPort != now.FtpPort)
{
await StopFtpLocked();
ok &= TryStart(StartFtpLocked, now.FtpPort);
}
await UpdateAnnouncerLocked();
}
}
finally
{
_gate.Release();
}
Changed?.Invoke();
return ok;
}
private bool TryStart(Action start, int port)
{
try
{
start();
return true;
}
catch (SocketException e)
{
Log.Error(Strings.CannotListen(port, e.Message));
return false;
}
}
/// <summary>Guarda la configuracion en su fichero y la aplica (el vigilante no la recarga dos veces).</summary>
public async Task<bool> SaveAndApplyAsync(SunfishConfig config)
{
if (config.FilePath is not null)
{
config.Save();
_lastWritten = File.ReadAllText(config.FilePath);
}
return await ApplyAsync(config);
}
/// <summary>
/// Vigila el fichero de configuracion: si otro programa (un editor, sunfish-cli user...) lo
/// cambia, se recarga. Si el fichero nuevo tiene errores se aplica igualmente lo valido y se
/// anotan los errores, como al arrancar.
/// </summary>
public void WatchConfigFile()
{
string? path = Config.FilePath;
if (path is null || _watcher is not null)
return;
string full = Path.GetFullPath(path);
_lastWritten = File.Exists(full) ? File.ReadAllText(full) : null;
_watcher = new FileSystemWatcher(Path.GetDirectoryName(full)!, Path.GetFileName(full))
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size,
};
_watcher.Changed += (_, _) => ScheduleReload(full);
_watcher.Created += (_, _) => ScheduleReload(full);
_watcher.Renamed += (_, _) => ScheduleReload(full);
_watcher.EnableRaisingEvents = true;
}
/// <summary>Los editores escriben en varias tandas: se espera un momento antes de leer.</summary>
private void ScheduleReload(string path)
{
var cts = new CancellationTokenSource();
Interlocked.Exchange(ref _reloadDelay, cts)?.Cancel();
_ = Task.Delay(400, cts.Token).ContinueWith(async t =>
{
if (t.IsCanceled)
return;
try
{
string text = File.ReadAllText(path);
if (text == _lastWritten)
return;
_lastWritten = text;
var config = SunfishConfig.Parse(text);
config.FilePath = path;
Overrides?.Invoke(config);
foreach (var error in config.Errors)
Log.Warning(Strings.ConfigReloadError(error));
Log.Info(Strings.ConfigReloaded);
await ApplyAsync(config);
}
catch (IOException) { ScheduleReload(path); } // aun bloqueado por quien escribe
catch (UnauthorizedAccessException) { }
}, TaskScheduler.Default);
}
public async ValueTask DisposeAsync()
{
_watcher?.Dispose();
_watcher = null;
await StopAsync();
}
}