diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 59006c911..374a1a4a5 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Threading.Tasks; using API.Data; using API.DTOs.Email; @@ -70,6 +71,27 @@ public class SettingsController : BaseApiController return await UpdateSettings(_mapper.Map(Seed.DefaultSettings)); } + /// + /// Resets the IP Addresses + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("reset-ip-addresses")] + public async Task> ResetIPAddressesSettings() + { + _logger.LogInformation("{UserName} is resetting IP Addresses Setting", User.GetUsername()); + var ipAddresses = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.IpAddresses); + ipAddresses.Value = Configuration.DefaultIPAddresses; + _unitOfWork.SettingsRepository.Update(ipAddresses); + + if (!await _unitOfWork.CommitAsync()) + { + await _unitOfWork.RollbackAsync(); + } + + return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()); + } + /// /// Resets the email service url /// @@ -145,6 +167,22 @@ public class SettingsController : BaseApiController _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) + { + // Validate IP addresses + foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',')) + { + if (!IPAddress.TryParse(ipAddress.Trim(), out _)) { + return BadRequest($"IP Address '{ipAddress}' is invalid"); + } + } + + setting.Value = updateSettingsDto.IpAddresses; + // IpAddesses is managed in appSetting.json + Configuration.IpAddresses = updateSettingsDto.IpAddresses; + _unitOfWork.SettingsRepository.Update(setting); + } + if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) { var path = !updateSettingsDto.BaseUrl.StartsWith("/") diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index fbec533b9..07aa08ce6 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -18,6 +18,10 @@ public class ServerSettingDto /// public int Port { get; set; } /// + /// Comma separated list of ip addresses the server listens on. Managed in appsettings.json + /// + public string IpAddresses { get; set; } + /// /// Allows anonymous information to be collected and sent to KavitaStats /// public bool AllowStatCollection { get; set; } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 9a5f65ec0..cd939c689 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -89,6 +89,9 @@ public static class Seed { Key = ServerSettingKey.Port, Value = "5000" }, // Not used from DB, but DB is sync with appSettings.json + new() { + Key = ServerSettingKey.IpAddresses, Value = "0.0.0.0,::" + }, // Not used from DB, but DB is sync with appSettings.json new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, new() {Key = ServerSettingKey.EnableOpds, Value = "true"}, new() {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, @@ -116,9 +119,11 @@ public static class Seed await context.SaveChangesAsync(); - // Port and LoggingLevel are managed in appSettings.json. Update the DB values to match + // Port, IpAddresses and LoggingLevel are managed in appSettings.json. Update the DB values to match context.ServerSetting.First(s => s.Key == ServerSettingKey.Port).Value = Configuration.Port + string.Empty; + context.ServerSetting.First(s => s.Key == ServerSettingKey.IpAddresses).Value = + Configuration.IpAddresses; context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value = directoryService.CacheDirectory + string.Empty; context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 8772ee075..084457d07 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -110,5 +110,9 @@ public enum ServerSettingKey /// [Description("HostName")] HostName = 20, - + /// + /// Ip addresses the server listens on. Not managed in DB. Managed in appsettings.json and synced to DB. + /// + [Description("IpAddresses")] + IpAddresses = 21, } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index df983021c..86493a9e2 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -30,6 +30,9 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.Port: destination.Port = int.Parse(row.Value); break; + case ServerSettingKey.IpAddresses: + destination.IpAddresses = row.Value; + break; case ServerSettingKey.AllowStatCollection: destination.AllowStatCollection = bool.Parse(row.Value); break; diff --git a/API/Program.cs b/API/Program.cs index 3f2ff8bae..50da97005 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.IO.Abstractions; using System.Linq; @@ -173,7 +173,25 @@ public class Program { webBuilder.UseKestrel((opts) => { - opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; }); + var ipAddresses = Configuration.IpAddresses; + if (ipAddresses == null || ipAddresses.Length == 0) + { + opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; }); + } + else + { + foreach(var ipAddress in ipAddresses.Split(',')) + { + try { + var address = System.Net.IPAddress.Parse(ipAddress.Trim()); + opts.Listen(address, HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; }); + } + catch(Exception ex) + { + Log.Fatal(ex, "Could not parse ip addess '{0}'", ipAddress); + } + } + } }); webBuilder.UseStartup(); diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index 2bb2debc0..b449ae2a0 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -1,4 +1,5 @@ { "TokenKey": "super secret unguessable key", - "Port": 5000 + "Port": 5000, + "IpAddresses": "0.0.0.0,::" } diff --git a/API/config/appsettings.json b/API/config/appsettings.json index be6c0b319..b449ae2a0 100644 --- a/API/config/appsettings.json +++ b/API/config/appsettings.json @@ -1,4 +1,5 @@ -{ +{ "TokenKey": "super secret unguessable key", - "Port": 5000 + "Port": 5000, + "IpAddresses": "0.0.0.0,::" } diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 0d507559d..560c1cebd 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -8,6 +8,7 @@ namespace Kavita.Common; public static class Configuration { + public const string DefaultIPAddresses = "0.0.0.0,::"; public static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); public static int Port @@ -16,6 +17,12 @@ public static class Configuration set => SetPort(GetAppSettingFilename(), value); } + public static string IpAddresses + { + get => GetIpAddresses(GetAppSettingFilename()); + set => SetIpAddresses(GetAppSettingFilename(), value); + } + public static string JwtToken { get => GetJwtToken(GetAppSettingFilename()); @@ -63,9 +70,10 @@ public static class Configuration { try { - var currentToken = GetJwtToken(filePath); - var json = File.ReadAllText(filePath) - .Replace("\"TokenKey\": \"" + currentToken, "\"TokenKey\": \"" + token); + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + jsonObj.TokenKey = token; + json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(filePath, json); } catch (Exception) @@ -101,8 +109,10 @@ public static class Configuration try { - var currentPort = GetPort(filePath); - var json = File.ReadAllText(filePath).Replace("\"Port\": " + currentPort, "\"Port\": " + port); + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + jsonObj.Port = port; + json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(filePath, json); } catch (Exception) @@ -139,4 +149,61 @@ public static class Configuration } #endregion + + #region Ip Addresses + + private static void SetIpAddresses(string filePath, string ipAddresses) + { + if (new OsInfo(Array.Empty()).IsDocker) + { + return; + } + + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + jsonObj.IpAddresses = ipAddresses; + json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(filePath, json); + } + catch (Exception) + { + /* Swallow Exception */ + } + } + + private static string GetIpAddresses(string filePath) + { + if (new OsInfo(Array.Empty()).IsDocker) + { + return DefaultIPAddresses; + } + + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + const string key = "IpAddresses"; + + if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) + { + return tokenElement.GetString(); + } + } + catch (Exception ex) + { + Console.WriteLine("Error writing app settings: " + ex.Message); + } + + return DefaultIPAddresses; + } + #endregion + + private class AppSettings + { + public string TokenKey { get; set; } + public int Port { get; set; } + public string IpAddresses { get; set; } + } } diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index cb48d3a5f..1cb0ace79 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -4,6 +4,7 @@ export interface ServerSettings { taskBackup: string; loggingLevel: string; port: number; + ipAddresses: string; allowStatCollection: boolean; enableOpds: boolean; baseUrl: string; diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index 7519a4216..d77dc147c 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -34,14 +34,32 @@
-
+
+   + This does not apply to Docker + Comma separated list of Ip addresses the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. +
+ + +
+
+
+ IP addresses can only contain valid IPv4 or IPv6 addresses +
+
+
+ +
  Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.
+
-
+
+
  The number of backups to maintain. Default is 30, minumum is 1, maximum is 30. The number of backups to maintain. Default is 30, minumum is 1, maximum is 30. @@ -61,7 +79,7 @@
-
+
  The number of logs to maintain. Default is 30, minumum is 1, maximum is 30. The number of backups to maintain. Default is 30, minumum is 1, maximum is 30. @@ -81,7 +99,7 @@
-
+
  Use debug to help identify issues. Debug can eat up a lot of disk space. Port the server listens on. diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts index 461543bf5..53d697db1 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts @@ -8,6 +8,7 @@ import { SettingsService } from '../settings.service'; import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component'; import { ServerSettings } from '../_models/server-settings'; +const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*\,)*\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*$/i; @Component({ selector: 'app-manage-settings', @@ -25,7 +26,7 @@ export class ManageSettingsComponent implements OnInit { return TagBadgeCursor; } - constructor(private settingsService: SettingsService, private toastr: ToastrService, + constructor(private settingsService: SettingsService, private toastr: ToastrService, private modalService: NgbModal) { } ngOnInit(): void { @@ -41,6 +42,7 @@ export class ManageSettingsComponent implements OnInit { this.settingsForm.addControl('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [Validators.required])); this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required])); this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required])); + this.settingsForm.addControl('ipAddresses', new FormControl(this.serverSettings.ipAddresses, [Validators.required, Validators.pattern(ValidIpAddress)])); this.settingsForm.addControl('port', new FormControl(this.serverSettings.port, [Validators.required])); this.settingsForm.addControl('loggingLevel', new FormControl(this.serverSettings.loggingLevel, [Validators.required])); this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required])); @@ -60,6 +62,7 @@ export class ManageSettingsComponent implements OnInit { this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory); this.settingsForm.get('scanTask')?.setValue(this.serverSettings.taskScan); this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup); + this.settingsForm.get('ipAddresses')?.setValue(this.serverSettings.ipAddresses); this.settingsForm.get('port')?.setValue(this.serverSettings.port); this.settingsForm.get('loggingLevel')?.setValue(this.serverSettings.loggingLevel); this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection); @@ -96,6 +99,16 @@ export class ManageSettingsComponent implements OnInit { }); } + resetIPAddresses() { + this.settingsService.resetIPAddressesSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => { + this.serverSettings.ipAddresses = settings.ipAddresses; + this.settingsForm.get("ipAddresses")?.setValue(this.serverSettings.ipAddresses); + this.toastr.success('IP Addresses Reset'); + }, (err: any) => { + console.error('error: ', err); + }); + } + openDirectoryChooser(existingDirectory: string, formControl: string) { const modalRef = this.modalService.open(DirectoryPickerComponent, { scrollable: true, size: 'lg' }); modalRef.componentInstance.startingFolder = existingDirectory || ''; diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index b6cf59cfe..a35649d1a 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -33,6 +33,10 @@ export class SettingsService { return this.http.post(this.baseUrl + 'settings/reset', {}); } + resetIPAddressesSettings() { + return this.http.post(this.baseUrl + 'settings/reset-ip-addresses', {}); + } + resetEmailServerSettings() { return this.http.post(this.baseUrl + 'settings/reset-email-url', {}); }