Starting a c# service with sc - c#

Im writing a small selfcontained service for windows and macos using worker service template in c# visual studio.
Its using the same Codebase hence the check in the Program.cs
I've written the service, and it works on windows, when started from within visual studio.
I've published it using
dotnet publish .\WorkerServiceTest2\ -c Release -r win-x64 -- self-contained true /p:PublishSingleFile=true /p:PublishedTrimmed=true
and tried to install it using
runas /user:MYUSERNAME "sc.exe create WorkerServiceTest2 c:\Users\MYYUSERNAME\Documents\bla\bla\bla\WorkerServiceTest2.exe"
But it does not show up in the services list, and
sc.exe start WorkerServiceTest2
says this service is not installed.
Is there anywhere i can see how the sc.exe create worked out ?
Or perhaps someone can see what I'm doing wrong ?
Sincerely Thankyou
My Service Program.cs looks like this
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Runtime.InteropServices;
namespace WorkerServiceTest2
{
public class Program
{
public static void Main(string[] args)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)){
Console.WriteLine("WinOS");
CreateHostBuilderWin(args).Build().Run();
} else
{
Console.WriteLine("MacOS");
CreateHostBuilderMac(args).Build().Run();
}
}
private static void configureServices(HostBuilderContext context, IServiceCollection services)
{
services.AddHostedService<Worker>();
}
public static IHostBuilder CreateHostBuilderWin(string[] args) =>
Host.CreateDefaultBuilder(args)
.UseWindowsService()
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<Worker>();
});
public static IHostBuilder CreateHostBuilderMac(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices(configureServices);
}
}
My Worker.cs looks like this
using Microsoft.Extensions.Hosting;
using System.Threading;
using System.Threading.Tasks;
using WorkerServiceTest2.SocketService;
namespace WorkerServiceTest2
{
public class Worker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
//Her skal business logic være.
SocketServer socketServer = new SocketServer();
await socketServer.start();
}
}
}
}

This is a script that you can use. It will check if the service is installed or not. If it already exists, it will uninstall it and install the new one. Save it as MyScript.ps1 (or your own preference) and run like:
.\MyScript.ps1 -serviceName name_of_service -serviceUsername some_username -servicePassword some_password -binaryPath "C:\yourProgram.exe"
Script:
# Sample: howto run ps-script from power-shell:
#.\Install-WindowsService_v3.ps1 -serviceName aTestservice -serviceUsername some_username -servicePassword some_password -binaryPath "C:\yourProgram.exe"
param
(
[string]$serviceName,
[string]$serviceUsername,
[string]$servicePassword,
[string]$binaryPath,
[string]$startupType='Automatic',
[string]$dependsOn
)
$secpasswd = ConvertTo-SecureString $servicePassword -AsPlainText -Force
Write-Output "########################################"
Write-Output "Starting installation of windows service."
Write-Output "[serviceName] = $serviceName"
Write-Output "[serviceUsername] = $serviceUsername" -verbose
Write-Output "[binaryPath] = $binaryPath"
#Check Parameters
if (!$binaryPath) { throw "[binaryPath] parameter missing" }
if ((Test-Path $binaryPath)-eq $false)
{
Write-Output "Path doesn't exist: $binaryPath"
Write-Output "Service will not be installed."
throw [System.IO.FileNotFoundException] "$binaryPath doesn't exist."
}
# verify if the service already exists, and if yes remove it first
if (Get-Service $serviceName -ErrorAction SilentlyContinue)
{
Stop-Service -Name $serviceName
# using WMI to remove Windows service because PowerShell does not have CmdLet for this
$serviceToRemove = Get-WmiObject -Class Win32_Service -Filter "name='$serviceName'"
$serviceToRemove.delete()
Write-Output "Service $serviceName was stopped and uninstalled."
}
else
{
Write-Output "Service didn't exist on the server"
}
if ($startupType -eq "AutomaticDelayedStart" )
{
$startupType = "Automatic"
$enableDelayed = "true"
}
Write-Output "Installing service"
# creating credentials which can be used to run my windows service
$mycreds = New-Object System.Management.Automation.PSCredential ($serviceUsername, $secpasswd)
# creating windows service using all provided parameters
New-Service -name $serviceName -binaryPathName $binaryPath -displayName $serviceName -startupType $startupType -credential $mycreds -DependsOn $dependsOn
# Set "automatic delayed" after service was installed, since it is not a valid argument when using "New-Service"
if ($enableDelayed -eq "true" )
{
$command = "sc.exe config $serviceName start= delayed-auto"
$Output = Invoke-Expression -Command $Command -ErrorAction Stop
if($LASTEXITCODE -ne 0){
Write-Host "$Computer : Failed to set $serviceName to delayed start.
More details: $Output" -foregroundcolor red
$failedcomputers +=$ComputerName
} else {
Write-Host "$Computer : Successfully changed $serviceName
to delayed start" -foregroundcolor green
$successcomputers +=$ComputerName
}
}
# verify if the service exists after installation
if (Get-Service $serviceName -ErrorAction SilentlyContinue)
{
Write-Output "Installation complete."
}
else
{
throw "Installation failed."
}
Write-Output "########################################"
Also, in all my application I start them up like so:
static async Task Main(string[] args)
{
isService = !(Debugger.IsAttached || args.Contains("--console"));
IWebHost host = CreateWebHostBuilder(args).Build();
if (isService)
{
var hostService = new MyCustomWebService(host);
ServiceBase.Run(hostService);
}
else
{
await host.RunAsync();
}
}
public class MyCustomWebService: WebHostService
{
private ILogger<MyCustomWebService> logger;
public MyCustomWebService(IWebHost host) : base(host)
{
var loggerFactory = host.Services.GetService<ILoggerFactory>();
logger = loggerFactory.CreateLogger<MyCustomWebService>();
logger.LogInformation("Starting...");
}
protected override void OnStopped()
{
logger.LogInformation("Will stop now.");
base.OnStopped();
}
}
It requires Microsoft.AspNetCore.Hosting.WindowsServices
Further recommended reading:
https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/windows-service?view=aspnetcore-5.0&tabs=visual-studio
https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.hosting.windowsservices?view=aspnetcore-5.0

Related

How can I get the GitVersion version into my binary?

I am using GitVersion to version my C#.NET application. My application also has a -V option, to show the current version of the binary.
How can I get data from GitVersion into my application, so that it is updated each time I build?
I got it using a combination of a PowerShell script and a pre-build event:
The script is as follows (saved as gitversion.ps1 in the project dir:
$gitVersionJson = dotnet gitversion /output json
$By = [System.Text.Encoding]::Unicode.GetBytes($gitVersionJson)
$output =[Convert]::ToBase64String($By)
"using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
class GitVersion {
private static Dictionary<string, object> _values;
private static Dictionary<string, object> Values {
get {
if (_values == null) {
byte[] data = Convert.FromBase64String(""$output"");
string decodedString = Encoding.Unicode.GetString(data);
_values = JsonSerializer.Deserialize<Dictionary<string, object>>(decodedString);
}
return _values;
}
}
public static object MajorMinorPatch {
get {
return Values[""MajorMinorPatch""];
}
}
}
" | Out-File GitVersion.cs
"Generated GitVersion.cs" | Write-Output
Then in as a pre-build event, I added this in the Build settings:
powershell -ExecutionPolicy Bypass -NoProfile -NonInteractive -File "$(ProjectDir)gitversion.ps1"
Or in myproject.csproj:
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
<Exec Command="powershell -ExecutionPolicy Bypass -NoProfile -NonInteractive -File "$(ProjectDir)gitversion.ps1" " />
</Target>
This will create a GitVersion class, you can use in your code:
Console.WriteLine(GitVersion.MajorMinorPatch);

Powershell command not recognized when calling from C#

This is in continuation to this Question here, I have a PowerShell command which I have created and am able to call the command in a PowerShell window, but when trying to call from C# method, I am getting error as the cmdlet is not recognized, I tried with other existing commands and get same error, so I suspect issue in Importing the Module, though I don't get that error in streams. Error. The only error I get is "Get-RowAndPartitionKey is not a recognized cmndlt, please check the spelling.....".
Would like to know if there is any other way, I should try it or if I can debug more here to see if my Module fetches all command or not. right now I am clueless how to fix this.
public string RunScript( string contentScript, Dictionary<string, EntityProperty> parameters )
{
List<string> parameterList = new List<string>();
foreach( var item in parameters )
{
parameterList.Add( item.Value.ToString() );
}
using( PowerShell ps = PowerShell.Create() )
{
IAsyncResult async =
ps.AddCommand( "Import-Module" ).AddArgument( #"C:\Users\...\.D.PowerShell.dll" )
.AddStatement()
.AddCommand( "Get-RowAndPartitionKey" ).AddParameter( "Properties", "test" )
.BeginInvoke();
StringBuilder stringBuilder = new StringBuilder();
foreach( PSObject result in ps.EndInvoke( async ) )
{
stringBuilder.AppendLine( result.ToString() );
}
return stringBuilder.ToString();
}
}
}
Below method do not return any error in Streams.Error or Verbose but no output also:
public async Task<IEnumerable<object>> RunScript( string scriptContents, List<string> scriptParameters )
{
// create a new hosted PowerShell instance using the default runspace.
// wrap in a using statement to ensure resources are cleaned up.
using( PowerShell ps = PowerShell.Create() )
{
// specify the script code to run.
ps.AddScript( scriptContents );
// specify the parameters to pass into the script.
ps.AddParameter( "Properties" ,"test") ;
// execute the script and await the result.
var pipelineObjects = await ps.InvokeAsync().ConfigureAwait( false );
return pipelineObjects;
}
}
scriptContent
"\"$path = 'C:\\Users...\\.TabularData.PowerShell.dll'\\r\\nImport-Module $path\\r\\nGet-RowAndPartitionKeys\""
The following is self-contained PowerShell sample code that uses on-demand compilation of C# code:
It shows that the approach works in principle, as described in this answer to your original question.
Prerequisites: The PowerShell SDK package and .NET runtime used in the C# project that calls your custom Get-RowAndPartitionKey" cmdlet must be compatible with the PowerShell SDK and .NET runtime that you used to compile the assembly DLL that houses that cmdlet, to be imported via Import-Module.
The sample code below ensures that implicitly, by running directly from PowerShell, using the Add-Type cmdlet to compile C# code on demand - it works in Windows PowerShell as well as in PowerShell (Core) 7+.
In practice I've found that a .NET Framework-compiled DLL (from Windows PowerShell) also works in PowerShell (Core) (.NET (Core) 5.0), but not vice versa.
It shows troubleshooting techniques, namely:
Adding the -Verbose switch to the Import-Module call to produce verbose output that lists the commands being imported from the given module (DLL).
Printing these verbose messages (look for // --- TROUBLESHOOTING CODE)
Printing any non-terminating PowerShell errors that occurred (as opposed to exceptions that you'd have to handle in C# code).
# Create a (temporary) assembly containing cmdlet "Get-RowAndPartitionKey".
# This assembly can directly be imported as a module from PowerShell.
# The cmdlet simply outputs "Hi from Get-RowAndPartitionKey" and
# echoes the elements of the list passed to -Properties, one by one.
$tempModuleDll = Join-Path ([IO.Path]::GetTempPath()) "TempModule_$PID.dll"
Remove-Item -ErrorAction Ignore $tempModuleDll
Add-Type #'
using System.Management.Automation;
using System.Collections.Generic;
[Cmdlet("Get", "RowAndPartitionKey")]
public class GetRowAndPartitionKeyCmdlet : PSCmdlet {
[Parameter] public List<string> Properties { get; set; }
protected override void ProcessRecord() {
WriteObject("Hi from Get-RowAndPartitionKey: ");
WriteObject(Properties, true);
}
}
'# -ErrorAction Stop -OutputAssembly $tempModuleDll
# Compile a C# class ad hoc to simulate your project, and call its static
# method, which imports the module and effectively calls
# Get-RowAndPartitionKey -Properties "foo", "bar"
(Add-Type #"
using System;
using System.Management.Automation;
using System.Collections.Generic;
using System.Text;
public static class Foo {
public static string RunScript(List<string> parameterList)
{
using (System.Management.Automation.PowerShell ps = PowerShell.Create())
{
IAsyncResult async =
// Add -Verbose to the Import-Module call, so that the list of
// commands being imported is written to the verbose output stream.
ps.AddCommand("Import-Module").AddArgument(#"$tempModuleDll").AddParameter("Verbose", true)
.AddStatement()
.AddCommand("Get-RowAndPartitionKey").AddParameter("Properties", parameterList)
.BeginInvoke();
StringBuilder stringBuilder = new StringBuilder();
foreach (PSObject result in ps.EndInvoke(async))
{
stringBuilder.AppendLine(result.ToString());
}
// --- TROUBLESHOOTING CODE
// Print verbose output from the Import-Module call
foreach (var v in ps.Streams.Verbose) { Console.WriteLine("VERBOSE: " + v.ToString()); }
// Print any errors.
foreach (var e in ps.Streams.Error) { Console.WriteLine("ERROR: " + e.ToString()); }
// ---
return stringBuilder.ToString();
}
}
}
"# -ErrorAction Stop -PassThru)::RunScript(("foo", "bar"))
# Clean-up instructions:
if ($env:OS -eq 'Windows_NT') {
Write-Verbose -vb "NOTE: Re-running this code requires you to start a NEW SESSION."
Write-Verbose -vb "After exiting this session, you can delete the temporary module DLL(s) with:`n`n Remove-Item $($tempModuleDll -replace '_.+', '_*.dll')`n "
} else {
Write-Verbose -vb "NOTE: Re-running this code after modifying the embedded C# code requires you to start a NEW SESSION."
Remove-Item $tempModuleDll
}
On my Windows 10 machine, both from PowerShell (Core) 7.0.5 and Windows PowerShell 5.1, the above yields (clean-up instructions omitted) the following, showing that everything worked as intended:
VERBOSE: Loading module from path 'C:\Users\jdoe\AppData\Local\Temp\TempModule_11876.dll'.
VERBOSE: Importing cmdlet 'Get-RowAndPartitionKey'.
Hi from Get-RowAndPartitionKey:
foo
bar
Specifically, line VERBOSE: Importing cmdlet 'Get-RowAndPartitionKey'. indicates that the custom cmdlet was successfully imported into the session.

c# Core System.Management.Automation remote connection error

I am attempting to write a C# core program to run powershell scripts on remote linux systems. Running on .net core is a requirement for this project. I am trying to loosely follow a guide I found on CodeProject.
This is my code:
using System;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
SSHConnectionInfo connectionInfo = new SSHConnectionInfo(userName: "user", computerName: "server", keyFilePath: "id_rsa");
using (Runspace runspace = RunspaceFactory.CreateRunspace(connectionInfo))
{
runspace.Open(); // The program errors out here
runspace.Close();
}
}
}
}
I have the "id_rsa" file located int the same folder as the program. I have verified that openssh for windows, powershell core 6.0.2, and .net core 2 SDK are installed and working. I am using the following nuget packages from the Microsoft Powershell Core repository: Microsoft.PowerShell.SDK (6.0.2) and Sytem.Managment.Automation (6.0.2)
This is the error I am receiving:
Unhandled Exception: System.Management.Automation.Remoting.PSRemotingDataStructureException: An error has occurred which PowerShell cannot handle. A remote session might have ended. ---> System.ArgumentException: The path is not of a legal form.
Parameter name: path
at System.IO.Path.GetDirectoryName(String path)
at System.Management.Automation.Runspaces.SSHConnectionInfo.StartSSHProcess(StreamWriter& stdInWriterVar, StreamReader& stdOutReaderVar, StreamReader& stdErrReaderVar)
at System.Management.Automation.Remoting.Client.SSHClientSessionTransportManager.CreateAsync()
at System.Management.Automation.Remoting.ClientRemoteSessionDSHandlerImpl.SendNegotiationAsync(RemoteSessionState sessionState)
at System.Management.Automation.Remoting.ClientRemoteSessionDSHandlerImpl.HandleStateChanged(Object sender, RemoteSessionStateEventArgs arg)
at System.Management.Automation.ExtensionMethods.SafeInvoke[T](EventHandler`1 eventHandler, Object sender, T eventArgs)
at System.Management.Automation.Remoting.ClientRemoteSessionDSHandlerStateMachine.RaiseStateMachineEvents()
at System.Management.Automation.Remoting.ClientRemoteSessionDSHandlerStateMachine.ProcessEvents()
--- End of inner exception stack trace ---
at System.Management.Automation.Runspaces.AsyncResult.EndInvoke()
at System.Management.Automation.Runspaces.Internal.RunspacePoolInternal.EndOpen(IAsyncResult asyncResult)
at System.Management.Automation.Runspaces.Internal.RemoteRunspacePoolInternal.Open()
at System.Management.Automation.RemoteRunspace.Open()
at ConsoleApp1.Program.Main(String[] args) in C:\ConsoleApp1\ConsoleApp1\Program.cs:line 18
Press any key to continue . . .
At this point I am not sure what I am missing.
I ended up opening an issue on github for this error. To work around this issue currently you will need to add the following code to your program until this issue gets resolved in a future version of powershell core (>6.0.4 or >6.1.0-rc.1). Here is the specific post regarding the issue.
if (System.Management.Automation.Runspaces.Runspace.DefaultRunspace == null)
{
var defaultRunspace = RunspaceFactory.CreateRunspace();
defaultRunspace.Open();
System.Management.Automation.Runspaces.Runspace.DefaultRunspace = defaultRunspace;
}
The following answer is derived from the Bruc3 work-around answer above and the RemoteRunspace Sample 01 from the Powershell SDK.
namespace Sample.PowerShell.Runspace
{
using System;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
/// <summary>
/// This class contains the Main enrty point for the application.
/// </summary>
internal class SshRemoteRunspace
{
public static void Main(string[] args)
{
SSHConnectionInfo sshConnectionInfo = new
SSHConnectionInfo("Administrator", "remote-hyper-v-server.mydomain.com", #"C:\Users\myself\.ssh\id_ed25519.pub");
// Bruc3 Workaround
if (System.Management.Automation.Runspaces.Runspace.DefaultRunspace is null)
{
var defaultRunspace = RunspaceFactory.CreateRunspace();
defaultRunspace.Open();
System.Management.Automation.Runspaces.Runspace.DefaultRunspace = defaultRunspace;
}
// Create a remote runspace using the connection information.
using (Runspace remoteRunspace = RunspaceFactory.CreateRunspace(sshConnectionInfo))
{
remoteRunspace.Open();
// Powershell command
using (PowerShell powershell = PowerShell.Create().AddCommand("Get-VMReplication"))
{
// makes the Powershell command run in the remote runspace instead of locally
powershell.Runspace = remoteRunspace;
// display the results in the local console
foreach (PSObject result in powershell.Invoke())
{
Console.WriteLine(result.ToString());
}
}
Console.WriteLine("Press 'Enter' to exit > ");
Console.ReadKey();
remoteRunspace.Close();
}
}
}
}

PowerShell: Cannot spawn a new thread

I am attempting to spawn a new thread in PowerShell's command line using:
$t = New-Object System.Threading.Thread ([System.Threading.ThreadStart]{
Write-Host "Hello World"
});
$t.Start();
What happens is that a dialog appears saying "Powershell has stopped working".
I want to use my own Job class, written in C#, with start, pause, continue and stop methods. It uses a couple of WaitHandles to achieve this together with a new Thead instance.
I am aware of Start-Job etc, but would like to use real threads.
Any way?
EDIT: There seems to be a way https://davewyatt.wordpress.com/2014/04/06/thread-synchronization-in-powershell/
UPDATE I have packaged the below into a module called PSRunspacedDelegate, which you can install using Install-Package PSRunspacedDelegate. You can find documentation on GitHub.
Adam Driscoll's PowerShell Parallel Foreach explains that a thread running PowerShell code must have a Runspace.
In other words [Runspace]::DefaultRunspace cannot be null.
I ended up writing a RunspacedDelegateModule.psm1 module, with a function New-RunspacedDelegate that does the work.
Add-Type -Path "$PSScriptRoot\RunspacedDelegateFactory.cs"
Function New-RunspacedDelegate(
[Parameter(Mandatory=$true)][System.Delegate]$Delegate,
[Runspace]$Runspace=[Runspace]::DefaultRunspace) {
[PowerShell.RunspacedDelegateFactory]::NewRunspacedDelegate($Delegate, $Runspace)
}
RunspacedDelegateFactory.cs:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Management.Automation.Runspaces;
namespace PowerShell
{
public class RunspacedDelegateFactory
{
public static Delegate NewRunspacedDelegate(Delegate _delegate, Runspace runspace)
{
Action setRunspace = () => Runspace.DefaultRunspace = runspace;
return ConcatActionToDelegate(setRunspace, _delegate);
}
private static Expression ExpressionInvoke(Delegate _delegate, params Expression[] arguments)
{
var invokeMethod = _delegate.GetType().GetMethod("Invoke");
return Expression.Call(Expression.Constant(_delegate), invokeMethod, arguments);
}
public static Delegate ConcatActionToDelegate(Action a, Delegate d)
{
var parameters =
d.GetType().GetMethod("Invoke").GetParameters()
.Select(p => Expression.Parameter(p.ParameterType, p.Name))
.ToArray();
Expression body = Expression.Block(ExpressionInvoke(a), ExpressionInvoke(d, parameters));
var lambda = Expression.Lambda(d.GetType(), body, parameters);
var compiled = lambda.Compile();
return compiled;
}
}
}
What I noticed is that it would still crash if I used Write-Host, but Out-File seems to be ok.
Here is how to use it:
Import-Module RunspacedDelegateModule;
$writeHello = New-RunspacedDelegate ([System.Threading.ThreadStart]{
"$([DateTime]::Now) hello world" | Out-File "C:\Temp\log.txt" -Append -Encoding utf8
});
$t = New-Object System.Threading.Thread $writeHello;
$t.Start();

Cannot use powershell to remotely start WCFapplication (System.ServiceModel)

I have a c# app using System.ServiceModel.dll. I can run the app locally, but when I try to use Power Shell to run it remotely, it hang:
Here is the simple code to recreate the problem:
using System;
using System.ServiceModel;
using System.ServiceModel.Discovery;
namespace PowerShellSecurity
{
class Program
{
static void Main(string[] args)
{
var serviceUri = "net.pipe://localhost/Foo/Bar";
var discoveryUri = new Uri("soap.udp://239.255.255.250:3702/");
var service = new MyService();
var serviceHost = new ServiceHost(service, new Uri(serviceUri));
serviceHost.Description.Behaviors.Add(new ServiceDiscoveryBehavior());
serviceHost.AddServiceEndpoint(new UdpDiscoveryEndpoint(discoveryUri));
serviceHost.AddServiceEndpoint(typeof(IMyService), new NetNamedPipeBinding(), serviceUri);
serviceHost.Open();
Console.WriteLine("It worked!");
}
}
[ServiceContract]
public interface IMyService
{
[OperationContract]
void DoStuff();
}
[ServiceBehavior(InstanceContextMode = InstanceContextMode.Single, ConcurrencyMode = ConcurrencyMode.Multiple)]
public class MyService : IMyService
{
public void DoStuff()
{
throw new NotImplementedException();
}
}
}
I can run it at the localhost and it works. But if I run the following powershell command from another host:
icm -ComputerName myHost -ScriptBlock {iex "& 'c:\Users\me\Documents\Visual Studio 2013\Projects\PowerShellSecurity\PowerShellSecurity\bin\Debug\PowerShellSecurity.exe'"}
I can see the process hanging at myHost using procexp.
Then I used visual studio to attach to this process, I can see it is stuck at:
serviceHost.Open();
How can I solve this problem, if I have to use power shell to run the application remotely?
Thank you very much!
If I'm not mistaken, named pipes are local to a single computer. As far as I know, you can't connect "remotely" using named pipes.
In this case, I'd recommend using a different binding (protocol.) For example, you could use http/https, nettcp, or WSHttp.

Categories

Resources