Add policy rules

This commit is contained in:
Frans van Dorsselaer committed 2024-04-14 10:45:48 +02:00
1 parent 46c7f5cb11
commit 6b6da498ec
14 files changed
+547 -29

No files matched your search

+4
View File
@@ -73,6 +73,10 @@ SPDX-License-Identifier: GPL-3.0-only
Key="Devices"
ForceCreateOnInstall="yes"
/>
<RegistryKey
Key="Policy"
ForceCreateOnInstall="yes"
/>
</RegistryKey>
<Environment
Id="PATH"
+14 -14
View File
@@ -74,8 +74,8 @@ sealed class BusId_Tests
"0-0",
"0-1",
"1-0",
"1-65536",
"65536-1",
"1-100",
"100-1",
"01-1",
"1-01",
];
@@ -89,20 +89,20 @@ sealed class BusId_Tests
"IncompatibleHub",
"1-1",
"1-2",
"1-65534",
"1-65535",
"1-98",
"1-99",
"2-1",
"2-2",
"2-65534",
"2-65535",
"65534-1",
"65534-2",
"65534-65534",
"65534-65535",
"65535-1",
"65535-2",
"65535-65534",
"65535-65535",
"2-98",
"2-99",
"98-1",
"98-2",
"98-98",
"98-99",
"99-1",
"99-2",
"99-98",
"99-99",
];
public static IEnumerable<string[]> Valid => from value in _Valid select new string[] { value };
+89
View File
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2022 Frans van Dorsselaer
//
// SPDX-License-Identifier: GPL-3.0-only
using System.CommandLine;
using Usbipd.Automation;
namespace UnitTests;
[TestClass]
sealed class Parse_policy_Tests
: ParseTestBase
{
static readonly BusId TestBusId = BusId.Parse("3-42");
static readonly Guid TestGuid = Guid.Parse("{E863A2AF-AE47-440B-A32B-FAB1C03017AB}");
static readonly VidPid TestHardwareId = VidPid.Parse("0123:cdef");
[TestMethod]
public void Help()
{
Test(ExitCode.Success, "policy", "--help");
}
[TestMethod]
public void CommandMissing()
{
Test(ExitCode.ParseError, "policy");
}
[TestMethod]
public void CommandInvalid()
{
Test(ExitCode.ParseError, "policy", "invalid-command");
}
[TestMethod]
public void OptionInvalid()
{
Test(ExitCode.ParseError, "policy", "--invalid-option");
}
[TestMethod]
public void list_Success()
{
var mock = CreateMock();
mock.Setup(m => m.PolicyList(It.IsNotNull<IConsole>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(ExitCode.Success));
Test(ExitCode.Success, mock, "policy", "list");
}
[TestMethod]
public void list_Help()
{
Test(ExitCode.Success, "policy", "list", "--help");
}
[TestMethod]
public void list_OptionInvalid()
{
Test(ExitCode.ParseError, "policy", "list", "--invalid-option");
}
[TestMethod]
public void list_StrayArgument()
{
Test(ExitCode.ParseError, "policy", "list", "stray-argument");
}
[TestMethod]
public void add_Success()
{
var mock = CreateMock();
mock.Setup(m => m.PolicyAdd(It.IsAny<PolicyRule>(),
It.IsNotNull<IConsole>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(ExitCode.Success));
Test(ExitCode.Success, mock, "policy", "add", "--type", "bind", "--access", "allow",
"--busid", TestBusId.ToString(), "--hardware-id", TestHardwareId.ToString());
}
[TestMethod]
public void remove_Success()
{
var mock = CreateMock();
mock.Setup(m => m.PolicyRemove(It.IsAny<Guid>(),
It.IsNotNull<IConsole>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(ExitCode.Success));
Test(ExitCode.Success, mock, "policy", "remove", "--guid", TestGuid.ToString());
}
}
+2 -2
View File
@@ -9,9 +9,9 @@ sealed class Parse_usbipd_Tests
: ParseTestBase
{
[TestMethod]
public void Success()
public void NoCommand()
{
Test(ExitCode.Success);
Test(ExitCode.ParseError);
}
[TestMethod]
+6 -4
View File
@@ -22,11 +22,13 @@ public readonly record struct BusId
{
// Do not allow the explicit creation of the special IncompatibleHub value.
// Instead, use the static IncompatibleHub field (preferrable) or "default".
if (bus == 0)
// USB supports up to 127 devices, but that would require multiple hubs; the "per hub" port will never be >99.
// And if you have more than 99 hubs on one system, then you win a prize! (but we're not going to support it...)
if (bus == 0 || bus > 99)
{
throw new ArgumentOutOfRangeException(nameof(bus));
}
if (port == 0)
if (port == 0 || port > 99)
{
throw new ArgumentOutOfRangeException(nameof(port));
}
@@ -63,8 +65,8 @@ public readonly record struct BusId
}
var match = Regex.Match(input, "^([1-9][0-9]*)-([1-9][0-9]*)$");
if (match.Success
&& ushort.TryParse(match.Groups[1].Value, out var bus)
&& ushort.TryParse(match.Groups[2].Value, out var port))
&& ushort.TryParse(match.Groups[1].Value, out var bus) && bus <= 99
&& ushort.TryParse(match.Groups[2].Value, out var port) && port <= 99)
{
busId = new(bus, port);
return true;
+59 -1
View File
@@ -142,7 +142,6 @@ sealed partial class CommandHandlers : ICommandHandlers
{
state = "Not shared";
}
// NOTE: Strictly speaking, both Bus and Port can be > 99. If you have one of those, you win a prize!
console.Write($"{(device.BusId.Value.IsIncompatibleHub ? string.Empty : device.BusId.Value),-5} ");
console.Write($"{device.HardwareId,-9} ");
console.WriteTruncated(GetDescription(device, usbids), 60, true);
@@ -474,4 +473,63 @@ sealed partial class CommandHandlers : ICommandHandlers
Console.Write(json);
return Task.FromResult(ExitCode.Success);
}
Task<ExitCode> ICommandHandlers.PolicyAdd(PolicyRule rule, IConsole console, CancellationToken cancellationToken)
{
if (RegistryUtils.GetPolicyRules().FirstOrDefault(r => r.Value == rule) is var existingRule && existingRule.Key != default)
{
console.ReportError($"Policy rule already exists with guid '{existingRule.Key:D}'.");
return Task.FromResult(ExitCode.Failure);
}
if (!CheckWriteAccess(console))
{
return Task.FromResult(ExitCode.AccessDenied);
}
var guid = RegistryUtils.AddPolicyRule(rule);
console.ReportInfo($"Policy rule created with guid '{guid:D}'.");
return Task.FromResult(ExitCode.Success);
}
Task<ExitCode> ICommandHandlers.PolicyList(IConsole console, CancellationToken cancellationToken)
{
var policyRules = RegistryUtils.GetPolicyRules();
console.WriteLine("Policy rules:");
console.WriteLine($"{"GUID",-36} {"TYPE",-4} {"ACCESS",-6} {"BUSID",-5} {"VID:PID",-9}");
foreach (var rule in policyRules)
{
console.Write($"{rule.Key,-36} ");
console.Write($"{rule.Value.Type,-4} ");
console.Write($"{(rule.Value.Allow ? "Allow" : "Deny"),-6} ");
switch (rule.Value.Type)
{
case PolicyRuleType.Bind:
var ruleBind = (PolicyRuleBind)rule.Value;
console.Write($"{(ruleBind.BusId.HasValue ? ruleBind.BusId.Value : string.Empty),-5} ");
console.Write($"{(ruleBind.HardwareId.HasValue ? ruleBind.HardwareId.Value : string.Empty),-9}");
break;
}
console.WriteLine(string.Empty);
}
console.WriteLine(string.Empty);
return Task.FromResult(ExitCode.Success);
}
Task<ExitCode> ICommandHandlers.PolicyRemove(Guid guid, IConsole console, CancellationToken cancellationToken)
{
if (!RegistryUtils.GetPolicyRules().ContainsKey(guid))
{
console.ReportError($"There is no policy rule with guid '{guid:D}'.");
return Task.FromResult(ExitCode.Failure);
}
if (!CheckWriteAccess(console))
{
return Task.FromResult(ExitCode.AccessDenied);
}
RegistryUtils.RemovePolicyRule(guid);
return Task.FromResult(ExitCode.Success);
}
}
+3
View File
@@ -26,4 +26,7 @@ interface ICommandHandlers
public Task<ExitCode> State(IConsole console, CancellationToken cancellationToken);
public Task<ExitCode> Install(IConsole console, CancellationToken cancellationToken);
public Task<ExitCode> Uninstall(IConsole console, CancellationToken cancellationToken);
public Task<ExitCode> PolicyAdd(PolicyRule rule, IConsole console, CancellationToken cancellationToken);
public Task<ExitCode> PolicyList(IConsole console, CancellationToken cancellationToken);
public Task<ExitCode> PolicyRemove(Guid guid, IConsole console, CancellationToken cancellationToken);
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2024 Frans van Dorsselaer
//
// SPDX-License-Identifier: GPL-3.0-only
using System.CommandLine;
using System.CommandLine.Parsing;
namespace Usbipd;
static class ParseResultExtensions
{
public static T? GetValueForOptionOrNull<T>(this ParseResult parseResult, Option<T> option) where T : struct
=> parseResult.HasOption(option) ? parseResult.GetValueForOption(option) : null;
}
+16
View File
@@ -0,0 +1,16 @@
// SPDX-FileCopyrightText: 2024 Frans van Dorsselaer
//
// SPDX-License-Identifier: GPL-3.0-only
using Microsoft.Win32;
namespace Usbipd;
abstract record PolicyRule(bool Allow, PolicyRuleType Type)
{
public abstract bool IsValid();
public abstract bool Matches(UsbDevice usbDevice);
public abstract void Save(RegistryKey registryKey);
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2024 Frans van Dorsselaer
//
// SPDX-License-Identifier: GPL-3.0-only
namespace Usbipd;
public enum PolicyRuleAccess
{
Allow,
Deny,
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2024 Frans van Dorsselaer
//
// SPDX-License-Identifier: GPL-3.0-only
using Microsoft.Win32;
using Usbipd.Automation;
namespace Usbipd;
sealed record PolicyRuleBind(bool allow, BusId? BusId, VidPid? HardwareId)
: PolicyRule(allow, PolicyRuleType.Bind)
{
const string BusIdName = "BusId";
const string HardwareIdName = "HardwareId";
public override bool IsValid() => BusId.HasValue || HardwareId.HasValue;
public override bool Matches(UsbDevice usbDevice)
{
if (!IsValid())
{
throw new InvalidOperationException("Invalid policy rule");
}
if (BusId.HasValue && BusId.Value != usbDevice.BusId)
{
return false;
}
if (HardwareId.HasValue && HardwareId.Value != usbDevice.HardwareId)
{
return false;
}
return true;
}
public override void Save(RegistryKey registryKey)
{
if (BusId.HasValue)
{
registryKey.SetValue(BusIdName, BusId.Value.ToString());
}
if (HardwareId.HasValue)
{
registryKey.SetValue(HardwareIdName, HardwareId.Value.ToString());
}
}
public static PolicyRuleBind Load(bool allow, RegistryKey registryKey)
{
BusId? busId = null;
if (Automation.BusId.TryParse(registryKey.GetValue(BusIdName) as string ?? string.Empty, out var parsedBusId))
{
busId = parsedBusId;
}
VidPid? hardwareId = null;
if (VidPid.TryParse(registryKey.GetValue(HardwareIdName) as string ?? string.Empty, out var parsedHardwareId))
{
hardwareId = parsedHardwareId;
}
return new(allow, busId, hardwareId);
}
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2024 Frans van Dorsselaer
//
// SPDX-License-Identifier: GPL-3.0-only
namespace Usbipd;
public enum PolicyRuleType
{
Bind,
}
+153 -8
View File
@@ -7,7 +7,6 @@ using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Completions;
using System.CommandLine.Help;
using System.CommandLine.IO;
using System.CommandLine.Parsing;
using System.Diagnostics;
using System.Reflection;
@@ -63,6 +62,17 @@ static class Program
return $"Exactly one of the options {list} is required.";
}
static string AtLeastOneOfRequiredText(params Option[] options)
{
Debug.Assert(options.Length >= 2);
var names = options.Select(o => $"'--{o.Name}'").ToArray();
var list = names.Length == 2
? $"{names[0]} or {names[1]}"
: string.Join(", ", names[0..(names.Length - 1)]) + ", or " + names[^1];
return $"At least one of the options {list} is required.";
}
static void ValidateOneOf(CommandResult commandResult, params Option[] options)
{
Debug.Assert(options.Length >= 2);
@@ -73,6 +83,16 @@ static class Program
}
}
static void ValidateAtLeastOneOf(CommandResult commandResult, params Option[] options)
{
Debug.Assert(options.Length >= 2);
if (!options.Any(option => commandResult.FindResultFor(option) is not null))
{
commandResult.ErrorMessage = AtLeastOneOfRequiredText(options);
}
}
internal static IEnumerable<string> CompletionGuard(CompletionContext completionContext, Func<IEnumerable<string>?> complete)
{
try
@@ -115,11 +135,6 @@ static class Program
internal static ExitCode Run(IConsole? optionalTestConsole, ICommandHandlers commandHandlers, params string[] args)
{
var rootCommand = new RootCommand("Shares locally connected USB devices to other machines, including Hyper-V guests and WSL 2.");
rootCommand.SetHandler(invocationContext =>
{
invocationContext.HelpBuilder.Write(rootCommand, invocationContext.Console.Out.CreateTextWriter());
});
{
//
// attach [--auto-attach]
@@ -169,7 +184,7 @@ static class Program
}.AddCompletions(completionContext => CompletionGuard(completionContext, () =>
UsbDevice.GetAll().Where(d => d.BusId.HasValue).GroupBy(d => d.HardwareId).Select(g => g.Key.ToString())));
//
// wsl attach
// attach
//
var attachCommand = new Command("attach", "Attach a USB device to a client\0"
+ "Attaches a USB device to a client.\n"
@@ -317,7 +332,7 @@ static class Program
}.AddCompletions(completionContext => CompletionGuard(completionContext, () =>
UsbDevice.GetAll().Where(d => d.BusId.HasValue).GroupBy(d => d.HardwareId).Select(g => g.Key.ToString())));
//
// wsl detach
// detach
//
var detachCommand = new Command("detach", "Detach a USB device from a client\0"
+ "Detaches one or more USB devices. The client sees this as a surprise "
@@ -396,6 +411,136 @@ static class Program
});
rootCommand.AddCommand(listCommand);
}
{
//
// policy
//
var policyCommand = new Command("policy", "Manage policy rules\0"
+ "Policy rules allow or deny specific functionality, such as bind and attach.\n");
{
//
// policy add --access <ACCESS>
//
var accessOption = new Option<PolicyRuleAccess>(
aliases: ["--access", "-a"]
)
{
ArgumentHelpName = "ACCESS",
Description = "Allow or Deny",
IsRequired = true,
};
//
// policy add --type <TYPE>
//
var typeOption = new Option<PolicyRuleType>(
aliases: ["--type", "-t"]
)
{
ArgumentHelpName = "TYPE",
Description = "Add a policy rule of type <TYPE>",
IsRequired = true,
};
//
// policy add [--busid <BUSID>]
//
var busIdOption = new Option<BusId>(
aliases: ["--busid", "-b"],
parseArgument: ParseCompatibleBusId
)
{
ArgumentHelpName = "BUSID",
Description = "Share device having <BUSID>",
}.AddCompletions(CompatibleBusIdCompletions);
//
// policy add [--hardware-id <VID>:<PID>]
//
var hardwareIdOption = new Option<VidPid>(
// NOTE: the alias '-h' is already for '--help'
aliases: ["--hardware-id", "-i"],
parseArgument: ParseVidPid
)
{
ArgumentHelpName = "VID:PID",
Description = "Attach device having <VID>:<PID>",
}.AddCompletions(completionContext => CompletionGuard(completionContext, () =>
UsbDevice.GetAll().GroupBy(d => d.HardwareId).Select(g => g.Key.ToString())));
//
// policy add
//
var addCommand = new Command("add", "Add a policy rule\0"
+ "Add a new policy rule. The resulting policy will be effective immediately.\n"
+ "\n"
+ AtLeastOneOfRequiredText(busIdOption, hardwareIdOption))
{
accessOption,
typeOption,
busIdOption,
hardwareIdOption,
};
addCommand.AddValidator(commandResult =>
{
ValidateAtLeastOneOf(commandResult, busIdOption, hardwareIdOption);
});
addCommand.SetHandler(async invocationContext =>
{
var policyRuleType = invocationContext.ParseResult.GetValueForOption(typeOption);
invocationContext.ExitCode = (int)await (policyRuleType switch
{
PolicyRuleType.Bind =>
commandHandlers.PolicyAdd(new PolicyRuleBind(invocationContext.ParseResult.GetValueForOption(accessOption) == PolicyRuleAccess.Allow,
invocationContext.ParseResult.GetValueForOptionOrNull(busIdOption),
invocationContext.ParseResult.GetValueForOptionOrNull(hardwareIdOption)),
invocationContext.Console, invocationContext.GetCancellationToken()),
_ => throw new UnexpectedResultException($"Unexpected policy rule type '{policyRuleType}'."),
});
});
policyCommand.AddCommand(addCommand);
}
{
//
// policy list
//
var listCommand = new Command("list", "List policy rules\0"
+ "List all policy rules.\n");
listCommand.SetHandler(async invocationContext =>
{
invocationContext.ExitCode = (int)
await commandHandlers.PolicyList(invocationContext.Console, invocationContext.GetCancellationToken());
});
policyCommand.AddCommand(listCommand);
}
{
//
// policy remove --guid <GUID>
//
var guidOption = new Option<Guid>(
aliases: ["--guid", "-g"],
parseArgument: ParseGuid
)
{
ArgumentHelpName = "GUID",
Description = "Remove the policy rule having <GUID>",
IsRequired = true,
}.AddCompletions(completionContext => CompletionGuard(completionContext, () =>
RegistryUtils.GetPolicyRules().Select(r => r.Key.ToString("D"))));
//
// policy remove
//
var removeCommand = new Command("remove", "Remove a policy rule\0"
+ "Remove an existing policy rule. The resulting policy will be effective immediately.\n")
{
guidOption,
};
removeCommand.SetHandler(async invocationContext =>
{
invocationContext.ExitCode = (int)
await commandHandlers.PolicyRemove(invocationContext.ParseResult.GetValueForOption(guidOption),
invocationContext.Console, invocationContext.GetCancellationToken());
});
policyCommand.AddCommand(removeCommand);
}
rootCommand.AddCommand(policyCommand);
}
{
//
// server [<KEY=VALUE>...]
+103
View File
@@ -34,6 +34,9 @@ static class RegistryUtils
const string AttachedName = "Attached";
const string BusIdName = "BusId";
const string IPAddressName = "IPAddress";
const string PolicyName = "Policy";
const string TypeName = "Type";
const string AllowName = "Allow";
/// <summary>
/// <see langword="null"/> if not installed
@@ -52,6 +55,18 @@ static class RegistryUtils
return devicesKey.OpenSubKey(guid.ToString("B"), writable);
}
static RegistryKey GetPolicyKey(bool writable)
{
return BaseKey(writable).OpenSubKey(PolicyName, writable)
?? throw new UnexpectedResultException("Registry key not found; try reinstalling the software.");
}
static RegistryKey? GetPolicyRuleKey(Guid guid, bool writable)
{
using var devicesKey = GetPolicyKey(writable);
return devicesKey.OpenSubKey(guid.ToString("B"), writable);
}
public static void Persist(string instanceId, string description)
{
var guid = Guid.NewGuid();
@@ -224,4 +239,92 @@ static class RegistryUtils
return false;
}
}
public static Guid AddPolicyRule(PolicyRule rule)
{
if (!rule.IsValid())
{
throw new ArgumentException("Invalid policy rule", nameof(rule));
}
if (GetPolicyRules().ContainsValue(rule))
{
throw new ArgumentException("Duplicate policy rule", nameof(rule));
}
var guid = Guid.NewGuid();
using var ruleKey = GetPolicyKey(true).CreateSubKey($"{guid:B}");
ruleKey.SetValue(AllowName, rule.Allow ? 1 : 0);
ruleKey.SetValue(TypeName, rule.Type.ToString());
rule.Save(ruleKey);
return guid;
}
public static void RemovePolicyRule(Guid guid)
{
using var policyKey = GetPolicyKey(true);
policyKey.DeleteSubKeyTree(guid.ToString("B"), false);
}
/// <summary>
/// Enumerates all rules.
/// <para>
/// This retrieves the entire (valid) registry state; it ignores invalid rules, as well as any duplicates.
/// </para>
/// </summary>
public static SortedDictionary<Guid, PolicyRule> GetPolicyRules()
{
var guids = new SortedSet<Guid>();
using var policyKey = GetPolicyKey(false);
foreach (var subKeyName in policyKey.GetSubKeyNames())
{
if (Guid.TryParseExact(subKeyName, "B", out var guid))
{
// Sanitize uniqueness.
guids.Add(guid);
}
}
var rules = new SortedDictionary<Guid, PolicyRule>();
foreach (var guid in guids)
{
using var ruleKey = GetPolicyRuleKey(guid, false);
if (ruleKey is null)
{
continue;
}
if (ruleKey.GetValue(AllowName) is not int allowValue)
{
// Must exist and be a DWORD.
continue;
}
var allow = (allowValue != 0);
if (!Enum.TryParse<PolicyRuleType>(ruleKey.GetValue(TypeName) as string, true, out var ruleType))
{
// Must exist and be a valid enum string.
continue;
}
PolicyRule rule;
switch (ruleType)
{
case PolicyRuleType.Bind:
rule = PolicyRuleBind.Load(allow, ruleKey);
break;
default:
// Invalid, ignore.
continue;
}
if (!rule.IsValid())
{
// Invalid, ignore.
continue;
}
if (rules.ContainsValue(rule))
{
// Duplicate, ignore.
continue;
}
rules.Add(guid, rule);
}
// All unique and valid.
return rules;
}
}