In lesson 3.0.2 you saw how “refused” and “no answer” differ. Behind “no answer” there is often a firewall silently dropping a packet. In this lesson you write such rules yourself: first as a table for the Kladde’s network from docs/network.md, then as a program that evaluates them the way Azure does and checks them with test cases. At the end you sign in over SSH with a key of your own to your codespace, and you know why an open port 22 on the internet is not a good idea.

What a firewall decides

A firewall checks every packet against a list of rules. A rule describes:

  • Direction: inbound (toward the protected machine) or outbound.
  • Source: the address or range the packet comes from.
  • Destination: the address or range it is headed for.
  • Port and protocol: for example TCP 443.
  • Action: allow or deny.

The most important principle is called deny by default: Only what a rule explicitly allows is allowed. Everything else falls through. The opposite approach, allowing everything and forbidding what’s dangerous, fails on the one service nobody thought of.

Almost all of today’s firewalls are stateful: If they let a connection in, they let the replies to it out again by themselves. So a rule “allow inbound TCP 443” is enough for HTTPS; no separate rule for the reply is needed.

Firewalls exist at every level. Your computer has one: the Windows firewall, and on Linux nftables with tools like ufw. PowerShell shows the Windows firewall’s basic settings:

PowerShell
Get-NetFirewallProfile | Format-Table Name, Enabled, DefaultInboundAction, DefaultOutboundAction
Name    Enabled DefaultInboundAction DefaultOutboundAction
----    ------- -------------------- ---------------------
Domain     True        NotConfigured         NotConfigured
Private    True        NotConfigured         NotConfigured
Public     True        NotConfigured         NotConfigured

NotConfigured means here: The Windows default applies, block inbound and allow outbound. That is “deny by default” for inbound connections. That’s why in lesson 3.0.2 the Windows firewall can ask whether dotnet may listen on the network.

Network security groups

In a virtual network in Azure, the rule sets are called network security groups (NSGs). An NSG is attached to a subnet or to the network card of a VM and contains rules for inbound and outbound traffic. Its particularities:

  • Priority: Every rule has a number from 100 to 4096. The smallest number is checked first, and the first matching rule decides. The search doesn’t go on after that.
  • Default rules: Every NSG has three inbound rules that you can’t delete, only override with smaller numbers:
Priority Name Source Destination Action
65000 AllowVnetInBound VirtualNetwork VirtualNetwork Allow
65001 AllowAzureLoadBalancerInBound AzureLoadBalancer any Allow
65500 DenyAllInBound any any Deny

Outbound there are AllowVnetOutBound, AllowInternetOutBound and DenyAllOutBound accordingly. Two more particularities:

  • Service tags: Instead of addresses, you can use names for address ranges that Microsoft maintains: Internet, VirtualNetwork, AzureLoadBalancer, AzureFrontDoor.Backend, Sql, Storage and many more. When Microsoft changes its addresses, the tags change with them.
  • Cost: NSGs cost nothing.

Read the first default rule closely: Inside the virtual network, everything is allowed. Every machine in every subnet may reach every other machine on every port, as long as no rule of your own with a smaller number forbids it. That is the most common gap in networks that “do have an NSG”.

Azure Firewall is something else: a central service of its own with a base fee, through which you route the traffic of a whole network. It can do what an NSG can’t: allow outbound connections by name (only api.ted.europa.eu, nothing else), block known bad addresses, log everything centrally. NSGs filter by addresses and ports, close to the resource. Many networks have both; the Kladde only needs NSGs. Both are built as Bicep, but only with the Kladde’s own network in K4 (lesson 4.7.2): Without a virtual network, there is no subnet an NSG could be attached to. You learn Bicep itself in module 3.2.

Whether an NSG lets a given packet through is answered in Azure by the IP flow verify check of Network Watcher (az network watcher test-ip-flow). The following program does the same on your computer, without Azure.

The Kladde’s NSGs

The address plan from lesson 3.0.2 gets a fourth subnet: snet-admin with 10.20.4.0/27 for a small management VM that joins in K4 for maintenance work. For two subnets, you now design the inbound rules:

nsg-kladde-apps on the subnet snet-apps (10.20.0.0/23):

Priority Name Source Destination Ports Action Why
100 AllowHttpsFromInternet Internet 10.20.0.0/23 443, 31443 Allow only HTTPS from outside; 31443 is the port on which the Container Apps environment’s proxy accepts HTTPS
110 AllowLoadBalancerProbes AzureLoadBalancer 10.20.0.0/23 30000–32767 Allow the load balancer’s health probes, which Container Apps requires

nsg-kladde-admin on the subnet snet-admin (10.20.4.0/27):

Priority Name Source Destination Ports Action Why
100 AllowSshFromBastion 10.20.3.0/26 10.20.4.0/27 22 Allow management only through Azure Bastion
4000 DenyRemoteAdminFromAnywhere * * 22, 3389 Deny otherwise AllowVnetInBound allows SSH from any subnet

Two things are part of being honest about this table:

  • A Container Apps environment with external access accepts public traffic through a public address outside your subnet. The NSG doesn’t apply to that traffic at all. Rule 100 only takes effect once the environment is internal and the traffic comes through your network, for example from Front Door over a private connection. That is the setup in K4. Until then, the Kladde restricts its access at the ingress itself.
  • Outbound, too, Container Apps needs a few things: Microsoft’s registry, Entra ID for the managed identity, Azure Monitor. If you restrict outbound traffic, take the list from the documentation (Read more), not from memory.

Checking rules like Azure

This program evaluates packets against the two NSGs: It finds the NSG of the destination subnet, puts the default rules behind your own, sorts by priority and takes the first rule that matches. For each test case it says what the result should be. If a case deviates, the program ends with exit code 1.

C#
// nsg.cs: evaluates inbound packets against the Kladde's network security groups the way Azure does:
// lowest priority number first, the first matching rule decides, and Azure's default rules come last.
using System.Net;

var vnet = IPNetwork.Parse("10.20.0.0/16");
var tags = new Dictionary<string, Func<IPAddress, bool>>
{
    ["*"] = _ => true,
    ["VirtualNetwork"] = vnet.Contains,
    ["AzureLoadBalancer"] = a => a.Equals(IPAddress.Parse("168.63.129.16")),
    ["Internet"] = a => !vnet.Contains(a),
};

Rule[] defaults =
[
    new(65000, "AllowVnetInBound", "VirtualNetwork", "VirtualNetwork", "*", Access.Allow),
    new(65001, "AllowAzureLoadBalancerInBound", "AzureLoadBalancer", "*", "*", Access.Allow),
    new(65500, "DenyAllInBound", "*", "*", "*", Access.Deny),
];

var nsgs = new Dictionary<string, (IPNetwork Subnet, Rule[] Rules)>
{
    ["nsg-kladde-apps"] = (IPNetwork.Parse("10.20.0.0/23"),
    [
        new(100, "AllowHttpsFromInternet", "Internet", "10.20.0.0/23", "443,31443", Access.Allow),
        new(110, "AllowLoadBalancerProbes", "AzureLoadBalancer", "10.20.0.0/23", "30000-32767", Access.Allow),
    ]),
    ["nsg-kladde-admin"] = (IPNetwork.Parse("10.20.4.0/27"),
    [
        new(100, "AllowSshFromBastion", "10.20.3.0/26", "10.20.4.0/27", "22", Access.Allow),
        new(4000, "DenyRemoteAdminFromAnywhere", "*", "*", "22,3389", Access.Deny),
    ]),
};

(string From, string To, int Port, string Expected)[] tests =
[
    ("203.0.113.7", "10.20.0.10", 443, "Allow"),
    ("203.0.113.7", "10.20.0.10", 22, "Deny"),
    ("203.0.113.7", "10.20.0.10", 8080, "Deny"),
    ("168.63.129.16", "10.20.0.10", 31080, "Allow"),
    ("10.20.3.4", "10.20.4.5", 22, "Allow"),
    ("10.20.0.20", "10.20.4.5", 22, "Deny"),
    ("203.0.113.7", "10.20.4.5", 22, "Deny"),
];

var failed = 0;
Console.WriteLine($"{"from",-15} {"to",-16} {"NSG",-17} {"rule",-34} Result");
foreach (var (from, to, port, expected) in tests)
{
    var source = IPAddress.Parse(from);
    var destination = IPAddress.Parse(to);
    var (name, (_, rules)) = nsgs.First(n => n.Value.Subnet.Contains(destination));
    var rule = rules.Concat(defaults)
        .OrderBy(r => r.Priority)
        .First(r => Matches(r.Source, source) && Matches(r.Destination, destination) && PortMatches(r.Ports, port));
    var verdict = rule.Access.ToString();
    var ok = verdict == expected;
    failed += ok ? 0 : 1;
    Console.WriteLine($"{from,-15} {$"{to}:{port}",-16} {name,-17} {$"{rule.Priority} {rule.Name}",-34} {verdict}{(ok ? "" : $"  EXPECTED {expected}")}");
}

Console.WriteLine(failed == 0 ? "All cases as expected." : $"Cases that differ: {failed}.");
return failed == 0 ? 0 : 1;

bool Matches(string spec, IPAddress address) =>
    tags.TryGetValue(spec, out var tag) ? tag(address) : IPNetwork.Parse(spec).Contains(address);

static bool PortMatches(string spec, int port) => spec == "*" || spec.Split(',').Any(part =>
    part.Split('-') is [var low, var high] ? port >= int.Parse(low) && port <= int.Parse(high) : port == int.Parse(part));

enum Access { Allow, Deny }

record Rule(int Priority, string Name, string Source, string Destination, string Ports, Access Access);
Bash
dotnet run nsg.cs
from            to               NSG               rule                               Result
203.0.113.7     10.20.0.10:443   nsg-kladde-apps   100 AllowHttpsFromInternet         Allow
203.0.113.7     10.20.0.10:22    nsg-kladde-apps   65500 DenyAllInBound               Deny
203.0.113.7     10.20.0.10:8080  nsg-kladde-apps   65500 DenyAllInBound               Deny
168.63.129.16   10.20.0.10:31080 nsg-kladde-apps   110 AllowLoadBalancerProbes        Allow
10.20.3.4       10.20.4.5:22     nsg-kladde-admin  100 AllowSshFromBastion            Allow
10.20.0.20      10.20.4.5:22     nsg-kladde-admin  4000 DenyRemoteAdminFromAnywhere   Deny
203.0.113.7     10.20.4.5:22     nsg-kladde-admin  4000 DenyRemoteAdminFromAnywhere   Deny
All cases as expected.

The program simplifies: It knows only inbound rules, only TCP, only four service tags, and pretends that everything outside the network is the internet. For thinking about rules, that’s enough. The test cases are the real value: They write down what the rules are supposed to achieve, and every change to the rules has to pass them. That is exactly how, in K4, a test checks the rules once they exist as Bicep in the Kladde’s network.

Exercise 1: Design and check rules

  1. Add snet-admin with 10.20.4.0/27 to the address plan in docs/network.md, enter it in netplan.cs from lesson 3.0.2 and check that the plan stays in order.
  2. Create nsg.cs and run it.
  3. Delete rule 4000 from nsg-kladde-admin. What does the program report, and why?
  4. Restore the rule and add two test cases with expectations: The management VM is supposed to be unreachable via Remote Desktop (3389) even from Bastion, and a packet from the internet to port 443 of the management VM is supposed to be turned away. Which rule decides in each case?
  5. Enter both tables with the “Why” column in docs/network.md.

How to check: netplan.cs reports “Plan is fine.”, nsg.cs ends with exit code 1 without rule 4000 and with 0 with it and your new test cases, and docs/network.md contains both tables.

Solution 1

Without rule 4000, the output ends like this:

10.20.3.4       10.20.4.5:22     nsg-kladde-admin  100 AllowSshFromBastion            Allow
10.20.0.20      10.20.4.5:22     nsg-kladde-admin  65000 AllowVnetInBound             Allow  EXPECTED Deny
203.0.113.7     10.20.4.5:22     nsg-kladde-admin  65500 DenyAllInBound               Deny
Cases that differ: 1.

From the internet, SSH is blocked even without rule 4000, by default rule 65500. But every container app in snet-apps could reach the management VM over SSH, because AllowVnetInBound allows everything inside the network. Whoever finds a hole in the Kladde would already stand at the management VM’s door. A test case showed that immediately; a glance at the table could easily have missed it.

The two new test cases:

C#
    ("10.20.3.4", "10.20.4.5", 3389, "Deny"),
    ("203.0.113.7", "10.20.4.5", 443, "Deny"),

The first is turned away by rule 4000, because rule 100 allows only port 22 from Bastion. The second doesn’t get caught on any rule of your own and ends at the default rule 65500 DenyAllInBound. Both outputs:

10.20.3.4       10.20.4.5:3389   nsg-kladde-admin  4000 DenyRemoteAdminFromAnywhere   Deny
203.0.113.7     10.20.4.5:443    nsg-kladde-admin  65500 DenyAllInBound               Deny

As Bicep, the language from module 3.2, the same rules become code. As a taste of the network in K4, nsg-kladde-admin looks like this:

bicep
// infra/modules/nsg-admin.bicep (preview for the network in K4): the rules of nsg-kladde-admin from docs/network.md.
param location string = resourceGroup().location

resource adminNsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = {
  name: 'nsg-kladde-admin'
  location: location
  properties: {
    securityRules: [
      {
        name: 'AllowSshFromBastion'
        properties: {
          priority: 100
          direction: 'Inbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourceAddressPrefix: '10.20.3.0/26'
          sourcePortRange: '*'
          destinationAddressPrefix: '10.20.4.0/27'
          destinationPortRange: '22'
        }
      }
      {
        name: 'DenyRemoteAdminFromAnywhere'
        properties: {
          priority: 4000
          direction: 'Inbound'
          access: 'Deny'
          protocol: 'Tcp'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRanges: [
            '22'
            '3389'
          ]
        }
      }
    ]
  }
}

Every row of the table becomes an entry in securityRules. You don’t have to deploy this now; az bicep build --file nsg-admin.bicep only checks whether the file is valid.

SSH: signing in with a key

SSH (Secure Shell) opens an encrypted shell on another computer, over TCP port 22. You sign in either with a password or with a key pair. Always take the key pair:

  • The private key stays on your computer and never leaves it. It is a secret in the sense of the house rules: not into the repository, not into a chat, not into a ticket.
  • The public key goes onto the server, into the file ~/.ssh/authorized_keys of the user. Anyone may see it.

At sign-in, your computer proves that it has the private key without sending it. A password, by contrast, travels over the wire, can be guessed and is tried around the clock on open ports.

You generate a key with ssh-keygen, which comes with Windows, macOS and Linux:

Bash
ssh-keygen -t ed25519 -C "kladde-codespace" -f ~/.ssh/id_ed25519_kladde
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/ada/.ssh/id_ed25519_kladde
Your public key has been saved in /home/ada/.ssh/id_ed25519_kladde.pub
The key fingerprint is:
SHA256:49XFVqKxDZgI+1Oiu0V9bUfC1iboRPs8Q7937RCwpgA kladde-codespace

Below that follows a small picture made of characters that makes the fingerprint visible. In PowerShell on Windows, you write the path as $HOME\.ssh\id_ed25519_kladde. The parts of the command:

  • -t ed25519 chooses a modern, short and fast method. Older guides take RSA with 4096 bits; that works too, Ed25519 is the better choice.
  • -C is a comment that ends up in the public key and tells you later what it’s for.
  • The passphrase encrypts the private key on the disk. Whoever copies your file can’t do anything with it without it. Enter one.

The fingerprint of a key is shown by ssh-keygen -lf ~/.ssh/id_ed25519_kladde.pub:

256 SHA256:49XFVqKxDZgI+1Oiu0V9bUfC1iboRPs8Q7937RCwpgA kladde-codespace (ED25519)

So you don’t have to type the passphrase on every connection, the ssh-agent keeps the unlocked key in memory until you sign out: ssh-add ~/.ssh/id_ed25519_kladde. On Windows, the ssh-agent service is switched off at first; a person with administrator rights switches it on once (Set-Service ssh-agent -StartupType Automatic, then Start-Service ssh-agent).

The server shows a key too

On the first connection, SSH asks:

The authenticity of host 'kladde-admin (10.20.4.5)' can't be established.
ED25519 key fingerprint is SHA256:Zp3T8yq0b4KcN1d9mXv2WfQ6eHs7uJ5aLr0oCkYtB1g.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])?

That is the same question as with TLS in lesson 3.0.4: Is this really the server I mean? Except that with SSH there is no chain of authorities, you decide yourself the first time. Compare the fingerprint with the one the operator gave you by another route, instead of typing yes blindly. After that, the key sits in ~/.ssh/known_hosts. If it changes later, SSH refuses the connection with a big warning. Take it seriously: Either the server was set up anew, or someone is sitting in between.

~/.ssh/config

Instead of typing user, address and key every time, you enter a server once in ~/.ssh/config:

Host kladde-admin
    HostName 10.20.4.5
    User ada
    IdentityFile ~/.ssh/id_ed25519_kladde
    IdentitiesOnly yes

After that, ssh kladde-admin is enough. IdentitiesOnly yes makes sure SSH offers only this key and not all those in the agent; some servers otherwise break off after too many attempts, and no server needs to learn which other keys you have.

Agent forwarding with care

With ssh -A or ForwardAgent yes, you take your agent along to the server: From there you can keep signing in with your keys without the keys leaving your own computer. That sounds safe but has a catch: As long as you’re connected, anyone with root rights on that server can use your agent and sign in as you, everywhere your keys are valid. On a foreign or shared server, that is a barn door left open. If you need to go on through an intermediate computer, take ssh -J intermediate target (ProxyJump) instead: The connection to the target then runs through the intermediate computer, but the sign-in happens from your own computer.

No open port 22

A server with port 22 on the internet is found within minutes and then bombarded with sign-in attempts without a break. Even with keys instead of passwords, that’s an unnecessary risk: Any hole in the SSH server could be exploited immediately. In Azure there is Azure Bastion for this: a managed service in its own subnet AzureBastionSubnet, through which you sign in to VMs in the browser or with the Azure CLI. The VMs themselves need no public address, and port 22 is reachable only from Bastion’s subnet, exactly as rule 100 of the table above says. Bastion has a base fee (price calculator), which is why it only joins the Kladde’s network in K4, and only for as long as it is needed.

SSH to your own codespace

Your codespace has an SSH server (the default image includes it; a custom image needs the sshd feature for that). You reach it without opening a port on the internet: The GitHub CLI builds the connection through your sign-in to GitHub.

Bash
gh codespace list
codespace=name-from-the-list
gh codespace ssh -c "$codespace" -- -i ~/.ssh/id_ed25519_kladde

The variable codespace takes the name from the list. Without -i, gh creates a key pair itself the first time. With -i after --, it takes yours. gh codespace ssh --config writes entries for ~/.ssh/config, after which a plain ssh to the codespace works too, and tools like scp and VS Code Remote know it.

Exercise 2: A key of your own for the codespace

  1. Generate a key pair id_ed25519_kladde with a passphrase on your computer.
  2. Read the fingerprint with ssh-keygen -lf.
  3. Put the key into the agent.
  4. Connect to your codespace with gh codespace ssh and this key and run whoami and hostname there.
  5. Generate the entries with gh codespace ssh --config, include them in ~/.ssh/config and connect with a plain ssh.
  6. Check that neither the private key nor any file from ~/.ssh is in one of your repositories.

How to check: ssh-keygen -lf shows a fingerprint SHA256:… with (ED25519), ssh-add -l lists the same fingerprint, and in the codespace, whoami answers with your user there (usually codespace).

Solution 2

In PowerShell on Windows, after the ssh-agent service has been switched on once:

PowerShell
ssh-keygen -t ed25519 -C "kladde-codespace" -f $HOME\.ssh\id_ed25519_kladde
ssh-keygen -lf $HOME\.ssh\id_ed25519_kladde.pub
ssh-add $HOME\.ssh\id_ed25519_kladde
ssh-add -l
$codespace = 'name-from-gh-codespace-list'
gh codespace ssh -c $codespace -- -i $HOME\.ssh\id_ed25519_kladde

In the codespace:

Bash
whoami
hostname
exit
codespace
codespaces-4f2a1c

Your computer’s name is a different one. For step 5:

PowerShell
gh codespace ssh --config > $HOME\.ssh\codespaces
Add-Content -Path $HOME\.ssh\config -Value "Match all`nInclude ~/.ssh/codespaces"

gh codespace ssh --config prints a Host entry for every codespace, whose name begins with cs.. After that, ssh connects with that name directly. The entry holds only as long as the codespace exists; after you delete a codespace, you generate the file again.

On step 6: git ls-files | Select-String id_ed25519 finds nothing in any repository, and ~/.ssh lies outside every repository. If a private key has ever ended up in a commit, deleting it doesn’t help: The key counts as known, you generate a new one and remove the old public key everywhere it was stored (lesson 3.3.3).

Exercise 3: Agent forwarding, yes or no?

In each of the three cases, decide whether ssh -A is defensible, and write down the better solution:

  1. You sign in to a build server that three teams share, to clone a private repository on GitHub from there.
  2. You want to reach a second VM in the same network through a management VM.
  3. You want to push to GitHub with your key from your own codespace.

How to check: For each case you have a yes or no with a reason and, where it’s no, a different way.

Solution 3

  1. No. Anyone with root rights on the shared server could use your agent for the duration of your session, for your other repositories too. Better: a deploy key with read access only for that one repository, stored on the build server, or better still a pipeline that works with federated credentials (module 3.7).
  2. No. With ssh -J management target, both connections run from your computer, and the agent stays with you. In ~/.ssh/config that is ProxyJump management. In Azure, Bastion takes the role of the intermediate computer.
  3. Not needed. A codespace is already signed in for Git: At start it gets a token that is valid for its repository. You give it further rights through the codespace’s settings, not through a forwarded agent.

The Kladde after this lesson

  • docs/network.md has the fourth subnet snet-admin and the tables for nsg-kladde-apps and nsg-kladde-admin with their reasons.
  • The test cases from nsg.cs record what the rules are supposed to achieve; in K4 (lesson 4.7.2) they become a test for the network’s Bicep.

Commit docs/network.md as a pull request.

What you can do now

  • You read and write firewall rules by direction, source, destination, port and action and apply “deny by default”.
  • You know priority, first matching rule and the default rules of an NSG, especially the gap caused by AllowVnetInBound.
  • You tell NSG and Azure Firewall apart and know what service tags are.
  • You check rules with test cases instead of just looking at them.
  • You generate SSH keys with a passphrase, use the agent and ~/.ssh/config and check fingerprints.
  • You know why agent forwarding is dangerous, what ProxyJump does better and why Bastion instead of an open port 22.
  • You connect to your codespace over SSH.

Read more