NuGet without Internet Survival Guide
Technicaly we can work normally without Internet by preparing required Docker images for storages like ElasticSearch and SqlServer
But when Internet dissapear and you switch between branches, Rider may decide to refresh packages and immediately will dia with bunch of errors about broken dependencies
To avoid such we gonna need to have local NuGet kind of cache
Thankfully NuGet has build in feature exactly for such scenario
All you need to do:
- save wanted packages somewhere on local machine
- add source pointing to folder where you saved all of them
Here is an short demo of how it can be used:
echo 'recreate offline directory for demo'
rm -rf offline || true
mkdir offline
echo 'create demo classlib'
rm -rf mylib || true
mkdir mylib
cd mylib
dotnet new console
dotnet pack
cd ..
echo 'pretend like we downloaded some nupkg for offline usage'
mv mylib/bin/Debug/mylib.1.0.0.nupkg ./offline/
echo 'list current nuget sources'
dotnet nuget list source
echo 'remove offline source if any'
dotnet nuget remove source offline
echo 'add offline source'
dotnet nuget add source "${PWD}/offline" --name=offline
echo 'remove original nuget.org source'
dotnet nuget remove source nuget.org
echo 'demo of offline usage'
rm -rf consoleapp || true
mkdir consoleapp
cd consoleapp
dotnet new console
dotnet add package mylib
dotnet restore
cd ..
echo 'add nuget.org back' # TODO: not sure if thats really important but given command does not add 'protocolVersion="3"', aka: '<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />'
dotnet nuget add source https://api.nuget.org/v3/index.json --name=nuget.org
echo 'cleanup'
rm -rf consoleapp mylib offline || true
Now we need to collect all packages we are using:
$basedir = '/path/to/dir/where/you/store/projects'
$repos = Get-ChildItem -Path $basedir -Directory | Select-Object -ExpandProperty Name
$packages = @()
foreach($repo in $repos) {
$projects = Get-ChildItem -Path (Join-Path $basedir $repo) -Recurse -Depth 3 -Include '*.csproj'
foreach($project in $projects) {
$xml = [xml](Get-Content $project.FullName)
foreach($reference in $xml.Project.ItemGroup.PackageReference) {
if (-not $reference) { continue }
$packages += [PSCustomObject]@{
Repository = $repo
Project = [System.IO.Path]::GetFileNameWithoutExtension($project.FullName)
Package = $reference.Include
Version = $reference.Version
}
}
}
}
$packages | ConvertTo-Json | Out-File -Encoding utf8 packages.json
This script will produce JSON with entries like:
{
"Repository": "MyAwesomeApp",
"Project": "MyAwesomeApp",
"Package": "Newtonsoft.JSON",
"Version": "12.0.3"
}
Now, when we will have an Internet, we may download them with URL like:
Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/$package/$version/$package.$version.nupkg" -OutFile "/Users/mac/Downloads/nucache/$package.$version.nupkg"
But, make sure to download package dependencies as well, otherwise when you will be offline an surprise is waiting for you, you gonna need not only download all that fancy libraries but bunch of system and Microsoft libraries as well
TODO: script out dependencies download
The best part of this approach is that you are downloading everything you need upfront which is much better than using any caching proxies
Alternative approaches
My very first attempt was to run nginx as a caching proxy
Here is config I ended up with:
# https://www.nginx.com/blog/nginx-caching-guide/
# http://nginx.org/en/docs/http/ngx_http_proxy_module.html
# https://gist.github.com/sirsquidness/710bc76d7bbc734c7a3ff69c6b8ff591
proxy_cache_path /cache keys_zone=nucache:10m inactive=100d max_size=30g;
server {
listen 80;
server_name localhost;
location / {
proxy_cache nucache;
proxy_pass http://demoa;
# replace with ip address because when there is no internet, address may be not resolved
# proxy_pass https://api.nuget.org;
# proxy_pass https://152.199.23.209;
# proxy_set_header Host api.nuget.org;
# better option is to add: "--add-host api.nuget.org:152.199.23.209" to docker run command
# Delivering Cached Content When the Origin is Down
proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504 http_403 http_404 http_429;
# This one should be added/used only when we have internet, in combination with "updating" option in "proxy_cache_use_stale" nginx will serve cached file, but in background will try to update it
# proxy_cache_background_update on;
proxy_cache_key $scheme$proxy_host$uri$is_args$args;
proxy_cache_valid 48h;
proxy_cache_methods GET HEAD POST;
add_header X-Nuget-Cache $upstream_cache_status;
proxy_intercept_errors on;
error_page 301 302 307 = @handle_redirects;
proxy_ignore_headers Set-Cookie;
proxy_ignore_headers Cache-Control;
proxy_ignore_headers Expires;
proxy_hide_header Set-Cookie;
proxy_hide_header Cache-Control;
proxy_hide_header Expires;
}
location @handle_redirects {
# store the current state of the world so we can reuse it in a minute
# We need to capture these values now, because as soon as we invoke
# the proxy_* directives, these will disappear
# set $original_uri $uri;
set $original_scheme $scheme;
set $original_proxy_host $proxy_host;
set $original_uri $uri;
set $original_is_args $is_args;
set $original_args $args;
set $orig_loc $upstream_http_location;
# nginx goes to fetch the value from the upstream Location header
proxy_pass $orig_loc;
proxy_cache nucache;
# But we store the result with the cache key of the original request URI
# so that future clients don't need to follow the redirect too
# proxy_cache_key $original_uri;
proxy_cache_key $original_scheme$original_proxy_host$original_uri$original_is_args$original_args;
proxy_cache_valid 48h;
proxy_cache_methods GET HEAD POST;
# resolver 8.8.8.8; # Seems to be needed for redirects.
proxy_ignore_headers Set-Cookie;
proxy_ignore_headers Cache-Control;
proxy_ignore_headers Expires;
proxy_hide_header Set-Cookie;
proxy_hide_header Cache-Control;
proxy_hide_header Expires;
}
}
But it did not worked out as expected, having troubles with redirections and cache keys, somehow it does not work and it is not easy to debug all this, plus if we want to cache requests with body there will be even more troubles, so it seems to be not working solution
The second attempt was to build own nano proxy exactly for nuget
Here is what I have ended up with:
package main
import (
"bytes"
"crypto/sha512"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
)
var online bool
var target string
var cache string
var port int
type CacheRequest struct {
Method string
Path string
Body []byte
}
type CacheResponse struct {
Status int
Header http.Header
Body []byte
}
type CacheEntry struct {
Request CacheRequest
Response CacheResponse
}
func main() {
flag.StringVar(&target, "target", "https://api.nuget.org", "proxy target")
flag.StringVar(&cache, "cache", "./cache", "cache directory")
flag.BoolVar(&online, "online", true, "online mode")
flag.IntVar(&port, "port", 8080, "port to listen")
flag.Parse()
fmt.Println("nucache")
fmt.Println()
fmt.Println("target:", target)
fmt.Println("cache:", cache)
fmt.Println("online:", online)
fmt.Println("port:", port)
fmt.Println()
fmt.Println("commands to switch between proxy and nuget:")
fmt.Println(" dotnet nuget list source")
fmt.Println(" dotnet nuget remove source nuget.org")
fmt.Printf(" dotnet nuget add source http://localhost:%d/v3/index.json --name=nuget.org\n", port)
fmt.Println(" dotnet nuget add source https://api.nuget.org/v3/index.json --name=nuget.org")
fmt.Println()
fmt.Println("commands to switch online/offline:")
fmt.Printf(" curl http://localhost:%d/online\n", port)
fmt.Printf(" curl http://localhost:%d/offline\n", port)
fmt.Println()
fmt.Println("notes:")
fmt.Println("- in online mode cache is not used at all, but all ok'ish responses are saved")
fmt.Println("- in offline mode network is not used at all, only saved cache entries are used")
fmt.Println("- switch between modes is manual")
fmt.Println("- there is no concept of time to live, cache entries live forever")
fmt.Println("- to renew cache entry just switch to online mode")
fmt.Println("- to remove cache entry just delete its file")
fmt.Println()
fmt.Println("example of online log:")
fmt.Println("Status: online, Method: GET, Path: /whatever, Key: 475676e11...")
fmt.Println()
fmt.Println("example of offline log:")
fmt.Println("Status: offline, Cache: HIT, Method: GET, Path: /whatever, Key: 475676e11...")
fmt.Println()
fmt.Println()
t, err := url.Parse(target)
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/offline", func(w http.ResponseWriter, r *http.Request) {
online = false
fmt.Fprintf(w, "offline\n")
})
http.HandleFunc("/online", func(w http.ResponseWriter, r *http.Request) {
online = true
fmt.Fprintf(w, "online\n")
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
var err error
if online {
err = onlineHandler(w, r, t)
} else {
err = offlineHandler(w, r)
}
if err != nil {
fmt.Println(err)
w.WriteHeader(500)
w.Write([]byte(err.Error()))
}
})
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil))
}
func onlineHandler(w http.ResponseWriter, r *http.Request, t *url.URL) error {
e := CacheEntry{
Request: CacheRequest{
Method: r.Method,
Path: r.URL.String(),
Body: reuse(r),
},
Response: CacheResponse{},
}
k := key(e.Request)
fmt.Printf("Status: online, Method: %s, Path: %s, Key: %s\n", r.Method, r.URL, k)
r.Host = t.Host
r.URL.Scheme = t.Scheme
r.URL.Host = t.Host
r.RequestURI = ""
res, err := http.DefaultClient.Do(r)
if err != nil {
return err
}
resb, err := io.ReadAll(res.Body)
if err != nil {
return err
}
res.Body = io.NopCloser(bytes.NewReader(resb))
e.Response.Status = res.StatusCode
e.Response.Header = res.Header
e.Response.Body = resb
eb, err := json.Marshal(e)
if err != nil {
return err
}
if res.StatusCode < 400 {
err = os.WriteFile(path.Join(cache, k), eb, 0644)
if err != nil {
return err
}
}
transmit(w, e.Response)
return nil
}
func offlineHandler(w http.ResponseWriter, r *http.Request) error {
k := key(CacheRequest{
Method: r.Method,
Path: r.URL.String(),
Body: reuse(r),
})
b, err := os.ReadFile(path.Join(cache, k))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
code := 404
fmt.Printf("Status: offline, Cache: MISS, Method: %s, Path: %s, Key: %s, Status: %d\n", r.Method, r.URL, k, code)
w.WriteHeader(code)
return nil
}
return err
}
var e CacheEntry
err = json.Unmarshal(b, &e)
if err != nil {
return err
}
fmt.Printf("Status: offline, Cache: HIT, Method: %s, Path: %s, Key: %s, Status: %d\n", r.Method, r.URL, k, e.Response.Status)
transmit(w, e.Response)
return nil
}
func reuse(r *http.Request) []byte {
b, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(b))
return b
}
func transmit(w http.ResponseWriter, r CacheResponse) {
for k, vv := range r.Header {
for _, v := range vv {
r.Header.Add(k, v)
}
}
w.WriteHeader(r.Status)
w.Write(r.Body)
}
func key(r CacheRequest) string {
h := sha512.New()
h.Write([]byte(r.Method))
h.Write([]byte(r.Path))
h.Write(r.Body)
bs := h.Sum(nil)
return fmt.Sprintf("%x", bs)
}
The cool thing here is that it can be used virtualy for anything and can be build for any platform and is just small single binary that can be used localy
Idea was that when you online - everything works as is, proxy just proxying requests to nuget, transmits response to cliend and saves it to cache
Whenever you become offline - proxy serves cache items only
Cache itself has no time to live and lives forewer
Whenever invalidation is needed and you online - just send request and new response will be written
If cache item must be removed - just delete its file
All cache files are plain json so can be easily traversed
Theoreticaly such proxy may check if we online or offline in background and switch mode automatically
Also it may perform some trick with hosts file and add itself instead of target but then there are chances of port binding issues so that needs some more investigation
Also interesting if there is a way to add some kind of system wide proxy for concrete hostname which will allow to use such proxy for things like npm, pip, whatever
NuCache
And the final solution vas to create dedicated script for offline
#!/usr/bin/env pwsh
function GetJson($url) {
$urlPath = New-Object uri $url | Select-Object -ExpandProperty AbsolutePath -ErrorAction SilentlyContinue
if (-not $urlPath) {
return Invoke-RestMethod $url
}
$httpCachePath = "~/.nuget/nucache/http$urlPath"
if (Test-Path $httpCachePath) {
$lastWriteTime = Get-ChildItem $httpCachePath | Select-Object -ExpandProperty LastWriteTime
$now = Get-Date
$minutesSinceLastUpdate = ($now - $lastWriteTime).TotalMinutes
if ($minutesSinceLastUpdate -lt 30) {
return Get-Content $httpCachePath | ConvertFrom-Json
}
}
$response = Invoke-RestMethod $url
if ($response) {
New-Item ([System.IO.Path]::GetDirectoryName($httpCachePath)) -ItemType Directory -ErrorAction SilentlyContinue | Out-Null
$response | ConvertTo-Json -Depth 100 | Out-File -Encoding utf8 $httpCachePath
}
return $response
}
function NuCache($package, $version) {
$registrationBaseUrls = GetJson 'https://api.nuget.org/v3/index.json' | Select-Object -ExpandProperty resources | Where-Object { $_.'@type'.StartsWith('RegistrationsBaseUrl') } | Select-Object -ExpandProperty '@id' -Unique
$packageBaseAddress = GetJson 'https://api.nuget.org/v3/index.json' | Select-Object -ExpandProperty resources | Where-Object '@type' -EQ 'PackageBaseAddress/3.0.0' | Select-Object -ExpandProperty '@id'
$package = $package.ToLower()
if ($version -and $version -match '^\[\d+\.\d+\.\d+,\d+\.\d+\.\d+\]$') {
$version = $version.Split(',')[0].Trim('[')
}
if (-not $version -or $version.Contains('*')) {
$versions = GetJson "$($packageBaseAddress)$($package)/index.json" | Select-Object -ExpandProperty versions
$last = $versions | Where-Object { -not $_.Contains('-') } | Select-Object -Last 1
if (-not $last) {
$last = $versions | Select-Object -Last 1
}
if ($version -and $version.Contains('*')) {
$wildcard = $versions | Where-Object { $_ -match $version } | Select-Object -Last 1
if ($wildcard) {
$version = $wildcard
} else {
$version = $last
}
} else {
$version = $last
}
}
$file = "$($package).$($version).nupkg"
$target = Join-Path -Path "~/.nuget/nucache/packages" -ChildPath $file
$registration = $null
foreach($registrationsBaseUrl in $registrationBaseUrls) {
try {
$registration = GetJson "$($registrationsBaseUrl)$($package)/$($version).json"
break
} catch {}
}
if (-not $registration) {
$v = $versions | Where-Object { $_ -match [regex]::Replace($version, "\.\d+$", "") } | Select-Object -Last 1
if (-not $v) {
$v = $versions | Where-Object { $_ -match [regex]::Replace($version, "\.\d+\.\d+$", "") } | Select-Object -Last 1
}
if (-not $v) {
$v = $versions | Where-Object { $_ -match [regex]::Replace($version, "\d+$", "") } | Select-Object -Last 1
}
if ($v) {
NuCache $package $v
} else {
Write-Host "$package $version - not found"
}
return
}
$catalogEntries = GetJson $registration.catalogEntry
$exists = Test-Path $target
if ($exists) {
$alg = $catalogEntries.packageHashAlgorithm
$wantedHash = $catalogEntries.packageHash
if ($alg -eq 'SHA512') {
$hasher = [System.Security.Cryptography.SHA512]::Create()
} elseif ($alg -eq 'SHA256') {
$hasher = [System.Security.Cryptography.SHA256]::Create()
} elseif ($alg -eq 'SHA1') {
$hasher = [System.Security.Cryptography.SHA1]::Create()
} elseif ($alg -eq 'MD5') {
$hasher = [System.Security.Cryptography.MD5]::Create()
} else {
Write-Host "Unknown $alg for $package $version"
}
if ($hasher) {
$stream = [System.IO.File]::OpenRead((Resolve-Path $target).Path)
$bytes = $hasher.ComputeHash($stream)
$actualHash = [System.Convert]::ToBase64String($bytes)
$stream.Dispose()
$hasher.Dispose()
if ($actualHash -ne $wantedHash) {
$exists = $false
}
}
}
if (-not $exists) {
New-Item ([System.IO.Path]::GetDirectoryName($target)) -ItemType Directory -ErrorAction SilentlyContinue | Out-Null
$ProgressPreference = 'SilentlyContinue' # Subsequent calls do not display UI.
Invoke-WebRequest -Uri ($registration.packageContent) -OutFile $target
$ProgressPreference = 'Continue' # Subsequent calls do display UI.
Write-Host "$package $version - downloaded"
} else {
# Write-Host "$package $version - exists"
}
$deps = $catalogEntries | Select-Object -ExpandProperty dependencyGroups -ErrorAction SilentlyContinue | Where-Object dependencies -NE $null | Select-Object -ExpandProperty dependencies | Select-Object id, range -Unique | Select-Object @{n='Package';e={ $_.id.ToLower() }}, @{n='Version';e={ $_.range.Split(',')[0].TrimStart('[').TrimStart('(') }}
foreach($d in $deps) {
$df = "$($d.Package).$($d.Version).nupkg"
$dt = Join-Path -Path "~/.nuget/nucache/packages" -ChildPath $df
if (Test-Path $dt) {
# Write-Host "$($d.Package) $($d.Version) - exists"
continue
}
NuCache $d.Package $d.Version
}
}
if ($args.Count -eq 0) {
Write-Host "NUCACHE"
Write-Host ""
Write-Host "Commands and usage examples"
Write-Host ""
Write-Host "nucache status`n`tdisplay current online/offline status"
Write-Host "nucache offline`n`tswitch to offline mode"
Write-Host "nucache online`n`tswitch to online mode"
Write-Host "nucache sync`n`titerates over ~/.nuget/packages/**/*.nupkg files and downloads missing ones and their dependencies"
Write-Host "nucache /path/to/project`n`titerates over /path/to/project/**/*.csproj files and downloads packages with dependencies"
Write-Host "nucache Newstonsoft.Json`n`tdownloads latest version of concrete package and its dependencies"
Write-Host "nucache Newstonsoft.Json 12.0.3`n`tdownloads concrete version of concrete package and its dependencies"
Write-Host "nucache add package Newstonsoft.Json`n`tdownloads latest version of concrete package and its dependencies"
Write-Host "nucache add package Newstonsoft.Json --version 12.0.3`n`tdownloads concrete version of concrete package and its dependencies"
Write-Host ""
Write-Host "Workflows"
Write-Host ""
Write-Host "Always offline - switch mode to offline it will force you to use nucache always, so before doing dotnet add package you will need to call nucache first"
Write-Host ""
Write-Host "Online/offline - switch between modes, whenever you will be online call nucache sync and nucache /path/to/project to sync packages, when become offline just switch mode"
Write-Host ""
$count = (Get-ChildItem "$($env:HOME)/.nuget/nucache/packages/*.nupkg").Count
Write-Host "Cached packages: $count"
if (dotnet nuget list source | Where-Object { $_.Contains("nucache") }) {
Write-Host "Current mode: online"
} else {
Write-Host "Current mode: offline"
}
Write-Host ""
exit 0
}
if ($args.Count -eq 1 -and $args[0] -eq "status") {
if (dotnet nuget list source | Where-Object { $_.Contains("nucache") }) {
Write-Host "online"
} else {
Write-Host "offline"
}
exit 0
}
if ($args.Count -eq 1 -and $args[0] -eq "offline") {
dotnet nuget remove source nuget.org
dotnet nuget add source "$($env:HOME)/.nuget/nucache/packages" --name=nucache
exit 0
}
if ($args.Count -eq 1 -and $args[0] -eq "online") {
dotnet nuget remove source nucache
dotnet nuget add source https://api.nuget.org/v3/index.json --name=nuget.org
exit 0
}
if ($args.Count -eq 1 -and $args[0] -eq "sync") {
foreach ($item in Get-ChildItem -Path "$($env:HOME)/.nuget/packages" -Recurse -Include *.nupkg -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name) {
if (Test-Path "$($env:HOME)/.nuget/nucache/packages/$item") { continue }
$item = $item.Replace('.nupkg', '')
$idx = [regex]::Matches($item, "\.\d+\.\d+\.\d+") | Select-Object -ExpandProperty Index -First 1
$package = $item.Substring(0, $idx)
$version = $item.Substring($idx + 1)
NuCache $package $version
}
exit 0
}
if ($args.Count -eq 1 -and (Test-Path $args[0])) {
$projects = Get-ChildItem -Path $args[0] -Recurse -Depth 4 -Include '*.csproj'
foreach($project in $projects) {
$xml = [xml](Get-Content $project.FullName)
foreach($reference in $xml.Project.ItemGroup.PackageReference) {
if (-not $reference) { continue }
$package = $reference.Include
$version = $reference.Version
NuCache $package $version
}
}
exit 0
}
if ($args.Count -eq 1) {
$package = $args[0]
$version = $null
NuCache $package $version
exit 0
}
if ($args.Count -eq 2) {
$package = $args[0]
$version = $args[1]
NuCache $package $version
exit 0
}
if ($args.Count -eq 3 -and $args[0] -eq 'add' -and $args[1] -eq 'package') {
$package = $args[2]
$version = $null
NuCache $package $version
exit 0
}
if ($args.Count -eq 5 -and $args[0] -eq 'add' -and $args[1] -eq 'package' -and $args[3] -eq '--version') {
$package = $args[2]
$version = $args[4]
NuCache $package $version
exit 0
}
Workflows
Always offline - switch mode to offline it will force you to use nucache always, so before doing dotnet add package you will need to call nucache first
Online/offline - switch between modes, whenever you will be online call nucache sync and nucache /path/to/project to sync packages, when become offline just switch mode
Paths:
~/.nuget/nucache/bin/nucache
- the script itself~/.nuget/nucache/http
- cache for http responses~/.nuget/nucache/packages
- saved packages