Skip to content

Smdn.Net.MuninNode version 2.0.0

Latest
Compare
Choose a tag to compare
@smdn smdn released this 03 Nov 14:06
· 3 commits to main since this release
a565585

Released package

Release notes

The full release notes are available at gist.

Change log

Change log in this release:

API changes

API changes in this release:
diff --git a/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-net6.0.apilist.cs b/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-net6.0.apilist.cs
index 130febd..62a4f2b 100644
--- a/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-net6.0.apilist.cs
+++ b/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-net6.0.apilist.cs
@@ -1,215 +1,221 @@
-// Smdn.Net.MuninNode.dll (Smdn.Net.MuninNode-1.3.0)
+// Smdn.Net.MuninNode.dll (Smdn.Net.MuninNode-2.0.0)
 //   Name: Smdn.Net.MuninNode
-//   AssemblyVersion: 1.3.0.0
-//   InformationalVersion: 1.3.0+191d215fe57392cb544e2ffea221644a1007cfc0
+//   AssemblyVersion: 2.0.0.0
+//   InformationalVersion: 2.0.0+0c4121c0bc87932e6486c3b38a123cb59460ac02
 //   TargetFramework: .NETCoreApp,Version=v6.0
 //   Configuration: Release
 //   Referenced assemblies:
 //     Microsoft.Extensions.DependencyInjection.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
 //     Microsoft.Extensions.Logging.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
 //     Smdn.Fundamental.Exception, Version=3.0.0.0, Culture=neutral
 //     System.Collections, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.ComponentModel, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.IO.Pipelines, Version=6.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
 //     System.Linq, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.Memory, Version=6.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
 //     System.Net.Primitives, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.Net.Sockets, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.Runtime, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.Security.Cryptography.Algorithms, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.Text.RegularExpressions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 #nullable enable annotations
 
 using System;
 using System.Collections.Generic;
 using System.Net;
 using System.Net.Sockets;
 using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Smdn.Net.MuninNode;
 using Smdn.Net.MuninPlugin;
 
 namespace Smdn.Net.MuninNode {
-  public class LocalNode : NodeBase {
-    public LocalNode(IPluginProvider pluginProvider, string hostName, int port, ILogger? logger = null) {}
-    public LocalNode(IPluginProvider pluginProvider, string hostName, int port, IServiceProvider? serviceProvider = null) {}
-    public LocalNode(IReadOnlyCollection<IPlugin> plugins, int port, IServiceProvider? serviceProvider = null) {}
-    public LocalNode(IReadOnlyCollection<IPlugin> plugins, string hostName, int port, IServiceProvider? serviceProvider = null) {}
+  public interface IAccessRule {
+    bool IsAcceptable(IPEndPoint remoteEndPoint);
+  }
+
+  public static class IAccessRuleServiceCollectionExtensions {
+    public static IServiceCollection AddMuninNodeAccessRule(this IServiceCollection services, IAccessRule accessRule) {}
+    public static IServiceCollection AddMuninNodeAccessRule(this IServiceCollection services, IReadOnlyList<IPAddress> addressListAllowFrom) {}
+  }
 
-    public IPEndPoint LocalEndPoint { get; }
+  public abstract class LocalNode : NodeBase {
+    public static LocalNode Create(IPluginProvider pluginProvider, int port, string? hostName = null, IReadOnlyList<IPAddress>? addressListAllowFrom = null, IServiceProvider? serviceProvider = null) {}
+    public static LocalNode Create(IReadOnlyCollection<IPlugin> plugins, int port, string? hostName = null, IReadOnlyList<IPAddress>? addressListAllowFrom = null, IServiceProvider? serviceProvider = null) {}
+
+    protected LocalNode(IAccessRule? accessRule, ILogger? logger = null) {}
 
     protected override Socket CreateServerSocket() {}
-    protected override bool IsClientAcceptable(IPEndPoint remoteEndPoint) {}
+    protected virtual EndPoint GetLocalEndPointToBind() {}
   }
 
   public abstract class NodeBase :
     IAsyncDisposable,
     IDisposable
   {
-    private protected class PluginProvider : IPluginProvider {
-      public PluginProvider(IReadOnlyCollection<IPlugin> plugins) {}
-
-      public IReadOnlyCollection<IPlugin> Plugins { get; }
-      public INodeSessionCallback? SessionCallback { get; }
-    }
-
-    protected NodeBase(IPluginProvider pluginProvider, string hostName, ILogger? logger) {}
-    protected NodeBase(IReadOnlyCollection<IPlugin> plugins, string hostName, ILogger? logger) {}
+    protected NodeBase(IAccessRule? accessRule, ILogger? logger) {}
 
     public virtual Encoding Encoding { get; }
-    public string HostName { get; }
+    public abstract string HostName { get; }
+    public EndPoint LocalEndPoint { get; }
     protected ILogger? Logger { get; }
     public virtual Version NodeVersion { get; }
-    [Obsolete("This member will be deprecated in future version.")]
-    public IReadOnlyCollection<IPlugin> Plugins { get; }
+    public abstract IPluginProvider PluginProvider { get; }
 
     public async ValueTask AcceptAsync(bool throwIfCancellationRequested, CancellationToken cancellationToken) {}
     public async ValueTask AcceptSingleSessionAsync(CancellationToken cancellationToken = default) {}
     protected abstract Socket CreateServerSocket();
     protected virtual void Dispose(bool disposing) {}
     public void Dispose() {}
     public async ValueTask DisposeAsync() {}
     protected virtual async ValueTask DisposeAsyncCore() {}
-    protected abstract bool IsClientAcceptable(IPEndPoint remoteEndPoint);
     public void Start() {}
+    protected void ThrowIfPluginProviderIsNull() {}
   }
 }
 
 namespace Smdn.Net.MuninPlugin {
   public interface INodeSessionCallback {
     ValueTask ReportSessionClosedAsync(string sessionId, CancellationToken cancellationToken);
     ValueTask ReportSessionStartedAsync(string sessionId, CancellationToken cancellationToken);
   }
 
   public interface IPlugin {
     IPluginDataSource DataSource { get; }
-    PluginGraphAttributes GraphAttributes { get; }
+    IPluginGraphAttributes GraphAttributes { get; }
     string Name { get; }
     INodeSessionCallback? SessionCallback { get; }
   }
 
   public interface IPluginDataSource {
     IReadOnlyCollection<IPluginField> Fields { get; }
   }
 
   public interface IPluginField {
     PluginFieldAttributes Attributes { get; }
     string Name { get; }
 
     ValueTask<string> GetFormattedValueStringAsync(CancellationToken cancellationToken);
   }
 
+  public interface IPluginGraphAttributes {
+    IEnumerable<string> EnumerateAttributes();
+  }
+
   public interface IPluginProvider {
     IReadOnlyCollection<IPlugin> Plugins { get; }
     INodeSessionCallback? SessionCallback { get; }
   }
 
   public enum PluginFieldGraphStyle : int {
     Area = 1,
     AreaStack = 3,
     Default = 0,
     Line = 100,
     LineStack = 200,
     LineStackWidth1 = 201,
     LineStackWidth2 = 202,
     LineStackWidth3 = 203,
     LineWidth1 = 101,
     LineWidth2 = 102,
     LineWidth3 = 103,
     Stack = 2,
   }
 
   public class Plugin :
     INodeSessionCallback,
     IPlugin,
     IPluginDataSource
   {
     public Plugin(string name, PluginGraphAttributes graphAttributes, IReadOnlyCollection<IPluginField> fields) {}
 
     public IReadOnlyCollection<IPluginField> Fields { get; }
     public PluginGraphAttributes GraphAttributes { get; }
     public string Name { get; }
     IPluginDataSource IPlugin.DataSource { get; }
+    IPluginGraphAttributes IPlugin.GraphAttributes { get; }
     INodeSessionCallback? IPlugin.SessionCallback { get; }
     IReadOnlyCollection<IPluginField> IPluginDataSource.Fields { get; }
 
     protected virtual ValueTask ReportSessionClosedAsync(string sessionId, CancellationToken cancellationToken) {}
     protected virtual ValueTask ReportSessionStartedAsync(string sessionId, CancellationToken cancellationToken) {}
     ValueTask INodeSessionCallback.ReportSessionClosedAsync(string sessionId, CancellationToken cancellationToken) {}
     ValueTask INodeSessionCallback.ReportSessionStartedAsync(string sessionId, CancellationToken cancellationToken) {}
   }
 
   public static class PluginFactory {
     public static IPluginField CreateField(string label, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string label, PluginFieldGraphStyle graphStyle, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string name, string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string name, string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, string? negativeFieldName, Func<double?> fetchValue) {}
     public static IPlugin CreatePlugin(string name, PluginGraphAttributes graphAttributes, IReadOnlyCollection<IPluginField> fields) {}
     public static IPlugin CreatePlugin(string name, PluginGraphAttributes graphAttributes, IReadOnlyCollection<PluginFieldBase> fields) {}
     public static IPlugin CreatePlugin(string name, PluginGraphAttributes graphAttributes, PluginFieldBase field) {}
     public static IPlugin CreatePlugin(string name, string fieldLabel, Func<double?> fetchFieldValue, PluginGraphAttributes graphAttributes) {}
     public static IPlugin CreatePlugin(string name, string fieldLabel, PluginFieldGraphStyle fieldGraphStyle, Func<double?> fetchFieldValue, PluginGraphAttributes graphAttributes) {}
   }
 
   public abstract class PluginFieldBase : IPluginField {
     protected PluginFieldBase(string label, string? name, PluginFieldGraphStyle graphStyle = PluginFieldGraphStyle.Default, PluginFieldNormalValueRange normalRangeForWarning = default, PluginFieldNormalValueRange normalRangeForCritical = default) {}
     protected PluginFieldBase(string label, string? name, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, string? negativeFieldName) {}
 
     public PluginFieldGraphStyle GraphStyle { get; }
     public string Label { get; }
     public string Name { get; }
     public string? NegativeFieldName { get; }
     public PluginFieldNormalValueRange NormalRangeForCritical { get; }
     public PluginFieldNormalValueRange NormalRangeForWarning { get; }
     PluginFieldAttributes IPluginField.Attributes { get; }
 
     protected abstract ValueTask<double?> FetchValueAsync(CancellationToken cancellationToken);
     async ValueTask<string> IPluginField.GetFormattedValueStringAsync(CancellationToken cancellationToken) {}
   }
 
-  public sealed class PluginGraphAttributes {
-    [Obsolete("This member will be deprecated in future version.")]
-    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan updateRate, int? width = null, int? height = null) {}
-    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan? updateRate = null, int? width = null, int? height = null) {}
-    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan? updateRate, int? width, int? height, IEnumerable<string>? order) {}
+  public sealed class PluginGraphAttributes : IPluginGraphAttributes {
+    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments) {}
+    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan? updateRate, int? width, int? height, IEnumerable<string>? order, string? totalValueLabel) {}
 
     public string Arguments { get; }
     public string Category { get; }
     public int? Height { get; }
     public string? Order { get; }
     public bool Scale { get; }
     public string Title { get; }
+    public string? TotalValueLabel { get; }
     public TimeSpan? UpdateRate { get; }
     public string VerticalLabel { get; }
     public int? Width { get; }
+
+    public IEnumerable<string> EnumerateAttributes() {}
   }
 
   public readonly struct PluginFieldAttributes {
     public PluginFieldAttributes(string label, PluginFieldGraphStyle graphStyle = PluginFieldGraphStyle.Default) {}
     public PluginFieldAttributes(string label, PluginFieldGraphStyle graphStyle = PluginFieldGraphStyle.Default, PluginFieldNormalValueRange normalRangeForWarning = default, PluginFieldNormalValueRange normalRangeForCritical = default) {}
     public PluginFieldAttributes(string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, string? negativeFieldName) {}
 
     public PluginFieldGraphStyle GraphStyle { get; }
     public string Label { get; }
     public string? NegativeFieldName { get; }
     public PluginFieldNormalValueRange NormalRangeForCritical { get; }
     public PluginFieldNormalValueRange NormalRangeForWarning { get; }
   }
 
   public readonly struct PluginFieldNormalValueRange {
     public static readonly PluginFieldNormalValueRange None; // = "Smdn.Net.MuninPlugin.PluginFieldNormalValueRange"
 
     public static PluginFieldNormalValueRange CreateMax(double max) {}
     public static PluginFieldNormalValueRange CreateMin(double min) {}
     public static PluginFieldNormalValueRange CreateRange(double min, double max) {}
 
     public bool HasValue { get; }
     public double? Max { get; }
     public double? Min { get; }
   }
 }
 // API list generated by Smdn.Reflection.ReverseGenerating.ListApi.MSBuild.Tasks v1.4.1.0.
 // Smdn.Reflection.ReverseGenerating.ListApi.Core v1.3.1.0 (https://github.com/smdn/Smdn.Reflection.ReverseGenerating)
diff --git a/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-net8.0.apilist.cs b/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-net8.0.apilist.cs
index 73b3402..d282525 100644
--- a/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-net8.0.apilist.cs
+++ b/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-net8.0.apilist.cs
@@ -1,215 +1,221 @@
-// Smdn.Net.MuninNode.dll (Smdn.Net.MuninNode-1.3.0)
+// Smdn.Net.MuninNode.dll (Smdn.Net.MuninNode-2.0.0)
 //   Name: Smdn.Net.MuninNode
-//   AssemblyVersion: 1.3.0.0
-//   InformationalVersion: 1.3.0+191d215fe57392cb544e2ffea221644a1007cfc0
+//   AssemblyVersion: 2.0.0.0
+//   InformationalVersion: 2.0.0+0c4121c0bc87932e6486c3b38a123cb59460ac02
 //   TargetFramework: .NETCoreApp,Version=v8.0
 //   Configuration: Release
 //   Referenced assemblies:
 //     Microsoft.Extensions.DependencyInjection.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
 //     Microsoft.Extensions.Logging.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
 //     Smdn.Fundamental.Exception, Version=3.0.0.0, Culture=neutral
 //     System.Collections, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.ComponentModel, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.IO.Pipelines, Version=6.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
 //     System.Linq, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.Memory, Version=8.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
 //     System.Net.Primitives, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.Net.Sockets, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.Security.Cryptography, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 //     System.Text.RegularExpressions, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
 #nullable enable annotations
 
 using System;
 using System.Collections.Generic;
 using System.Net;
 using System.Net.Sockets;
 using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Smdn.Net.MuninNode;
 using Smdn.Net.MuninPlugin;
 
 namespace Smdn.Net.MuninNode {
-  public class LocalNode : NodeBase {
-    public LocalNode(IPluginProvider pluginProvider, string hostName, int port, ILogger? logger = null) {}
-    public LocalNode(IPluginProvider pluginProvider, string hostName, int port, IServiceProvider? serviceProvider = null) {}
-    public LocalNode(IReadOnlyCollection<IPlugin> plugins, int port, IServiceProvider? serviceProvider = null) {}
-    public LocalNode(IReadOnlyCollection<IPlugin> plugins, string hostName, int port, IServiceProvider? serviceProvider = null) {}
+  public interface IAccessRule {
+    bool IsAcceptable(IPEndPoint remoteEndPoint);
+  }
+
+  public static class IAccessRuleServiceCollectionExtensions {
+    public static IServiceCollection AddMuninNodeAccessRule(this IServiceCollection services, IAccessRule accessRule) {}
+    public static IServiceCollection AddMuninNodeAccessRule(this IServiceCollection services, IReadOnlyList<IPAddress> addressListAllowFrom) {}
+  }
 
-    public IPEndPoint LocalEndPoint { get; }
+  public abstract class LocalNode : NodeBase {
+    public static LocalNode Create(IPluginProvider pluginProvider, int port, string? hostName = null, IReadOnlyList<IPAddress>? addressListAllowFrom = null, IServiceProvider? serviceProvider = null) {}
+    public static LocalNode Create(IReadOnlyCollection<IPlugin> plugins, int port, string? hostName = null, IReadOnlyList<IPAddress>? addressListAllowFrom = null, IServiceProvider? serviceProvider = null) {}
+
+    protected LocalNode(IAccessRule? accessRule, ILogger? logger = null) {}
 
     protected override Socket CreateServerSocket() {}
-    protected override bool IsClientAcceptable(IPEndPoint remoteEndPoint) {}
+    protected virtual EndPoint GetLocalEndPointToBind() {}
   }
 
   public abstract class NodeBase :
     IAsyncDisposable,
     IDisposable
   {
-    private protected class PluginProvider : IPluginProvider {
-      public PluginProvider(IReadOnlyCollection<IPlugin> plugins) {}
-
-      public IReadOnlyCollection<IPlugin> Plugins { get; }
-      public INodeSessionCallback? SessionCallback { get; }
-    }
-
-    protected NodeBase(IPluginProvider pluginProvider, string hostName, ILogger? logger) {}
-    protected NodeBase(IReadOnlyCollection<IPlugin> plugins, string hostName, ILogger? logger) {}
+    protected NodeBase(IAccessRule? accessRule, ILogger? logger) {}
 
     public virtual Encoding Encoding { get; }
-    public string HostName { get; }
+    public abstract string HostName { get; }
+    public EndPoint LocalEndPoint { get; }
     protected ILogger? Logger { get; }
     public virtual Version NodeVersion { get; }
-    [Obsolete("This member will be deprecated in future version.")]
-    public IReadOnlyCollection<IPlugin> Plugins { get; }
+    public abstract IPluginProvider PluginProvider { get; }
 
     public async ValueTask AcceptAsync(bool throwIfCancellationRequested, CancellationToken cancellationToken) {}
     public async ValueTask AcceptSingleSessionAsync(CancellationToken cancellationToken = default) {}
     protected abstract Socket CreateServerSocket();
     protected virtual void Dispose(bool disposing) {}
     public void Dispose() {}
     public async ValueTask DisposeAsync() {}
     protected virtual async ValueTask DisposeAsyncCore() {}
-    protected abstract bool IsClientAcceptable(IPEndPoint remoteEndPoint);
     public void Start() {}
+    protected void ThrowIfPluginProviderIsNull() {}
   }
 }
 
 namespace Smdn.Net.MuninPlugin {
   public interface INodeSessionCallback {
     ValueTask ReportSessionClosedAsync(string sessionId, CancellationToken cancellationToken);
     ValueTask ReportSessionStartedAsync(string sessionId, CancellationToken cancellationToken);
   }
 
   public interface IPlugin {
     IPluginDataSource DataSource { get; }
-    PluginGraphAttributes GraphAttributes { get; }
+    IPluginGraphAttributes GraphAttributes { get; }
     string Name { get; }
     INodeSessionCallback? SessionCallback { get; }
   }
 
   public interface IPluginDataSource {
     IReadOnlyCollection<IPluginField> Fields { get; }
   }
 
   public interface IPluginField {
     PluginFieldAttributes Attributes { get; }
     string Name { get; }
 
     ValueTask<string> GetFormattedValueStringAsync(CancellationToken cancellationToken);
   }
 
+  public interface IPluginGraphAttributes {
+    IEnumerable<string> EnumerateAttributes();
+  }
+
   public interface IPluginProvider {
     IReadOnlyCollection<IPlugin> Plugins { get; }
     INodeSessionCallback? SessionCallback { get; }
   }
 
   public enum PluginFieldGraphStyle : int {
     Area = 1,
     AreaStack = 3,
     Default = 0,
     Line = 100,
     LineStack = 200,
     LineStackWidth1 = 201,
     LineStackWidth2 = 202,
     LineStackWidth3 = 203,
     LineWidth1 = 101,
     LineWidth2 = 102,
     LineWidth3 = 103,
     Stack = 2,
   }
 
   public class Plugin :
     INodeSessionCallback,
     IPlugin,
     IPluginDataSource
   {
     public Plugin(string name, PluginGraphAttributes graphAttributes, IReadOnlyCollection<IPluginField> fields) {}
 
     public IReadOnlyCollection<IPluginField> Fields { get; }
     public PluginGraphAttributes GraphAttributes { get; }
     public string Name { get; }
     IPluginDataSource IPlugin.DataSource { get; }
+    IPluginGraphAttributes IPlugin.GraphAttributes { get; }
     INodeSessionCallback? IPlugin.SessionCallback { get; }
     IReadOnlyCollection<IPluginField> IPluginDataSource.Fields { get; }
 
     protected virtual ValueTask ReportSessionClosedAsync(string sessionId, CancellationToken cancellationToken) {}
     protected virtual ValueTask ReportSessionStartedAsync(string sessionId, CancellationToken cancellationToken) {}
     ValueTask INodeSessionCallback.ReportSessionClosedAsync(string sessionId, CancellationToken cancellationToken) {}
     ValueTask INodeSessionCallback.ReportSessionStartedAsync(string sessionId, CancellationToken cancellationToken) {}
   }
 
   public static class PluginFactory {
     public static IPluginField CreateField(string label, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string label, PluginFieldGraphStyle graphStyle, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string name, string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string name, string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, string? negativeFieldName, Func<double?> fetchValue) {}
     public static IPlugin CreatePlugin(string name, PluginGraphAttributes graphAttributes, IReadOnlyCollection<IPluginField> fields) {}
     public static IPlugin CreatePlugin(string name, PluginGraphAttributes graphAttributes, IReadOnlyCollection<PluginFieldBase> fields) {}
     public static IPlugin CreatePlugin(string name, PluginGraphAttributes graphAttributes, PluginFieldBase field) {}
     public static IPlugin CreatePlugin(string name, string fieldLabel, Func<double?> fetchFieldValue, PluginGraphAttributes graphAttributes) {}
     public static IPlugin CreatePlugin(string name, string fieldLabel, PluginFieldGraphStyle fieldGraphStyle, Func<double?> fetchFieldValue, PluginGraphAttributes graphAttributes) {}
   }
 
   public abstract class PluginFieldBase : IPluginField {
     protected PluginFieldBase(string label, string? name, PluginFieldGraphStyle graphStyle = PluginFieldGraphStyle.Default, PluginFieldNormalValueRange normalRangeForWarning = default, PluginFieldNormalValueRange normalRangeForCritical = default) {}
     protected PluginFieldBase(string label, string? name, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, string? negativeFieldName) {}
 
     public PluginFieldGraphStyle GraphStyle { get; }
     public string Label { get; }
     public string Name { get; }
     public string? NegativeFieldName { get; }
     public PluginFieldNormalValueRange NormalRangeForCritical { get; }
     public PluginFieldNormalValueRange NormalRangeForWarning { get; }
     PluginFieldAttributes IPluginField.Attributes { get; }
 
     protected abstract ValueTask<double?> FetchValueAsync(CancellationToken cancellationToken);
     async ValueTask<string> IPluginField.GetFormattedValueStringAsync(CancellationToken cancellationToken) {}
   }
 
-  public sealed class PluginGraphAttributes {
-    [Obsolete("This member will be deprecated in future version.")]
-    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan updateRate, int? width = null, int? height = null) {}
-    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan? updateRate = null, int? width = null, int? height = null) {}
-    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan? updateRate, int? width, int? height, IEnumerable<string>? order) {}
+  public sealed class PluginGraphAttributes : IPluginGraphAttributes {
+    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments) {}
+    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan? updateRate, int? width, int? height, IEnumerable<string>? order, string? totalValueLabel) {}
 
     public string Arguments { get; }
     public string Category { get; }
     public int? Height { get; }
     public string? Order { get; }
     public bool Scale { get; }
     public string Title { get; }
+    public string? TotalValueLabel { get; }
     public TimeSpan? UpdateRate { get; }
     public string VerticalLabel { get; }
     public int? Width { get; }
+
+    public IEnumerable<string> EnumerateAttributes() {}
   }
 
   public readonly struct PluginFieldAttributes {
     public PluginFieldAttributes(string label, PluginFieldGraphStyle graphStyle = PluginFieldGraphStyle.Default) {}
     public PluginFieldAttributes(string label, PluginFieldGraphStyle graphStyle = PluginFieldGraphStyle.Default, PluginFieldNormalValueRange normalRangeForWarning = default, PluginFieldNormalValueRange normalRangeForCritical = default) {}
     public PluginFieldAttributes(string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, string? negativeFieldName) {}
 
     public PluginFieldGraphStyle GraphStyle { get; }
     public string Label { get; }
     public string? NegativeFieldName { get; }
     public PluginFieldNormalValueRange NormalRangeForCritical { get; }
     public PluginFieldNormalValueRange NormalRangeForWarning { get; }
   }
 
   public readonly struct PluginFieldNormalValueRange {
     public static readonly PluginFieldNormalValueRange None; // = "Smdn.Net.MuninPlugin.PluginFieldNormalValueRange"
 
     public static PluginFieldNormalValueRange CreateMax(double max) {}
     public static PluginFieldNormalValueRange CreateMin(double min) {}
     public static PluginFieldNormalValueRange CreateRange(double min, double max) {}
 
     public bool HasValue { get; }
     public double? Max { get; }
     public double? Min { get; }
   }
 }
 // API list generated by Smdn.Reflection.ReverseGenerating.ListApi.MSBuild.Tasks v1.4.1.0.
 // Smdn.Reflection.ReverseGenerating.ListApi.Core v1.3.1.0 (https://github.com/smdn/Smdn.Reflection.ReverseGenerating)
diff --git a/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-netstandard2.1.apilist.cs b/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-netstandard2.1.apilist.cs
index 48ee340..db8e8bb 100644
--- a/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-netstandard2.1.apilist.cs
+++ b/doc/api-list/Smdn.Net.MuninNode/Smdn.Net.MuninNode-netstandard2.1.apilist.cs
@@ -1,208 +1,214 @@
-// Smdn.Net.MuninNode.dll (Smdn.Net.MuninNode-1.3.0)
+// Smdn.Net.MuninNode.dll (Smdn.Net.MuninNode-2.0.0)
 //   Name: Smdn.Net.MuninNode
-//   AssemblyVersion: 1.3.0.0
-//   InformationalVersion: 1.3.0+191d215fe57392cb544e2ffea221644a1007cfc0
+//   AssemblyVersion: 2.0.0.0
+//   InformationalVersion: 2.0.0+0c4121c0bc87932e6486c3b38a123cb59460ac02
 //   TargetFramework: .NETStandard,Version=v2.1
 //   Configuration: Release
 //   Referenced assemblies:
 //     Microsoft.Extensions.DependencyInjection.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
 //     Microsoft.Extensions.Logging.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
 //     Smdn.Fundamental.Encoding.Buffer, Version=3.0.0.0, Culture=neutral
 //     Smdn.Fundamental.Exception, Version=3.0.0.0, Culture=neutral
 //     System.IO.Pipelines, Version=6.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
 //     netstandard, Version=2.1.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
 #nullable enable annotations
 
 using System;
 using System.Collections.Generic;
 using System.Net;
 using System.Net.Sockets;
 using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Smdn.Net.MuninNode;
 using Smdn.Net.MuninPlugin;
 
 namespace Smdn.Net.MuninNode {
-  public class LocalNode : NodeBase {
-    public LocalNode(IPluginProvider pluginProvider, string hostName, int port, ILogger? logger = null) {}
-    public LocalNode(IPluginProvider pluginProvider, string hostName, int port, IServiceProvider? serviceProvider = null) {}
-    public LocalNode(IReadOnlyCollection<IPlugin> plugins, int port, IServiceProvider? serviceProvider = null) {}
-    public LocalNode(IReadOnlyCollection<IPlugin> plugins, string hostName, int port, IServiceProvider? serviceProvider = null) {}
+  public interface IAccessRule {
+    bool IsAcceptable(IPEndPoint remoteEndPoint);
+  }
+
+  public static class IAccessRuleServiceCollectionExtensions {
+    public static IServiceCollection AddMuninNodeAccessRule(this IServiceCollection services, IAccessRule accessRule) {}
+    public static IServiceCollection AddMuninNodeAccessRule(this IServiceCollection services, IReadOnlyList<IPAddress> addressListAllowFrom) {}
+  }
 
-    public IPEndPoint LocalEndPoint { get; }
+  public abstract class LocalNode : NodeBase {
+    public static LocalNode Create(IPluginProvider pluginProvider, int port, string? hostName = null, IReadOnlyList<IPAddress>? addressListAllowFrom = null, IServiceProvider? serviceProvider = null) {}
+    public static LocalNode Create(IReadOnlyCollection<IPlugin> plugins, int port, string? hostName = null, IReadOnlyList<IPAddress>? addressListAllowFrom = null, IServiceProvider? serviceProvider = null) {}
+
+    protected LocalNode(IAccessRule? accessRule, ILogger? logger = null) {}
 
     protected override Socket CreateServerSocket() {}
-    protected override bool IsClientAcceptable(IPEndPoint remoteEndPoint) {}
+    protected virtual EndPoint GetLocalEndPointToBind() {}
   }
 
   public abstract class NodeBase :
     IAsyncDisposable,
     IDisposable
   {
-    private protected class PluginProvider : IPluginProvider {
-      public PluginProvider(IReadOnlyCollection<IPlugin> plugins) {}
-
-      public IReadOnlyCollection<IPlugin> Plugins { get; }
-      public INodeSessionCallback? SessionCallback { get; }
-    }
-
-    protected NodeBase(IPluginProvider pluginProvider, string hostName, ILogger? logger) {}
-    protected NodeBase(IReadOnlyCollection<IPlugin> plugins, string hostName, ILogger? logger) {}
+    protected NodeBase(IAccessRule? accessRule, ILogger? logger) {}
 
     public virtual Encoding Encoding { get; }
-    public string HostName { get; }
+    public abstract string HostName { get; }
+    public EndPoint LocalEndPoint { get; }
     protected ILogger? Logger { get; }
     public virtual Version NodeVersion { get; }
-    [Obsolete("This member will be deprecated in future version.")]
-    public IReadOnlyCollection<IPlugin> Plugins { get; }
+    public abstract IPluginProvider PluginProvider { get; }
 
     public async ValueTask AcceptAsync(bool throwIfCancellationRequested, CancellationToken cancellationToken) {}
     public async ValueTask AcceptSingleSessionAsync(CancellationToken cancellationToken = default) {}
     protected abstract Socket CreateServerSocket();
     protected virtual void Dispose(bool disposing) {}
     public void Dispose() {}
     public async ValueTask DisposeAsync() {}
     protected virtual ValueTask DisposeAsyncCore() {}
-    protected abstract bool IsClientAcceptable(IPEndPoint remoteEndPoint);
     public void Start() {}
+    protected void ThrowIfPluginProviderIsNull() {}
   }
 }
 
 namespace Smdn.Net.MuninPlugin {
   public interface INodeSessionCallback {
     ValueTask ReportSessionClosedAsync(string sessionId, CancellationToken cancellationToken);
     ValueTask ReportSessionStartedAsync(string sessionId, CancellationToken cancellationToken);
   }
 
   public interface IPlugin {
     IPluginDataSource DataSource { get; }
-    PluginGraphAttributes GraphAttributes { get; }
+    IPluginGraphAttributes GraphAttributes { get; }
     string Name { get; }
     INodeSessionCallback? SessionCallback { get; }
   }
 
   public interface IPluginDataSource {
     IReadOnlyCollection<IPluginField> Fields { get; }
   }
 
   public interface IPluginField {
     PluginFieldAttributes Attributes { get; }
     string Name { get; }
 
     ValueTask<string> GetFormattedValueStringAsync(CancellationToken cancellationToken);
   }
 
+  public interface IPluginGraphAttributes {
+    IEnumerable<string> EnumerateAttributes();
+  }
+
   public interface IPluginProvider {
     IReadOnlyCollection<IPlugin> Plugins { get; }
     INodeSessionCallback? SessionCallback { get; }
   }
 
   public enum PluginFieldGraphStyle : int {
     Area = 1,
     AreaStack = 3,
     Default = 0,
     Line = 100,
     LineStack = 200,
     LineStackWidth1 = 201,
     LineStackWidth2 = 202,
     LineStackWidth3 = 203,
     LineWidth1 = 101,
     LineWidth2 = 102,
     LineWidth3 = 103,
     Stack = 2,
   }
 
   public class Plugin :
     INodeSessionCallback,
     IPlugin,
     IPluginDataSource
   {
     public Plugin(string name, PluginGraphAttributes graphAttributes, IReadOnlyCollection<IPluginField> fields) {}
 
     public IReadOnlyCollection<IPluginField> Fields { get; }
     public PluginGraphAttributes GraphAttributes { get; }
     public string Name { get; }
     IPluginDataSource IPlugin.DataSource { get; }
+    IPluginGraphAttributes IPlugin.GraphAttributes { get; }
     INodeSessionCallback? IPlugin.SessionCallback { get; }
     IReadOnlyCollection<IPluginField> IPluginDataSource.Fields { get; }
 
     protected virtual ValueTask ReportSessionClosedAsync(string sessionId, CancellationToken cancellationToken) {}
     protected virtual ValueTask ReportSessionStartedAsync(string sessionId, CancellationToken cancellationToken) {}
     ValueTask INodeSessionCallback.ReportSessionClosedAsync(string sessionId, CancellationToken cancellationToken) {}
     ValueTask INodeSessionCallback.ReportSessionStartedAsync(string sessionId, CancellationToken cancellationToken) {}
   }
 
   public static class PluginFactory {
     public static IPluginField CreateField(string label, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string label, PluginFieldGraphStyle graphStyle, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string name, string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, Func<double?> fetchValue) {}
     public static IPluginField CreateField(string name, string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, string? negativeFieldName, Func<double?> fetchValue) {}
     public static IPlugin CreatePlugin(string name, PluginGraphAttributes graphAttributes, IReadOnlyCollection<IPluginField> fields) {}
     public static IPlugin CreatePlugin(string name, PluginGraphAttributes graphAttributes, IReadOnlyCollection<PluginFieldBase> fields) {}
     public static IPlugin CreatePlugin(string name, PluginGraphAttributes graphAttributes, PluginFieldBase field) {}
     public static IPlugin CreatePlugin(string name, string fieldLabel, Func<double?> fetchFieldValue, PluginGraphAttributes graphAttributes) {}
     public static IPlugin CreatePlugin(string name, string fieldLabel, PluginFieldGraphStyle fieldGraphStyle, Func<double?> fetchFieldValue, PluginGraphAttributes graphAttributes) {}
   }
 
   public abstract class PluginFieldBase : IPluginField {
     protected PluginFieldBase(string label, string? name, PluginFieldGraphStyle graphStyle = PluginFieldGraphStyle.Default, PluginFieldNormalValueRange normalRangeForWarning = default, PluginFieldNormalValueRange normalRangeForCritical = default) {}
     protected PluginFieldBase(string label, string? name, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, string? negativeFieldName) {}
 
     public PluginFieldGraphStyle GraphStyle { get; }
     public string Label { get; }
     public string Name { get; }
     public string? NegativeFieldName { get; }
     public PluginFieldNormalValueRange NormalRangeForCritical { get; }
     public PluginFieldNormalValueRange NormalRangeForWarning { get; }
     PluginFieldAttributes IPluginField.Attributes { get; }
 
     protected abstract ValueTask<double?> FetchValueAsync(CancellationToken cancellationToken);
     async ValueTask<string> IPluginField.GetFormattedValueStringAsync(CancellationToken cancellationToken) {}
   }
 
-  public sealed class PluginGraphAttributes {
-    [Obsolete("This member will be deprecated in future version.")]
-    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan updateRate, int? width = null, int? height = null) {}
-    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan? updateRate = null, int? width = null, int? height = null) {}
-    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan? updateRate, int? width, int? height, IEnumerable<string>? order) {}
+  public sealed class PluginGraphAttributes : IPluginGraphAttributes {
+    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments) {}
+    public PluginGraphAttributes(string title, string category, string verticalLabel, bool scale, string arguments, TimeSpan? updateRate, int? width, int? height, IEnumerable<string>? order, string? totalValueLabel) {}
 
     public string Arguments { get; }
     public string Category { get; }
     public int? Height { get; }
     public string? Order { get; }
     public bool Scale { get; }
     public string Title { get; }
+    public string? TotalValueLabel { get; }
     public TimeSpan? UpdateRate { get; }
     public string VerticalLabel { get; }
     public int? Width { get; }
+
+    public IEnumerable<string> EnumerateAttributes() {}
   }
 
   public readonly struct PluginFieldAttributes {
     public PluginFieldAttributes(string label, PluginFieldGraphStyle graphStyle = PluginFieldGraphStyle.Default) {}
     public PluginFieldAttributes(string label, PluginFieldGraphStyle graphStyle = PluginFieldGraphStyle.Default, PluginFieldNormalValueRange normalRangeForWarning = default, PluginFieldNormalValueRange normalRangeForCritical = default) {}
     public PluginFieldAttributes(string label, PluginFieldGraphStyle graphStyle, PluginFieldNormalValueRange normalRangeForWarning, PluginFieldNormalValueRange normalRangeForCritical, string? negativeFieldName) {}
 
     public PluginFieldGraphStyle GraphStyle { get; }
     public string Label { get; }
     public string? NegativeFieldName { get; }
     public PluginFieldNormalValueRange NormalRangeForCritical { get; }
     public PluginFieldNormalValueRange NormalRangeForWarning { get; }
   }
 
   public readonly struct PluginFieldNormalValueRange {
     public static readonly PluginFieldNormalValueRange None; // = "Smdn.Net.MuninPlugin.PluginFieldNormalValueRange"
 
     public static PluginFieldNormalValueRange CreateMax(double max) {}
     public static PluginFieldNormalValueRange CreateMin(double min) {}
     public static PluginFieldNormalValueRange CreateRange(double min, double max) {}
 
     public bool HasValue { get; }
     public double? Max { get; }
     public double? Min { get; }
   }
 }
 // API list generated by Smdn.Reflection.ReverseGenerating.ListApi.MSBuild.Tasks v1.4.1.0.
 // Smdn.Reflection.ReverseGenerating.ListApi.Core v1.3.1.0 (https://github.com/smdn/Smdn.Reflection.ReverseGenerating)

Full changes

Full changes in this release:
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode.csproj b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode.csproj
index 6384660..338df69 100644
--- a/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode.csproj
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode.csproj
@@ -5,14 +5,11 @@ SPDX-License-Identifier: MIT
 <Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
     <TargetFrameworks>netstandard2.1;net6.0;net8.0</TargetFrameworks>
-    <VersionPrefix>1.3.0</VersionPrefix>
+    <VersionPrefix>2.0.0</VersionPrefix>
     <VersionSuffix></VersionSuffix>
-    <PackageValidationBaselineVersion>1.2.0</PackageValidationBaselineVersion>
+    <!-- <PackageValidationBaselineVersion>2.0.0</PackageValidationBaselineVersion> -->
     <RootNamespace/> <!-- empty the root namespace so that the namespace is determined only by the directory name, for code style rule IDE0030 -->
     <Nullable>enable</Nullable>
-    <DefineConstants
-      Condition="$([MSBuild]::VersionGreaterThanOrEquals('$(NETCoreSdkVersion)', '7.0.0'))"
-    >$(DefineConstants);LANG_VERSION_11_OR_GREATER</DefineConstants> <!-- required to use the UTF-8 string literals in C# 11 -->
     <NoWarn>CS1591;$(NoWarn)</NoWarn> <!-- CS1591: Missing XML comment for publicly visible type or member 'Type_or_Member' -->
   </PropertyGroup>
 
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/AddressListAccessRule.cs b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/AddressListAccessRule.cs
new file mode 100644
index 0000000..9723a86
--- /dev/null
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/AddressListAccessRule.cs
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+
+namespace Smdn.Net.MuninNode;
+
+internal sealed class AddressListAccessRule : IAccessRule {
+  private readonly IReadOnlyList<IPAddress> addressListAllowFrom;
+
+  public AddressListAccessRule(IReadOnlyList<IPAddress> addressListAllowFrom)
+  {
+    this.addressListAllowFrom = addressListAllowFrom ?? throw new ArgumentNullException(nameof(addressListAllowFrom));
+  }
+
+  public bool IsAcceptable(IPEndPoint remoteEndPoint)
+  {
+    if (remoteEndPoint is null)
+      throw new ArgumentNullException(nameof(remoteEndPoint));
+
+    var remoteAddress = remoteEndPoint.Address;
+
+    foreach (var addressAllowFrom in addressListAllowFrom) {
+      if (addressAllowFrom.AddressFamily == AddressFamily.InterNetwork) {
+        // test for client acceptability by IPv4 address
+        if (remoteAddress.IsIPv4MappedToIPv6)
+          remoteAddress = remoteAddress.MapToIPv4();
+      }
+
+      if (addressAllowFrom.Equals(remoteAddress))
+        return true;
+    }
+
+    return false;
+  }
+}
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/IAccessRule.cs b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/IAccessRule.cs
new file mode 100644
index 0000000..b87f0eb
--- /dev/null
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/IAccessRule.cs
@@ -0,0 +1,9 @@
+// SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System.Net;
+
+namespace Smdn.Net.MuninNode;
+
+public interface IAccessRule {
+  bool IsAcceptable(IPEndPoint remoteEndPoint);
+}
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/IAccessRuleServiceCollectionExtensions.cs b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/IAccessRuleServiceCollectionExtensions.cs
new file mode 100644
index 0000000..96cf384
--- /dev/null
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/IAccessRuleServiceCollectionExtensions.cs
@@ -0,0 +1,46 @@
+// SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+using System.Collections.Generic;
+using System.Net;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Smdn.Net.MuninNode;
+
+public static class IAccessRuleServiceCollectionExtensions {
+  /// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
+  /// <param name="addressListAllowFrom">The <see cref="IReadOnlyList{IPAddress}"/> indicates the read-only list of addresses allowed to access <see cref="NodeBase"/>.</param>
+  public static IServiceCollection AddMuninNodeAccessRule(
+    this IServiceCollection services,
+    IReadOnlyList<IPAddress> addressListAllowFrom
+  )
+    => AddMuninNodeAccessRule(
+      services: services ?? throw new ArgumentNullException(nameof(services)),
+      accessRule: new AddressListAccessRule(
+        addressListAllowFrom: addressListAllowFrom ?? throw new ArgumentNullException(nameof(addressListAllowFrom))
+      )
+    );
+
+  /// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
+  /// <param name="accessRule">The <see cref="IAccessRule"/> which defines access rules to <see cref="NodeBase"/>.</param>
+  public static IServiceCollection AddMuninNodeAccessRule(
+    this IServiceCollection services,
+    IAccessRule accessRule
+  )
+  {
+#pragma warning disable CA1510
+    if (services is null)
+      throw new ArgumentNullException(nameof(services));
+    if (accessRule is null)
+      throw new ArgumentNullException(nameof(accessRule));
+#pragma warning restore CA1510
+
+    services.TryAdd(
+      ServiceDescriptor.Singleton(typeof(IAccessRule), accessRule)
+    );
+
+    return services;
+  }
+}
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/LocalNode.Create.cs b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/LocalNode.Create.cs
new file mode 100644
index 0000000..28dc002
--- /dev/null
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/LocalNode.Create.cs
@@ -0,0 +1,134 @@
+// SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+using System.Collections.Generic;
+using System.Net;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+using Smdn.Net.MuninPlugin;
+
+namespace Smdn.Net.MuninNode;
+
+#pragma warning disable IDE0040
+partial class LocalNode {
+#pragma warning restore IDE0040
+  private class ReadOnlyCollectionPluginProvider : IPluginProvider {
+    public IReadOnlyCollection<IPlugin> Plugins { get; }
+    public INodeSessionCallback? SessionCallback => null;
+
+    public ReadOnlyCollectionPluginProvider(IReadOnlyCollection<IPlugin> plugins)
+    {
+      Plugins = plugins;
+    }
+  }
+
+  private sealed class ConcreteLocalNode : LocalNode {
+    public override IPluginProvider PluginProvider { get; }
+    public override string HostName { get; }
+
+    private readonly int port;
+
+    public ConcreteLocalNode(
+      IPluginProvider pluginProvider,
+      string hostName,
+      int port,
+      IAccessRule? accessRule,
+      IServiceProvider? serviceProvider = null
+    )
+      : base(
+        accessRule: accessRule ?? serviceProvider?.GetService<IAccessRule>(),
+        logger: serviceProvider?.GetService<ILoggerFactory>()?.CreateLogger<LocalNode>()
+      )
+    {
+      PluginProvider = pluginProvider;
+      HostName = hostName;
+      this.port = port;
+    }
+
+    protected override EndPoint GetLocalEndPointToBind()
+      => new IPEndPoint(
+        address: ((IPEndPoint)base.GetLocalEndPointToBind()).Address,
+        port: port
+      );
+  }
+
+  /// <summary>
+  /// Creates a new instance of the <see cref="LocalNode"/> class.
+  /// </summary>
+  /// <param name="plugins">
+  /// The readolny collection of <see cref="IPlugin"/>s provided by this node.
+  /// </param>
+  /// <param name="port">
+  /// The port number on which this node accepts connections.
+  /// </param>
+  /// <param name="hostName">
+  /// The hostname advertised by this node. This value is used as the display name for HTML generated by Munin.
+  /// If <see langword="null"/> or empty, the default hostname is used.
+  /// </param>
+  /// <param name="addressListAllowFrom">
+  /// The <see cref="IReadOnlyList{IPAddress}"/> indicates the read-only list of addresses allowed to access <see cref="LocalNode"/>.
+  /// </param>
+  /// <param name="serviceProvider">
+  /// The <see cref="IServiceProvider"/>.
+  /// This overload attempts to get a service of <see cref="ILoggerFactory"/>, to create an <see cref="ILogger"/>.
+  /// Also attempts to get a service of <see cref="IAccessRule"/> if <paramref name="addressListAllowFrom"/> is <see langword="null"/>.
+  /// </param>
+  /// <remarks>
+  /// Most Munin-Node uses port 4949 by default, but it is recommended to use other port numbers to avoid conflicts with other nodes.
+  /// </remarks>
+  public static LocalNode Create(
+    IReadOnlyCollection<IPlugin> plugins,
+    int port,
+    string? hostName = null,
+    IReadOnlyList<IPAddress>? addressListAllowFrom = null,
+    IServiceProvider? serviceProvider = null
+  )
+    => Create(
+      pluginProvider: new ReadOnlyCollectionPluginProvider(plugins ?? throw new ArgumentNullException(nameof(plugins))),
+      hostName: string.IsNullOrEmpty(hostName) ? DefaultHostName : hostName,
+      port: port,
+      addressListAllowFrom: addressListAllowFrom,
+      serviceProvider: serviceProvider
+    );
+
+  /// <summary>
+  /// Creates a new instance of the <see cref="LocalNode"/> class.
+  /// </summary>
+  /// <param name="pluginProvider">
+  /// The <see cref="IPluginProvider"/> that provides <see cref="IPlugin"/>s for this node.
+  /// </param>
+  /// <param name="port">
+  /// The port number on which this node accepts connections.
+  /// </param>
+  /// <param name="hostName">
+  /// The hostname advertised by this node. This value is used as the display name for HTML generated by Munin.
+  /// If <see langword="null"/> or empty, the default hostname is used.
+  /// </param>
+  /// <param name="addressListAllowFrom">
+  /// The <see cref="IReadOnlyList{IPAddress}"/> indicates the read-only list of addresses allowed to access <see cref="LocalNode"/>.
+  /// </param>
+  /// <param name="serviceProvider">
+  /// The <see cref="IServiceProvider"/>.
+  /// This overload attempts to get a service of <see cref="ILoggerFactory"/>, to create an <see cref="ILogger"/>.
+  /// Also attempts to get a service of <see cref="IAccessRule"/> if <paramref name="addressListAllowFrom"/> is <see langword="null"/>.
+  /// </param>
+  /// <remarks>
+  /// Most Munin-Node uses port 4949 by default, but it is recommended to use other port numbers to avoid conflicts with other nodes.
+  /// </remarks>
+  public static LocalNode Create(
+    IPluginProvider pluginProvider,
+    int port,
+    string? hostName = null,
+    IReadOnlyList<IPAddress>? addressListAllowFrom = null,
+    IServiceProvider? serviceProvider = null
+  )
+    => new ConcreteLocalNode(
+      pluginProvider: pluginProvider ?? throw new ArgumentNullException(nameof(pluginProvider)),
+      hostName: string.IsNullOrEmpty(hostName) ? DefaultHostName : hostName,
+      port: port,
+      accessRule: addressListAllowFrom is null ? null : new AddressListAccessRule(addressListAllowFrom),
+      serviceProvider: serviceProvider
+    );
+}
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/LocalNode.cs b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/LocalNode.cs
index 9810630..dae7c3c 100644
--- a/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/LocalNode.cs
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/LocalNode.cs
@@ -1,167 +1,66 @@
 // SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
 // SPDX-License-Identifier: MIT
 using System;
-using System.Collections.Generic;
 using System.Net;
 using System.Net.Sockets;
 
-using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 
-using Smdn.Net.MuninPlugin;
-
 namespace Smdn.Net.MuninNode;
 
 /// <summary>
 /// Implement a <c>Munin-Node</c> that acts as a node on the localhost and only accepts connections from the local loopback address (127.0.0.1, ::1).
 /// </summary>
-public class LocalNode : NodeBase {
+public abstract partial class LocalNode : NodeBase {
   private const string DefaultHostName = "munin-node.localhost";
-  private static readonly int MaxClients = 1;
-
-  public IPEndPoint LocalEndPoint { get; }
-
-  /// <inheritdoc cref="LocalNode(IReadOnlyCollection{IPlugin}, string, int, IServiceProvider)"/>
-  public LocalNode(
-    IReadOnlyCollection<IPlugin> plugins,
-    int port,
-    IServiceProvider? serviceProvider = null
-  )
-    : this(
-      plugins: plugins,
-      hostName: DefaultHostName,
-      port: port,
-      serviceProvider: serviceProvider
-    )
-  {
-  }
-
-  /// <summary>
-  /// Initializes a new instance of the <see cref="LocalNode"/> class.
-  /// </summary>
-  /// <param name="plugins">
-  /// The collection of plugins provided by this node.
-  /// </param>
-  /// <param name="hostName">
-  /// The hostname advertised by this node. This value is used as the display name for HTML generated by Munin.
-  /// </param>
-  /// <param name="port">
-  /// The port number on which this node accepts connections.
-  /// </param>
-  /// <param name="serviceProvider">
-  /// The <see cref="IServiceProvider"/>.
-  /// This constructor overload attempts to get a service of <see cref="ILoggerFactory"/>, to create an <see cref="ILogger"/>.
-  /// </param>
-  /// <remarks>
-  /// Most Munin-Node uses port 4949 by default, but it is recommended to use other port numbers to avoid conflicts with other nodes.
-  /// </remarks>
-  public LocalNode(
-    IReadOnlyCollection<IPlugin> plugins,
-    string hostName,
-    int port,
-    IServiceProvider? serviceProvider = null
-  )
-    : this(
-      pluginProvider: new PluginProvider(plugins ?? throw new ArgumentNullException(nameof(plugins))),
-      hostName: hostName,
-      port: port,
-      serviceProvider: serviceProvider
-    )
-  {
-  }
-
-  /// <summary>
-  /// Initializes a new instance of the <see cref="LocalNode"/> class.
-  /// </summary>
-  /// <param name="pluginProvider">
-  /// The <see cref="IPluginProvider"/> that provides <see cref="IPlugin"/>s for this node.
-  /// </param>
-  /// <param name="hostName">
-  /// The hostname advertised by this node. This value is used as the display name for HTML generated by Munin.
-  /// </param>
-  /// <param name="port">
-  /// The port number on which this node accepts connections.
-  /// </param>
-  /// <param name="serviceProvider">
-  /// The <see cref="IServiceProvider"/>.
-  /// This constructor overload attempts to get a service of <see cref="ILoggerFactory"/>, to create an <see cref="ILogger"/>.
-  /// </param>
-  /// <remarks>
-  /// Most Munin-Node uses port 4949 by default, but it is recommended to use other port numbers to avoid conflicts with other nodes.
-  /// </remarks>
-  public LocalNode(
-    IPluginProvider pluginProvider,
-    string hostName,
-    int port,
-    IServiceProvider? serviceProvider = null
-  )
-    : this(
-      pluginProvider: pluginProvider ?? throw new ArgumentNullException(nameof(pluginProvider)),
-      hostName: hostName,
-      port: port,
-      logger: serviceProvider?.GetService<ILoggerFactory>()?.CreateLogger<LocalNode>()
-    )
-  {
-  }
 
   /// <summary>
   /// Initializes a new instance of the <see cref="LocalNode"/> class.
   /// </summary>
-  /// <param name="pluginProvider">
-  /// The <see cref="IPluginProvider"/> that provides <see cref="IPlugin"/>s for this node.
-  /// </param>
-  /// <param name="hostName">
-  /// The hostname advertised by this node. This value is used as the display name for HTML generated by Munin.
-  /// </param>
-  /// <param name="port">
-  /// The port number on which this node accepts connections.
+  /// <param name="accessRule">
+  /// The <see cref="IAccessRule"/> to determine whether to accept or reject a remote host that connects to <see cref="LocalNode"/>.
   /// </param>
   /// <param name="logger">
   /// The <see cref="ILogger"/> to report the situation.
   /// </param>
-  /// <remarks>
-  /// Most Munin-Node uses port 4949 by default, but it is recommended to use other port numbers to avoid conflicts with other nodes.
-  /// </remarks>
-  public LocalNode(
-    IPluginProvider pluginProvider,
-    string hostName,
-    int port,
+  protected LocalNode(
+    IAccessRule? accessRule,
     ILogger? logger = null
   )
     : base(
-      pluginProvider: pluginProvider,
-      hostName: hostName,
+      accessRule: accessRule,
       logger: logger
     )
   {
-    if (Socket.OSSupportsIPv6) {
-      LocalEndPoint = new IPEndPoint(
-        address: IPAddress.IPv6Loopback,
-        port: port
-      );
   }
-#pragma warning disable IDE0045
-    else if (Socket.OSSupportsIPv4) {
-#pragma warning restore IDE0045
-      LocalEndPoint = new IPEndPoint(
-        address: IPAddress.Loopback,
-        port: port
+
+  /// <summary>
+  /// Gets the <see cref="EndPoint"/> to be bound as the <c>Munin-Node</c>'s endpoint.
+  /// </summary>
+  /// <returns>
+  /// An <see cref="EndPoint"/>.
+  /// The default implementation returns an <see cref="IPEndPoint"/> with the port number <c>0</c>
+  /// and <see cref="IPAddress.IPv6Loopback"/>/<see cref="IPAddress.Loopback"/>.
+  /// </returns>
+  protected virtual EndPoint GetLocalEndPointToBind()
+    => new IPEndPoint(
+      address:
+        Socket.OSSupportsIPv6
+          ? IPAddress.IPv6Loopback
+          : Socket.OSSupportsIPv4
+            ? IPAddress.Loopback
+            : throw new NotSupportedException(),
+      port: 0
     );
-    }
-    else {
-      throw new NotSupportedException();
-    }
-  }
 
   protected override Socket CreateServerSocket()
   {
+    const int MaxClients = 1;
+
     Socket? server = null;
 
     try {
-      var endPoint = new IPEndPoint(
-        address: Socket.OSSupportsIPv6 ? IPAddress.IPv6Any : IPAddress.Any,
-        port: LocalEndPoint.Port
-      );
+      var endPoint = GetLocalEndPointToBind();
 
       server = new Socket(
         endPoint.AddressFamily,
@@ -179,15 +78,8 @@ public class LocalNode : NodeBase {
       return server;
     }
     catch {
-#pragma warning disable CA1508
       server?.Dispose();
-#pragma warning restore CA1508
       throw;
     }
   }
-
-  protected override bool IsClientAcceptable(IPEndPoint remoteEndPoint)
-    => IPAddress.IsLoopback(
-      (remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint))).Address
-    );
 }
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/NodeBase.cs b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/NodeBase.cs
index df4b7a1..53eca82 100644
--- a/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/NodeBase.cs
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/NodeBase.cs
@@ -32,58 +32,26 @@ namespace Smdn.Net.MuninNode;
 public abstract class NodeBase : IDisposable, IAsyncDisposable {
   private static readonly Version DefaultNodeVersion = new(1, 0, 0, 0);
 
-  [Obsolete("This member will be deprecated in future version.")]
-  public IReadOnlyCollection<IPlugin> Plugins => pluginProvider.Plugins;
-
-  public string HostName { get; }
+  public abstract IPluginProvider PluginProvider { get; }
+  public abstract string HostName { get; }
 
   public virtual Version NodeVersion => DefaultNodeVersion;
   public virtual Encoding Encoding => Encoding.Default;
 
-  private readonly IPluginProvider pluginProvider;
-
   protected ILogger? Logger { get; }
 
-  private Socket? server;
-
-  protected NodeBase(
-    IReadOnlyCollection<IPlugin> plugins,
-    string hostName,
-    ILogger? logger
-  )
-    : this(
-      pluginProvider: new PluginProvider(plugins ?? throw new ArgumentNullException(nameof(plugins))),
-      hostName: hostName,
-      logger: logger
-    )
-  {
-  }
+  private readonly IAccessRule? accessRule;
 
-  private protected class PluginProvider : IPluginProvider {
-    public IReadOnlyCollection<IPlugin> Plugins { get; }
-    public INodeSessionCallback? SessionCallback => null;
+  private Socket? server;
 
-    public PluginProvider(IReadOnlyCollection<IPlugin> plugins)
-    {
-      Plugins = plugins;
-    }
-  }
+  public EndPoint LocalEndPoint => server?.LocalEndPoint ?? throw new InvalidOperationException("not yet bound or already disposed");
 
   protected NodeBase(
-    IPluginProvider pluginProvider,
-    string hostName,
+    IAccessRule? accessRule,
     ILogger? logger
   )
   {
-    this.pluginProvider = pluginProvider ?? throw new ArgumentNullException(nameof(pluginProvider));
-
-    if (hostName == null)
-      throw new ArgumentNullException(nameof(hostName));
-    if (hostName.Length == 0)
-      throw ExceptionUtils.CreateArgumentMustBeNonEmptyString(nameof(hostName));
-
-    HostName = hostName;
-
+    this.accessRule = accessRule;
     Logger = logger;
   }
 
@@ -147,6 +115,12 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
     server = null!;
   }
 
+  protected void ThrowIfPluginProviderIsNull()
+  {
+    if (PluginProvider is null)
+      throw new InvalidOperationException($"{nameof(PluginProvider)} cannot be null");
+  }
+
   protected abstract Socket CreateServerSocket();
 
   public void Start()
@@ -161,8 +135,6 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
     Logger?.LogInformation("started (end point: {LocalEndPoint})", server.LocalEndPoint);
   }
 
-  protected abstract bool IsClientAcceptable(IPEndPoint remoteEndPoint);
-
   /// <summary>
   /// Starts accepting multiple sessions.
   /// The <see cref="ValueTask" /> this method returns will never complete unless the cancellation requested by the <paramref name="cancellationToken" />.
@@ -213,6 +185,8 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
     if (server is null)
       throw new InvalidOperationException("not started or already closed");
 
+    ThrowIfPluginProviderIsNull();
+
     Logger?.LogInformation("accepting...");
 
     var client = await server
@@ -239,7 +213,7 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
         return;
       }
 
-      if (!IsClientAcceptable(remoteEndPoint)) {
+      if (accessRule is not null && !accessRule.IsAcceptable(remoteEndPoint)) {
         Logger?.LogWarning("access refused: {RemoteEndPoint}", remoteEndPoint);
         return;
       }
@@ -289,10 +263,10 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
       Logger?.LogInformation("[{RemoteEndPoint}] session started; ID={SessionId}", remoteEndPoint, sessionId);
 
       try {
-        if (pluginProvider.SessionCallback is not null)
-          await pluginProvider.SessionCallback.ReportSessionStartedAsync(sessionId, cancellationToken).ConfigureAwait(false);
+        if (PluginProvider.SessionCallback is not null)
+          await PluginProvider.SessionCallback.ReportSessionStartedAsync(sessionId, cancellationToken).ConfigureAwait(false);
 
-        foreach (var plugin in pluginProvider.Plugins) {
+        foreach (var plugin in PluginProvider.Plugins) {
           if (plugin.SessionCallback is not null)
             await plugin.SessionCallback.ReportSessionStartedAsync(sessionId, cancellationToken).ConfigureAwait(false);
         }
@@ -308,13 +282,13 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
         Logger?.LogInformation("[{RemoteEndPoint}] session closed; ID={SessionId}", remoteEndPoint, sessionId);
       }
       finally {
-        foreach (var plugin in pluginProvider.Plugins) {
+        foreach (var plugin in PluginProvider.Plugins) {
           if (plugin.SessionCallback is not null)
             await plugin.SessionCallback.ReportSessionClosedAsync(sessionId, cancellationToken).ConfigureAwait(false);
         }
 
-        if (pluginProvider.SessionCallback is not null)
-          await pluginProvider.SessionCallback.ReportSessionClosedAsync(sessionId, cancellationToken).ConfigureAwait(false);
+        if (PluginProvider.SessionCallback is not null)
+          await PluginProvider.SessionCallback.ReportSessionClosedAsync(sessionId, cancellationToken).ConfigureAwait(false);
       }
     }
     finally {
@@ -483,20 +457,13 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
       var reader = new SequenceReader<byte>(buffer);
       const byte LF = (byte)'\n';
 
-#pragma warning disable SA1003
       if (
-#if LANG_VERSION_11_OR_GREATER
-        !reader.TryReadTo(out line, delimiter: "\r\n"u8, advancePastDelimiter: true)
-#else
-        !reader.TryReadTo(out line, delimiter: CRLF.Span, advancePastDelimiter: true)
-#endif
-        &&
+        !reader.TryReadTo(out line, delimiter: "\r\n"u8, advancePastDelimiter: true) &&
         !reader.TryReadTo(out line, delimiter: LF, advancePastDelimiter: true)
       ) {
         line = default;
         return false;
       }
-#pragma warning restore SA1003
 
 #if SYSTEM_BUFFERS_SEQUENCEREADER_UNREADSEQUENCE
       buffer = reader.UnreadSequence;
@@ -508,10 +475,6 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
     }
   }
 
-#if !LANG_VERSION_11_OR_GREATER
-  private static readonly ReadOnlyMemory<byte> CRLF = Encoding.ASCII.GetBytes("\r\n");
-#endif
-
   private static bool ExpectCommand(
     ReadOnlySequence<byte> commandLine,
     ReadOnlySpan<byte> expectedCommand,
@@ -547,7 +510,6 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
 
   private static readonly byte CommandQuitShort = (byte)'.';
 
-#if LANG_VERSION_11_OR_GREATER
   private ValueTask RespondToCommandAsync(
     Socket client,
     ReadOnlySequence<byte> commandLine,
@@ -592,62 +554,6 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
       );
     }
   }
-#else
-  private static readonly ReadOnlyMemory<byte> commandFetch       = Encoding.ASCII.GetBytes("fetch");
-  private static readonly ReadOnlyMemory<byte> commandNodes       = Encoding.ASCII.GetBytes("nodes");
-  private static readonly ReadOnlyMemory<byte> commandList        = Encoding.ASCII.GetBytes("list");
-  private static readonly ReadOnlyMemory<byte> commandConfig      = Encoding.ASCII.GetBytes("config");
-  private static readonly ReadOnlyMemory<byte> commandQuit        = Encoding.ASCII.GetBytes("quit");
-  private static readonly ReadOnlyMemory<byte> commandCap         = Encoding.ASCII.GetBytes("cap");
-  private static readonly ReadOnlyMemory<byte> commandVersion     = Encoding.ASCII.GetBytes("version");
-
-  private ValueTask RespondToCommandAsync(
-    Socket client,
-    ReadOnlySequence<byte> commandLine,
-    CancellationToken cancellationToken
-  )
-  {
-    cancellationToken.ThrowIfCancellationRequested();
-
-    if (ExpectCommand(commandLine, commandFetch.Span, out var fetchArguments)) {
-      return ProcessCommandFetchAsync(client, fetchArguments, cancellationToken);
-    }
-    else if (ExpectCommand(commandLine, commandNodes.Span, out _)) {
-      return ProcessCommandNodesAsync(client, cancellationToken);
-    }
-    else if (ExpectCommand(commandLine, commandList.Span, out var listArguments)) {
-      return ProcessCommandListAsync(client, listArguments, cancellationToken);
-    }
-    else if (ExpectCommand(commandLine, commandConfig.Span, out var configArguments)) {
-      return ProcessCommandConfigAsync(client, configArguments, cancellationToken);
-    }
-    else if (
-      ExpectCommand(commandLine, commandQuit.Span, out _) ||
-      (commandLine.Length == 1 && commandLine.FirstSpan[0] == commandQuitShort)
-    ) {
-      client.Close();
-#if SYSTEM_THREADING_TASKS_VALUETASK_COMPLETEDTASK
-      return ValueTask.CompletedTask;
-#else
-      return default;
-#endif
-    }
-    else if (ExpectCommand(commandLine, commandCap.Span, out var capArguments)) {
-      return ProcessCommandCapAsync(client, capArguments, cancellationToken);
-    }
-    else if (ExpectCommand(commandLine, commandVersion.Span, out _)) {
-      return ProcessCommandVersionAsync(client, cancellationToken);
-    }
-    else {
-      return SendResponseAsync(
-        client: client,
-        encoding: Encoding,
-        responseLine: "# Unknown command. Try cap, list, nodes, config, fetch, version or quit",
-        cancellationToken: cancellationToken
-      );
-    }
-  }
-#endif
 
 #pragma warning disable IDE0230
   private static readonly ReadOnlyMemory<byte> EndOfLine = new[] { (byte)'\n' };
@@ -756,11 +662,13 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
     CancellationToken cancellationToken
   )
   {
+    ThrowIfPluginProviderIsNull();
+
     // XXX: ignore [node] arguments
     return SendResponseAsync(
       client: client,
       encoding: Encoding,
-      responseLine: string.Join(" ", pluginProvider.Plugins.Select(static plugin => plugin.Name)),
+      responseLine: string.Join(" ", PluginProvider.Plugins.Select(static plugin => plugin.Name)),
       cancellationToken: cancellationToken
     );
   }
@@ -771,7 +679,9 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
     CancellationToken cancellationToken
   )
   {
-    var plugin = pluginProvider.Plugins.FirstOrDefault(
+    ThrowIfPluginProviderIsNull();
+
+    var plugin = PluginProvider.Plugins.FirstOrDefault(
       plugin => string.Equals(Encoding.GetString(arguments), plugin.Name, StringComparison.Ordinal)
     );
 
@@ -829,7 +739,9 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
     CancellationToken cancellationToken
   )
   {
-    var plugin = pluginProvider.Plugins.FirstOrDefault(
+    ThrowIfPluginProviderIsNull();
+
+    var plugin = PluginProvider.Plugins.FirstOrDefault(
       plugin => string.Equals(Encoding.GetString(arguments), plugin.Name, StringComparison.Ordinal)
     );
 
@@ -842,24 +754,11 @@ public abstract class NodeBase : IDisposable, IAsyncDisposable {
       );
     }
 
-    var graphAttrs = plugin.GraphAttributes;
-
-    var responseLines = new List<string>() {
-      $"graph_title {graphAttrs.Title}",
-      $"graph_category {graphAttrs.Category}",
-      $"graph_args {graphAttrs.Arguments}",
-      $"graph_scale {(graphAttrs.Scale ? "yes" : "no")}",
-      $"graph_vlabel {graphAttrs.VerticalLabel}",
-    };
+    var responseLines = new List<string>(capacity: 20);
 
-    if (graphAttrs.UpdateRate.HasValue)
-      responseLines.Add($"update_rate {(int)graphAttrs.UpdateRate.Value.TotalSeconds}");
-    if (graphAttrs.Width.HasValue)
-      responseLines.Add($"graph_width {graphAttrs.Width.Value}");
-    if (graphAttrs.Height.HasValue)
-      responseLines.Add($"graph_height {graphAttrs.Height.Value}");
-    if (!string.IsNullOrEmpty(graphAttrs.Order))
-      responseLines.Add($"graph_order {graphAttrs.Order}");
+    responseLines.AddRange(
+      plugin.GraphAttributes.EnumerateAttributes()
+    );
 
     // The fields referenced by {fieldname}.negative must be defined ahread of others,
     // and thus lists the negative field settings first.
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/IPlugin.cs b/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/IPlugin.cs
index cf725ac..783a667 100644
--- a/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/IPlugin.cs
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/IPlugin.cs
@@ -12,9 +12,10 @@ public interface IPlugin {
   /// <remarks>This value is used as the plugin name returned by the 'list' argument, or the plugin name specified by the 'fetch' argument.</remarks>
   string Name { get; }
 
-  /// <summary>Gets a <see cref="PluginGraphAttributes"/> that represents the graph attributes when the field values (<see cref="IPluginField"/>) are drawn as a graph.</summary>
+  /// <summary>Gets a <see cref="IPluginGraphAttributes"/> that represents the graph attributes when the field values (<see cref="IPluginField"/>) are drawn as a graph.</summary>
+  /// <seealso cref="IPluginGraphAttributes"/>
   /// <seealso cref="PluginGraphAttributes"/>
-  PluginGraphAttributes GraphAttributes { get; }
+  IPluginGraphAttributes GraphAttributes { get; }
 
   /// <summary>Gets a <see cref="IPluginDataSource"/> that serves as the data source for the plugin.</summary>
   /// <seealso cref="IPluginDataSource"/>
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/IPluginGraphAttributes.cs b/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/IPluginGraphAttributes.cs
new file mode 100644
index 0000000..b659b61
--- /dev/null
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/IPluginGraphAttributes.cs
@@ -0,0 +1,17 @@
+// SPDX-FileCopyrightText: 2024 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System.Collections.Generic;
+
+namespace Smdn.Net.MuninPlugin;
+
+/// <summary>
+/// Provides an interface that abstracts the plugin graph attributes, related to the drawing of a single graph.
+/// </summary>
+/// <seealso href="https://guide.munin-monitoring.org/en/latest/reference/plugin.html#global-attributes">Plugin reference - Global attributes</seealso>
+public interface IPluginGraphAttributes {
+  /// <summary>
+  /// Enumerates plugin graph attributes defined by types that implement this interface.
+  /// </summary>
+  /// <returns><see cref="IEnumerable{String}"/> that enumerates graph attributes.</returns>
+  IEnumerable<string> EnumerateAttributes();
+}
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/Plugin.cs b/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/Plugin.cs
index 8a599bf..16f7d37 100644
--- a/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/Plugin.cs
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/Plugin.cs
@@ -15,6 +15,8 @@ public class Plugin : IPlugin, IPluginDataSource, INodeSessionCallback {
   public IReadOnlyCollection<IPluginField> Fields { get; }
 
 #pragma warning disable CA1033
+  IPluginGraphAttributes IPlugin.GraphAttributes => GraphAttributes;
+
   IPluginDataSource IPlugin.DataSource => this;
 
   IReadOnlyCollection<IPluginField> IPluginDataSource.Fields => Fields;
diff --git a/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/PluginGraphAttributes.cs b/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/PluginGraphAttributes.cs
index 3e76d32..8358320 100644
--- a/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/PluginGraphAttributes.cs
+++ b/src/Smdn.Net.MuninNode/Smdn.Net.MuninPlugin/PluginGraphAttributes.cs
@@ -11,8 +11,9 @@ namespace Smdn.Net.MuninPlugin;
 /// Defines graph attributes that should be returned when the plugin is called with the 'config' argument.
 /// This type represents the collection of 'field name attributes'.
 /// </summary>
+/// <seealso cref="IPluginGraphAttributes"/>
 /// <seealso href="https://guide.munin-monitoring.org/en/latest/reference/plugin.html#global-attributes">Plugin reference - Global attributes</seealso>
-public sealed class PluginGraphAttributes {
+public sealed class PluginGraphAttributes : IPluginGraphAttributes {
   /// <summary>Gets a value for the <c>graph_title</c>.</summary>
   /// <seealso href="https://guide.munin-monitoring.org/en/latest/reference/plugin.html#graph-title">Plugin reference - Global attributes - graph_title</seealso>
   public string Title { get; }
@@ -51,40 +52,16 @@ public sealed class PluginGraphAttributes {
   /// <seealso href="https://guide.munin-monitoring.org/en/latest/reference/plugin.html#graph-order">Plugin reference - Global attributes - graph_order</seealso>
   public string? Order { get; }
 
-  public PluginGraphAttributes(
-    string title,
-    string category,
-    string verticalLabel,
-    bool scale,
-    string arguments,
-    TimeSpan? updateRate = null,
-    int? width = null,
-    int? height = null
-  )
-    : this(
-      title: title,
-      category: category,
-      verticalLabel: verticalLabel,
-      scale: scale,
-      arguments: arguments,
-      updateRate: updateRate,
-      width: width,
-      height: height,
-      order: null
-    )
-  {
-  }
+  /// <summary>Gets a value for the <c>graph_total</c>.</summary>
+  /// <seealso href="https://guide.munin-monitoring.org/en/latest/reference/plugin.html#graph-total">Plugin reference - Global attributes - graph_total</seealso>
+  public string? TotalValueLabel { get; }
 
-  [Obsolete("This member will be deprecated in future version.")]
   public PluginGraphAttributes(
     string title,
     string category,
     string verticalLabel,
     bool scale,
-    string arguments,
-    TimeSpan updateRate,
-    int? width = null,
-    int? height = null
+    string arguments
   )
     : this(
       title: title,
@@ -92,10 +69,11 @@ public sealed class PluginGraphAttributes {
       verticalLabel: verticalLabel,
       scale: scale,
       arguments: arguments,
-      updateRate: updateRate,
-      width: width,
-      height: height,
-      order: null
+      updateRate: null,
+      width: null,
+      height: null,
+      order: null,
+      totalValueLabel: null
     )
   {
   }
@@ -109,7 +87,8 @@ public sealed class PluginGraphAttributes {
     TimeSpan? updateRate,
     int? width,
     int? height,
-    IEnumerable<string>? order
+    IEnumerable<string>? order,
+    string? totalValueLabel
   )
   {
     if (title == null)
@@ -141,10 +120,31 @@ public sealed class PluginGraphAttributes {
     Width = width;
     Height = height;
     Order = order is null ? null : string.Join(" ", order);
+    TotalValueLabel = totalValueLabel;
 
     if (updateRate.HasValue && updateRate.Value < TimeSpan.FromSeconds(1.0))
       throw new ArgumentOutOfRangeException(nameof(updateRate), updateRate, "must be at least 1 seconds");
 
     UpdateRate = updateRate;
   }
+
+  public IEnumerable<string> EnumerateAttributes()
+  {
+    yield return $"graph_title {Title}";
+    yield return $"graph_category {Category}";
+    yield return $"graph_args {Arguments}";
+    yield return $"graph_scale {(Scale ? "yes" : "no")}";
+    yield return $"graph_vlabel {VerticalLabel}";
+
+    if (UpdateRate.HasValue)
+      yield return $"update_rate {(int)UpdateRate.Value.TotalSeconds}";
+    if (Width.HasValue)
+      yield return $"graph_width {Width.Value}";
+    if (Height.HasValue)
+      yield return $"graph_height {Height.Value}";
+    if (!string.IsNullOrEmpty(Order))
+      yield return $"graph_order {Order}";
+    if (!string.IsNullOrEmpty(TotalValueLabel))
+      yield return $"graph_total {TotalValueLabel}";
+  }
 }

Notes

Full Changelog: releases/Smdn.Net.MuninNode-1.3.0...releases/Smdn.Net.MuninNode-2.0.0