diff --git a/APIDOCS.md b/APIDOCS.md index 058f7511..1a3a00d8 100644 --- a/APIDOCS.md +++ b/APIDOCS.md @@ -4689,6 +4689,110 @@ RESPONSE: } ``` +### List Community App Repository Apps + +Lists apps available from all configured community (third-party) DNS App repositories. A repository is any HTTPS URL serving a JSON manifest in the same schema as the official DNS App Store (a JSON array of app entries, or a single app entry as a bare JSON object). Each configured repository is fetched independently; if a repository is unreachable, returns invalid JSON, or has an unsupported JSON shape, it is reported with an `error` and does not affect the other repositories' listings. See `CommunityAppRepositories.md` for the full manifest schema and a guide for app authors who want to publish one. + +Apps installed from a community repository are covered by their own separate automatic update timer, controlled by the `dnsAppsEnableAutomaticUpdateCommunity` DNS setting (disabled by default) — the official DNS App Store's `dnsAppsEnableAutomaticUpdate` timer only checks the official store and has no effect on community apps. Updates for community apps can also be applied manually via `downloadAndUpdate` after calling this endpoint to check for a newer version. + +URL:\ +`http://localhost:5380/api/apps/repositories/list` + +PERMISSIONS:\ +Apps: View + +HEADERS: +- Authorization: Bearer + +WHERE: +- `token`: The session token generated by the `login` or the `createToken` call. +- `node` (optional): The node domain name for which the this API call is intended. When unspecified, the current node is used. This parameter can be used only when Clustering is initialized. + +RESPONSE: +``` +{ + "response": { + "repositories": [ + { + "name": "Example Repository", + "url": "https://example.com/store.json" + }, + { + "name": "Another Repository", + "url": "https://example.org/store.json", + "error": "Response status code does not indicate success: 404 (Not Found)." + } + ], + "storeApps": [ + { + "name": "Example Community App", + "description": "An example app served from a community repository.", + "version": "1.0.0", + "url": "https://example.com/apps/ExampleApp.zip", + "size": "12.3 KB", + "repository": "https://example.com/store.json", + "repositoryName": "Example Repository", + "installed": false + } + ] + }, + "status": "ok" +} +``` + +### Add App Repository + +Adds a community (third-party) DNS App repository URL. The URL must serve a JSON manifest in the same schema as the official DNS App Store (an array of app entries, each with `name`, `description`, and `versions`). + +URL:\ +`http://localhost:5380/api/apps/repositories/add?name=Example%20Repository&url=https://example.com/store.json` + +PERMISSIONS:\ +Apps: Modify + +HEADERS: +- Authorization: Bearer + +WHERE: +- `token`: The session token generated by the `login` or the `createToken` call. +- `name` (optional): A display name for the repository. When unspecified, the URL is used as the display name. +- `url`: The HTTPS URL of the DNS App repository's JSON manifest. +- `node` (optional): The node domain name for which the this API call is intended. When unspecified, the current node is used. This parameter can be used only when Clustering is initialized. + +RESPONSE: +``` +{ + "response": {}, + "status": "ok" +} +``` + +### Remove App Repository + +Removes a previously added community (third-party) DNS App repository URL. This does not uninstall any apps already installed from that repository. + +URL:\ +`http://localhost:5380/api/apps/repositories/remove?url=https://example.com/store.json` + +PERMISSIONS:\ +Apps: Modify + +HEADERS: +- Authorization: Bearer + +WHERE: +- `token`: The session token generated by the `login` or the `createToken` call. +- `url`: The HTTPS URL of the DNS App repository's JSON manifest. +- `node` (optional): The node domain name for which the this API call is intended. When unspecified, the current node is used. This parameter can be used only when Clustering is initialized. + +RESPONSE: +``` +{ + "response": {}, + "status": "ok" +} +``` + ### Download And Install App Download an app zip file from given URL and installs it on the DNS Server. @@ -5118,6 +5222,7 @@ RESPONSE: "notifyAllowedNetworks": [], "dnsServerEnableCheckForUpdate": true, "dnsAppsEnableAutomaticUpdate": true, + "dnsAppsEnableAutomaticUpdateCommunity": false, "ipv6Mode": "Disabled", "preferIPv6": false, "enableUdpSocketPool": true, @@ -5327,6 +5432,7 @@ WHERE: - `notifyAllowedNetworks` (optional, cluster parameter): A comma separated list of IP addresses or network addresses that are allowed to Notify all secondary zones. - `dnsServerEnableCheckForUpdate` (optional): Set to `true` to enable the DNS Server to check if an update is available when the Check For Update API is called which usually occurs after a user logs into the Web Console. - `dnsAppsEnableAutomaticUpdate` (optional, cluster parameter): Set to `true` to allow DNS server to automatically update the DNS Apps from the DNS App Store. The DNS Server will check for updates every 24 hrs when this option is enabled. +- `dnsAppsEnableAutomaticUpdateCommunity` (optional, cluster parameter): Set to `true` to allow the DNS server to automatically update apps installed from Community (third-party) DNS App repositories. Independent of `dnsAppsEnableAutomaticUpdate`, which only covers the official DNS App Store. The DNS Server will check for updates every 24 hrs when this option is enabled. Initial value is `false`. - `ipv6Mode` (optional): Valid options are `Disabled`, `Enabled`, and `Preferred`. Initial value is `Disabled`. - `enableUdpSocketPool` (optional): Set this to `true` to enable UDP socket pool. The DNS Server will use UDP socket pool for all outbound DNS-over-UDP requests when enabled. - `socketPoolExcludedPorts` (optional): A comma separated list of port numbers that must be excluded from being used by the UDP socket pool. diff --git a/CommunityAppRepositories.md b/CommunityAppRepositories.md new file mode 100644 index 00000000..428fd0bf --- /dev/null +++ b/CommunityAppRepositories.md @@ -0,0 +1,84 @@ +# Community DNS App Repositories + +DNS Server admins can add any HTTPS URL as a "Community" DNS App repository from the Apps → Community tab, giving apps that aren't accepted into the official DNS App Store (for example because they need hardware/infrastructure the maintainer can't test against — see [issue #2058](https://github.com/TechnitiumSoftware/DnsServer/issues/2058)) a distribution path of their own. + +This document covers both sides: admins who want to **install** someone else's community app, and authors who want to **publish** one. For the admin-facing API itself (`apps/repositories/add`, `apps/repositories/list`, `apps/repositories/remove`), see `APIDOCS.md`. + +## Installing someone else's community app + +There's no central index or directory of community repositories — this is a decentralized, ad-hoc distribution model, not a curated store like the official DNS App Store. To install an app someone else published: + +1. Get the repository's manifest URL directly from the app author — their project's README, GitHub repo, an issue/PR discussion, or wherever they've shared it (Technitium community forums/Discord/Reddit, etc.). +2. In the DNS Server web console, go to Apps → Community, and add that URL as a repository (give it any display name you like). +3. If the app has a version compatible with your server, it shows up in the Community tab's app list with an Install button. + +Since nothing here is vetted or reviewed, only add repository URLs from authors/sources you trust — an installed app runs as native code inside the DNS server process. See "No checksum or signature verification" below. + +By default, community apps are **not** auto-updated, unlike apps from the official DNS App Store. To have the server check daily and automatically download/install newer versions for community apps too, enable "Enable Automatic Update For Community Apps" in Settings → General → Software Update — this is a separate switch from the official store's "Enable Automatic Update", so turning one off does not affect the other. + +## Publishing your own community app + +The rest of this document is for **app authors** who want their app installable this way. + +### What you need to host + +A repository is just a static JSON file served over HTTPS — a GitHub raw URL, a GitHub Pages page, a gist, anything that returns the right JSON. There's no submission process and no approval; the admin who wants your app pastes your manifest URL into their server. + +The manifest is the exact same schema the official DNS App Store uses: either a JSON array of app entries, or a single app entry as a bare JSON object (useful if your repo only ever serves one app). + +```json +[ + { + "name": "Example Community App", + "description": "One-line description shown in the Community tab's app list.", + "versions": [ + { + "serverVersion": "15.0", + "version": "1.0.0", + "url": "https://github.com/you/your-app/releases/download/v1.0.0/YourApp-1.0.0.zip", + "size": "12.3 KB" + }, + { + "serverVersion": "15.4", + "version": "1.1.0", + "url": "https://github.com/you/your-app/releases/download/v1.1.0/YourApp-1.1.0.zip", + "size": "13.1 KB" + } + ] + }, + { + "name": "Another Community App", + "description": "A second, unrelated app served from the same repository.", + "versions": [ + { + "serverVersion": "15.0", + "version": "2.3.0", + "url": "https://github.com/someone-else/another-app/releases/download/v2.3.0/AnotherApp-2.3.0.zip", + "size": "8.7 KB" + } + ] + } +] +``` + +A single repository can serve as many app entries as you like — one repo isn't limited to one app. Each entry is looked up and resolved independently by `name`, so unrelated apps (even from different authors) can share one manifest URL. + +Field notes: + +- **`name`** must exactly match the app name your app registers with the DNS Server (the name shown in the Installed Apps list). The server uses this string as the install/uninstall/update key — a mismatch means "installed" detection and updates silently won't work. +- **`description`** is plain text (rendered HTML-encoded), shown in the Community tab. +- **`versions`** is a list, not a single object, even if you only ever publish one version. Each entry: + - **`serverVersion`**: the minimum DNS Server version this build requires. + - **`version`**: your app's version string. + - **`url`**: direct HTTPS download link to the `.zip` package (same package format as a manual/sideloaded app install — same as what you'd upload via Apps → Install from Zip). + - **`size`**: a human-readable size string (e.g. `"50.02 KB"`). It's display-only, not validated against the actual download. +- When resolving which version to offer, the server picks the entry with the **highest `serverVersion` that is still `<=` the running server's version**. This lets you publish multiple builds targeting different server compatibility ranges in one manifest, same as the official store does. +- Any entry the server can't parse (missing fields, bad JSON) is skipped without breaking the rest of your manifest or any other configured repository. + +### No checksum or signature verification + +Same trust model as the official store: the server fetches your `url` and installs whatever `.zip` is there over HTTPS. There is no checksum/signature check on the package. An admin adding your repository URL is trusting your HTTPS endpoint (and its release infrastructure) directly — say so in your own README if you use a mutable "latest" URL versus a pinned release tag. + +### Writing the app itself + +This repository's manifest format only covers *distribution*. For the actual app implementation (the interfaces to implement, how config/records/query logging hooks work), the `DnsServerCore.ApplicationCommon` project's interfaces (`IDnsApplication`, `IDnsQueryLogger`, `IDnsAuthoritativeRequestHandler`, `IDnsRequestBlockingHandler`, `IDnsAppRecordRequestHandler`, `IDnsPostProcessor`, etc.) are the contract, and the `Apps/` folder in this repository has working, real examples (e.g. `Apps/DnsBlockListApp`, `Apps/QueryLogsSqliteApp`) to copy patterns from. diff --git a/DnsServerCore/Dns/Applications/DnsApplicationManager.cs b/DnsServerCore/Dns/Applications/DnsApplicationManager.cs index 97050779..eed60d0c 100644 --- a/DnsServerCore/Dns/Applications/DnsApplicationManager.cs +++ b/DnsServerCore/Dns/Applications/DnsApplicationManager.cs @@ -60,6 +60,36 @@ public sealed class DnsApplicationManager : IDisposable const int APP_UPDATE_TIMER_INITIAL_INTERVAL = 10000; const int APP_UPDATE_TIMER_PERIODIC_INTERVAL = 86400000; + Timer _communityAppUpdateTimer; + + readonly string _customRepositoriesConfigFile; + readonly object _customRepositoriesLock = new object(); + List _customRepositories = new List(); + readonly ConcurrentDictionary _customRepositoryCache = new ConcurrentDictionary(); + + sealed class CustomRepositoryCacheEntry + { + public string JsonData; + public DateTime UpdatedOn; + public string LastError; + } + + #endregion + + #region public types + + public readonly struct AppRepository + { + public readonly string Name; + public readonly string Url; + + public AppRepository(string name, string url) + { + Name = name; + Url = url; + } + } + #endregion #region constructor @@ -72,6 +102,9 @@ public DnsApplicationManager(DnsServer dnsServer) if (!Directory.Exists(_appsPath)) Directory.CreateDirectory(_appsPath); + + _customRepositoriesConfigFile = Path.Combine(_dnsServer.ConfigFolder, "appRepositories.config"); + LoadCustomRepositoriesConfig(); } #endregion @@ -88,6 +121,7 @@ private void Dispose(bool disposing) if (disposing) { _appUpdateTimer?.Dispose(); + _communityAppUpdateTimer?.Dispose(); if (_applications != null) UnloadAllApplications(); @@ -288,6 +322,112 @@ private void StopAutomaticUpdate() } } + private void StartCommunityAutomaticUpdate() + { + if (_communityAppUpdateTimer is null) + { + _communityAppUpdateTimer = new Timer(async delegate (object state) + { + try + { + if (_applications.IsEmpty) + return; + + IReadOnlyList repositories = GetCustomRepositories(); + if (repositories.Count == 0) + return; + + _dnsServer.LogManager.Write("DNS Server has started automatic update check for Community DNS Apps."); + + IReadOnlyList<(string name, string repoUrl, string jsonData, string error)> repoResults = await GetCustomRepositoriesStoreAppsJsonDataAsync(); + + Version currentVersion = Assembly.GetExecutingAssembly().GetName().Version; + + foreach (DnsApplication application in _applications.Values) + { + string bestUrl = null; + Version bestVersion = null; + Version lastServerVersion = null; + + foreach ((_, string repoUrl, string jsonData, _) in repoResults) + { + if (jsonData is null) + continue; + + try + { + using JsonDocument jsonDocument = JsonDocument.Parse(jsonData); + JsonElement root = jsonDocument.RootElement; + + IEnumerable jsonStoreApps = (root.ValueKind == JsonValueKind.Array) ? root.EnumerateArray() : [root]; + + foreach (JsonElement jsonStoreApp in jsonStoreApps) + { + string name = jsonStoreApp.GetProperty("name").GetString(); + if (!name.Equals(application.Name, StringComparison.Ordinal)) + continue; + + foreach (JsonElement jsonVersion in jsonStoreApp.GetProperty("versions").EnumerateArray()) + { + string strServerVersion = jsonVersion.GetProperty("serverVersion").GetString(); + Version requiredServerVersion = new Version(strServerVersion); + + if (currentVersion < requiredServerVersion) + continue; + + if ((lastServerVersion is not null) && (lastServerVersion > requiredServerVersion)) + continue; + + string version = jsonVersion.GetProperty("version").GetString(); + + bestVersion = new Version(version); + bestUrl = jsonVersion.GetProperty("url").GetString(); + lastServerVersion = requiredServerVersion; + } + + break; + } + } + catch (Exception ex) + { + _dnsServer.LogManager.Write("DNS App repository has a malformed manifest and was skipped during Community app automatic update check: " + repoUrl, ex); + } + } + + if ((bestVersion is not null) && (bestVersion > application.Version)) + { + try + { + await DownloadAndUpdateAppAsync(application.Name, new Uri(bestUrl)); + + _dnsServer.LogManager.Write("Community DNS application '" + application.Name + "' was automatically updated successfully from: " + bestUrl); + } + catch (Exception ex) + { + _dnsServer.LogManager.Write("Failed to automatically download and update Community DNS application '" + application.Name + "'.", ex); + } + } + } + } + catch (Exception ex) + { + _dnsServer.LogManager.Write(ex); + } + }); + + _communityAppUpdateTimer.Change(APP_UPDATE_TIMER_INITIAL_INTERVAL, APP_UPDATE_TIMER_PERIODIC_INTERVAL); + } + } + + private void StopCommunityAutomaticUpdate() + { + if (_communityAppUpdateTimer is not null) + { + _communityAppUpdateTimer.Dispose(); + _communityAppUpdateTimer = null; + } + } + internal async Task GetStoreAppsJsonData() { if ((_storeAppsJsonData is null) || (DateTime.UtcNow > _storeAppsJsonDataUpdatedOn.AddSeconds(STORE_APPS_JSON_DATA_CACHE_TIME_SECONDS))) @@ -307,6 +447,118 @@ internal async Task GetStoreAppsJsonData() return _storeAppsJsonData; } + private void LoadCustomRepositoriesConfig() + { + if (!File.Exists(_customRepositoriesConfigFile)) + return; + + using (FileStream fS = new FileStream(_customRepositoriesConfigFile, FileMode.Open, FileAccess.Read)) + { + if (Encoding.ASCII.GetString(fS.ReadExactly(2)) != "AR") + throw new InvalidDataException("DNS Apps repositories config file format is invalid."); + + BinaryReader bR = new BinaryReader(fS); + + int version = bR.ReadByte(); + if (version > 3) + throw new InvalidDataException("DNS Apps repositories config version not supported."); + + int count = bR.ReadInt32(); + List repositories = new List(count); + + for (int i = 0; i < count; i++) + { + if (version >= 2) + { + string name = fS.ReadShortString(); + string url = fS.ReadShortString(); + + repositories.Add(new AppRepository(name, url)); + } + else + { + string url = fS.ReadShortString(); + + repositories.Add(new AppRepository(url, url)); //v1 had no name; use URL as the display name + } + } + + bool enableCommunityAutomaticUpdate = (version >= 3) && bR.ReadBoolean(); + + lock (_customRepositoriesLock) + { + _customRepositories = repositories; + } + + if (enableCommunityAutomaticUpdate) + StartCommunityAutomaticUpdate(); + } + } + + private void SaveCustomRepositoriesConfig() + { + //must be called with _customRepositoriesLock already held + string tmpFile = _customRepositoriesConfigFile + ".tmp"; + + using (FileStream fS = new FileStream(tmpFile, FileMode.Create, FileAccess.ReadWrite)) + { + fS.Write(Encoding.ASCII.GetBytes("AR")); + + BinaryWriter bW = new BinaryWriter(fS); + bW.Write((byte)3); //version + bW.Write(_customRepositories.Count); + + foreach (AppRepository repository in _customRepositories) + { + fS.WriteShortString(repository.Name); + fS.WriteShortString(repository.Url); + } + + bW.Write(_communityAppUpdateTimer is not null); + } + + File.Copy(tmpFile, _customRepositoriesConfigFile, true); + File.Delete(tmpFile); + } + + private async Task<(string jsonData, string error)> GetCustomRepositoryJsonDataAsync(string repoUrl) + { + if (_customRepositoryCache.TryGetValue(repoUrl, out CustomRepositoryCacheEntry cache) && (DateTime.UtcNow <= cache.UpdatedOn.AddSeconds(STORE_APPS_JSON_DATA_CACHE_TIME_SECONDS))) + return (cache.JsonData, cache.LastError); + + try + { + HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + handler.Proxy = _dnsServer.Proxy; + handler.NetworkType = HttpClientNetworkHandler.GetNetworkType(_dnsServer.IPv6Mode); + handler.DnsClient = _dnsServer; + + using (HttpClient http = new HttpClient(handler)) + { + string jsonData = await http.GetStringAsync(new Uri(repoUrl)); + + _customRepositoryCache[repoUrl] = new CustomRepositoryCacheEntry() { JsonData = jsonData, UpdatedOn = DateTime.UtcNow, LastError = null }; + + return (jsonData, null); + } + } + catch (Exception ex) + { + _dnsServer.LogManager.Write("DNS Server failed to fetch DNS App repository data from: " + repoUrl, ex); + + CustomRepositoryCacheEntry existing = _customRepositoryCache.AddOrUpdate(repoUrl, + new CustomRepositoryCacheEntry() { JsonData = null, UpdatedOn = DateTime.UtcNow, LastError = ex.Message }, + delegate (string key, CustomRepositoryCacheEntry old) + { + old.LastError = ex.Message; + old.UpdatedOn = DateTime.UtcNow; + return old; + }); + + return (existing.JsonData, existing.LastError); + } + } + #endregion #region public @@ -568,6 +820,81 @@ public async Task DownloadAndUpdateAppAsync(string applicationNa } } + public void AddCustomRepository(string name, string repoUrl) + { + if (string.IsNullOrWhiteSpace(repoUrl)) + throw new DnsServerException("DNS App repository URL is required."); + + repoUrl = repoUrl.Trim(); + + if (!repoUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + throw new DnsServerException("DNS App repository URL must start with 'https://'."); + + if (string.IsNullOrWhiteSpace(name)) + name = repoUrl; + else + name = name.Trim(); + + lock (_customRepositoriesLock) + { + foreach (AppRepository existing in _customRepositories) + { + if (existing.Url.Equals(repoUrl, StringComparison.OrdinalIgnoreCase)) + throw new DnsServerException("DNS App repository already exists: " + repoUrl); + } + + List updated = new List(_customRepositories) { new AppRepository(name, repoUrl) }; + _customRepositories = updated; + + SaveCustomRepositoriesConfig(); + } + } + + public void RemoveCustomRepository(string repoUrl) + { + lock (_customRepositoriesLock) + { + int index = _customRepositories.FindIndex(delegate (AppRepository existing) { return existing.Url.Equals(repoUrl, StringComparison.OrdinalIgnoreCase); }); + if (index < 0) + throw new DnsServerException("DNS App repository does not exist: " + repoUrl); + + List updated = new List(_customRepositories); + updated.RemoveAt(index); + _customRepositories = updated; + + SaveCustomRepositoriesConfig(); + } + + _customRepositoryCache.TryRemove(repoUrl, out _); + } + + public IReadOnlyList GetCustomRepositories() + { + lock (_customRepositoriesLock) + { + return _customRepositories; + } + } + + public async Task> GetCustomRepositoriesStoreAppsJsonDataAsync() + { + IReadOnlyList repositories = GetCustomRepositories(); + + Task<(string jsonData, string error)>[] tasks = new Task<(string, string)>[repositories.Count]; + + for (int i = 0; i < repositories.Count; i++) + tasks[i] = GetCustomRepositoryJsonDataAsync(repositories[i].Url); + + (string jsonData, string error)[] results = await Task.WhenAll(tasks); + + List<(string, string, string, string)> repoResults = new List<(string, string, string, string)>(repositories.Count); + + for (int i = 0; i < repositories.Count; i++) + repoResults.Add((repositories[i].Name, repositories[i].Url, results[i].jsonData, results[i].error)); + + return repoResults; + } + #endregion #region properties @@ -602,6 +929,23 @@ public bool EnableAutomaticUpdate } } + public bool EnableCommunityAutomaticUpdate + { + get { return _communityAppUpdateTimer is not null; } + set + { + lock (_customRepositoriesLock) + { + if (value) + StartCommunityAutomaticUpdate(); + else + StopCommunityAutomaticUpdate(); + + SaveCustomRepositoriesConfig(); + } + } + } + #endregion } } diff --git a/DnsServerCore/DnsWebService.cs b/DnsServerCore/DnsWebService.cs index 7f5c5f3f..58ff8765 100644 --- a/DnsServerCore/DnsWebService.cs +++ b/DnsServerCore/DnsWebService.cs @@ -2185,6 +2185,9 @@ private void ConfigureWebServiceRoutes() _webService.MapGetAndPost("/api/apps/uninstall", _appsApi.UninstallApp); _webService.MapGetAndPost("/api/apps/config/get", _appsApi.GetAppConfigAsync); _webService.MapGetAndPost("/api/apps/config/set", _appsApi.SetAppConfigAsync); + _webService.MapGetAndPost("/api/apps/repositories/list", _appsApi.ListCustomRepositoryAppsAsync); + _webService.MapGetAndPost("/api/apps/repositories/add", _appsApi.AddAppRepository); + _webService.MapGetAndPost("/api/apps/repositories/remove", _appsApi.RemoveAppRepository); //dns client _webService.MapGetAndPost("/api/dnsClient/resolve", _api.ResolveQueryAsync); diff --git a/DnsServerCore/WebServiceAppsApi.cs b/DnsServerCore/WebServiceAppsApi.cs index 26f58b2d..ffb9aaa6 100644 --- a/DnsServerCore/WebServiceAppsApi.cs +++ b/DnsServerCore/WebServiceAppsApi.cs @@ -276,6 +276,198 @@ public async Task ListStoreApps(HttpContext context) jsonWriter.WriteEndArray(); } + public async Task ListCustomRepositoryAppsAsync(HttpContext context) + { + User sessionUser = _dnsWebService.GetSessionUser(context); + + if (!_dnsWebService._authManager.IsPermitted(PermissionSection.Apps, sessionUser, PermissionFlag.View)) + throw new DnsWebServiceException("Access was denied."); + + IReadOnlyList<(string name, string repoUrl, string jsonData, string error)> repoResults = await _dnsWebService._dnsServer.DnsApplicationManager.GetCustomRepositoriesStoreAppsJsonDataAsync(); + + Utf8JsonWriter jsonWriter = context.GetCurrentJsonWriter(); + + //parse each repo's data upfront so a parse failure can be reported as the repo's error too, not just a fetch failure + Dictionary parsedRepoData = new Dictionary(); + Dictionary repoNames = new Dictionary(); + Dictionary repoErrors = new Dictionary(); + + try + { + foreach ((string name, string repoUrl, string jsonData, string fetchError) in repoResults) + { + repoNames[repoUrl] = name; + + if (fetchError is not null) + { + repoErrors[repoUrl] = fetchError; + continue; + } + + if (jsonData is null) + continue; //should not happen when fetchError is null, but guard anyway + + JsonDocument jsonDocument; + + try + { + jsonDocument = JsonDocument.Parse(jsonData); + } + catch (Exception ex) + { + _dnsWebService._log.Write("DNS App repository returned invalid JSON data: " + repoUrl, ex); + repoErrors[repoUrl] = "Repository data is not valid JSON: " + ex.Message; + continue; + } + + //a repository is expected to be a JSON array of app entries, but also accept a single + //app entry as a bare JSON object (as published directly by some single-app repositories) + JsonValueKind rootKind = jsonDocument.RootElement.ValueKind; + if ((rootKind != JsonValueKind.Array) && (rootKind != JsonValueKind.Object)) + { + jsonDocument.Dispose(); + repoErrors[repoUrl] = "Repository data must be a JSON array of app entries or a single app entry object."; + continue; + } + + parsedRepoData[repoUrl] = jsonDocument; + } + + jsonWriter.WritePropertyName("repositories"); + jsonWriter.WriteStartArray(); + + foreach ((string name, string repoUrl, _, _) in repoResults) + { + jsonWriter.WriteStartObject(); + + jsonWriter.WriteString("name", name); + jsonWriter.WriteString("url", repoUrl); + + if (repoErrors.TryGetValue(repoUrl, out string error)) + jsonWriter.WriteString("error", error); + + jsonWriter.WriteEndObject(); + } + + jsonWriter.WriteEndArray(); + + jsonWriter.WritePropertyName("storeApps"); + jsonWriter.WriteStartArray(); + + foreach (KeyValuePair parsedRepo in parsedRepoData) + { + string repoUrl = parsedRepo.Key; + string repoName = repoNames[repoUrl]; + JsonElement root = parsedRepo.Value.RootElement; + + IEnumerable jsonStoreApps = (root.ValueKind == JsonValueKind.Array) ? root.EnumerateArray() : new[] { root }; + + foreach (JsonElement jsonStoreApp in jsonStoreApps) + { + try + { + string name = jsonStoreApp.GetProperty("name").GetString(); + string description = jsonStoreApp.GetProperty("description").GetString(); + string version = null; + string url = null; + string size = null; + Version storeAppVersion = null; + Version lastServerVersion = null; + + foreach (JsonElement jsonVersion in jsonStoreApp.GetProperty("versions").EnumerateArray()) + { + string strServerVersion = jsonVersion.GetProperty("serverVersion").GetString(); + Version requiredServerVersion = new Version(strServerVersion); + + if (_dnsWebService._currentVersion < requiredServerVersion) + continue; + + if ((lastServerVersion is not null) && (lastServerVersion > requiredServerVersion)) + continue; + + version = jsonVersion.GetProperty("version").GetString(); + url = jsonVersion.GetProperty("url").GetString(); + size = jsonVersion.GetProperty("size").GetString(); + + storeAppVersion = new Version(version); + lastServerVersion = requiredServerVersion; + } + + if (storeAppVersion is null) + continue; //app is not compatible with this server version + + jsonWriter.WriteStartObject(); + + jsonWriter.WriteString("name", name); + jsonWriter.WriteString("description", description); + jsonWriter.WriteString("version", version); + jsonWriter.WriteString("url", url); + jsonWriter.WriteString("size", size); + jsonWriter.WriteString("repository", repoUrl); + jsonWriter.WriteString("repositoryName", repoName); + + bool installed = _dnsWebService._dnsServer.DnsApplicationManager.Applications.TryGetValue(name, out DnsApplication installedApp); + + jsonWriter.WriteBoolean("installed", installed); + + if (installed) + { + jsonWriter.WriteString("installedVersion", DnsWebService.GetCleanVersion(installedApp.Version)); + jsonWriter.WriteBoolean("updateAvailable", storeAppVersion > installedApp.Version); + } + + jsonWriter.WriteEndObject(); + } + catch (Exception ex) + { + //a single malformed app entry must not break the rest of this repo's listing, nor any other repo's + _dnsWebService._log.Write("DNS App repository has a malformed app entry: " + repoUrl, ex); + } + } + } + } + finally + { + foreach (JsonDocument jsonDocument in parsedRepoData.Values) + jsonDocument.Dispose(); + } + + jsonWriter.WriteEndArray(); + } + + public void AddAppRepository(HttpContext context) + { + User sessionUser = _dnsWebService.GetSessionUser(context); + + if (!_dnsWebService._authManager.IsPermitted(PermissionSection.Apps, sessionUser, PermissionFlag.Modify)) + throw new DnsWebServiceException("Access was denied."); + + HttpRequest request = context.Request; + + string name = request.GetQueryOrForm("name", "").Trim(); + string url = request.GetQueryOrForm("url").Trim(); + + _dnsWebService._dnsServer.DnsApplicationManager.AddCustomRepository(name, url); + + _dnsWebService._log.Write(_dnsWebService.GetRemoteEndPoint(context), "[" + sessionUser.Username + "] DNS App repository was added: " + url); + } + + public void RemoveAppRepository(HttpContext context) + { + User sessionUser = _dnsWebService.GetSessionUser(context); + + if (!_dnsWebService._authManager.IsPermitted(PermissionSection.Apps, sessionUser, PermissionFlag.Modify)) + throw new DnsWebServiceException("Access was denied."); + + HttpRequest request = context.Request; + + string url = request.GetQueryOrForm("url").Trim(); + + _dnsWebService._dnsServer.DnsApplicationManager.RemoveCustomRepository(url); + + _dnsWebService._log.Write(_dnsWebService.GetRemoteEndPoint(context), "[" + sessionUser.Username + "] DNS App repository was removed: " + url); + } + public async Task DownloadAndInstallAppAsync(HttpContext context) { User sessionUser = _dnsWebService.GetSessionUser(context); diff --git a/DnsServerCore/WebServiceSettingsApi.cs b/DnsServerCore/WebServiceSettingsApi.cs index b6e742ca..d778bad6 100644 --- a/DnsServerCore/WebServiceSettingsApi.cs +++ b/DnsServerCore/WebServiceSettingsApi.cs @@ -98,6 +98,7 @@ private void WriteDnsSettings(Utf8JsonWriter jsonWriter) jsonWriter.WriteBoolean("dnsServerEnableCheckForUpdate", _dnsWebService._dnsServer.EnableCheckForUpdate); jsonWriter.WriteBoolean("dnsAppsEnableAutomaticUpdate", _dnsWebService._dnsServer.DnsApplicationManager.EnableAutomaticUpdate); + jsonWriter.WriteBoolean("dnsAppsEnableAutomaticUpdateCommunity", _dnsWebService._dnsServer.DnsApplicationManager.EnableCommunityAutomaticUpdate); jsonWriter.WriteString("ipv6Mode", _dnsWebService._dnsServer.IPv6Mode.ToString()); jsonWriter.WriteBoolean("preferIPv6", _dnsWebService._dnsServer.IPv6Mode == IPv6Mode.Preferred); @@ -639,6 +640,13 @@ public async Task SetDnsSettingsAsync(HttpContext context) clusterParameters.Add("dnsAppsEnableAutomaticUpdate", dnsAppsEnableAutomaticUpdate.ToString()); } + if (request.TryGetQueryOrForm("dnsAppsEnableAutomaticUpdateCommunity", bool.Parse, out bool dnsAppsEnableAutomaticUpdateCommunity)) + { + _dnsWebService._dnsServer.DnsApplicationManager.EnableCommunityAutomaticUpdate = dnsAppsEnableAutomaticUpdateCommunity; + + clusterParameters.Add("dnsAppsEnableAutomaticUpdateCommunity", dnsAppsEnableAutomaticUpdateCommunity.ToString()); + } + if (request.TryGetQueryOrFormEnum("ipv6Mode", out IPv6Mode ipv6Mode)) _dnsWebService._dnsServer.IPv6Mode = ipv6Mode; else if (request.TryGetQueryOrForm("preferIPv6", bool.Parse, out bool preferIPv6)) diff --git a/DnsServerCore/www/index.html b/DnsServerCore/www/index.html index 1678bf9e..28f71c35 100644 --- a/DnsServerCore/www/index.html +++ b/DnsServerCore/www/index.html @@ -150,7 +150,7 @@

DNS Server

- + @@ -805,30 +805,77 @@

-
+ + +
+
+
+ +
+
+
+ + +
+
+
-
-
-
- - + + + + + + + + + + + + +
Installed Apps
Total Apps: 0
-
- - - - - - - - - - - - -
Installed Apps
Total Apps: 0
+
+
+ +
+ +

Add a third-party DNS App repository URL (a JSON manifest in the same format as the official DNS App Store) to browse and install apps from it. These apps are not maintained or vetted by Technitium. Enable automatic updates for these apps from Settings, or check back here and click Update manually when a new version is available.

+ +
+ + + +
+ + + + +
+
+ + + +
+ + + + + + + + + + + + +
Community Apps
Total Apps: 0
+
+
@@ -1105,17 +1152,24 @@

-
Enables the DNS Server to check if an update is available when the Check For Update API is called which usually occurs after a user logs into the Web Console.
+
Enables the DNS Server to check if a core DNS Server update is available when the Check For Update API is called which usually occurs after a user logs into the Web Console.
+
+
The DNS Server will check for updates to apps installed from the official DNS App Store once every day and will automatically download and install the updates.
+ +
+
-
The DNS Server will check for DNS Apps update once every day and will automatically download and install the updates.
+
Independently of the option above, the DNS Server will check for updates once every day for apps installed from Community (third-party) DNS App repositories and will automatically download and install the updates.

diff --git a/DnsServerCore/www/js/apps.js b/DnsServerCore/www/js/apps.js index ee4b8042..32bb4b70 100644 --- a/DnsServerCore/www/js/apps.js +++ b/DnsServerCore/www/js/apps.js @@ -25,42 +25,66 @@ function refreshApps() { divViewAppsLoader.show(); HTTPRequest({ - url: "api/apps/list", + url: "api/apps/repositories/list", token: sessionData.token, success: function (responseJSON) { - var apps = responseJSON.response.apps; - var tableHtmlRows = ""; + var storeApps = responseJSON.response.storeApps; + var communityAppNames = {}; - for (var i = 0; i < apps.length; i++) { - tableHtmlRows += getAppRowHtml(apps[i]); + for (var i = 0; i < storeApps.length; i++) { + if (storeApps[i].installed) + communityAppNames[storeApps[i].name] = true; } - $("#tableAppsBody").html(tableHtmlRows); - - if (apps.length > 0) - $("#tableAppsFooter").html("Total Apps: " + apps.length + ""); - else - $("#tableAppsFooter").html("No Apps Found"); - - divViewAppsLoader.hide(); - divViewApps.show(); + refreshAppsList(communityAppNames); }, error: function () { - divViewAppsLoader.hide(); - divViewApps.show(); + refreshAppsList({}); }, invalidToken: function () { showPageLogin(); - }, - objLoaderPlaceholder: divViewAppsLoader + } }); + + function refreshAppsList(communityAppNames) { + HTTPRequest({ + url: "api/apps/list", + token: sessionData.token, + success: function (responseJSON) { + var apps = responseJSON.response.apps; + var tableHtmlRows = ""; + + for (var i = 0; i < apps.length; i++) { + tableHtmlRows += getAppRowHtml(apps[i], communityAppNames[apps[i].name] === true); + } + + $("#tableAppsBody").html(tableHtmlRows); + + if (apps.length > 0) + $("#tableAppsFooter").html("Total Apps: " + apps.length + ""); + else + $("#tableAppsFooter").html("No Apps Found"); + + divViewAppsLoader.hide(); + divViewApps.show(); + }, + error: function () { + divViewAppsLoader.hide(); + divViewApps.show(); + }, + invalidToken: function () { + showPageLogin(); + }, + objLoaderPlaceholder: divViewAppsLoader + }); + } } function getAppRowId(appName) { return btoa(appName).replace(/=/g, ""); } -function getAppRowHtml(app) { +function getAppRowHtml(app, isCommunity) { var name = app.name; var version = app.version; var updateVersion = app.updateVersion; @@ -113,7 +137,7 @@ function getAppRowHtml(app) { } var id = getAppRowId(name); - var tableHtmlRow = "
" + htmlEncode(name) + "
Version " + htmlEncode(version) + " Update " + htmlEncode(updateVersion) + "
"; + var tableHtmlRow = "
" + htmlEncode(name) + "
Version " + htmlEncode(version) + " Update " + htmlEncode(updateVersion) + " " + (isCommunity ? "Community" : "") + "
"; if (app.description != null) tableHtmlRow += "
" + htmlEncode(app.description).replace(/\n/g, "
") + "
"; @@ -520,3 +544,287 @@ function saveAppConfig() { objAlertPlaceholder: divAppConfigAlert }); } + +//third party app repositories +// +//Note: unlike the official DNS App Store, this data comes from repository URLs +//added by the admin, so name/description/url/error values are not trusted +//content - always render them via htmlEncode(), never as raw inline JS string +//literals in onclick attributes. + +function refreshCommunityApps() { + var divCommunityAppsAlert = $("#divCommunityAppsAlert"); + var divCommunityAppsLoader = $("#divCommunityAppsLoader"); + var divCommunityApps = $("#divCommunityApps"); + + divCommunityApps.hide(); + divCommunityAppsLoader.show(); + + HTTPRequest({ + url: "api/apps/repositories/list", + token: sessionData.token, + success: function (responseJSON) { + var repositories = responseJSON.response.repositories; + var storeApps = responseJSON.response.storeApps; + + var repoHtmlRows = ""; + + for (var i = 0; i < repositories.length; i++) + repoHtmlRows += getCommunityRepoRowHtml(repositories[i]); + + if (repositories.length > 0) + $("#tableCommunityReposBody").html(repoHtmlRows); + else + $("#tableCommunityReposBody").html("No Repositories Added"); + + var tableHtmlRows = ""; + + for (var i = 0; i < storeApps.length; i++) + tableHtmlRows += getCommunityStoreAppRowHtml(storeApps[i]); + + $("#tableCommunityStoreAppsBody").html(tableHtmlRows); + + if (storeApps.length > 0) + $("#tableCommunityStoreAppsFooter").html("Total Apps: " + storeApps.length + ""); + else + $("#tableCommunityStoreAppsFooter").html("No Apps Found"); + + divCommunityAppsLoader.hide(); + divCommunityApps.show(); + }, + error: function () { + divCommunityAppsLoader.hide(); + divCommunityApps.show(); + }, + invalidToken: function () { + showPageLogin(); + }, + objAlertPlaceholder: divCommunityAppsAlert, + objLoaderPlaceholder: divCommunityAppsLoader + }); +} + +function getCommunityRepoRowId(url) { + return btoa(url).replace(/[^a-zA-Z0-9]/g, ""); +} + +function getCommunityRepoRowHtml(repo) { + var id = getCommunityRepoRowId(repo.url); + + var row = "
" + htmlEncode(repo.name) + "
" + htmlEncode(repo.url) + "
"; + + if (repo.error != null) + row += "
Unreachable: " + htmlEncode(repo.error) + "
"; + + row += ""; + + return row; +} + +function addCommunityRepository() { + var divCommunityAppsAlert = $("#divCommunityAppsAlert"); + var txtCommunityRepoName = $("#txtCommunityRepoName"); + var txtCommunityRepoUrl = $("#txtCommunityRepoUrl"); + var name = txtCommunityRepoName.val(); + var url = txtCommunityRepoUrl.val(); + + if ((name === null) || (name === "")) { + showAlert("warning", "Missing!", "Please enter a name for the DNS App repository.", divCommunityAppsAlert); + txtCommunityRepoName.trigger("focus"); + return; + } + + if ((url === null) || (url === "")) { + showAlert("warning", "Missing!", "Please enter a DNS App repository URL.", divCommunityAppsAlert); + txtCommunityRepoUrl.trigger("focus"); + return; + } + + var btn = $("#btnCommunityRepoAdd"); + btn.button("loading"); + + HTTPRequest({ + url: "api/apps/repositories/add?name=" + encodeURIComponent(name) + "&url=" + encodeURIComponent(url), + token: sessionData.token, + success: function (responseJSON) { + btn.button("reset"); + txtCommunityRepoName.val(""); + txtCommunityRepoUrl.val(""); + + showAlert("success", "Repository Added!", "DNS App repository was added successfully.", divCommunityAppsAlert); + + refreshCommunityApps(); + }, + error: function () { + btn.button("reset"); + }, + invalidToken: function () { + showPageLogin(); + }, + objAlertPlaceholder: divCommunityAppsAlert + }); +} + +function removeCommunityRepository(objBtn) { + var btn = $(objBtn); + var url = btn.attr("data-url"); + + if (!confirm("Are you sure you want to remove the DNS App repository '" + url + "'?")) + return; + + var divCommunityAppsAlert = $("#divCommunityAppsAlert"); + + btn.button("loading"); + + HTTPRequest({ + url: "api/apps/repositories/remove?url=" + encodeURIComponent(url), + token: sessionData.token, + success: function (responseJSON) { + showAlert("success", "Repository Removed!", "DNS App repository was removed successfully.", divCommunityAppsAlert); + + refreshCommunityApps(); + }, + error: function () { + btn.button("reset"); + }, + invalidToken: function () { + showPageLogin(); + }, + objAlertPlaceholder: divCommunityAppsAlert + }); +} + +function getCommunityStoreAppRowHtml(app) { + var id = Math.floor(Math.random() * 1000000); + var name = app.name; + var version = app.version; + var description = app.description; + var url = app.url; + var size = app.size; + var repositoryName = app.repositoryName; + var installed = app.installed; + var installedVersion = app.installedVersion; + var updateAvailable = installed ? app.updateAvailable : false; + + var displayVersion = installed ? installedVersion : version; + + var row = "
" + htmlEncode(name) + "
"; + row += "Version " + htmlEncode(displayVersion) + " "; + row += "Update " + htmlEncode(version) + "
"; + row += "
" + htmlEncode(description).replace(/\n/g, "
") + "
"; + row += "
Repository: " + htmlEncode(repositoryName) + "
App Zip File: " + htmlEncode(url) + "
Size: " + htmlEncode(size) + "
"; + + row += ""; + row += ""; + row += ""; + row += ""; + + return row; +} + +function installCommunityStoreApp(objBtn) { + var divCommunityAppsAlert = $("#divCommunityAppsAlert"); + var btn = $(objBtn); + var appName = btn.attr("data-name"); + var url = btn.attr("data-url"); + + btn.button("loading"); + + HTTPRequest({ + url: "api/apps/downloadAndInstall?name=" + encodeURIComponent(appName) + "&url=" + encodeURIComponent(url), + token: sessionData.token, + success: function (responseJSON) { + btn.button("reset"); + btn.hide(); + + var id = btn.attr("data-id"); + $("#btnCommunityStoreAppUninstall" + id).show(); + + var tableHtmlRow = getAppRowHtml(responseJSON.response.installedApp, true); + $("#tableAppsBody").prepend(tableHtmlRow); + updateAppsFooterCount(); + + showAlert("success", "App Installed!", "DNS application '" + appName + "' was installed successfully from the third-party repository.", divCommunityAppsAlert); + }, + error: function () { + btn.button("reset"); + }, + invalidToken: function () { + showPageLogin(); + }, + objAlertPlaceholder: divCommunityAppsAlert + }); +} + +function updateCommunityStoreApp(objBtn) { + var divCommunityAppsAlert = $("#divCommunityAppsAlert"); + var btn = $(objBtn); + var appName = btn.attr("data-name"); + var url = btn.attr("data-url"); + + btn.button("loading"); + + HTTPRequest({ + url: "api/apps/downloadAndUpdate?name=" + encodeURIComponent(appName) + "&url=" + encodeURIComponent(url), + token: sessionData.token, + success: function (responseJSON) { + btn.button("reset"); + btn.hide(); + + var id = btn.attr("data-id"); + $("#spanCommunityStoreAppUpdateVersion" + id).hide(); + $("#spanCommunityStoreAppDisplayVersion" + id).text($("#spanCommunityStoreAppUpdateVersion" + id).text().replace(/Update/g, "Version")); + + var tableHtmlRow = getAppRowHtml(responseJSON.response.updatedApp, true); + var appRowId = getAppRowId(responseJSON.response.updatedApp.name); + $("#trApp" + appRowId).replaceWith(tableHtmlRow); + + showAlert("success", "App Updated!", "DNS application '" + appName + "' was updated successfully from the third-party repository.", divCommunityAppsAlert); + }, + error: function () { + btn.button("reset"); + }, + invalidToken: function () { + showPageLogin(); + }, + objAlertPlaceholder: divCommunityAppsAlert + }); +} + +function uninstallCommunityStoreApp(objBtn) { + var btn = $(objBtn); + var appName = btn.attr("data-name"); + + if (!confirm("Are you sure you want to uninstall the DNS application '" + appName + "'?")) + return; + + var divCommunityAppsAlert = $("#divCommunityAppsAlert"); + + btn.button("loading"); + + HTTPRequest({ + url: "api/apps/uninstall?name=" + encodeURIComponent(appName), + token: sessionData.token, + success: function (responseJSON) { + btn.button("reset"); + btn.hide(); + + var id = btn.attr("data-id"); + $("#btnCommunityStoreAppInstall" + id).show(); + $("#btnCommunityStoreAppUpdate" + id).hide(); + + var appRowId = getAppRowId(appName); + $("#trApp" + appRowId).remove(); + updateAppsFooterCount(); + + showAlert("success", "App Uninstalled!", "DNS application '" + appName + "' was uninstalled successfully.", divCommunityAppsAlert); + }, + error: function () { + btn.button("reset"); + }, + invalidToken: function () { + showPageLogin(); + }, + objAlertPlaceholder: divCommunityAppsAlert + }); +} diff --git a/DnsServerCore/www/js/main.js b/DnsServerCore/www/js/main.js index 91fdc862..c5a44bab 100644 --- a/DnsServerCore/www/js/main.js +++ b/DnsServerCore/www/js/main.js @@ -1196,6 +1196,7 @@ function loadDnsSettings(responseJSON) { $("#chkDnsServerEnableCheckForUpdate").prop("checked", responseJSON.response.dnsServerEnableCheckForUpdate); $("#chkDnsAppsEnableAutomaticUpdate").prop("checked", responseJSON.response.dnsAppsEnableAutomaticUpdate); + $("#chkDnsAppsEnableAutomaticUpdateCommunity").prop("checked", responseJSON.response.dnsAppsEnableAutomaticUpdateCommunity); switch (responseJSON.response.ipv6Mode) { case "Enabled": @@ -1687,8 +1688,9 @@ function saveDnsSettings(objBtn) { var dnsServerEnableCheckForUpdate = $("#chkDnsServerEnableCheckForUpdate").prop("checked"); var dnsAppsEnableAutomaticUpdate = $("#chkDnsAppsEnableAutomaticUpdate").prop("checked"); + var dnsAppsEnableAutomaticUpdateCommunity = $("#chkDnsAppsEnableAutomaticUpdateCommunity").prop("checked"); - formData += "&defaultRecordTtl=" + encodeURIComponent(defaultRecordTtl) + "&defaultNsRecordTtl=" + encodeURIComponent(defaultNsRecordTtl) + "&defaultSoaRecordTtl=" + encodeURIComponent(defaultSoaRecordTtl) + "&defaultResponsiblePerson=" + encodeURIComponent(defaultResponsiblePerson) + "&useSoaSerialDateScheme=" + useSoaSerialDateScheme + "&minSoaRefresh=" + encodeURIComponent(minSoaRefresh) + "&minSoaRetry=" + encodeURIComponent(minSoaRetry) + "&zoneTransferAllowedNetworks=" + encodeURIComponent(zoneTransferAllowedNetworks) + "¬ifyAllowedNetworks=" + encodeURIComponent(notifyAllowedNetworks) + "&dnsServerEnableCheckForUpdate=" + dnsServerEnableCheckForUpdate + "&dnsAppsEnableAutomaticUpdate=" + dnsAppsEnableAutomaticUpdate; + formData += "&defaultRecordTtl=" + encodeURIComponent(defaultRecordTtl) + "&defaultNsRecordTtl=" + encodeURIComponent(defaultNsRecordTtl) + "&defaultSoaRecordTtl=" + encodeURIComponent(defaultSoaRecordTtl) + "&defaultResponsiblePerson=" + encodeURIComponent(defaultResponsiblePerson) + "&useSoaSerialDateScheme=" + useSoaSerialDateScheme + "&minSoaRefresh=" + encodeURIComponent(minSoaRefresh) + "&minSoaRetry=" + encodeURIComponent(minSoaRetry) + "&zoneTransferAllowedNetworks=" + encodeURIComponent(zoneTransferAllowedNetworks) + "¬ifyAllowedNetworks=" + encodeURIComponent(notifyAllowedNetworks) + "&dnsServerEnableCheckForUpdate=" + dnsServerEnableCheckForUpdate + "&dnsAppsEnableAutomaticUpdate=" + dnsAppsEnableAutomaticUpdate + "&dnsAppsEnableAutomaticUpdateCommunity=" + dnsAppsEnableAutomaticUpdateCommunity; } if (includeNodeParameters) { diff --git a/docs/screenshots/community-app-badge.png b/docs/screenshots/community-app-badge.png new file mode 100644 index 00000000..de2cba79 Binary files /dev/null and b/docs/screenshots/community-app-badge.png differ diff --git a/docs/screenshots/community-apps-tab.png b/docs/screenshots/community-apps-tab.png new file mode 100644 index 00000000..78b2af10 Binary files /dev/null and b/docs/screenshots/community-apps-tab.png differ diff --git a/docs/screenshots/settings-software-update.png b/docs/screenshots/settings-software-update.png new file mode 100644 index 00000000..061d408a Binary files /dev/null and b/docs/screenshots/settings-software-update.png differ