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:
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,Storageand 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.
// 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);
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
- Add
snet-adminwith10.20.4.0/27to the address plan indocs/network.md, enter it innetplan.csfrom lesson 3.0.2 and check that the plan stays in order. - Create
nsg.csand run it. - Delete rule 4000 from
nsg-kladde-admin. What does the program report, and why? - 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?
- 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:
("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:
// 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_keysof 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:
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 ed25519chooses a modern, short and fast method. Older guides take RSA with 4096 bits; that works too, Ed25519 is the better choice.-Cis 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.
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
- Generate a key pair
id_ed25519_kladdewith a passphrase on your computer. - Read the fingerprint with
ssh-keygen -lf. - Put the key into the agent.
- Connect to your codespace with
gh codespace sshand this key and runwhoamiandhostnamethere. - Generate the entries with
gh codespace ssh --config, include them in~/.ssh/configand connect with a plainssh. - Check that neither the private key nor any file from
~/.sshis 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:
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:
whoami
hostname
exit
codespace
codespaces-4f2a1c
Your computer’s name is a different one. For step 5:
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:
- You sign in to a build server that three teams share, to clone a private repository on GitHub from there.
- You want to reach a second VM in the same network through a management VM.
- 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
- 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).
- No. With
ssh -J management target, both connections run from your computer, and the agent stays with you. In~/.ssh/configthat isProxyJump management. In Azure, Bastion takes the role of the intermediate computer. - 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.mdhas the fourth subnetsnet-adminand the tables fornsg-kladde-appsandnsg-kladde-adminwith their reasons.- The test cases from
nsg.csrecord 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/configand 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
- Network security groups: rules, priorities and default rules.
- Virtual network service tags: all service tags.
- Network security groups for a virtual network in Azure Container Apps: the rules Container Apps needs inbound and outbound.
- What is Azure Firewall? and What is Azure Bastion?
- Detailed steps: Create and manage SSH keys for authentication to a Linux VM in Azure: keys, agent and configuration.
- Read more for AZ-104: the learning path Configure and manage virtual networks for Azure administrators.