thinkncollab-shell 2.0.0-beta.1.1 → 2.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/Readme.md +41 -10
  2. package/dist/native/tnc-tray/build-macos.sh +54 -0
  3. package/dist/native/tnc-tray/main.swift +201 -0
  4. package/dist/native/tnc-tray/tray.ps1 +136 -0
  5. package/dist/src/commands/dock/containers.js +1 -1
  6. package/dist/src/commands/dock/dock.js +1 -1
  7. package/dist/src/dock/api/routes.js +1 -1
  8. package/dist/src/dock/api/server.js +1 -1
  9. package/dist/src/dock/api/websocket.js +7 -0
  10. package/dist/src/dock/api/wire.js +1 -1
  11. package/dist/src/dock/build/builder.js +1 -1
  12. package/dist/src/dock/build/buildkit.js +1 -1
  13. package/dist/src/dock/build/cache.js +7 -0
  14. package/dist/src/dock/build/sshagent.js +7 -0
  15. package/dist/src/dock/cli/api.js +1 -1
  16. package/dist/src/dock/cli/commands.js +1 -1
  17. package/dist/src/dock/cli/compose.js +1 -1
  18. package/dist/src/dock/cli/trust.js +7 -0
  19. package/dist/src/dock/compat/cases.js +1 -1
  20. package/dist/src/dock/config.js +1 -1
  21. package/dist/src/dock/daemon-main.js +1 -1
  22. package/dist/src/dock/daemon.js +1 -1
  23. package/dist/src/dock/engine.js +1 -1
  24. package/dist/src/dock/image/auth.js +1 -1
  25. package/dist/src/dock/image/credhelpers.js +7 -0
  26. package/dist/src/dock/image/keychain.js +7 -0
  27. package/dist/src/dock/image/registry.js +1 -1
  28. package/dist/src/dock/image/scan.js +7 -0
  29. package/dist/src/dock/image/sign.js +7 -0
  30. package/dist/src/dock/linux/host.js +1 -1
  31. package/dist/src/dock/paths.js +1 -1
  32. package/dist/src/dock/runtime/cdi.js +7 -0
  33. package/dist/src/dock/runtime/fsops.js +1 -1
  34. package/dist/src/dock/runtime/network.js +1 -1
  35. package/dist/src/dock/runtime/overlaydns.js +7 -0
  36. package/dist/src/dock/runtime/ports.js +1 -1
  37. package/dist/src/dock/runtime/rootfs.js +1 -1
  38. package/dist/src/dock/runtime/spec.js +1 -1
  39. package/dist/src/dock/runtime/stats.js +1 -1
  40. package/dist/src/dock/runtime/userns.js +7 -0
  41. package/dist/src/dock/runtime/volumes.js +1 -1
  42. package/dist/src/dock/setup/autostart.js +1 -1
  43. package/dist/src/dock/setup/diagnose.js +7 -0
  44. package/dist/src/dock/setup/index.js +1 -1
  45. package/dist/src/dock/state/store.js +1 -1
  46. package/dist/src/dock/tray/agent.js +7 -0
  47. package/dist/src/dock/tray/install.js +7 -0
  48. package/dist/src/dock/ui/page.js +228 -15
  49. package/dist/src/dock/ui/server.js +1 -1
  50. package/package.json +1 -1
  51. package/dist/src/dock/setup/app.js +0 -7
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const fs=require("fs"),path=require("path"),crypto=require("crypto"),{Readable}=require("stream"),{MANIFEST_LIST_V2,MANIFEST_V2,DEFAULT_REGISTRY_HOST,DEFAULT_REGISTRY_API}=require("../api/wire"),MEDIA={OCI_INDEX:"application/vnd.oci.image.index.v1+json",OCI_MANIFEST:"application/vnd.oci.image.manifest.v1+json",OCI_CONFIG:"application/vnd.oci.image.config.v1+json",OCI_LAYER:"application/vnd.oci.image.layer.v1.tar",OCI_LAYER_GZIP:"application/vnd.oci.image.layer.v1.tar+gzip",OCI_LAYER_ZSTD:"application/vnd.oci.image.layer.v1.tar+zstd",V2_LIST:MANIFEST_LIST_V2,V2_MANIFEST:MANIFEST_V2},ACCEPT=[MEDIA.OCI_INDEX,MEDIA.V2_LIST,MEDIA.OCI_MANIFEST,MEDIA.V2_MANIFEST].join(", ");function httpError(t,e){const s=new Error(e);return s.statusCode=t,s}function parseAuthenticate(t){const e=/^\s*(Bearer|Basic)\b\s*(.*)$/i.exec(t||"");if(!e)return null;const s={scheme:e[1].toLowerCase()};for(const[,t,a,r]of e[2].matchAll(/([\w-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/g))s[t.toLowerCase()]=void 0!==a?a.replace(/\\(.)/g,"$1"):r;return"bearer"!==s.scheme||s.realm?s:null}function parseChallenge(t){const e=parseAuthenticate(t);return e&&"bearer"===e.scheme?e:null}function registryScheme(t,e=process.env){const s=t.replace(/:\d+$/,"").replace(/^\[|\]$/g,"");if("localhost"===s||s.endsWith(".localhost")||"::1"===s||/^127\./.test(s))return"http";const a=(e.TNC_DOCK_INSECURE_REGISTRIES||"").split(",").map(t=>t.trim().toLowerCase()).filter(Boolean);return a.includes(t.toLowerCase())||a.includes(s.toLowerCase())?"http":"https"}const basic=t=>"Basic "+Buffer.from(`${t.username}:${t.password}`).toString("base64");class RegistryClient{constructor(t,{userAgent:e="tnc-dock",credentials:s=null,actions:a=["pull"],mountFrom:r=[]}={}){this.ref=t,this.origin=`${registryScheme(t.apiHost)}://${t.apiHost}`,this.base=`${this.origin}/v2/${t.path}`,this.userAgent=e,this.credentials=s,this.scopes=t.path?[`repository:${t.path}:${a.join(",")}`,...r.map(t=>`repository:${t}:pull`)]:[],this.authorization=null,this.token=null}get name(){return this.ref.familiarName||this.ref.apiHost}async _authenticate(t){if("basic"===t.scheme){if(!this.credentials)throw httpError(401,`${this.ref.apiHost} requires a login: run thinknsh login ${this.ref.domain||this.ref.apiHost}`);return void(this.authorization=basic(this.credentials))}const e=new URL(t.realm);t.service&&e.searchParams.set("service",t.service);const s=new Set(this.scopes);for(const e of(t.scope||"").split(" ").filter(Boolean))s.add(e);for(const t of s)e.searchParams.append("scope",t);const a={"User-Agent":this.userAgent};this.credentials&&(a.Authorization=basic(this.credentials));const r=await fetch(e,{headers:a});if(401===r.status||403===r.status)throw await(r.body?.cancel()),httpError(401,this.credentials?`unauthorized: incorrect username or password for ${this.ref.apiHost}`:`${this.name} needs a login (HTTP ${r.status}): run thinknsh login ${this.ref.domain||this.ref.apiHost}`);if(!r.ok)throw await(r.body?.cancel()),httpError(502,`registry auth failed for ${this.name}: HTTP ${r.status}`);const i=await r.json().catch(()=>({}));if(this.token=i.token||i.access_token,!this.token)throw httpError(502,`registry auth for ${this.name} returned no token`);this.authorization=`Bearer ${this.token}`}async request(t,e,{headers:s={},body:a,retried:r=!1}={}){const i={method:t,headers:{"User-Agent":this.userAgent,...s,...this.authorization?{Authorization:this.authorization}:{}},redirect:"follow"};void 0!==a&&(i.body="function"==typeof a?a():a,i.body&&"function"==typeof i.body[Symbol.asyncIterator]&&(i.duplex="half"));const o=await fetch(e,i);if(401===o.status&&!r){const r=parseAuthenticate(o.headers.get("www-authenticate"));return await(o.body?.cancel()),r?(await this._authenticate(r),this.request(t,e,{headers:s,body:a,retried:!0})):o}return o}async _get(t,e={}){const s=()=>httpError(404,`pull access denied for ${this.name}, repository does not exist or may require authentication`+(this.credentials?"":`: run thinknsh login ${this.ref.domain}`));let a;try{a=await this.request("GET",t,{headers:e})}catch(t){if(401===t.statusCode&&!this.credentials)throw s();throw t}if(401===a.status||403===a.status||404===a.status)throw await(a.body?.cancel()),s();if(!a.ok)throw await(a.body?.cancel()),httpError(502,`registry error for ${this.name}: HTTP ${a.status}`);return a}async manifest(t){const e=await this._get(`${this.base}/manifests/${t}`,{Accept:ACCEPT}),s=Buffer.from(await e.arrayBuffer()),a="sha256:"+crypto.createHash("sha256").update(s).digest("hex");if(t.startsWith("sha256:")&&a!==t)throw httpError(502,`manifest digest mismatch for ${this.ref.familiarName}: expected ${t}, got ${a}`);const r=JSON.parse(s.toString("utf8"));return{body:r,digest:a,mediaType:r.mediaType||e.headers.get("content-type")}}async blob(t,e,s,a){if(fs.existsSync(e)&&fs.statSync(e).size===s)return!1;const r=await this._get(`${this.base}/blobs/${t}`),i=`${e}.${crypto.randomBytes(6).toString("hex")}.part`,o=crypto.createHash("sha256"),n=fs.createWriteStream(i);let c=0,h=0;try{for await(const t of Readable.fromWeb(r.body))o.update(t),c+=t.length,n.write(t)||await new Promise(t=>n.once("drain",t)),a&&Date.now()-h>100&&(h=Date.now(),a(c,s));await new Promise((t,e)=>n.end(s=>s?e(s):t()));const l="sha256:"+o.digest("hex");if(l!==t)throw httpError(502,`blob digest mismatch: expected ${t}, got ${l}`);return fs.renameSync(i,e),a?.(c,s),!0}catch(t){throw n.destroy(),fs.rmSync(i,{force:!0}),t}}_pushDenied(t){return httpError(404===t.status?404:403,`denied: requested access to ${this.name} is denied (HTTP ${t.status})`+(this.credentials?"":`; log in first: thinknsh login ${this.ref.domain}`))}async _expect(t,e,s){if(e.includes(t.status))return t;let a="";try{const e=await t.json();a=e?.errors?.[0]?.message?`: ${e.errors[0].message}`:""}catch(t){}if(401===t.status||403===t.status)throw this._pushDenied(t);throw httpError(502,`${s} failed for ${this.name}: HTTP ${t.status}${a}`)}async hasBlob(t){const e=await this.request("HEAD",`${this.base}/blobs/${t}`);return await(e.body?.cancel()),200===e.status||(404===e.status||await this._expect(e,[200,404],"blob check"),!1)}async mountBlob(t,e){const s=await this.request("POST",`${this.base}/blobs/uploads/?mount=${encodeURIComponent(t)}&from=${encodeURIComponent(e)}`);return await(s.body?.cancel()),201===s.status||(202===s.status&&s.headers.get("location")?new URL(s.headers.get("location"),this.origin):null)}async startUpload(){const t=await this._expect(await this.request("POST",`${this.base}/blobs/uploads/`),[202],"upload start");await(t.body?.cancel());const e=t.headers.get("location");if(!e)throw httpError(502,`registry ${this.ref.apiHost} gave no upload location`);return new URL(e,this.origin)}async uploadBlob(t,e,s,a,r){const i=new URL(t);i.searchParams.set("digest",e);const o=await this.request("PUT",i,{headers:{"Content-Type":"application/octet-stream","Content-Length":String(a)},body:()=>async function*(){let t=0,e=0;for await(const i of fs.createReadStream(s,{highWaterMark:1<<20}))t+=i.length,r&&Date.now()-e>100&&(e=Date.now(),r(t,a)),yield i}()});await this._expect(o,[201,204],"blob upload"),await(o.body?.cancel()),r?.(a,a)}async putManifest(t,e,s){const a=await this.request("PUT",`${this.base}/manifests/${t}`,{headers:{"Content-Type":s},body:e});await this._expect(a,[200,201],"manifest upload"),await(a.body?.cancel())}async verify(){const t=await this.request("GET",`${this.origin}/v2/`);if(await(t.body?.cancel()),401===t.status||403===t.status)throw httpError(401,`unauthorized: incorrect username or password for ${this.ref.apiHost}`);if(!t.ok)throw httpError(502,`login to ${this.ref.apiHost} failed: HTTP ${t.status}`)}}const shortId=t=>t.replace(/^sha256:/,"").slice(0,12);function pickPlatform(t,e,s,a){const r=(t.manifests||[]).filter(t=>t.platform&&"linux"===t.platform.os&&t.platform.architecture===e&&(a?(t.platform.variant||("arm64"===e?"v8":""))===a:"arm64"!==e||!t.platform.variant||"v8"===t.platform.variant));if(0===r.length){const r=["linux",e,a].filter(Boolean).join("/"),i=(t.manifests||[]).filter(t=>t.platform&&"unknown"!==t.platform.architecture).map(t=>[t.platform.os,t.platform.architecture,t.platform.variant].filter(Boolean).join("/")).join(", ");throw httpError(404,`no matching manifest for ${r} in the manifest list entries for ${s.familiar} (available: ${i})`)}return r[0]}async function pullImage(t,{blobsDir:e,arch:s,variant:a,credentials:r=null,progress:i=()=>{}}){fs.mkdirSync(e,{recursive:!0});const o=new RegistryClient(t,{credentials:r});i({status:`Pulling from ${t.familiarName}`,id:t.tag||t.digest});let n=await o.manifest(t.digest||t.tag);const c=`${t.familiarName}@${n.digest}`;let h=n;if(n.body.manifests){const e=pickPlatform(n.body,s,t,a);h=await o.manifest(e.digest)}if(!h.body.config||!Array.isArray(h.body.layers))throw httpError(502,`unsupported manifest type ${h.mediaType} for ${t.familiar}`);const l=t=>path.join(e,t.replace(/^sha256:/,"")),d=h.body.config;await o.blob(d.digest,l(d.digest),d.size);const u=JSON.parse(fs.readFileSync(l(d.digest),"utf8"));!n.body.manifests&&u.architecture&&s&&u.architecture!==s&&i({status:`Warning: ${t.familiar} is a linux/${u.architecture} image (requested linux/${s})`});const p=h.body.layers.map(t=>({digest:t.digest,size:t.size,mediaType:t.mediaType}));for(const e of p)if(/zstd/.test(e.mediaType))throw httpError(501,`zstd-compressed layers are not supported yet (${t.familiar})`);let f=!1;for(const t of p)i({status:"Pulling fs layer",progressDetail:{},id:shortId(t.digest)});for(const t of p){const e=shortId(t.digest),s=await o.blob(t.digest,l(t.digest),t.size,(t,s)=>i({status:"Downloading",progressDetail:{current:t,total:s},id:e}));s&&(f=!0),i({status:s?"Download complete":"Already exists",progressDetail:{},id:e}),s&&i({status:"Pull complete",progressDetail:{},id:e})}return i({status:`Digest: ${n.digest}`}),{fresh:f,image:{id:d.digest,repoTags:t.tagged?[t.tagged]:[],repoDigests:[c],digest:h.digest,size:p.reduce((t,e)=>t+e.size,0),layers:p,config:u,architecture:u.architecture,os:u.os,createdAt:Date.parse(u.created)||Date.now()}}}function ociLayerType(t=""){return/zstd$/.test(t)?MEDIA.OCI_LAYER_ZSTD:/gzip$/.test(t)?MEDIA.OCI_LAYER_GZIP:/\.tar$|layer\.v1\.tar$/.test(t)?MEDIA.OCI_LAYER:t||MEDIA.OCI_LAYER_GZIP}async function pushImage(t,e,{blobsDir:s,credentials:a=null,sources:r=[],progress:i=()=>{}}){if(!t.tag)throw httpError(400,`push needs a tag: ${t.familiar}`);const o=[...new Set(r)].filter(e=>e&&e!==t.path),n=new RegistryClient(t,{credentials:a,actions:["pull","push"],mountFrom:o}),c=t=>path.join(s,t.replace(/^sha256:/,"")),h=t=>{try{return fs.statSync(c(t)).size}catch(t){return-1}};i({status:`The push refers to repository [${t.domain}/${t.path}]`});const l=h(e.id);if(l<0)throw httpError(404,`image config ${shortId(e.id)} is missing from the local store; rebuild or pull ${t.familiarName}`);const d=(e.layers||[]).map(t=>({mediaType:ociLayerType(t.mediaType),digest:t.digest,size:t.size}));for(const t of d){const e=h(t.digest);if(e<0)throw httpError(404,`layer ${shortId(t.digest)} is missing from the local store; pull or rebuild the image`);t.size=e}const u=async(t,e,s)=>{if(await n.hasBlob(t))return void(s&&i({status:"Layer already exists",progressDetail:{},id:s}));let a=null;for(const e of o){const r=await n.mountBlob(t,e);if(!0===r)return void(s&&i({status:`Mounted from ${e}`,progressDetail:{},id:s}));if(r){a=r;break}}a=a||await n.startUpload(),await n.uploadBlob(a,t,c(t),e,s?(t,e)=>i({status:"Pushing",progressDetail:{current:t,total:e},id:s}):null),s&&i({status:"Pushed",progressDetail:{},id:s})},p=[...d].reverse();for(const t of p)i({status:"Preparing",progressDetail:{},id:shortId(t.digest)});for(const t of p)await u(t.digest,t.size,shortId(t.digest));await u(e.id,l,null);const f={schemaVersion:2,mediaType:MEDIA.OCI_MANIFEST,config:{mediaType:MEDIA.OCI_CONFIG,digest:e.id,size:l},layers:d},g=Buffer.from(JSON.stringify(f)),m="sha256:"+crypto.createHash("sha256").update(g).digest("hex");return await n.putManifest(t.tag,g,MEDIA.OCI_MANIFEST),i({status:`${t.tag}: digest: ${m} size: ${g.length}`}),i({aux:{Tag:t.tag,Digest:m,Size:g.length}}),{digest:m,size:g.length}}async function verifyLogin(t,e){const s=t===DEFAULT_REGISTRY_HOST?DEFAULT_REGISTRY_API:t;await new RegistryClient({apiHost:s,domain:t,path:"",familiarName:t},{credentials:e}).verify()}module.exports={pullImage,pushImage,verifyLogin,RegistryClient,MEDIA,shortId,parseAuthenticate,parseChallenge,registryScheme,ociLayerType};
7
+ "use strict";const fs=require("fs"),path=require("path"),crypto=require("crypto"),{Readable}=require("stream"),{MANIFEST_LIST_V2,MANIFEST_V2,DEFAULT_REGISTRY_HOST,DEFAULT_REGISTRY_API}=require("../api/wire"),MEDIA={OCI_INDEX:"application/vnd.oci.image.index.v1+json",OCI_MANIFEST:"application/vnd.oci.image.manifest.v1+json",OCI_CONFIG:"application/vnd.oci.image.config.v1+json",OCI_LAYER:"application/vnd.oci.image.layer.v1.tar",OCI_LAYER_GZIP:"application/vnd.oci.image.layer.v1.tar+gzip",OCI_LAYER_ZSTD:"application/vnd.oci.image.layer.v1.tar+zstd",V2_LIST:MANIFEST_LIST_V2,V2_MANIFEST:MANIFEST_V2},UPLOAD_RETRIES=5,DOWNLOAD_RETRIES=5,sleep=t=>new Promise(e=>setTimeout(e,t)),uploadChunkSize=()=>Math.max(65536,Number(process.env.TNC_DOCK_UPLOAD_CHUNK)||16<<20),ACCEPT=[MEDIA.OCI_INDEX,MEDIA.V2_LIST,MEDIA.OCI_MANIFEST,MEDIA.V2_MANIFEST].join(", ");function httpError(t,e){const s=new Error(e);return s.statusCode=t,s}function parseAuthenticate(t){const e=/^\s*(Bearer|Basic)\b\s*(.*)$/i.exec(t||"");if(!e)return null;const s={scheme:e[1].toLowerCase()};for(const[,t,a,i]of e[2].matchAll(/([\w-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/g))s[t.toLowerCase()]=void 0!==a?a.replace(/\\(.)/g,"$1"):i;return"bearer"!==s.scheme||s.realm?s:null}function parseChallenge(t){const e=parseAuthenticate(t);return e&&"bearer"===e.scheme?e:null}function registryScheme(t,e=process.env,s=null){const a=t.replace(/:\d+$/,"").replace(/^\[|\]$/g,"");if("localhost"===a||a.endsWith(".localhost")||"::1"===a||/^127\./.test(a))return"http";const i=(e.TNC_DOCK_INSECURE_REGISTRIES||"").split(",").map(t=>t.trim().toLowerCase()).filter(Boolean),r=s||require("../config").readConfig();i.push(...(r.insecureRegistries||[]).map(t=>String(t).toLowerCase()));for(const t of r.registryMirrors||[])/^http:\/\//i.test(t)&&i.push(new URL(t).host.toLowerCase());return i.includes(t.toLowerCase())||i.includes(a.toLowerCase())?"http":"https"}function mirrorsFor(t,e=require("../config").readConfig()){return t.domain!==DEFAULT_REGISTRY_HOST?[]:(e.registryMirrors||[]).map(t=>{try{return new URL(/^[a-z]+:\/\//i.test(t)?t:`https://${t}`).host}catch(t){return null}}).filter(Boolean)}const basic=t=>"Basic "+Buffer.from(`${t.username}:${t.password}`).toString("base64");class RegistryClient{constructor(t,{userAgent:e="tnc-dock",credentials:s=null,actions:a=["pull"],mountFrom:i=[]}={}){this.ref=t,this.origin=`${registryScheme(t.apiHost)}://${t.apiHost}`,this.base=`${this.origin}/v2/${t.path}`,this.userAgent=e,this.credentials=s,this.scopes=t.path?[`repository:${t.path}:${a.join(",")}`,...i.map(t=>`repository:${t}:pull`)]:[],this.authorization=null,this.token=null}get name(){return this.ref.familiarName||this.ref.apiHost}async _authenticate(t){if("basic"===t.scheme){if(!this.credentials)throw httpError(401,`${this.ref.apiHost} requires a login: run thinknsh login ${this.ref.domain||this.ref.apiHost}`);return void(this.authorization=basic(this.credentials))}const e=new URL(t.realm);t.service&&e.searchParams.set("service",t.service);const s=new Set(this.scopes);for(const e of(t.scope||"").split(" ").filter(Boolean))s.add(e);for(const t of s)e.searchParams.append("scope",t);const a={"User-Agent":this.userAgent};this.credentials&&(a.Authorization=basic(this.credentials));const i=await fetch(e,{headers:a});if(401===i.status||403===i.status)throw await(i.body?.cancel()),httpError(401,this.credentials?`unauthorized: incorrect username or password for ${this.ref.apiHost}`:`${this.name} needs a login (HTTP ${i.status}): run thinknsh login ${this.ref.domain||this.ref.apiHost}`);if(!i.ok)throw await(i.body?.cancel()),httpError(502,`registry auth failed for ${this.name}: HTTP ${i.status}`);const r=await i.json().catch(()=>({}));if(this.token=r.token||r.access_token,!this.token)throw httpError(502,`registry auth for ${this.name} returned no token`);this.authorization=`Bearer ${this.token}`}async request(t,e,{headers:s={},body:a,retried:i=!1}={}){const r={method:t,headers:{"User-Agent":this.userAgent,...s,...this.authorization?{Authorization:this.authorization}:{}},redirect:"follow"};void 0!==a&&(r.body="function"==typeof a?a():a,r.body&&"function"==typeof r.body[Symbol.asyncIterator]&&(r.duplex="half"));const o=await fetch(e,r);if(401===o.status&&!i){const i=parseAuthenticate(o.headers.get("www-authenticate"));return await(o.body?.cancel()),i?(await this._authenticate(i),this.request(t,e,{headers:s,body:a,retried:!0})):o}return o}async _get(t,e={}){const s=()=>httpError(404,`pull access denied for ${this.name}, repository does not exist or may require authentication`+(this.credentials?"":`: run thinknsh login ${this.ref.domain}`));let a;try{a=await this.request("GET",t,{headers:e})}catch(t){if(401===t.statusCode&&!this.credentials)throw s();throw t}if(401===a.status||403===a.status||404===a.status)throw await(a.body?.cancel()),s();if(!a.ok)throw await(a.body?.cancel()),httpError(502,`registry error for ${this.name}: HTTP ${a.status}`);return a}async manifest(t){const e=await this._get(`${this.base}/manifests/${t}`,{Accept:ACCEPT}),s=Buffer.from(await e.arrayBuffer()),a="sha256:"+crypto.createHash("sha256").update(s).digest("hex");if(t.startsWith("sha256:")&&a!==t)throw httpError(502,`manifest digest mismatch for ${this.ref.familiarName}: expected ${t}, got ${a}`);const i=JSON.parse(s.toString("utf8"));return{body:i,digest:a,size:s.length,mediaType:i.mediaType||e.headers.get("content-type")}}async blob(t,e,s,a){if(fs.existsSync(e)&&fs.statSync(e).size===s)return!1;const i=`${e}.${crypto.randomBytes(6).toString("hex")}.part`;let r=crypto.createHash("sha256"),o=fs.createWriteStream(i),n=0,c=0;try{for(let e=0;;e++)try{const e=await this._get(`${this.base}/blobs/${t}`,n?{Range:`bytes=${n}-`}:{});n&&206!==e.status&&(await new Promise(t=>o.end(t)),o=fs.createWriteStream(i),r=crypto.createHash("sha256"),n=0);for await(const t of Readable.fromWeb(e.body))r.update(t),n+=t.length,o.write(t)||await new Promise(t=>o.once("drain",t)),a&&Date.now()-c>100&&(c=Date.now(),a(n,s));break}catch(t){if(t.statusCode||e>=5||s&&n>=s)throw t;await sleep(500*2**e)}await new Promise((t,e)=>o.end(s=>s?e(s):t()));const l="sha256:"+r.digest("hex");if(l!==t)throw httpError(502,`blob digest mismatch: expected ${t}, got ${l}`);return fs.renameSync(i,e),a?.(n,s),!0}catch(t){throw o.destroy(),fs.rmSync(i,{force:!0}),t}}_pushDenied(t){return httpError(404===t.status?404:403,`denied: requested access to ${this.name} is denied (HTTP ${t.status})`+(this.credentials?"":`; log in first: thinknsh login ${this.ref.domain}`))}async _expect(t,e,s){if(e.includes(t.status))return t;let a="";try{const e=await t.json();a=e?.errors?.[0]?.message?`: ${e.errors[0].message}`:""}catch(t){}if(401===t.status||403===t.status)throw this._pushDenied(t);throw httpError(502,`${s} failed for ${this.name}: HTTP ${t.status}${a}`)}async hasBlob(t){const e=await this.request("HEAD",`${this.base}/blobs/${t}`);return await(e.body?.cancel()),200===e.status||(404===e.status||await this._expect(e,[200,404],"blob check"),!1)}async mountBlob(t,e){const s=await this.request("POST",`${this.base}/blobs/uploads/?mount=${encodeURIComponent(t)}&from=${encodeURIComponent(e)}`);return await(s.body?.cancel()),201===s.status||(202===s.status&&s.headers.get("location")?new URL(s.headers.get("location"),this.origin):null)}async startUpload(){const t=await this._expect(await this.request("POST",`${this.base}/blobs/uploads/`),[202],"upload start");await(t.body?.cancel());const e=t.headers.get("location");if(!e)throw httpError(502,`registry ${this.ref.apiHost} gave no upload location`);return new URL(e,this.origin)}async uploadBlob(t,e,s,a,i){const r=uploadChunkSize();if(a>r)try{return await this._uploadChunked(t,e,s,a,r,i)}catch(e){if(!e.chunksRefused)throw e;t=await this.startUpload()}for(let r=0;;r++)try{return await this._uploadWhole(t,e,s,a,i),t}catch(e){if(e.statusCode||r>=5)throw e;await sleep(500*2**r),t=await this.startUpload()}}async _uploadWhole(t,e,s,a,i){const r=new URL(t);r.searchParams.set("digest",e);const o=await this.request("PUT",r,{headers:{"Content-Type":"application/octet-stream","Content-Length":String(a)},body:()=>async function*(){let t=0,e=0;for await(const r of fs.createReadStream(s,{highWaterMark:1<<20}))t+=r.length,i&&Date.now()-e>100&&(e=Date.now(),i(t,a)),yield r}()});await this._expect(o,[201,204],"blob upload"),await(o.body?.cancel()),i?.(a,a)}async _uploadChunked(t,e,s,a,i,r){let o=new URL(t),n=0,c=0;const l=fs.openSync(s,"r");try{for(;n<a;){const t=Math.min(n+i,a),e=Buffer.alloc(t-n);let s;fs.readSync(l,e,0,e.length,n);try{s=await this.request("PATCH",o,{headers:{"Content-Type":"application/octet-stream","Content-Length":String(e.length),"Content-Range":`${n}-${t-1}`},body:e})}catch(t){if(t.statusCode||++c>5)throw t;await sleep(500*2**(c-1)),({url:o,offset:n}=await this._resumePoint(o));continue}if(202===s.status){await(s.body?.cancel()),s.headers.get("location")&&(o=new URL(s.headers.get("location"),this.origin));const e=/^(?:bytes=)?0-(\d+)$/.exec(s.headers.get("range")||"");n=e?Number(e[1])+1:t,r?.(n,a),c=0;continue}if(0===n&&[400,405,413,416,501].includes(s.status)){await(s.body?.cancel());const t=new Error("chunked uploads refused");throw t.chunksRefused=!0,t}s.status>=500&&++c<=5?(await(s.body?.cancel()),await sleep(500*2**(c-1)),({url:o,offset:n}=await this._resumePoint(o))):await this._expect(s,[202],"blob upload")}}finally{fs.closeSync(l)}const h=new URL(o);h.searchParams.set("digest",e);const d=await this.request("PUT",h,{headers:{"Content-Length":"0"},body:Buffer.alloc(0)});return await this._expect(d,[201,204],"blob upload"),await(d.body?.cancel()),r?.(a,a),o}async _resumePoint(t){const e=await this.request("GET",t);if(await(e.body?.cancel()),404===e.status)throw httpError(502,`the registry dropped the upload session for ${this.name}; push again`);await this._expect(e,[200,204],"upload status");const s=/^(?:bytes=)?0-(\d+)$/.exec(e.headers.get("range")||"");return{url:e.headers.get("location")?new URL(e.headers.get("location"),this.origin):t,offset:s?Number(s[1])+1:0}}async putManifest(t,e,s){const a=await this.request("PUT",`${this.base}/manifests/${t}`,{headers:{"Content-Type":s},body:e});await this._expect(a,[200,201],"manifest upload"),await(a.body?.cancel())}async verify(){const t=await this.request("GET",`${this.origin}/v2/`);if(await(t.body?.cancel()),401===t.status||403===t.status)throw httpError(401,`unauthorized: incorrect username or password for ${this.ref.apiHost}`);if(!t.ok)throw httpError(502,`login to ${this.ref.apiHost} failed: HTTP ${t.status}`)}}const shortId=t=>t.replace(/^sha256:/,"").slice(0,12);function pickPlatform(t,e,s,a){const i=(t.manifests||[]).filter(t=>t.platform&&"linux"===t.platform.os&&t.platform.architecture===e&&(a?(t.platform.variant||("arm64"===e?"v8":""))===a:"arm64"!==e||!t.platform.variant||"v8"===t.platform.variant));if(0===i.length){const i=["linux",e,a].filter(Boolean).join("/"),r=(t.manifests||[]).filter(t=>t.platform&&"unknown"!==t.platform.architecture).map(t=>[t.platform.os,t.platform.architecture,t.platform.variant].filter(Boolean).join("/")).join(", ");throw httpError(404,`no matching manifest for ${i} in the manifest list entries for ${s.familiar} (available: ${r})`)}return i[0]}async function pullImage(t,{blobsDir:e,arch:s,variant:a,credentials:i=null,progress:r=()=>{}}){fs.mkdirSync(e,{recursive:!0});let o=new RegistryClient(t,{credentials:i});r({status:`Pulling from ${t.familiarName}`,id:t.tag||t.digest});let n=null;for(const e of mirrorsFor(t)){const s=new RegistryClient({...t,apiHost:e},{});try{n=await s.manifest(t.digest||t.tag),o=s;break}catch(t){}}n||(n=await o.manifest(t.digest||t.tag));const c=`${t.familiarName}@${n.digest}`;let l=n;if(n.body.manifests){const e=pickPlatform(n.body,s,t,a);l=await o.manifest(e.digest)}if(!l.body.config||!Array.isArray(l.body.layers))throw httpError(502,`unsupported manifest type ${l.mediaType} for ${t.familiar}`);const h=t=>path.join(e,t.replace(/^sha256:/,"")),d=l.body.config;await o.blob(d.digest,h(d.digest),d.size);const u=JSON.parse(fs.readFileSync(h(d.digest),"utf8"));!n.body.manifests&&u.architecture&&s&&u.architecture!==s&&r({status:`Warning: ${t.familiar} is a linux/${u.architecture} image (requested linux/${s})`});const p=l.body.layers.map(t=>({digest:t.digest,size:t.size,mediaType:t.mediaType}));let f=!1;for(const t of p)r({status:"Pulling fs layer",progressDetail:{},id:shortId(t.digest)});for(const t of p){const e=shortId(t.digest),s=await o.blob(t.digest,h(t.digest),t.size,(t,s)=>r({status:"Downloading",progressDetail:{current:t,total:s},id:e}));s&&(f=!0),r({status:s?"Download complete":"Already exists",progressDetail:{},id:e}),s&&r({status:"Pull complete",progressDetail:{},id:e})}return r({status:`Digest: ${n.digest}`}),{fresh:f,image:{id:d.digest,repoTags:t.tagged?[t.tagged]:[],repoDigests:[c],digest:l.digest,size:p.reduce((t,e)=>t+e.size,0),layers:p,config:u,architecture:u.architecture,os:u.os,createdAt:Date.parse(u.created)||Date.now()}}}function ociLayerType(t=""){return/zstd$/.test(t)?MEDIA.OCI_LAYER_ZSTD:/gzip$/.test(t)?MEDIA.OCI_LAYER_GZIP:/\.tar$|layer\.v1\.tar$/.test(t)?MEDIA.OCI_LAYER:t||MEDIA.OCI_LAYER_GZIP}async function sendBlob(t,e,s,a,{mountFrom:i=[],progress:r=()=>{},id:o=null}={}){if(await t.hasBlob(e))return void(o&&r({status:"Layer already exists",progressDetail:{},id:o}));let n=null;for(const s of i){const a=await t.mountBlob(e,s);if(!0===a)return void(o&&r({status:`Mounted from ${s}`,progressDetail:{},id:o}));if(a){n=a;break}}n=n||await t.startUpload(),await t.uploadBlob(n,e,s,a,o?(t,e)=>r({status:"Pushing",progressDetail:{current:t,total:e},id:o}):null),o&&r({status:"Pushed",progressDetail:{},id:o})}async function pushArtifact(t,{config:e,layers:s,annotations:a,credentials:i=null,tag:r=t.tag,progress:o=()=>{}}){const n=new RegistryClient(t,{credentials:i,actions:["pull","push"]});for(const t of s)await sendBlob(n,t.digest,t.file,t.size,{progress:o});const c=Buffer.from(e.raw),l="sha256:"+crypto.createHash("sha256").update(c).digest("hex");if(!await n.hasBlob(l)){const t=path.join(require("os").tmpdir(),`tnc-artifact-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);fs.writeFileSync(t,c);try{await n.uploadBlob(await n.startUpload(),l,t,c.length)}finally{fs.rmSync(t,{force:!0})}}const h={schemaVersion:2,mediaType:MEDIA.OCI_MANIFEST,config:{mediaType:e.mediaType,digest:l,size:c.length},layers:s.map(t=>({mediaType:t.mediaType,digest:t.digest,size:t.size,...t.annotations?{annotations:t.annotations}:{}})),...a?{annotations:a}:{}},d=Buffer.from(JSON.stringify(h)),u="sha256:"+crypto.createHash("sha256").update(d).digest("hex");return await n.putManifest(r,d,MEDIA.OCI_MANIFEST),o({status:`${r}: digest: ${u} size: ${d.length}`}),{digest:u,size:d.length}}async function pushImage(t,e,{blobsDir:s,credentials:a=null,sources:i=[],progress:r=()=>{},byDigest:o=!1}){if(!t.tag&&!o)throw httpError(400,`push needs a tag: ${t.familiar}`);const n=[...new Set(i)].filter(e=>e&&e!==t.path),c=new RegistryClient(t,{credentials:a,actions:["pull","push"],mountFrom:n}),l=t=>path.join(s,t.replace(/^sha256:/,"")),h=t=>{try{return fs.statSync(l(t)).size}catch(t){return-1}};r({status:`The push refers to repository [${t.domain}/${t.path}]`});const d=h(e.id);if(d<0)throw httpError(404,`image config ${shortId(e.id)} is missing from the local store; rebuild or pull ${t.familiarName}`);const u=(e.layers||[]).map(t=>({mediaType:ociLayerType(t.mediaType),digest:t.digest,size:t.size}));for(const t of u){const e=h(t.digest);if(e<0)throw httpError(404,`layer ${shortId(t.digest)} is missing from the local store; pull or rebuild the image`);t.size=e}const p=(t,e,s)=>sendBlob(c,t,l(t),e,{mountFrom:n,progress:r,id:s}),f=[...u].reverse();for(const t of f)r({status:"Preparing",progressDetail:{},id:shortId(t.digest)});for(const t of f)await p(t.digest,t.size,shortId(t.digest));await p(e.id,d,null);const g={schemaVersion:2,mediaType:MEDIA.OCI_MANIFEST,config:{mediaType:MEDIA.OCI_CONFIG,digest:e.id,size:d},layers:u},m=Buffer.from(JSON.stringify(g)),y="sha256:"+crypto.createHash("sha256").update(m).digest("hex");return await c.putManifest(o?y:t.tag,m,MEDIA.OCI_MANIFEST),o||(r({status:`${t.tag}: digest: ${y} size: ${m.length}`}),r({aux:{Tag:t.tag,Digest:y,Size:m.length}})),{digest:y,size:m.length}}async function pushIndex(t,e,{blobsDir:s,credentials:a=null,sources:i=[],progress:r=()=>{}}){if(!t.tag)throw httpError(400,`push needs a tag: ${t.familiar}`);const o=new Set,n=[];for(const{image:c,platform:l}of e){const e=[l.os,l.architecture,l.variant].filter(Boolean).join("/");if(o.has(e))throw httpError(400,`two images for ${e} in one index`);o.add(e),r({status:`Pushing ${e}`});const h=await pushImage(t,c,{blobsDir:s,credentials:a,sources:i,progress:r,byDigest:!0});n.push({mediaType:MEDIA.OCI_MANIFEST,digest:h.digest,size:h.size,platform:{architecture:l.architecture,os:l.os||"linux",...l.variant?{variant:l.variant}:{}}})}const c={schemaVersion:2,mediaType:MEDIA.OCI_INDEX,manifests:n},l=Buffer.from(JSON.stringify(c)),h="sha256:"+crypto.createHash("sha256").update(l).digest("hex"),d=new RegistryClient(t,{credentials:a,actions:["pull","push"]});return await d.putManifest(t.tag,l,MEDIA.OCI_INDEX),r({status:`${t.tag}: digest: ${h} size: ${l.length} (${n.length} platforms)`}),r({aux:{Tag:t.tag,Digest:h,Size:l.length}}),{digest:h,size:l.length,manifests:n}}async function describe(t,{blobsDir:e,credentials:s=null}={}){const a=new RegistryClient(t,{credentials:s}),i=await a.manifest(t.digest||t.tag),r=i.size;let o;if(i.body.manifests)o=i.body.manifests.filter(t=>t.platform&&"unknown"!==t.platform.architecture).map(t=>t.platform);else{const t=i.body.config,s=path.join(e,t.digest.replace(/^sha256:/,""));fs.mkdirSync(e,{recursive:!0}),await a.blob(t.digest,s,t.size);const r=JSON.parse(fs.readFileSync(s,"utf8"));o=[{architecture:r.architecture,os:r.os,...r.variant?{variant:r.variant}:{}}]}return{descriptor:{mediaType:i.mediaType,digest:i.digest,size:r},platforms:o,manifest:i.body}}async function verifyLogin(t,e){const s=t===DEFAULT_REGISTRY_HOST?DEFAULT_REGISTRY_API:t;await new RegistryClient({apiHost:s,domain:t,path:"",familiarName:t},{credentials:e}).verify()}module.exports={pullImage,pushImage,pushIndex,pushArtifact,sendBlob,describe,verifyLogin,RegistryClient,MEDIA,shortId,parseAuthenticate,parseChallenge,registryScheme,mirrorsFor,ociLayerType};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
+ * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
+ * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
+ * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
+ */
7
+ "use strict";const fs=require("fs"),zlib=require("zlib"),{TarReader}=require("./tar"),SEVERITIES=["UNKNOWN","LOW","MEDIUM","HIGH","CRITICAL"],MAX_FILE=20<<20;function wanted(e){return"etc/os-release"===e||"usr/lib/os-release"===e||"lib/apk/db/installed"===e||"var/lib/dpkg/status"===e||/^var\/lib\/dpkg\/status\.d\/[^/]+$/.test(e)&&!e.endsWith(".md5sums")||/(^|\/)node_modules\/(@[^/]+\/)?[^/@][^/]*\/package\.json$/.test(e)||/(^|\/)(site|dist)-packages\/[^/]+\.dist-info\/METADATA$/.test(e)||/^(var\/lib\/rpm\/(rpmdb\.sqlite|Packages)|usr\/lib\/sysimage\/rpm\/rpmdb\.sqlite)$/.test(e)}const norm=e=>e.replace(/^\.?\/+/,"").replace(/\/+$/,"");async function applyLayer(e,t){const s=new TarReader(e=>{const s=norm(e.name),n=s.split("/").pop();if(".wh..wh..opq"===n){const e=s.slice(0,-n.length);for(const s of[...t.keys()])s.startsWith(e)&&t.delete(s);return null}if(n.startsWith(".wh.")){const e=s.slice(0,-n.length)+n.slice(4);for(const s of[...t.keys()])(s===e||s.startsWith(e+"/"))&&t.delete(s);return null}if(t.has(s)&&"0"!==e.type&&t.delete(s),"0"!==e.type&&"7"!==e.type||!wanted(s)||e.size>20971520)return null;const r=[];return{write:e=>r.push(Buffer.from(e)),end:()=>t.set(s,Buffer.concat(r))}});for await(const t of e)s.push(t);s.end()}function layerStream(e,t=""){const s=fs.createReadStream(e);if(/zstd/.test(t)){if(!zlib.createZstdDecompress)throw new Error(`zstd layers need Node 22.15+ or 23.8+ (this is ${process.version})`);return s.pipe(zlib.createZstdDecompress())}if(/gzip/.test(t)||!/\.tar$/.test(t)){const t=fs.openSync(e,"r"),n=Buffer.alloc(2);if(fs.readSync(t,n,0,2,0),fs.closeSync(t),31===n[0]&&139===n[1])return s.pipe(zlib.createGunzip())}return s}async function readImageFiles(e,t){const s=new Map;for(const n of t.layers||[])await applyLayer(layerStream(`${e}/${n.digest.replace(/^sha256:/,"")}`,n.mediaType),s);return s}function parseOsRelease(e){const t={};for(const s of String(e||"").split("\n")){const e=/^([A-Z_]+)=(.*)$/.exec(s.trim());e&&(t[e[1]]=e[2].replace(/^["']|["']$/g,""))}return t}function paragraphs(e){return String(e).split(/\n\s*\n/).map(e=>{const t={};let s=null;for(const n of e.split("\n")){if(/^\s/.test(n)&&s){t[s]+="\n"+n.trim();continue}const e=n.indexOf(":");e<=0||(s=n.slice(0,e).trim(),t[s]=n.slice(e+1).trim())}return t}).filter(e=>Object.keys(e).length)}function apkPackages(e){return String(e).split(/\n\s*\n/).map(e=>{const t={};for(const s of e.split("\n"))":"===s[1]&&(t[s[0]]=s.slice(2));return t}).filter(e=>e.P&&e.V).map(e=>({type:"apk",name:e.P,version:e.V,source:e.o||e.P}))}function dpkgPackages(e){return paragraphs(e).filter(e=>e.Package&&e.Version&&(!e.Status||/\binstalled\b/.test(e.Status)&&!/not-installed/.test(e.Status))).map(e=>{const t=/^(\S+)(?:\s+\(([^)]+)\))?/.exec(e.Source||"");return{type:"deb",name:e.Package,version:e.Version,source:t?t[1]:e.Package,sourceVersion:t?.[2]||e.Version}})}const RPM_TAG={NAME:1e3,VERSION:1001,RELEASE:1002,EPOCH:1003,ARCH:1022,SOURCERPM:1044};function rpmHeader(e){const t=e.readUInt32BE(0),s=e.readUInt32BE(4),n=8+16*t;if(t>1e5||n+s>e.length)throw new Error("not an RPM header");const r={};for(let s=0;s<t;s++){const t=8+16*s,a=e.readUInt32BE(t),o=e.readUInt32BE(t+4),i=n+e.readUInt32BE(t+8),c=Object.keys(RPM_TAG).find(e=>RPM_TAG[e]===a);!c||i>=e.length||(6===o||9===o?r[c]=e.toString("utf8",i,e.indexOf(0,i)):4===o&&(r[c]=e.readUInt32BE(i)))}return r}function rpmPackages(e){const t=require("os"),s=require("fs"),n=require("path").join(t.tmpdir(),`tnc-rpmdb-${process.pid}-${Date.now()}.sqlite`);s.writeFileSync(n,e);try{const{DatabaseSync:e}=require("node:sqlite"),t=new e(n,{readOnly:!0});try{const e=[];for(const s of t.prepare("SELECT blob FROM Packages").all()){let t;try{t=rpmHeader(Buffer.from(s.blob))}catch(e){continue}if(!t.NAME||!t.VERSION||"gpg-pubkey"===t.NAME)continue;const n=`${t.EPOCH?`${t.EPOCH}:`:""}${t.VERSION}-${t.RELEASE||""}`.replace(/-$/,""),r=(t.SOURCERPM||"").replace(/-[^-]+-[^-]+\.src\.rpm$/,"")||t.NAME;e.push({type:"rpm",name:t.NAME,version:n,arch:t.ARCH||"",source:r})}return e}finally{t.close()}}finally{s.rmSync(n,{force:!0})}}function packagesFromFiles(e){const t=parseOsRelease(e.get("etc/os-release")||e.get("usr/lib/os-release")),s=[],n=[];e.has("lib/apk/db/installed")&&s.push(...apkPackages(e.get("lib/apk/db/installed"))),e.has("var/lib/dpkg/status")&&s.push(...dpkgPackages(e.get("var/lib/dpkg/status")));for(const[t,n]of e)t.startsWith("var/lib/dpkg/status.d/")&&s.push(...dpkgPackages(n));const r=new Set;for(const[t,n]of e){let e=null;if(t.endsWith("/package.json")||"package.json"===t)try{const s=JSON.parse(n.toString("utf8"));s.name&&s.version&&(e={type:"npm",name:s.name,version:String(s.version),path:t})}catch(e){}else if(t.endsWith("/METADATA")){const s=paragraphs(n.toString("utf8").split(/\n\n/)[0])[0]||{};s.Name&&s.Version&&(e={type:"pypi",name:s.Name,version:s.Version,path:t})}e&&!r.has(`${e.type}/${e.name}@${e.version}`)&&(r.add(`${e.type}/${e.name}@${e.version}`),s.push(e))}const a=e.get("var/lib/rpm/rpmdb.sqlite")||e.get("usr/lib/sysimage/rpm/rpmdb.sqlite");if(a)try{s.push(...rpmPackages(a))}catch(e){n.push(`the RPM database could not be read (${e.message})`)}else e.has("var/lib/rpm/Packages")&&n.push("this image keeps its RPM packages in the older Berkeley DB format (RHEL/CentOS 7 and 8), which is not read; only its language packages were checked");return{os:t,packages:s,notes:n}}function osEcosystem(e){const t=(e.ID||"").toLowerCase(),s=e.VERSION_ID||"";return"alpine"===t&&s?`Alpine:v${s.split(".").slice(0,2).join(".")}`:"debian"===t&&s?`Debian:${s.split(".")[0]}`:"ubuntu"===t&&s?/LTS/.test(e.VERSION||"")?`Ubuntu:${s}:LTS`:`Ubuntu:${s}`:"almalinux"===t&&s?`AlmaLinux:${s.split(".")[0]}`:"rocky"===t&&s?`Rocky Linux:${s.split(".")[0]}`:"opensuse-leap"!==t&&"opensuse"!==t||!s?null:`openSUSE:Leap ${s}`}function osvQuery(e,t){if("npm"===e.type)return{package:{name:e.name,ecosystem:"npm"},version:e.version};if("pypi"===e.type)return{package:{name:e.name,ecosystem:"PyPI"},version:e.version};const s=osEcosystem(t);return s?"rpm"===e.type?{package:{name:e.name,ecosystem:s},version:e.version}:{package:{name:e.source||e.name,ecosystem:s},version:"deb"===e.type&&e.sourceVersion||e.version}:null}const vulnCache=new Map;async function osvFetch(e,t){const s=(process.env.TNC_OSV_URL||"https://api.osv.dev").replace(/\/+$/,""),n=await fetch(`${s}${e}`,t?{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}:{});if(!n.ok)throw Object.assign(new Error(`OSV ${e.split("?")[0]}: HTTP ${n.status}`),{statusCode:502});return n.json()}async function vulnDetails(e){const t=vulnCache.get(e);if(t&&Date.now()-t.at<216e5)return t.data;const s=await osvFetch(`/v1/vulns/${encodeURIComponent(e)}`);return vulnCache.set(e,{at:Date.now(),data:s}),s}function cvss3(e){const t={};for(const s of String(e).split("/")){const[e,n]=s.split(":");t[e]=n}if(!/^CVSS:3/.test(e)||!t.AV)return null;const s={N:.85,A:.62,L:.55,P:.2}[t.AV],n={L:.77,H:.44}[t.AC],r={N:.85,R:.62}[t.UI],a="C"===t.S,o={N:.85,L:a?.68:.62,H:a?.5:.27}[t.PR],i=e=>({H:.56,L:.22,N:0}[e]);if([s,n,r,o,i(t.C),i(t.I),i(t.A)].some(e=>void 0===e))return null;const c=1-(1-i(t.C))*(1-i(t.I))*(1-i(t.A)),p=a?7.52*(c-.029)-3.25*Math.pow(c-.02,15):6.42*c,l=8.22*s*n*o*r;return p<=0?0:(e=>{const t=Math.round(1e5*e);return t%1e4==0?t/1e5:(Math.floor(t/1e4)+1)/10})(Math.min(a?1.08*(p+l):p+l,10))}function scoreSeverity(e){return null==e?"UNKNOWN":e>=9?"CRITICAL":e>=7?"HIGH":e>=4?"MEDIUM":e>0?"LOW":"UNKNOWN"}function severityOf(e,t){const s=e=>{const t=String(e||"").toUpperCase();return"MODERATE"===t?"MEDIUM":"IMPORTANT"===t?"HIGH":"NEGLIGIBLE"===t||"UNIMPORTANT"===t?"LOW":SEVERITIES.includes(t)?t:null},n=(e.affected||[]).find(e=>e.package?.ecosystem?.split(":")[0]===t?.split(":")[0]),r=s(n?.ecosystem_specific?.severity)||s(n?.database_specific?.severity);if(r)return r;const a=s(e.database_specific?.severity);if(a)return a;for(const t of e.severity||[])if(/CVSS_V3/.test(t.type)){const e=cvss3(t.score);if(null!==e)return scoreSeverity(e)}return"UNKNOWN"}function fixedVersion(e,t,s,n=""){const r=[];for(const n of e.affected||[])if(!(n.package?.name!==t||s&&n.package?.ecosystem!==s))for(const e of n.ranges||[])for(const t of e.events||[])t.fixed&&r.push(t.fixed);const a=e=>String(e).replace(/^\d+:/,"").split(/[.\-+~]/)[0];return r.find(e=>a(e)===a(n))||r[0]||""}async function findVulnerabilities(e,t){const s=[],n=[];for(const r of e){const e=osvQuery(r,t);e&&(s.push(e),n.push(r))}const r=[];for(let e=0;e<s.length;e+=1e3)((await osvFetch("/v1/querybatch",{queries:s.slice(e,e+1e3)})).results||[]).forEach((t,a)=>{for(const o of t.vulns||[])r.push({pkg:n[e+a],query:s[e+a],id:o.id})});const a=[...new Set(r.map(e=>e.id))],o=new Map;for(let e=0;e<a.length;e+=8){const t=a.slice(e,e+8),s=await Promise.all(t.map(e=>vulnDetails(e).catch(()=>({id:e}))));t.forEach((e,t)=>o.set(e,s[t]))}const i=new Set,c=[];for(const e of r){const t=`${e.id} ${e.pkg.type} ${e.pkg.name} ${e.pkg.version}`;if(i.has(t))continue;i.add(t);const s=o.get(e.id)||{id:e.id},n=e.query.package.ecosystem;c.push({id:e.id,aliases:[...new Set([...s.aliases||[],...s.upstream||[]].filter(e=>/^CVE-/.test(e)))],package:e.pkg.name,version:e.pkg.version,type:e.pkg.type,...e.pkg.path?{path:e.pkg.path}:{},fixed:fixedVersion(s,e.query.package.name,n,e.query.version),severity:severityOf(s,n),summary:(s.summary||s.details||"").split("\n")[0].slice(0,200)})}return c.sort((e,t)=>SEVERITIES.indexOf(t.severity)-SEVERITIES.indexOf(e.severity)||e.package.localeCompare(t.package)),c}function purl(e,t){const s=t.ID?`?distro=${encodeURIComponent(`${t.ID}-${t.VERSION_ID||""}`)}`:"",n=encodeURIComponent(e.version);if("npm"===e.type){const[t,s]=e.name.startsWith("@")?e.name.split("/"):[null,e.name];return`pkg:npm/${t?`${encodeURIComponent(t)}/`:""}${encodeURIComponent(s)}@${n}`}if("pypi"===e.type)return`pkg:pypi/${encodeURIComponent(e.name.toLowerCase().replace(/[-_.]+/g,"-"))}@${n}`;const r="apk"===e.type?"alpine":t.ID||("rpm"===e.type?"redhat":"debian"),a=e.arch?`${s?"&":"?"}arch=${encodeURIComponent(e.arch)}`:"";return`pkg:${e.type}/${r}/${encodeURIComponent(e.name)}@${n}${s}${a}`}function sbom(e,t,s){const{DOCK_VERSION:n}=require("../version"),r=(new Date).toISOString();if("spdx"===e){const e=e=>`SPDXRef-${e.replace(/[^A-Za-z0-9.-]/g,"-")}`;return{spdxVersion:"SPDX-2.3",dataLicense:"CC0-1.0",SPDXID:"SPDXRef-DOCUMENT",name:t,documentNamespace:`https://thinkncollab.com/spdx/${encodeURIComponent(t)}-${Date.now()}`,creationInfo:{created:r,creators:[`Tool: thinknsh-dock-${n}`]},packages:s.packages.map((t,n)=>({name:t.name,SPDXID:e(`${t.type}-${t.name}-${n}`),versionInfo:t.version,downloadLocation:"NOASSERTION",filesAnalyzed:!1,externalRefs:[{referenceCategory:"PACKAGE-MANAGER",referenceType:"purl",referenceLocator:purl(t,s.os)}]}))}}if("cyclonedx"!==e)throw Object.assign(new Error("SBOM format must be cyclonedx or spdx"),{statusCode:400});return{bomFormat:"CycloneDX",specVersion:"1.5",version:1,metadata:{timestamp:r,tools:[{vendor:"ThinkNCollab",name:"thinknsh dock",version:n}],component:{type:"container",name:t}},components:[...s.os.ID?[{type:"operating-system",name:s.os.ID,version:s.os.VERSION_ID||""}]:[],...s.packages.map(e=>({type:"library",name:e.name,version:e.version,purl:purl(e,s.os)}))]}}async function scanImage(e,t,{offline:s=!1}={}){const n=e.requireImage(t),r=await readImageFiles(e.paths.blobs,n),{os:a,packages:o,notes:i}=packagesFromFiles(r),c={image:t,id:n.id,os:{ID:a.ID||"",VERSION_ID:a.VERSION_ID||"",PRETTY_NAME:a.PRETTY_NAME||""},packages:o,vulnerabilities:[],notes:i,counts:Object.fromEntries(SEVERITIES.map(e=>[e,0])),scanned:!s};if(o.some(e=>["apk","deb","rpm"].includes(e.type))&&!osEcosystem(a)&&i.push(`OSV has no advisories for ${a.PRETTY_NAME||a.ID||"this distribution"}; its OS packages were not checked`),!s){c.vulnerabilities=await findVulnerabilities(o,a);for(const e of c.vulnerabilities)c.counts[e.severity]++}return c}module.exports={scanImage,sbom,purl,packagesFromFiles,rpmHeader,rpmPackages,applyLayer,apkPackages,dpkgPackages,parseOsRelease,osEcosystem,cvss3,severityOf,findVulnerabilities,SEVERITIES};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
+ * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
+ * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
+ * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
+ */
7
+ "use strict";const fs=require("fs"),os=require("os"),path=require("path"),crypto=require("crypto"),{SIGNING_IDENTITY_KEY,SIGNING_DIGEST_KEY,SIGNING_DEFAULT_REGISTRY,DEFAULT_REGISTRY_HOST}=require("../api/wire"),PAYLOAD_MEDIA="application/vnd.dev.cosign.simplesigning.v1+json",SIG_ANNOTATION="dev.cosignproject.cosign/signature",PAYLOAD_TYPE="cosign container image signature",ZERO_TIME="0001-01-01T00:00:00Z";function signError(e,t){const i=new Error(t);return i.statusCode=e,i}function generateKeyPair(e=""){const{privateKey:t,publicKey:i}=crypto.generateKeyPairSync("ec",{namedCurve:"prime256v1"});return{privatePem:t.export({type:"pkcs8",format:"pem",...e?{cipher:"aes-256-cbc",passphrase:e}:{}}),publicPem:i.export({type:"spki",format:"pem"})}}function privateKey(e,t=""){if(/ENCRYPTED (SIGSTORE|COSIGN) PRIVATE KEY/.test(e))throw signError(400,"this is a cosign-encrypted key, which only cosign reads; make a key with: thinknsh trust key generate (or sign with cosign itself)");try{return crypto.createPrivateKey({key:e,format:"pem",...t?{passphrase:t}:{}})}catch(e){if(/passphrase|decrypt|bad decrypt/i.test(e.message))throw signError(400,"the signing key is encrypted: give its password (TNC_SIGN_PASSWORD or the prompt)");throw signError(400,`not a private key: ${e.message}`)}}function publicKey(e){try{return crypto.createPublicKey({key:e,format:"pem"})}catch(e){throw signError(400,`not a public key: ${e.message}`)}}function identity(e){return`${e.domain===DEFAULT_REGISTRY_HOST?SIGNING_DEFAULT_REGISTRY:e.domain}/${e.path}`}function payload(e,t,i=null){return JSON.stringify({critical:{identity:{[SIGNING_IDENTITY_KEY]:e},image:{[SIGNING_DIGEST_KEY]:t},type:PAYLOAD_TYPE},optional:i&&Object.keys(i).length?i:null})}const sigTag=e=>`${e.replace(":","-")}.sig`,sha256=e=>"sha256:"+crypto.createHash("sha256").update(e).digest("hex");async function existingSignatures(e,t){try{return(await e.manifest(sigTag(t))).body}catch(e){if(404===e.statusCode)return null;throw e}}async function resolveDigest(e,t){return t.digest?t.digest:(await e.manifest(t.tag||"latest")).digest}async function signImage(e,t,{key:i,passphrase:r="",annotations:s=null}={}){const{parseReference:n}=require("./reference"),{RegistryClient:a,pushArtifact:o,MEDIA:c}=require("./registry"),y=n(t),p=privateKey(i,r),g=e._credentials(y.domain),l=new a(y,{credentials:g,actions:["pull","push"]}),f=await resolveDigest(l,y),u=identity(y),d=Buffer.from(payload(u,f,s)),I=crypto.sign("sha256",d,p).toString("base64"),h=await existingSignatures(l,f),T=(h?.layers||[]).filter(e=>e.mediaType===PAYLOAD_MEDIA).map(e=>({...e,file:null})),E=path.join(os.tmpdir(),`tnc-sig-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);fs.writeFileSync(E,d);try{const e=sha256(d);T.some(t=>t.digest===e&&t.annotations?.[SIG_ANNOTATION]===I)||T.push({mediaType:PAYLOAD_MEDIA,digest:e,size:d.length,file:E,annotations:{[SIG_ANNOTATION]:I}});const t={architecture:"",created:ZERO_TIME,history:T.map(()=>({created:ZERO_TIME})),os:"",rootfs:{type:"layers",diff_ids:T.map(e=>e.digest)},config:{}};await o(y,{config:{mediaType:c.OCI_CONFIG,raw:JSON.stringify(t)},layers:T,credentials:g,tag:sigTag(f)})}finally{fs.rmSync(E,{force:!0})}return{digest:f,identity:u,tag:sigTag(f),signatures:T.length}}async function verifyImage(e,t,{key:i,digest:r=null}={}){const{parseReference:s}=require("./reference"),{RegistryClient:n}=require("./registry"),a=s(t),o=publicKey(i),c=new n(a,{credentials:e._credentials(a.domain)}),y=r||await resolveDigest(c,a),p=await existingSignatures(c,y),g=[];for(const e of p?.layers||[]){const t=e.annotations?.[SIG_ANNOTATION];if(e.mediaType!==PAYLOAD_MEDIA||!t||e.size>1<<20)continue;const i=path.join(os.tmpdir(),`tnc-sig-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);let r;try{await c.blob(e.digest,i,e.size),r=fs.readFileSync(i)}finally{fs.rmSync(i,{force:!0})}let s,n=!1;try{n=crypto.verify("sha256",r,o,Buffer.from(t,"base64"))}catch(e){}if(n){try{s=JSON.parse(r.toString("utf8"))}catch(e){continue}s?.critical?.image?.[SIGNING_DIGEST_KEY]===y&&s.critical.type===PAYLOAD_TYPE&&g.push({identity:s.critical.identity?.[SIGNING_IDENTITY_KEY]||"",digest:y,annotations:s.optional||null})}}return{digest:y,verified:g,identity:identity(a)}}function scopeMatches(e,t){const i=e.replace(/\/+$/,"");return"*"===i||i===t||new RegExp("^"+i.split("*").map(e=>e.replace(/[.+?^${}()|[\]\\]/g,"\\$&")).join(".*")+"$").test(t)}function policyFor(e,t=require("../config").effective().trustPolicy||[]){const i=identity(e),r=`${e.domain}/${e.path}`;return(Array.isArray(t)?t:[]).filter(e=>e&&e.scope&&e.key&&(scopeMatches(e.scope,i)||scopeMatches(e.scope,r)))}async function enforcePolicy(e,t,i){const r=policyFor(t);for(const s of r){let r;try{r=fs.readFileSync(s.key,"utf8")}catch(e){throw signError(500,`trust policy for ${s.scope}: cannot read ${s.key}`)}const n=await verifyImage(e,t.familiar,{key:r,digest:i}).catch(e=>({verified:[],error:e}));if(!n.verified.length)throw signError(403,`${t.familiar}@${i} is not signed by ${path.basename(s.key)}, which the trust policy for ${s.scope} requires`+(n.error?` (${n.error.message})`:""))}return r.length}module.exports={generateKeyPair,privateKey,publicKey,identity,payload,sigTag,signImage,verifyImage,scopeMatches,policyFor,enforcePolicy,PAYLOAD_MEDIA,SIG_ANNOTATION};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const fs=require("fs"),os=require("os"),net=require("net"),path=require("path"),{spawn:cpSpawn}=require("child_process"),{PassThrough}=require("stream"),{EventEmitter}=require("events"),FRAME={STDIN:0,STDOUT:1,STDERR:2,EXIT:3,SIGNAL:4},SIGNALS={SIGHUP:1,SIGINT:2,SIGQUIT:3,SIGKILL:9,SIGUSR1:10,SIGUSR2:12,SIGTERM:15};function collect(t){return new Promise(s=>{const e=[],n=[];t.stdout.on("data",t=>e.push(t)),t.stderr.on("data",t=>n.push(t)),t.exited.then(t=>s({code:t,stdout:Buffer.concat(e).toString("utf8"),stderr:Buffer.concat(n).toString("utf8")}))})}class LinuxHostBase{async run(t,s={}){const e=this.spawn(t,s),n=collect(e);return void 0!==s.input&&e.write(s.input),e.end(),n}async check(t,s={}){const e=await this.run(t,s);if(0!==e.code){const s="/bin/sh"===t[0]?"Linux step":`${t[0]} ${t.slice(1,3).join(" ")}`,n=new Error(`${s} failed (exit ${e.code}): ${(e.stderr||e.stdout).trim().slice(0,500)||"no output"}`);throw n.result=e,n}return e}sh(t,s=[],e={}){return this.check(["/bin/sh","-c",t,"dock",...s.map(String)],e)}}class LocalLinuxHost extends LinuxHostBase{constructor(){super(),this.kind="local"}async ready(){if(process.getuid&&0!==process.getuid())throw new Error("dock on Linux must run as root (it manages namespaces, mounts and iptables)")}guestPath(t){return t}async isUp(){return!0}binaries(){const t="arm64"===process.arch?"aarch64":"x86_64",s=path.join(os.homedir(),".tnc","bin",`crun-linux-${t}`),e=process.env.TNC_BUSYBOX||path.join(os.homedir(),".tnc","bin",`tnc-busybox-linux-${t}`);return{crun:process.env.TNC_CRUN||(fs.existsSync(s)?s:"crun"),shim:process.env.TNC_SHIM||path.join(os.homedir(),".tnc","bin",`tnc-shim-linux-${t}`),busybox:fs.existsSync(e)?e:null}}spawn(t,{env:s,cwd:e}={}){const n=cpSpawn(t[0],t.slice(1),{cwd:e||"/",env:s?Object.fromEntries(s.map(t=>{const s=t.indexOf("=");return[t.slice(0,s),t.slice(s+1)]})):process.env,stdio:["pipe","pipe","pipe"]}),r=new Promise(t=>{n.on("error",()=>t(127)),n.on("close",(s,e)=>t(s??128+(os.constants.signals[e]||0)))});return n.stdin.on("error",()=>{}),{stdout:n.stdout,stderr:n.stderr,write:t=>n.stdin.write(t),end:()=>n.stdin.end(),kill:(t="SIGTERM")=>{try{n.kill(t)}catch(t){}},exited:r}}connect(t,s){return net.createConnection({host:t,port:s})}}const TNC_DIR=path.join(os.homedir(),".tnc"),VM_SOCK=path.join(TNC_DIR,"vmctl.sock"),TNC_VZ=path.join(TNC_DIR,"bin","tnc-vz"),VZ_CTL=path.join(TNC_DIR,"vzctl.sock"),DATA_DISK=path.join(TNC_DIR,"dock","vm-disk.img");function vmRequest(t,s=5e3){return new Promise((e,n)=>{const r=net.createConnection(VM_SOCK);let o=Buffer.alloc(0);const i=setTimeout(()=>{r.destroy(),n(new Error(`MicroVM did not answer "${t.cmd}"`))},s),a=t=>{o=Buffer.concat([o,t]);const s=o.indexOf(10);if(-1===s)return;let c;clearTimeout(i),r.removeListener("data",a),r.pause();try{c=JSON.parse(o.subarray(0,s).toString("utf8"))}catch(t){return r.destroy(),n(new Error(`bad MicroVM reply: ${o.subarray(0,s)}`))}e({sock:r,reply:c,rest:o.subarray(s+1)})};r.on("connect",()=>r.write(JSON.stringify(t)+"\n")),r.on("data",a),r.on("error",t=>{clearTimeout(i),n(new Error(`MicroVM unreachable: ${t.message}`))})})}const b64argv=t=>Buffer.from(t.map(t=>t+"\0").join(""),"utf8").toString("base64");function vmShares(t=[]){return[{host:"/private/tmp",also:["/tmp"],guest:"/tmp_host"},{host:"/private/var/folders",also:["/var/folders"],guest:"/mnt/tnc/varfolders",tag:"tnc_varfolders"},{host:"/Volumes",also:[],guest:"/mnt/tnc/volumes",tag:"tnc_volumes"},...t.map((t,s)=>({host:t,also:[],guest:`/mnt/tnc/share-${s}`,tag:`tnc_share_${s}`}))]}class VmLinuxHost extends LinuxHostBase{constructor({log:t=()=>{},memMB:s,cpus:e}={}){super();const n=require("../config").effective();this.kind="vm",this.log=t,this.memMB=s||n.memoryMB,this.cpus=e||n.cpus,this.diskGB=n.diskGB,this.home=os.homedir(),this.shares=vmShares(n.shares),this.unmounted=new Set,this._sharesReady=!1,this._booting=null}guestPath(t){const s=path.resolve(t);if(s===this.home)return"/workspace";if(s.startsWith(this.home+path.sep))return"/workspace"+s.slice(this.home.length).split(path.sep).join("/");for(const t of this.shares)for(const e of[t.host,...t.also])if(s===e||s.startsWith(e+path.sep)){if(t.tag&&this.unmounted.has(t.tag))break;return t.guest+s.slice(e.length).split(path.sep).join("/")}const e=[this.home,...this.shares.filter(t=>!this.unmounted.has(t.tag)).map(t=>t.host)].join(", "),n=new Error(`${s} is not in a folder shared with the MicroVM (${e}); add one with: thinknsh dock config --share /path`);throw n.statusCode=400,n}async _mountShares(){if(this._sharesReady)return;let t=this.shares.filter(t=>t.tag);for(let s=0;s<15&&t.length;s++){s&&await new Promise(t=>setTimeout(t,200));const e=await this.run(["/bin/sh","-c",'while [ $# -gt 1 ]; do mkdir -p "$2"; mountpoint -q "$2" || mount -t virtiofs "$1" "$2" 2>/dev/null || echo "$1"; shift 2; done',"dock",...t.flatMap(t=>[t.tag,t.guest])]),n=new Set(e.stdout.split("\n").map(t=>t.trim()).filter(Boolean));t=t.filter(t=>n.has(t.tag))}this.unmounted=new Set(t.map(t=>t.tag)),this.unmounted.size&&this.log(`shared folders not available in this MicroVM: ${[...this.unmounted].join(", ")} (an older tnc-vz? run: thinknsh dock setup)`),this._sharesReady=!0}binaries(){return{crun:"/usr/bin/crun",shim:"/usr/bin/tnc-shim",busybox:"/bin/busybox.static"}}balloon(t){return new Promise(s=>{const e=net.createConnection(VZ_CTL,()=>e.write(t?`balloon ${Math.round(t)}\n`:"target\n"));let n="";e.on("data",t=>{n+=t}),e.on("end",()=>{const t=/^ok (\d+)/.exec(n);s(t?Number(t[1]):null)}),e.on("error",()=>s(null)),e.setTimeout(3e3,()=>{e.destroy(),s(null)})})}async stop(){this._sharesReady=!1,await new Promise(t=>{const s=cpSpawn(TNC_VZ,["stop"],{stdio:"ignore"});s.on("close",t),s.on("error",t)})}async _ping(){try{const{sock:t,reply:s}=await vmRequest({cmd:"ping"},1500);return t.destroy(),s}catch(t){return null}}async isUp(){const t=await this._ping();return!(!t||1!==t.dock)}async ready(){const t=await this._ping();if(t&&1===t.dock)return this._mountShares();if(t&&!t.dock)throw new Error("The running MicroVM has no dock runtime. Run: thinknsh dock setup");return this._sharesReady=!1,this._booting||(this._booting=this._boot().finally(()=>{this._booting=null})),await this._booting,this._mountShares()}async _boot(){if(!fs.existsSync(TNC_VZ))throw new Error("The dock runtime is not installed. Run: thinknsh dock setup");this.log(`booting MicroVM (${this.cpus} CPUs, ${this.memMB} MB)`);const t=fs.openSync(path.join(TNC_DIR,"tnc-vz.log"),"a");fs.mkdirSync(path.dirname(DATA_DISK),{recursive:!0}),cpSpawn(TNC_VZ,["start","--cpus",String(this.cpus),"--mem",String(this.memMB),"--workspace",this.home,"--disk",DATA_DISK,"--disk-size",String(this.diskGB||64),"--shares",this.shares.filter(t=>t.tag).map(t=>`${t.tag}=${t.host}`).join(",")],{detached:!0,stdio:["ignore",t,t]}).unref(),fs.closeSync(t);const s=Date.now()+3e4;for(;Date.now()<s;){const t=await this._ping();if(t){if(!t.dock)throw new Error("MicroVM booted without the dock runtime. Run: thinknsh dock setup");return}await new Promise(t=>setTimeout(t,200))}throw new Error("MicroVM did not become ready within 30s (see ~/.tnc/tnc-vz.log)")}spawn(t,{env:s,cwd:e}={}){const n=new PassThrough,r=new PassThrough,o=new EventEmitter;let i=null,a=null;const c=[],u=(t,s=Buffer.alloc(0))=>{const e=Buffer.alloc(5);e[0]=t,e.writeUInt32BE(s.length,1);const n=Buffer.concat([e,s]);i?i.write(n):c.push(n)},h=new Promise(t=>o.once("exit",t)),d=t=>{null===a&&(a=t,n.end(),r.end(),o.emit("exit",t))},l={cmd:"spawn",argv_b64:b64argv(t),cwd:e||"/"};return s&&(l.env_b64=b64argv(s)),vmRequest(l,1e4).then(({sock:t,reply:s,rest:e})=>{if(!s.ok)return r.write(`${s.error}\n`),t.destroy(),d(127);i=t;for(const t of c.splice(0))i.write(t);let o=e;const u=()=>{for(;o.length>=5;){const t=o.readUInt32BE(1);if(o.length<5+t)return;const s=o[0],e=o.subarray(5,5+t);o=o.subarray(5+t),s===FRAME.STDOUT?n.write(e):s===FRAME.STDERR?r.write(e):s===FRAME.EXIT&&(d(e.readInt32BE(0)),i.end())}};i.on("data",t=>{o=Buffer.concat([o,t]),u()}),i.on("close",()=>d(a??255)),i.on("error",()=>{}),i.resume(),u()}).catch(t=>{r.write(`${t.message}\n`),d(127)}),{stdout:n,stderr:r,write:t=>u(FRAME.STDIN,Buffer.isBuffer(t)?t:Buffer.from(String(t))),end:()=>u(FRAME.STDIN),kill:(t="SIGTERM")=>u(FRAME.SIGNAL,Buffer.from([SIGNALS[t]||15])),exited:h}}connect(t,s){const e=new PassThrough,n=new PassThrough,r=require("stream").Duplex.from({writable:e,readable:n});return vmRequest({cmd:"connect",host:t,port:s},5e3).then(({sock:t,reply:s,rest:o})=>{if(!s.ok)return t.destroy(),void r.destroy(new Error(s.error));o.length&&n.write(o),r.emit("ready"),t.pipe(n),e.pipe(t),t.on("error",t=>r.destroy(t)),r.on("close",()=>t.destroy()),t.resume()}).catch(t=>r.destroy(t)),r}}function windowsToWsl(t){const s=/^([A-Za-z]):[\\/]?(.*)$/.exec(t);if(!s){const s=new Error(`${t} is not a local Windows drive path; dock can only share drive paths (C:\\...) with WSL`);throw s.statusCode=400,s}const e=s[2].split(/[\\/]+/).filter(Boolean).join("/");return`/mnt/${s[1].toLowerCase()}${e?"/"+e:""}`}class WslLinuxHost extends LinuxHostBase{constructor({log:t=()=>{},distro:s=process.env.TNC_WSL_DISTRO||"tnc-dock",wsl:e=process.env.TNC_WSL_EXE||"wsl.exe"}={}){super(),this.kind="wsl",this.log=t,this.distro=s,this.wslCmd=Array.isArray(e)?e:[e],this._readyAt=0}guestPath(t){return windowsToWsl(path.win32.resolve(t))}binaries(){return{crun:"/usr/bin/crun",shim:"/usr/bin/tnc-shim",busybox:"/bin/busybox.static"}}_wslArgs(t,{env:s,cwd:e}={}){return["-d",this.distro,"-u","root","--cd",e||"/","--exec",...s?["/usr/bin/env","-i",...s]:[],...t]}_list(t){return new Promise(s=>{const e=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),"--list","--quiet",...t?["--running"]:[]],{windowsHide:!0}),n=[];e.stdout.on("data",t=>n.push(t)),e.on("error",()=>s(null)),e.on("close",e=>{if(0!==e)return s(t?[]:null);const r=Buffer.concat(n),o=r.includes(0)?r.toString("utf16le"):r.toString("utf8");s(o.split(/\r?\n/).map(t=>t.replace(/\0/g,"").trim()).filter(Boolean))})})}async isUp(){const t=await this._list(!0);return!!t&&t.includes(this.distro)}async ready(){if(Date.now()-this._readyAt<5e3)return;const t=await this._list(!1);if(null===t)throw new Error("WSL is not installed. Run in an admin PowerShell: wsl --install --no-distribution, then: thinknsh dock setup");if(!t.includes(this.distro))throw new Error(`The WSL distro "${this.distro}" is missing. Run: thinknsh dock setup`);const s=await this.run(["/usr/local/bin/tnc-wsl-init"]);if(0!==s.code)throw new Error(`could not start WSL distro ${this.distro}: ${s.stderr||s.stdout}`);this._readyAt=Date.now()}async stop(){this._readyAt=0,await new Promise(t=>{const s=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),"--terminate",this.distro],{stdio:"ignore",windowsHide:!0});s.on("close",t),s.on("error",t)})}spawn(t,s={}){const e=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),...this._wslArgs(t,s)],{stdio:["pipe","pipe","pipe"],windowsHide:!0}),n=new Promise(t=>{e.on("error",()=>t(127)),e.on("close",s=>t(s??255))});return e.stdin.on("error",()=>{}),{stdout:e.stdout,stderr:e.stderr,write:t=>e.stdin.write(t),end:()=>e.stdin.end(),kill:(t="SIGTERM")=>{try{e.kill(t)}catch(t){}},exited:n}}connect(t,s){const e=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),...this._wslArgs(["/usr/bin/tnc-shim","connect",t,String(s)])],{stdio:["pipe","pipe","ignore"],windowsHide:!0}),n=require("stream").Duplex.from({writable:e.stdin,readable:e.stdout});return e.on("error",t=>n.destroy(t)),e.on("close",e=>{e&&n.destroy(Object.assign(new Error(`connect ${t}:${s} failed`),{code:"ECONNREFUSED"}))}),n.on("close",()=>{try{e.kill()}catch(t){}}),n}async probe(t,s){return 0===(await this.run(["/usr/bin/tnc-shim","probe",t,String(s)])).code}}function createLinuxHost(t={}){if("linux"===process.platform)return new LocalLinuxHost(t);if("darwin"===process.platform)return new VmLinuxHost(t);if("win32"===process.platform)return new WslLinuxHost(t);throw new Error(`dock does not support ${process.platform}`)}module.exports={createLinuxHost,LocalLinuxHost,VmLinuxHost,WslLinuxHost,windowsToWsl,vmRequest,vmShares};
7
+ "use strict";const fs=require("fs"),os=require("os"),net=require("net"),path=require("path"),{spawn:cpSpawn}=require("child_process"),{PassThrough}=require("stream"),{EventEmitter}=require("events"),FRAME={STDIN:0,STDOUT:1,STDERR:2,EXIT:3,SIGNAL:4},SIGNALS={SIGHUP:1,SIGINT:2,SIGQUIT:3,SIGKILL:9,SIGUSR1:10,SIGUSR2:12,SIGTERM:15};function collect(t){return new Promise(e=>{const s=[],r=[];t.stdout.on("data",t=>s.push(t)),t.stderr.on("data",t=>r.push(t)),t.exited.then(t=>e({code:t,stdout:Buffer.concat(s).toString("utf8"),stderr:Buffer.concat(r).toString("utf8")}))})}class LinuxHostBase{async run(t,e={}){const s=this.spawn(t,e),r=collect(s);return void 0!==e.input&&s.write(e.input),s.end(),r}async check(t,e={}){const s=await this.run(t,e);if(0!==s.code){const e="/bin/sh"===t[0]?"Linux step":`${t[0]} ${t.slice(1,3).join(" ")}`,r=new Error(`${e} failed (exit ${s.code}): ${(s.stderr||s.stdout).trim().slice(0,500)||"no output"}`);throw r.result=s,r}return s}sh(t,e=[],s={}){return this.check(["/bin/sh","-c",t,"dock",...e.map(String)],s)}}class LocalLinuxHost extends LinuxHostBase{constructor(){super(),this.kind="local"}async ready(){if(process.getuid&&0!==process.getuid())throw new Error("dock on Linux must run as root (it manages namespaces, mounts and iptables)")}guestPath(t){return t}async isUp(){return!0}binaries(){const t="arm64"===process.arch?"aarch64":"x86_64",e=path.join(os.homedir(),".tnc","bin",`crun-linux-${t}`),s=process.env.TNC_BUSYBOX||path.join(os.homedir(),".tnc","bin",`tnc-busybox-linux-${t}`);return{crun:process.env.TNC_CRUN||(fs.existsSync(e)?e:"crun"),shim:process.env.TNC_SHIM||path.join(os.homedir(),".tnc","bin",`tnc-shim-linux-${t}`),busybox:fs.existsSync(s)?s:null}}spawn(t,{env:e,cwd:s}={}){const r=cpSpawn(t[0],t.slice(1),{cwd:s||"/",env:e?Object.fromEntries(e.map(t=>{const e=t.indexOf("=");return[t.slice(0,e),t.slice(e+1)]})):process.env,stdio:["pipe","pipe","pipe"]}),n=new Promise(t=>{r.on("error",()=>t(127)),r.on("close",(e,s)=>t(e??128+(os.constants.signals[s]||0)))});return r.stdin.on("error",()=>{}),{stdout:r.stdout,stderr:r.stderr,write:t=>r.stdin.write(t),end:()=>r.stdin.end(),kill:(t="SIGTERM")=>{try{r.kill(t)}catch(t){}},exited:n}}connect(t,e){return net.createConnection({host:t,port:e})}}const ROOTLESS_SETUP='\nset -e\ndata="$1"\nmkdir -p "$data/lib" "$data/save"\n# the host\'s resolver files live under /run, which is replaced below: keep copies\n[ -r /run/systemd/resolve/resolv.conf ] && cp /run/systemd/resolve/resolv.conf "$data/save/resolved.conf" || true\nreal=$(readlink -f /etc/resolv.conf 2>/dev/null || echo /etc/resolv.conf)\n[ -r "$real" ] && cp "$real" "$data/save/resolv.conf" || true\nmount -t tmpfs -o mode=755 tnc-run /run\nmkdir -p /run/netns /run/tnc-dock /run/crun /run/tnc-xdg /run/systemd/resolve\n[ -f "$data/save/resolved.conf" ] && cp "$data/save/resolved.conf" /run/systemd/resolve/resolv.conf || true\ncase "$real" in /run/*) mkdir -p "$(dirname "$real")"; cp "$data/save/resolv.conf" "$real" 2>/dev/null || true;; esac\nmount -t tmpfs -o mode=755 tnc-lib /var/lib\nmkdir -p /var/lib/tnc-dock\nmount --bind "$data/lib" /var/lib/tnc-dock\necho tnc-rootless-ready\nexec sleep 2147483647\n',ROOTLESS_MARK="tnc-rootless-ready",ROOTLESS_DNS="10.0.2.3",which=t=>(process.env.PATH||"").split(path.delimiter).map(e=>path.join(e,t)).find(t=>{try{return fs.accessSync(t,fs.constants.X_OK),!0}catch(t){return!1}})||null,pidAlive=t=>{try{return process.kill(t,0),!0}catch(t){return"EPERM"===t.code}};function rootlessProblems({user:t=os.userInfo().username,read:e=t=>fs.readFileSync(t,"utf8"),has:s=which}={}){const r=[],[n,o]=os.release().split(".").map(Number);(n<5||5===n&&o<11)&&r.push(`Linux ${os.release()} is too old: rootless mode needs 5.11 or newer (overlay mounts in user namespaces)`);for(const t of["unshare","nsenter","newuidmap","newgidmap","iptables","ip"])s(t)||r.push(`${t} is missing: sudo apt-get install -y uidmap util-linux iproute2 iptables (Fedora: sudo dnf install shadow-utils util-linux iproute iptables)`);s("pasta")||s("slirp4netns")||r.push("no user-mode network: sudo apt-get install -y passt (or slirp4netns)");const i=s=>{try{return e(s).split("\n").some(e=>e.split(":")[0]===t||e.split(":")[0]===String(process.getuid()))}catch(t){return!1}};i("/etc/subuid")&&i("/etc/subgid")||r.push(`no subordinate ids for ${t}: sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 ${t}`);try{"1"===e("/proc/sys/kernel/apparmor_restrict_unprivileged_userns").trim()&&r.push("AppArmor blocks user namespaces (Ubuntu 23.10+): sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 (keep it: add the line to /etc/sysctl.d/60-thinknsh.conf)")}catch(t){}try{"0"===e("/proc/sys/kernel/unprivileged_userns_clone").trim()&&r.push("user namespaces are off: sudo sysctl -w kernel.unprivileged_userns_clone=1")}catch(t){}try{0===Number(e("/proc/sys/user/max_user_namespaces").trim())&&r.push("user namespaces are off: sudo sysctl -w user.max_user_namespaces=28633")}catch(t){}return[...new Set(r)]}class RootlessLinuxHost extends LocalLinuxHost{constructor({log:t=()=>{},home:e=process.env.TNC_DOCK_HOME||path.join(os.homedir(),".tnc","dock")}={}){super(),this.kind="rootless",this.log=t,this.dir=path.join(e,"rootless"),this.pidFile=path.join(this.dir,"holder.pid"),this.netPidFile=path.join(this.dir,"net.pid"),this.pid=null,this.gateway=null,this._starting=null,this.cgroupBase=null,this.cgroupControllers=[]}cgroupManager(){return this.cgroupBase?"cgroupfs":"disabled"}cgroupParent(){return this.cgroupBase||void 0}resolvConf(){return"nameserver 10.0.2.3\n"}warnings(){if(!this.cgroupBase)return["rootless mode without a delegated cgroup: memory, CPU and process limits (-m, --cpus, --pids-limit) are not enforced and pause is not available; start dock from a systemd user session (thinknsh dock autostart on) to get them"];const t=["memory","cpu","pids","io"].filter(t=>!this.cgroupControllers.includes(t));return t.length?[`rootless mode: the ${t.join(", ")} controller${t.length>1?"s are":" is"} not delegated to this user, so those limits are not enforced (sudo systemctl edit user@.service: [Service] Delegate=cpu cpuset io memory pids)`]:[]}_setupCgroups(t){this.cgroupBase=null,this.cgroupControllers=[];const e="/sys/fs/cgroup",s=t=>{try{return(fs.readFileSync(`/proc/${t}/cgroup`,"utf8").split("\n").find(t=>t.startsWith("0::"))||"").slice(3).trim()||null}catch(t){return null}},r=t=>{try{return fs.accessSync(t,fs.constants.W_OK),!0}catch(t){return!1}};let n=null;if(t){if(n=s(process.pid),!n||"/"===n||!r(`${e}${n}`)||!r(`${e}${n}/cgroup.procs`))return;try{for(const t of["daemon","holder","containers"])fs.mkdirSync(`${e}${n}/${t}`,{recursive:!0});const t=fs.readFileSync(`${e}${n}/cgroup.procs`,"utf8").split("\n").filter(Boolean);for(const s of t)try{fs.writeFileSync(`${e}${n}/${Number(s)===this.pid?"holder":"daemon"}/cgroup.procs`,s)}catch(t){}s(this.pid)!==`${n}/holder`&&fs.writeFileSync(`${e}${n}/holder/cgroup.procs`,String(this.pid))}catch(t){return void this.log?.(`rootless cgroups: ${t.message}`)}}else{const t=s(this.pid);if(!t||!t.endsWith("/holder"))return;if(n=t.slice(0,-7),!r(`${e}${n}/containers`))return}let o=[];try{o=fs.readFileSync(`${e}${n}/cgroup.controllers`,"utf8").trim().split(/\s+/).filter(Boolean)}catch(t){return}for(const t of[`${e}${n}`,`${e}${n}/containers`])for(const e of["cpu","cpuset","io","memory","pids"])if(o.includes(e))try{fs.writeFileSync(`${t}/cgroup.subtree_control`,`+${e}`)}catch(t){}try{this.cgroupControllers=fs.readFileSync(`${e}${n}/containers/cgroup.controllers`,"utf8").trim().split(/\s+/).filter(Boolean)}catch(t){}this.cgroupBase=`${n}/containers`}_holderPid(){let t;try{t=Number(fs.readFileSync(this.pidFile,"utf8").trim())}catch(t){return null}if(!t||!pidAlive(t))return null;try{if(!fs.readFileSync(`/proc/${t}/cmdline`,"utf8").includes("2147483647"))return null;if(fs.readlinkSync(`/proc/${t}/ns/user`)===fs.readlinkSync("/proc/self/ns/user"))return null}catch(t){return null}return t}async isUp(){return!!this._holderPid()}ready(){return this.pid&&this._holderPid()===this.pid?Promise.resolve():(this._starting||(this._starting=this._start().finally(()=>{this._starting=null})),this._starting)}async _start(){const t=this._holderPid();if(t)this.pid=t,this._setupCgroups(!1);else{const t=rootlessProblems();if(t.length)throw new Error(`rootless mode is not ready on this machine:\n - ${t.join("\n - ")}`);fs.mkdirSync(this.dir,{recursive:!0,mode:448}),this.pid=await this._startHolder(),this._setupCgroups(!0),await this._startNetwork()}const e=await this.run(["/bin/sh","-c","ip -4 route show default | awk '{print $3; exit}'"]);if(this.gateway=e.stdout.trim()||null,!this.gateway)throw new Error("rootless network is not up (no default route in the namespace); see "+path.join(this.dir,"net.log"))}_startHolder(){return new Promise((t,e)=>{const s=fs.openSync(path.join(this.dir,"holder.log"),"a"),r=cpSpawn("unshare",["--user","--map-root-user","--map-auto","--mount","--net","--propagation","private","--","/bin/sh","-c",ROOTLESS_SETUP,"tnc-rootless",this.dir],{stdio:["ignore","pipe",s],detached:!0});fs.closeSync(s);let n="";const o=setTimeout(()=>{r.kill("SIGKILL"),e(new Error(`the rootless namespace did not start; see ${path.join(this.dir,"holder.log")}`))},15e3);r.stdout.on("data",e=>{n+=e,n.includes(ROOTLESS_MARK)&&(clearTimeout(o),r.stdout.destroy(),r.unref(),fs.writeFileSync(this.pidFile,String(r.pid)),t(r.pid))}),r.on("error",t=>{clearTimeout(o),e(t)}),r.on("exit",t=>{clearTimeout(o),e(new Error(`the rootless namespace exited (code ${t}); see ${path.join(this.dir,"holder.log")}`))})})}async _startNetwork(){const t=fs.openSync(path.join(this.dir,"net.log"),"a"),e=which("pasta"),s=e?[e,["--config-net","--quiet","--foreground","-a","10.0.2.100","-n","24","-g","10.0.2.2","--dns-forward","10.0.2.3","-t","none","-u","none","-T","none","-U","none",String(this.pid)]]:[which("slirp4netns"),["--configure","--mtu=65520",String(this.pid),"tap0"]],r=cpSpawn(s[0],s[1],{stdio:["ignore",t,t],detached:!0});fs.closeSync(t),r.unref(),fs.writeFileSync(this.netPidFile,String(r.pid));for(let t=0;t<50;t++){if((await this.run(["/bin/sh","-c","ip -4 route show default"])).stdout.trim())return;if(null!==r.exitCode)break;await new Promise(t=>setTimeout(t,100))}throw new Error(`${path.basename(s[0])} did not bring up the rootless network; see ${path.join(this.dir,"net.log")}`)}_ns(t){if(this.pid||(this.pid=this._holderPid()),!this.pid)throw new Error("the rootless namespace is not running");return["nsenter","--target",String(this.pid),"--user","--mount","--net","--",...t]}spawn(t,e={}){const s=e.env||Object.entries(process.env).map(([t,e])=>`${t}=${e}`);return super.spawn(this._ns(t),{...e,env:[...s,"XDG_RUNTIME_DIR=/run/tnc-xdg","TNC_ROOTLESS=1"]})}connect(t,e){const[s,...r]=this._ns([this.binaries().shim,"connect",t,String(e)]),n=cpSpawn(s,r,{stdio:["pipe","pipe","ignore"]}),o=require("stream").Duplex.from({writable:n.stdin,readable:n.stdout});return n.on("error",t=>o.destroy(t)),n.on("close",s=>{s&&o.destroy(Object.assign(new Error(`connect ${t}:${e} failed`),{code:"ECONNREFUSED"}))}),o.on("close",()=>{try{n.kill()}catch(t){}}),o}stop(){for(const t of[this.netPidFile,this.pidFile]){try{const e=Number(fs.readFileSync(t,"utf8"));e&&process.kill(e,"SIGKILL")}catch(t){}fs.rmSync(t,{force:!0})}this.pid=null}}const{rootlessMode}=require("../paths"),TNC_DIR=path.join(os.homedir(),".tnc"),VM_SOCK=path.join(TNC_DIR,"vmctl.sock"),TNC_VZ=path.join(TNC_DIR,"bin","tnc-vz"),VZ_CTL=path.join(TNC_DIR,"vzctl.sock"),DATA_DISK=path.join(TNC_DIR,"dock","vm-disk.img");function vmRequest(t,e=5e3){return new Promise((s,r)=>{const n=net.createConnection(VM_SOCK);let o=Buffer.alloc(0);const i=setTimeout(()=>{n.destroy(),r(new Error(`MicroVM did not answer "${t.cmd}"`))},e),a=t=>{o=Buffer.concat([o,t]);const e=o.indexOf(10);if(-1===e)return;let c;clearTimeout(i),n.removeListener("data",a),n.pause();try{c=JSON.parse(o.subarray(0,e).toString("utf8"))}catch(t){return n.destroy(),r(new Error(`bad MicroVM reply: ${o.subarray(0,e)}`))}s({sock:n,reply:c,rest:o.subarray(e+1)})};n.on("connect",()=>n.write(JSON.stringify(t)+"\n")),n.on("data",a),n.on("error",t=>{clearTimeout(i),r(new Error(`MicroVM unreachable: ${t.message}`))})})}const b64argv=t=>Buffer.from(t.map(t=>t+"\0").join(""),"utf8").toString("base64");function vmShares(t=[]){return[{host:"/private/tmp",also:["/tmp"],guest:"/tmp_host"},{host:"/private/var/folders",also:["/var/folders"],guest:"/mnt/tnc/varfolders",tag:"tnc_varfolders"},{host:"/Volumes",also:[],guest:"/mnt/tnc/volumes",tag:"tnc_volumes"},...t.map((t,e)=>({host:t,also:[],guest:`/mnt/tnc/share-${e}`,tag:`tnc_share_${e}`}))]}class VmLinuxHost extends LinuxHostBase{constructor({log:t=()=>{},memMB:e,cpus:s}={}){super();const r=require("../config").effective();this.kind="vm",this.log=t,this.memMB=e||r.memoryMB,this.cpus=s||r.cpus,this.diskGB=r.diskGB,this.home=os.homedir(),this.shares=vmShares(r.shares),this.unmounted=new Set,this._sharesReady=!1,this._booting=null}guestPath(t){const e=path.resolve(t);if(e===this.home)return"/workspace";if(e.startsWith(this.home+path.sep))return"/workspace"+e.slice(this.home.length).split(path.sep).join("/");for(const t of this.shares)for(const s of[t.host,...t.also])if(e===s||e.startsWith(s+path.sep)){if(t.tag&&this.unmounted.has(t.tag))break;return t.guest+e.slice(s.length).split(path.sep).join("/")}const s=[this.home,...this.shares.filter(t=>!this.unmounted.has(t.tag)).map(t=>t.host)].join(", "),r=new Error(`${e} is not in a folder shared with the MicroVM (${s}); add one with: thinknsh dock config --share /path`);throw r.statusCode=400,r}async _mountShares(){if(this._sharesReady)return;let t=this.shares.filter(t=>t.tag);for(let e=0;e<15&&t.length;e++){e&&await new Promise(t=>setTimeout(t,200));const s=await this.run(["/bin/sh","-c",'while [ $# -gt 1 ]; do mkdir -p "$2"; mountpoint -q "$2" || mount -t virtiofs "$1" "$2" 2>/dev/null || echo "$1"; shift 2; done',"dock",...t.flatMap(t=>[t.tag,t.guest])]),r=new Set(s.stdout.split("\n").map(t=>t.trim()).filter(Boolean));t=t.filter(t=>r.has(t.tag))}this.unmounted=new Set(t.map(t=>t.tag)),this.unmounted.size&&this.log(`shared folders not available in this MicroVM: ${[...this.unmounted].join(", ")} (an older tnc-vz? run: thinknsh dock setup)`),this._sharesReady=!0}binaries(){return{crun:"/usr/bin/crun",shim:"/usr/bin/tnc-shim",busybox:"/bin/busybox.static"}}balloon(t){return new Promise(e=>{const s=net.createConnection(VZ_CTL,()=>s.write(t?`balloon ${Math.round(t)}\n`:"target\n"));let r="";s.on("data",t=>{r+=t}),s.on("end",()=>{const t=/^ok (\d+)/.exec(r);e(t?Number(t[1]):null)}),s.on("error",()=>e(null)),s.setTimeout(3e3,()=>{s.destroy(),e(null)})})}async stop(){this._sharesReady=!1,await new Promise(t=>{const e=cpSpawn(TNC_VZ,["stop"],{stdio:"ignore"});e.on("close",t),e.on("error",t)})}async _ping(){try{const{sock:t,reply:e}=await vmRequest({cmd:"ping"},1500);return t.destroy(),e}catch(t){return null}}async isUp(){const t=await this._ping();return!(!t||1!==t.dock)}async ready(){const t=await this._ping();if(t&&1===t.dock)return this._mountShares();if(t&&!t.dock)throw new Error("The running MicroVM has no dock runtime. Run: thinknsh dock setup");return this._sharesReady=!1,this._booting||(this._booting=this._boot().finally(()=>{this._booting=null})),await this._booting,this._mountShares()}async _boot(){if(!fs.existsSync(TNC_VZ))throw new Error("The dock runtime is not installed. Run: thinknsh dock setup");this.log(`booting MicroVM (${this.cpus} CPUs, ${this.memMB} MB)`);const t=fs.openSync(path.join(TNC_DIR,"tnc-vz.log"),"a");fs.mkdirSync(path.dirname(DATA_DISK),{recursive:!0}),cpSpawn(TNC_VZ,["start","--cpus",String(this.cpus),"--mem",String(this.memMB),"--workspace",this.home,"--disk",DATA_DISK,"--disk-size",String(this.diskGB||64),"--shares",this.shares.filter(t=>t.tag).map(t=>`${t.tag}=${t.host}`).join(",")],{detached:!0,stdio:["ignore",t,t]}).unref(),fs.closeSync(t);const e=Date.now()+3e4;for(;Date.now()<e;){const t=await this._ping();if(t){if(!t.dock)throw new Error("MicroVM booted without the dock runtime. Run: thinknsh dock setup");return}await new Promise(t=>setTimeout(t,200))}throw new Error("MicroVM did not become ready within 30s (see ~/.tnc/tnc-vz.log)")}spawn(t,{env:e,cwd:s}={}){const r=new PassThrough,n=new PassThrough,o=new EventEmitter;let i=null,a=null;const c=[],u=(t,e=Buffer.alloc(0))=>{const s=Buffer.alloc(5);s[0]=t,s.writeUInt32BE(e.length,1);const r=Buffer.concat([s,e]);i?i.write(r):c.push(r)},l=new Promise(t=>o.once("exit",t)),d=t=>{null===a&&(a=t,r.end(),n.end(),o.emit("exit",t))},h={cmd:"spawn",argv_b64:b64argv(t),cwd:s||"/"};return e&&(h.env_b64=b64argv(e)),vmRequest(h,1e4).then(({sock:t,reply:e,rest:s})=>{if(!e.ok)return n.write(`${e.error}\n`),t.destroy(),d(127);i=t;for(const t of c.splice(0))i.write(t);let o=s;const u=()=>{for(;o.length>=5;){const t=o.readUInt32BE(1);if(o.length<5+t)return;const e=o[0],s=o.subarray(5,5+t);o=o.subarray(5+t),e===FRAME.STDOUT?r.write(s):e===FRAME.STDERR?n.write(s):e===FRAME.EXIT&&(d(s.readInt32BE(0)),i.end())}};i.on("data",t=>{o=Buffer.concat([o,t]),u()}),i.on("close",()=>d(a??255)),i.on("error",()=>{}),i.resume(),u()}).catch(t=>{n.write(`${t.message}\n`),d(127)}),{stdout:r,stderr:n,write:t=>u(FRAME.STDIN,Buffer.isBuffer(t)?t:Buffer.from(String(t))),end:()=>u(FRAME.STDIN),kill:(t="SIGTERM")=>u(FRAME.SIGNAL,Buffer.from([SIGNALS[t]||15])),exited:l}}connect(t,e){const s=new PassThrough,r=new PassThrough,n=require("stream").Duplex.from({writable:s,readable:r});return vmRequest({cmd:"connect",host:t,port:e},5e3).then(({sock:t,reply:e,rest:o})=>{if(!e.ok)return t.destroy(),void n.destroy(new Error(e.error));o.length&&r.write(o),n.emit("ready"),t.pipe(r),s.pipe(t),t.on("error",t=>n.destroy(t)),n.on("close",()=>t.destroy()),t.resume()}).catch(t=>n.destroy(t)),n}}function windowsToWsl(t){const e=/^([A-Za-z]):[\\/]?(.*)$/.exec(t);if(!e){const e=new Error(`${t} is not a local Windows drive path; dock can only share drive paths (C:\\...) with WSL`);throw e.statusCode=400,e}const s=e[2].split(/[\\/]+/).filter(Boolean).join("/");return`/mnt/${e[1].toLowerCase()}${s?"/"+s:""}`}class WslLinuxHost extends LinuxHostBase{constructor({log:t=()=>{},distro:e=process.env.TNC_WSL_DISTRO||"tnc-dock",wsl:s=process.env.TNC_WSL_EXE||"wsl.exe"}={}){super(),this.kind="wsl",this.log=t,this.distro=e,this.wslCmd=Array.isArray(s)?s:[s],this._readyAt=0}guestPath(t){return windowsToWsl(path.win32.resolve(t))}binaries(){return{crun:"/usr/bin/crun",shim:"/usr/bin/tnc-shim",busybox:"/bin/busybox.static"}}_wslArgs(t,{env:e,cwd:s}={}){return["-d",this.distro,"-u","root","--cd",s||"/","--exec",...e?["/usr/bin/env","-i",...e]:[],...t]}_list(t){return new Promise(e=>{const s=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),"--list","--quiet",...t?["--running"]:[]],{windowsHide:!0}),r=[];s.stdout.on("data",t=>r.push(t)),s.on("error",()=>e(null)),s.on("close",s=>{if(0!==s)return e(t?[]:null);const n=Buffer.concat(r),o=n.includes(0)?n.toString("utf16le"):n.toString("utf8");e(o.split(/\r?\n/).map(t=>t.replace(/\0/g,"").trim()).filter(Boolean))})})}async isUp(){const t=await this._list(!0);return!!t&&t.includes(this.distro)}async ready(){if(Date.now()-this._readyAt<5e3)return;const t=await this._list(!1);if(null===t)throw new Error("WSL is not installed. Run in an admin PowerShell: wsl --install --no-distribution, then: thinknsh dock setup");if(!t.includes(this.distro))throw new Error(`The WSL distro "${this.distro}" is missing. Run: thinknsh dock setup`);const e=await this.run(["/usr/local/bin/tnc-wsl-init"]);if(0!==e.code)throw new Error(`could not start WSL distro ${this.distro}: ${e.stderr||e.stdout}`);this._readyAt=Date.now()}async stop(){this._readyAt=0,await new Promise(t=>{const e=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),"--terminate",this.distro],{stdio:"ignore",windowsHide:!0});e.on("close",t),e.on("error",t)})}spawn(t,e={}){const s=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),...this._wslArgs(t,e)],{stdio:["pipe","pipe","pipe"],windowsHide:!0}),r=new Promise(t=>{s.on("error",()=>t(127)),s.on("close",e=>t(e??255))});return s.stdin.on("error",()=>{}),{stdout:s.stdout,stderr:s.stderr,write:t=>s.stdin.write(t),end:()=>s.stdin.end(),kill:(t="SIGTERM")=>{try{s.kill(t)}catch(t){}},exited:r}}connect(t,e){const s=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),...this._wslArgs(["/usr/bin/tnc-shim","connect",t,String(e)])],{stdio:["pipe","pipe","ignore"],windowsHide:!0}),r=require("stream").Duplex.from({writable:s.stdin,readable:s.stdout});return s.on("error",t=>r.destroy(t)),s.on("close",s=>{s&&r.destroy(Object.assign(new Error(`connect ${t}:${e} failed`),{code:"ECONNREFUSED"}))}),r.on("close",()=>{try{s.kill()}catch(t){}}),r}async probe(t,e){return 0===(await this.run(["/usr/bin/tnc-shim","probe",t,String(e)])).code}}function createLinuxHost(t={}){if("linux"===process.platform)return rootlessMode()?new RootlessLinuxHost(t):new LocalLinuxHost(t);if("darwin"===process.platform)return new VmLinuxHost(t);if("win32"===process.platform)return new WslLinuxHost(t);throw new Error(`dock does not support ${process.platform}`)}module.exports={createLinuxHost,LocalLinuxHost,RootlessLinuxHost,VmLinuxHost,WslLinuxHost,windowsToWsl,vmRequest,vmShares,rootlessMode,rootlessProblems,ROOTLESS_SETUP};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const path=require("path"),os=require("os"),crypto=require("crypto");function dockHome(){return process.env.TNC_DOCK_HOME||path.join(os.homedir(),".tnc","dock")}function dockPaths(o=dockHome()){let t;return t="win32"===process.platform?"\\\\.\\pipe\\tnc-dock"+(o===path.join(os.homedir(),".tnc","dock")?"":"-"+crypto.createHash("sha1").update(o).digest("hex").slice(0,8)):path.join(o,"dock.sock"),{home:o,socket:t,db:path.join(o,"state.db"),pidFile:path.join(o,"dock.pid"),logFile:path.join(o,"dock.log"),auth:path.join(o,"auth.json"),blobs:path.join(o,"blobs","sha256"),containers:path.join(o,"containers"),volumes:path.join(o,"volumes")}}module.exports={dockHome,dockPaths};
7
+ "use strict";const path=require("path"),os=require("os"),crypto=require("crypto");function dockHome(){return process.env.TNC_DOCK_HOME||path.join(os.homedir(),".tnc","dock")}const SYSTEM_SOCKET="/run/thinknsh/dock.sock";function isDefaultHome(o){return path.resolve(o)===path.join(os.homedir(),".tnc","dock")}function rootlessMode(){return!("linux"!==process.platform||!process.getuid||0===process.getuid()||"1"!==process.env.TNC_DOCK_ROOTLESS&&("0"===process.env.TNC_DOCK_ROOTLESS||require("fs").existsSync(process.env.TNC_DOCK_SOCKET||SYSTEM_SOCKET)))}function dockPaths(o=dockHome()){let e;return e="linux"===process.platform&&isDefaultHome(o)&&!rootlessMode()?process.env.TNC_DOCK_SOCKET||SYSTEM_SOCKET:"win32"===process.platform?"\\\\.\\pipe\\tnc-dock"+(o===path.join(os.homedir(),".tnc","dock")?"":"-"+crypto.createHash("sha1").update(o).digest("hex").slice(0,8)):path.join(o,"dock.sock"),{home:o,socket:e,db:path.join(o,"state.db"),pidFile:path.join(o,"dock.pid"),logFile:path.join(o,"dock.log"),auth:path.join(o,"auth.json"),blobs:path.join(o,"blobs","sha256"),containers:path.join(o,"containers"),volumes:path.join(o,"volumes")}}module.exports={dockHome,dockPaths,isDefaultHome,rootlessMode,SYSTEM_SOCKET};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
+ * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
+ * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
+ * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
+ */
7
+ "use strict";const yaml=require("js-yaml"),SPEC_DIRS=["/etc/cdi","/var/run/cdi"],CDI_NAME=/^[a-z0-9][a-z0-9.-]*\/[a-zA-Z0-9][a-zA-Z0-9_.-]*=[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/,GPU_KIND="nvidia.com/gpu",isGpuRequest=e=>e&&("nvidia"===e.Driver||(e.Capabilities||[]).some(e=>[].concat(e).includes("gpu")));function requested(e={}){const n=[];for(const t of e.DeviceRequests||[])if("cdi"===t.Driver)for(const e of t.DeviceIDs||[]){if(!CDI_NAME.test(e))throw badRequest(`invalid CDI device name "${e}" (vendor.com/class=name)`);const[t,s]=e.split("=");n.push({kind:t,ids:[s]})}else isGpuRequest(t)&&((t.DeviceIDs||[]).length?n.push({kind:GPU_KIND,ids:t.DeviceIDs.map(String)}):n.push({kind:GPU_KIND,count:void 0===t.Count||t.Count<0?-1:Number(t.Count)}));const t=[];for(const s of e.Devices||[])if(CDI_NAME.test(s.PathOnHost||"")){const[e,t]=s.PathOnHost.split("=");n.push({kind:e,ids:[t]})}else t.push(s);return{requests:n,devices:t}}function badRequest(e){const n=new Error(e);return n.statusCode=400,n}function parseSpecs(e){const n=[];for(const t of String(e).split(/^=== /m).filter(Boolean)){const e=t.indexOf("\n"),s=t.slice(0,e).trim();try{const o=/\.json$/.test(s)?JSON.parse(t.slice(e+1)):yaml.load(t.slice(e+1));o&&o.kind&&Array.isArray(o.devices)&&n.push({...o,file:s})}catch(e){}}return n}const LIST_SCRIPT=`for d in ${SPEC_DIRS.join(" ")}; do for f in "$d"/*.yaml "$d"/*.yml "$d"/*.json; do [ -f "$f" ] && { echo "=== $f"; cat "$f"; echo; }; done; done; true`;function resolve(e,n){const t={env:[],deviceNodes:[],mounts:[],hooks:{}};if(!e.length)return t;const s=new Set,o=(e,n)=>{const o=`${e.kind}=${n.name}`;s.has(o)||(s.add(o),merge(t,n.containerEdits))},i=new Set;for(const s of e){const e=n.filter(e=>e.kind===s.kind);if(!e.length)throw badRequest(s.kind===GPU_KIND?"no GPUs are described for containers on this machine: install the NVIDIA Container Toolkit and run: sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml":`no CDI spec for ${s.kind} in ${SPEC_DIRS.join(" or ")}`);const a=e.flatMap(e=>e.devices.map(n=>({spec:e,dev:n})));let c;if(s.ids)c=s.ids.map(e=>{if("all"===e&&!a.some(e=>"all"===e.dev.name))return null;const n=a.find(n=>n.dev.name===e)||a.find(n=>String(n.dev.name)===`gpu${e}`);if(!n)throw badRequest(`unknown CDI device ${s.kind}=${e} (known: ${a.map(e=>e.dev.name).join(", ")})`);return n}),c.includes(null)&&(c=a);else{const e=a.filter(e=>"all"!==e.dev.name),n=a.find(e=>"all"===e.dev.name);if(-1===s.count)c=n?[n]:e;else{if(s.count>e.length)throw badRequest(`${s.count} GPUs requested, ${e.length} available`);c=e.slice(0,s.count)}}for(const{spec:e,dev:n}of c)i.has(e.file)||(i.add(e.file),merge(t,e.containerEdits)),o(e,n)}return t.env=[...new Map(t.env.map(e=>[e.split("=")[0],e])).values()],t}function merge(e,n={}){if(n){e.env.push(...n.env||[]);for(const t of n.deviceNodes||[])e.deviceNodes.some(e=>e.path===t.path)||e.deviceNodes.push(t);for(const t of n.mounts||[])e.mounts.some(e=>e.containerPath===t.containerPath)||e.mounts.push(t);for(const t of n.hooks||[])(e.hooks[t.hookName]||=[]).push({path:t.path,...t.args?{args:t.args}:{},...t.env?{env:t.env}:{},...t.timeout?{timeout:t.timeout}:{}})}}function ociMounts(e,n=e=>e){return e.mounts.map(e=>({destination:e.containerPath,type:e.type||"bind",source:n(e.hostPath),options:e.options||["rbind","ro","nosuid","nodev"]}))}module.exports={requested,parseSpecs,resolve,ociMounts,isGpuRequest,LIST_SCRIPT,CDI_NAME,GPU_KIND};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const path=require("path"),{ENV_MARKER}=require("../api/wire");function httpError(t,i){const n=new Error(i);return n.statusCode=t,n}const GO_MODE={dir:2**31,symlink:2**27,device:2**26,pipe:2**25,socket:2**24,setuid:2**23,setgid:2**22,char:2**21,sticky:2**20};function goFileMode(t){const i=parseInt(t,16);let n=511&i;const e=61440&i;return 16384===e?n+=GO_MODE.dir:40960===e?n+=GO_MODE.symlink:24576===e?n+=GO_MODE.device:8192===e?n+=GO_MODE.device+GO_MODE.char:4096===e?n+=GO_MODE.pipe:49152===e&&(n+=GO_MODE.socket),2048&i&&(n+=GO_MODE.setuid),1024&i&&(n+=GO_MODE.setgid),512&i&&(n+=GO_MODE.sticky),{mode:n,isDir:16384===e,isLink:40960===e}}function cleanPath(t){if("string"!=typeof t||!t)throw httpError(400,"path is required");const i=/\/\.$/.test(t)||"."===t;return{abs:path.posix.resolve("/",t),contents:i}}const DIFF_IGNORE=new Set(["/etc/hosts","/etc/hostname","/etc/resolv.conf","/dev","/proc","/sys",ENV_MARKER,"/sbin/tnc-init"]),DIFF='\nupper="$1"; shift\ncd "$upper" || exit 0\nfind . -mindepth 1 | while IFS= read -r f; do\n p="${f#.}"\n if [ -c "$f" ] && [ "$(stat -c %t:%T "$f")" = 0:0 ]; then echo "D $p"; continue; fi\n kind=A\n for l; do if [ -e "$l$p" ] || [ -L "$l$p" ]; then kind=C; break; fi; done\n echo "$kind $p"\ndone\n',WITH_MOUNTS='\nroot="$1"; n="$2"; shift 2\ni=0\nwhile [ "$i" -lt "$n" ]; do\n src="$1"; dst="$2"; mode="$3"; shift 3; i=$((i+1))\n [ -e "$src" ] || continue\n case "$dst" in *..*) continue;; esac\n p="$root"; ok=1; IFS=/\n for part in $dst; do [ -z "$part" ] && continue; p="$p/$part"; if [ -L "$p" ]; then ok=0; break; fi; done\n unset IFS\n [ "$ok" = 1 ] || continue\n t="$root$dst"\n if [ -d "$src" ]; then mkdir -p "$t"; else mkdir -p "${t%/*}"; [ -e "$t" ] || : > "$t"; fi\n mount --bind "$src" "$t" || exit 1\n [ "$mode" = ro ] && mount -o remount,bind,ro "$t"\ndone\nexec "$@"\n';class ContainerFs{constructor(t){this.engine=t,this.busy=new Map,this.mountsFor=new Map}get host(){return this.engine.host}_tools(){const{shim:t,busybox:i}=this.host.binaries();if(!i)throw httpError(501,"this dock runtime has no file tools for cp; update it with: thinknsh dock setup");return{shim:t,busybox:i}}_inroot(t,i,n){const{shim:e,busybox:s}=this._tools(),o=[e,"inroot",t,s,i,...n],r=this.mountsFor.get(t);if(!r||!r.length)return o;const a=r.flatMap(t=>[t.source,t.destination,(t.options||[]).includes("ro")?"ro":"rw"]);return["unshare","-m","--propagation","private","/bin/sh","-c",WITH_MOUNTS,"dock",t,String(r.length),...a,...o]}idle(t){return!this.busy.get(t)}async withRoot(t,i,{volumes:n=!0}={}){const e=this.engine,s=e.findContainer(t);if(await e.ready(),"running"===s.state&&s.runtime.pid>0)return i(`/proc/${s.runtime.pid}/root`,s);const o=e.store.getImage(s.image);if(!o)throw httpError(404,`No such image: ${s.config.imageRef}`);this.busy.set(s.id,(this.busy.get(s.id)||0)+1);try{const t=await e.rootfs.prepare(s.id,o,"/"),r=n?(s.runtime.ociMounts||[]).filter(t=>"bind"===t.type&&t.source&&t.destination):[];r.length&&this.mountsFor.set(t,[...r].sort((t,i)=>t.destination.split("/").length-i.destination.split("/").length));try{return await i(t,s)}finally{(!this.busy.get(s.id)||this.busy.get(s.id)<=1)&&this.mountsFor.delete(t)}}finally{const t=(this.busy.get(s.id)||1)-1;t?this.busy.set(s.id,t):this.busy.delete(s.id);const i=e.store.getContainer(s.id);t||!i||"running"===i.state||e.starting.has(s.id)||await e.rootfs.unmount(s.id).catch(()=>{})}}async _stat(t,i,{follow:n=!1}={}){const e=await this.host.run(this._inroot(t,"stat",[...n?["-L"]:[],"-c","%f %s %Y",i]));if(0!==e.code&&/tnc-shim: open|usage: tnc-shim/.test(e.stderr))throw httpError(501,"this dock runtime is too old for cp; update it with: thinknsh dock setup");if(0!==e.code){if(/No such file|can't stat/i.test(e.stderr))return null;throw httpError(500,`stat ${i}: ${(e.stderr||e.stdout).trim()}`)}const[s,o,r]=e.stdout.trim().split(/\s+/),a=goFileMode(s);let h="";if(a.isLink){const n=await this.host.run(this._inroot(t,"readlink",["-f",i]));h=0===n.code?n.stdout.trim():""}return{name:"/"===i?"/":path.posix.basename(i),size:Number(o),mode:a.mode,isDir:a.isDir,mtime:new Date(1e3*Number(r)).toISOString(),linkTarget:h}}async stat(t,i){const{abs:n}=cleanPath(i);return this.withRoot(t,async e=>{const s=await this._stat(e,n);if(!s)throw httpError(404,`Could not find the file ${i} in container ${t}`);const{isDir:o,...r}=s;return r})}async getArchive(t,i,n,e){const{abs:s,contents:o}=cleanPath(i);return this.withRoot(t,async r=>{const a=await this._stat(r,s);if(!a)throw httpError(404,`Could not find the file ${i} in container ${t}`);const{isDir:h,...c}=a,d=o&&h?["-c","-f","-","-C",s,"."]:["-c","-f","-","-C",path.posix.dirname(s),path.posix.basename(s)||"."];await n(c),await this._pipeOut(this._inroot(r,"tar",d),e)})}async putArchive(t,i,n,{copyUIDGID:e=!1,noOverwriteDirNonDir:s=!1}={}){const{abs:o}=cleanPath(i),r=this.engine.findContainer(t);if(r.config.HostConfig.ReadonlyRootfs)throw httpError(403,"container rootfs is marked read-only");return this.withRoot(t,async a=>{const h=await this._stat(a,o,{follow:!0});if(!h)throw httpError(404,`Could not find the file ${i} in container ${t}`);if(!h.isDir)throw httpError(400,`extraction point is not a directory: ${i}`);const c=["-x","-f","-","-C",o];e||c.push("-o"),s&&c.push("-k");const d=this.host.spawn(this._inroot(a,"tar",c));let u="";d.stdout.resume(),d.stderr.on("data",t=>{u.length<4096&&(u+=t)});for await(const t of n)d.write(t);d.end();const p=await d.exited;if(0!==p)throw httpError(400,`extracting into ${i} failed: ${u.trim()||`exit ${p}`}`);this.engine.emitEvent("container","extract-to-dir",r.id,this.engine._attrs(r))})}async exportTar(t,i){const n=this.engine.findContainer(t);await this.engine.ready();const e=this.engine.store.getImage(n.image);this.busy.set(n.id,(this.busy.get(n.id)||0)+1);try{const t="running"===n.state?this.engine.rootfs.mergedPath(n.id):await this.engine.rootfs.prepare(n.id,e,"/");await this._pipeOut(this._inroot(t,"tar",["-c","-f","-","-C","/","."]),i),this.engine.emitEvent("container","export",n.id,this.engine._attrs(n))}finally{const t=(this.busy.get(n.id)||1)-1;t?this.busy.set(n.id,t):this.busy.delete(n.id);const i=this.engine.store.getContainer(n.id);t||!i||"running"===i.state||this.engine.starting.has(n.id)||await this.engine.rootfs.unmount(n.id).catch(()=>{})}}async changes(t){const i=this.engine.findContainer(t);await this.engine.ready();const n=this.engine.store.getImage(i.image),e=this.engine.rootfs.root,s=[...n?.layers||[]].reverse().map(t=>`${e}/layers/${t.digest.replace(/^sha256:/,"")}`),o=await this.host.run(["/bin/sh","-c",DIFF,"dock",`${e}/containers/${i.id}/upper`,...s]),r={C:0,A:1,D:2};return o.stdout.split("\n").filter(Boolean).map(t=>({Kind:r[t[0]],Path:t.slice(2)})).filter(t=>void 0!==t.Kind&&!DIFF_IGNORE.has(t.Path))}async _pipeOut(t,i){const n=this.host.spawn(t);let e="";n.stderr.on("data",t=>{e.length<4096&&(e+=t)}),n.stdout.on("data",t=>i.write(t)),n.end();const s=await n.exited;0!==s&&this.engine.log(`tar in container: exit ${s}: ${e.trim()}`)}}module.exports={ContainerFs,goFileMode,cleanPath};
7
+ "use strict";const path=require("path"),{ENV_MARKER}=require("../api/wire");function httpError(t,i){const n=new Error(i);return n.statusCode=t,n}const GO_MODE={dir:2**31,symlink:2**27,device:2**26,pipe:2**25,socket:2**24,setuid:2**23,setgid:2**22,char:2**21,sticky:2**20};function goFileMode(t){const i=parseInt(t,16);let n=511&i;const e=61440&i;return 16384===e?n+=GO_MODE.dir:40960===e?n+=GO_MODE.symlink:24576===e?n+=GO_MODE.device:8192===e?n+=GO_MODE.device+GO_MODE.char:4096===e?n+=GO_MODE.pipe:49152===e&&(n+=GO_MODE.socket),2048&i&&(n+=GO_MODE.setuid),1024&i&&(n+=GO_MODE.setgid),512&i&&(n+=GO_MODE.sticky),{mode:n,isDir:16384===e,isLink:40960===e}}function cleanPath(t){if("string"!=typeof t||!t)throw httpError(400,"path is required");const i=/\/\.$/.test(t)||"."===t;return{abs:path.posix.resolve("/",t),contents:i}}const DIFF_IGNORE=new Set(["/etc/hosts","/etc/hostname","/etc/resolv.conf","/dev","/proc","/sys",ENV_MARKER,"/sbin/tnc-init"]),DIFF='\nupper="$1"; shift\ncd "$upper" || exit 0\nfind . -mindepth 1 | while IFS= read -r f; do\n p="${f#.}"\n if [ -c "$f" ] && [ "$(stat -c %t:%T "$f")" = 0:0 ]; then echo "D $p"; continue; fi\n kind=A\n for l; do if [ -e "$l$p" ] || [ -L "$l$p" ]; then kind=C; break; fi; done\n echo "$kind $p"\ndone\n',WITH_MOUNTS='\nroot="$1"; n="$2"; shift 2\ni=0\nwhile [ "$i" -lt "$n" ]; do\n src="$1"; dst="$2"; mode="$3"; shift 3; i=$((i+1))\n [ -e "$src" ] || continue\n case "$dst" in *..*) continue;; esac\n p="$root"; ok=1; IFS=/\n for part in $dst; do [ -z "$part" ] && continue; p="$p/$part"; if [ -L "$p" ]; then ok=0; break; fi; done\n unset IFS\n [ "$ok" = 1 ] || continue\n t="$root$dst"\n if [ -d "$src" ]; then mkdir -p "$t"; else mkdir -p "${t%/*}"; [ -e "$t" ] || : > "$t"; fi\n mount --bind "$src" "$t" || exit 1\n [ "$mode" = ro ] && mount -o remount,bind,ro "$t"\ndone\nexec "$@"\n';class ContainerFs{constructor(t){this.engine=t,this.busy=new Map,this.mountsFor=new Map}get host(){return this.engine.host}_tools(){const{shim:t,busybox:i}=this.host.binaries();if(!i)throw httpError(501,"this dock runtime has no file tools for cp; update it with: thinknsh dock setup");return{shim:t,busybox:i}}_inroot(t,i,n){const{shim:e,busybox:s}=this._tools(),o=[e,"inroot",t,s,i,...n],r=this.mountsFor.get(t);if(!r||!r.length)return o;const a=r.flatMap(t=>[t.source,t.destination,(t.options||[]).includes("ro")?"ro":"rw"]);return["unshare","-m","--propagation","private","/bin/sh","-c",WITH_MOUNTS,"dock",t,String(r.length),...a,...o]}idle(t){return!this.busy.get(t)}async withRoot(t,i,{volumes:n=!0}={}){const e=this.engine,s=e.findContainer(t);if(await e.ready(),"running"===s.state&&s.runtime.pid>0)return i(`/proc/${s.runtime.pid}/root`,s);const o=e.store.getImage(s.image);if(!o)throw httpError(404,`No such image: ${s.config.imageRef}`);this.busy.set(s.id,(this.busy.get(s.id)||0)+1);try{const t=await e.rootfs.prepare(s.id,o,"/",{userns:s.runtime.userns}),r=n?(s.runtime.ociMounts||[]).filter(t=>"bind"===t.type&&t.source&&t.destination):[];r.length&&this.mountsFor.set(t,[...r].sort((t,i)=>t.destination.split("/").length-i.destination.split("/").length));try{return await i(t,s)}finally{(!this.busy.get(s.id)||this.busy.get(s.id)<=1)&&this.mountsFor.delete(t)}}finally{const t=(this.busy.get(s.id)||1)-1;t?this.busy.set(s.id,t):this.busy.delete(s.id);const i=e.store.getContainer(s.id);t||!i||"running"===i.state||e.starting.has(s.id)||await e.rootfs.unmount(s.id).catch(()=>{})}}async _stat(t,i,{follow:n=!1}={}){const e=await this.host.run(this._inroot(t,"stat",[...n?["-L"]:[],"-c","%f %s %Y",i]));if(0!==e.code&&/tnc-shim: open|usage: tnc-shim/.test(e.stderr))throw httpError(501,"this dock runtime is too old for cp; update it with: thinknsh dock setup");if(0!==e.code){if(/No such file|can't stat/i.test(e.stderr))return null;throw httpError(500,`stat ${i}: ${(e.stderr||e.stdout).trim()}`)}const[s,o,r]=e.stdout.trim().split(/\s+/),a=goFileMode(s);let h="";if(a.isLink){const n=await this.host.run(this._inroot(t,"readlink",["-f",i]));h=0===n.code?n.stdout.trim():""}return{name:"/"===i?"/":path.posix.basename(i),size:Number(o),mode:a.mode,isDir:a.isDir,mtime:new Date(1e3*Number(r)).toISOString(),linkTarget:h}}async stat(t,i){const{abs:n}=cleanPath(i);return this.withRoot(t,async e=>{const s=await this._stat(e,n);if(!s)throw httpError(404,`Could not find the file ${i} in container ${t}`);const{isDir:o,...r}=s;return r})}async getArchive(t,i,n,e){const{abs:s,contents:o}=cleanPath(i);return this.withRoot(t,async r=>{const a=await this._stat(r,s);if(!a)throw httpError(404,`Could not find the file ${i} in container ${t}`);const{isDir:h,...c}=a,d=o&&h?["-c","-f","-","-C",s,"."]:["-c","-f","-","-C",path.posix.dirname(s),path.posix.basename(s)||"."];await n(c),await this._pipeOut(this._inroot(r,"tar",d),this._unshift(t,e))})}_unshift(t,i){const n=this.engine.findContainer(t).runtime.userns;return n?require("./userns").shiftTarSink(i,n,!1):i}async putArchive(t,i,n,{copyUIDGID:e=!1,noOverwriteDirNonDir:s=!1}={}){const{abs:o}=cleanPath(i),r=this.engine.findContainer(t);if(r.config.HostConfig.ReadonlyRootfs)throw httpError(403,"container rootfs is marked read-only");return this.withRoot(t,async a=>{const h=await this._stat(a,o,{follow:!0});if(!h)throw httpError(404,`Could not find the file ${i} in container ${t}`);if(!h.isDir)throw httpError(400,`extraction point is not a directory: ${i}`);const c=["-x","-f","-","-C",o],d=r.runtime.userns;d?n=require("./userns").shiftTarStream(n,d,!0,{asRoot:!e}):e||c.push("-o"),s&&c.push("-k");const u=this.host.spawn(this._inroot(a,"tar",c));let p="";u.stdout.resume(),u.stderr.on("data",t=>{p.length<4096&&(p+=t)});for await(const t of n)u.write(t);u.end();const f=await u.exited;if(0!==f)throw httpError(400,`extracting into ${i} failed: ${p.trim()||`exit ${f}`}`);this.engine.emitEvent("container","extract-to-dir",r.id,this.engine._attrs(r))})}async exportTar(t,i){const n=this.engine.findContainer(t);await this.engine.ready();const e=this.engine.store.getImage(n.image);this.busy.set(n.id,(this.busy.get(n.id)||0)+1);try{const t="running"===n.state?this.engine.rootfs.mergedPath(n.id):await this.engine.rootfs.prepare(n.id,e,"/",{userns:n.runtime.userns});await this._pipeOut(this._inroot(t,"tar",["-c","-f","-","-C","/","."]),this._unshift(n.id,i)),this.engine.emitEvent("container","export",n.id,this.engine._attrs(n))}finally{const t=(this.busy.get(n.id)||1)-1;t?this.busy.set(n.id,t):this.busy.delete(n.id);const i=this.engine.store.getContainer(n.id);t||!i||"running"===i.state||this.engine.starting.has(n.id)||await this.engine.rootfs.unmount(n.id).catch(()=>{})}}async changes(t){const i=this.engine.findContainer(t);await this.engine.ready();const n=this.engine.store.getImage(i.image),e=this.engine.rootfs.root,s=i.runtime.userns?`layers-${i.runtime.userns.uid}`:"layers",o=[...n?.layers||[]].reverse().map(t=>`${e}/${s}/${t.digest.replace(/^sha256:/,"")}`),r=await this.host.run(["/bin/sh","-c",DIFF,"dock",`${e}/containers/${i.id}/upper`,...o]),a={C:0,A:1,D:2};return r.stdout.split("\n").filter(Boolean).map(t=>({Kind:a[t[0]],Path:t.slice(2)})).filter(t=>void 0!==t.Kind&&!DIFF_IGNORE.has(t.Path))}async _pipeOut(t,i){const n=this.host.spawn(t);let e="";n.stderr.on("data",t=>{e.length<4096&&(e+=t)}),n.stdout.on("data",t=>i.write(t)),n.end();const s=await n.exited;0!==s&&this.engine.log(`tar in container: exit ${s}: ${e.trim()}`)}}module.exports={ContainerFs,goFileMode,cleanPath};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const crypto=require("crypto"),DEFAULT_BRIDGE={id:"bridge",name:"bridge",driver:"bridge",builtin:!0,dns:!1,bridgeIf:"tnc0",subnet:"172.28.0.0/16",gateway:"172.28.0.1",prefixLen:16,poolStart:"172.28.128.1",poolEnd:"172.28.255.254"},HOST={id:"host",name:"host",driver:"host",builtin:!0},NONE={id:"none",name:"none",driver:"null",builtin:!0},USER_POOL={base:"172.29",first:0,last:255},ipToInt=e=>e.split(".").reduce((e,n)=>(e<<8|Number(n))>>>0,0),intToIp=e=>[e>>>24,e>>>16&255,e>>>8&255,255&e].join(".");function httpError(e,n){const t=new Error(n);return t.statusCode=e,t}const BASE_SETUP="\nmkdir -p /var/run/netns\necho 1 > /proc/sys/net/ipv4/ip_forward\niptables -N TNC-ISOLATION 2>/dev/null || true\niptables -C FORWARD -j TNC-ISOLATION 2>/dev/null || iptables -I FORWARD 1 -j TNC-ISOLATION\niptables -C FORWARD -i tnc+ -j ACCEPT 2>/dev/null || iptables -A FORWARD -i tnc+ -j ACCEPT\niptables -C FORWARD -o tnc+ -j ACCEPT 2>/dev/null || iptables -A FORWARD -o tnc+ -j ACCEPT\n",BRIDGE_SETUP=`\n${BASE_SETUP}\nbr="$1"; gw="$2"; plen="$3"; subnet="$4"; chain="$5"; internal="$6"\nip link show "$br" >/dev/null 2>&1 || ip link add "$br" type bridge\nip -4 addr show dev "$br" | grep -q "$gw/$plen" || ip addr add "$gw/$plen" dev "$br"\nip link set "$br" up\nif [ "$internal" = 1 ]; then\n iptables -t nat -D POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null || true\nelse\n iptables -t nat -C POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE\nfi\nif ! iptables -L "$chain" -n >/dev/null 2>&1; then\n iptables -N "$chain"\n iptables -A "$chain" -o "$br" -j RETURN\n iptables -A "$chain" -o tnc+ -j DROP\n if [ "$internal" = 1 ]; then iptables -A "$chain" -j DROP; fi\n iptables -A "$chain" -j RETURN\nfi\niptables -C TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null || iptables -A TNC-ISOLATION -i "$br" -j "$chain"\n`,BRIDGE_REMOVE='\nbr="$1"; subnet="$2"; chain="$3"\niptables -D TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null\niptables -F "$chain" 2>/dev/null; iptables -X "$chain" 2>/dev/null\niptables -t nat -D POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null\nip link del "$br" 2>/dev/null\ntrue\n',ATTACH='\nset -e\nns="$1"; br="$2"; vh="$3"; vp="$4"; ifn="$5"; ip="$6"; plen="$7"; mac="$8"; gw="$9"; route="${10}"\nmkdir -p /var/run/netns\n[ -e "/var/run/netns/$ns" ] || { ip netns add "$ns"; ip -n "$ns" link set lo up; }\nip link del "$vh" 2>/dev/null || true\nip link add "$vh" type veth peer name "$vp"\nip link set "$vh" master "$br"\nip link set "$vh" up\nip link set "$vp" netns "$ns"\nip -n "$ns" link set "$vp" name "$ifn"\nip -n "$ns" link set "$ifn" address "$mac"\nip -n "$ns" addr add "$ip/$plen" dev "$ifn"\n# an address may have belonged to a container that just left: drop the bridge\'s\n# stale neighbour entry and announce the new MAC (gratuitous ARP on link up)\nip neigh flush to "$ip" dev "$br" 2>/dev/null || true\nip netns exec "$ns" sh -c "echo 1 > /proc/sys/net/ipv4/conf/$ifn/arp_notify" 2>/dev/null || true\nip -n "$ns" link set "$ifn" up\nif [ "$route" = 1 ]; then ip -n "$ns" route replace default via "$gw"; fi\n',NETNS_ONLY='set -e; mkdir -p /var/run/netns; [ -e "/var/run/netns/$1" ] || { ip netns add "$1"; ip -n "$1" link set lo up; }',DETACH_ONE='ip -n "$1" link del "$2" 2>/dev/null; ip link del "$3" 2>/dev/null; true',DETACH_ALL='ip netns del "$1" 2>/dev/null; true';class Network{constructor({host:e,store:n}){this.host=e,this.store=n,this.reserved=new Set,this.lastIp=new Map}builtins(){return[DEFAULT_BRIDGE,HOST,NONE]}resolve(e){if(!e||"default"===e||"bridge"===e)return DEFAULT_BRIDGE;if("host"===e)return HOST;if("none"===e)return NONE;const n=this.store.findNetwork(e);if(!n)throw httpError(404,`network ${e} not found`);return{...n,dns:!0,prefixLen:24}}list(){return[...this.builtins(),...this.store.listNetworks().map(e=>({...e,dns:!0,prefixLen:24}))]}create({Name:e,Driver:n,Labels:t,Internal:i=!1,Options:s}){if(n=n||"bridge",t=t||{},s=s||{},!e)throw httpError(400,"network name is required");if(!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(e))throw httpError(400,`invalid network name "${e}", only [a-zA-Z0-9][a-zA-Z0-9_.-] are allowed`);if(["bridge","host","none","default"].includes(e))throw httpError(403,`${e} is a pre-defined network and cannot be created`);if("bridge"!==n)throw httpError(400,`network driver "${n}" is not supported; only bridge`);if(this.store.findNetwork(e)?.name===e)throw httpError(409,`network with name ${e} already exists`);const r=new Set(this.store.listNetworks().map(e=>e.subnet));let a=null;for(let e=USER_POOL.first;e<=USER_POOL.last;e++){const n=`${USER_POOL.base}.${e}.0/24`;if(!r.has(n)){a=n;break}}if(!a)throw httpError(500,"no free subnets left in 172.29.0.0/16");const o=crypto.randomBytes(32).toString("hex"),l=a.split("/")[0].split(".").slice(0,3).join(".");return this.store.insertNetwork({id:o,name:e,driver:"bridge",subnet:a,gateway:`${l}.1`,poolStart:`${l}.2`,poolEnd:`${l}.254`,bridgeIf:`tncb-${o.slice(0,8)}`,chain:`TNC-ISO-${o.slice(0,8)}`,labels:t,internal:!!i,options:s})}async remove(e){if(e.builtin)throw httpError(403,`${e.name} is a pre-defined network and cannot be removed`);await this.host.isUp().catch(()=>!1)&&await this.host.sh(BRIDGE_REMOVE,[e.bridgeIf,e.subnet,e.chain]),this.store.deleteNetwork(e.id)}_allocate(e,n){const t=new Set(this.reserved);for(const e of this.store.listContainers())if(e.id!==n){for(const n of Object.values(e.runtime.networks||{}))n.ip&&t.add(n.ip);e.runtime.ip&&t.add(e.runtime.ip)}const i=ipToInt(e.poolStart),s=ipToInt(e.poolEnd)-i+1,r=this.lastIp.get(e.id),a=r?(ipToInt(r)-i+1)%s:0;for(let n=0;n<s;n++){const r=intToIp(i+(a+n)%s);if(!t.has(r))return this.reserved.add(r),this.lastIp.set(e.id,r),r}throw httpError(500,`no free addresses left in network ${e.name}`)}names(e,n){return{ns:`tnc-${e.slice(0,12)}`,vh:`vt${e.slice(0,8)}${n}`,vp:`vp${e.slice(0,8)}${n}`}}async _ensureBridge(e){const n=e.builtin?"TNC-ISO-default":e.chain;await this.host.sh(BRIDGE_SETUP,[e.bridgeIf,e.gateway,e.prefixLen,e.subnet,n,e.internal?1:0])}async attachOne(e,n,t,i=[]){const{ns:s,vh:r,vp:a}=this.names(e,t);if("null"===n.driver)return await this.host.sh(NETNS_ONLY,[s]),{networkId:"none",ifname:null,aliases:i};await this._ensureBridge(n);const o=this._allocate(n,e),l="02:42:"+crypto.createHash("sha1").update(`${e}:${n.id}`).digest("hex").slice(0,8).match(/../g).join(":");try{await this.host.sh(ATTACH,[s,n.bridgeIf,r,a,`eth${t}`,o,n.prefixLen,l,n.gateway,0===t?1:0])}catch(e){throw this.reserved.delete(o),e}return{networkId:n.id,ifname:`eth${t}`,idx:t,ip:o,mac:l,gateway:n.gateway,prefixLen:n.prefixLen,aliases:i}}async attachAll(e,n){const t=n.map(e=>({net:this.resolve(e.name),aliases:e.aliases||[]}));if("host"===t[0].net.driver)return{nsPath:null,endpoints:{host:{networkId:"host"}}};const i={};try{for(let n=0;n<t.length;n++)i[t[n].net.name]=await this.attachOne(e,t[n].net,n,t[n].aliases)}catch(n){throw await this.detachAll(e,i),n}return{nsPath:`/var/run/netns/${this.names(e,0).ns}`,endpoints:i}}async detachOne(e,n){if(!n||void 0===n.idx)return;const{ns:t,vh:i}=this.names(e,n.idx);await this.host.sh(DETACH_ONE,[t,n.ifname,i]).catch(()=>{}),n.ip&&this.reserved.delete(n.ip)}async detachAll(e,n={}){if(!n.host){await this.host.sh(DETACH_ALL,[this.names(e,0).ns]).catch(()=>{});for(const e of Object.values(n))e.ip&&this.reserved.delete(e.ip)}}}module.exports={Network,DEFAULT_BRIDGE,ipToInt};
7
+ "use strict";const crypto=require("crypto"),DEFAULT_BRIDGE={id:"bridge",name:"bridge",driver:"bridge",builtin:!0,dns:!1,bridgeIf:"tnc0",subnet:"172.28.0.0/16",gateway:"172.28.0.1",prefixLen:16,poolStart:"172.28.128.1",poolEnd:"172.28.255.254"},HOST={id:"host",name:"host",driver:"host",builtin:!0},NONE={id:"none",name:"none",driver:"null",builtin:!0},USER_POOL={base:"172.29",first:0,last:255},ipToInt=e=>e.split(".").reduce((e,n)=>(e<<8|Number(n))>>>0,0),intToIp=e=>[e>>>24,e>>>16&255,e>>>8&255,255&e].join(".");function httpError(e,n){const t=new Error(n);return t.statusCode=e,t}function ipv4Range(e){const n=/^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(e.Subnet);if(!n||Number(n[2])<8||Number(n[2])>30)throw httpError(400,`invalid subnet ${e.Subnet}`);const t=Number(n[2]),i=-1<<32-t>>>0,r=(ipToInt(n[1])&i)>>>0,s=(r|~i)>>>0;let[a,o]=[r+1,s-1];if(e.IPRange){const n=/^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(e.IPRange);if(!n)throw httpError(400,`invalid ip range ${e.IPRange}`);const t=-1<<32-Number(n[2])>>>0,s=(ipToInt(n[1])&t)>>>0;if((s&i)>>>0!==r)throw httpError(400,`ip range ${e.IPRange} is not inside ${e.Subnet}`);[a,o]=[Math.max(a,s),Math.min(o,(s|~t)>>>0)]}return{netInt:r,plen:t,lo:a,hi:o}}const NETNS_DEFAULTS='ip netns exec "$NS" sh -c \'echo 0 > /proc/sys/net/ipv4/ip_unprivileged_port_start; echo "0 2147483647" > /proc/sys/net/ipv4/ping_group_range\' 2>/dev/null || true',BASE_SETUP="\nmkdir -p /var/run/netns\necho 1 > /proc/sys/net/ipv4/ip_forward\niptables -N TNC-ISOLATION 2>/dev/null || true\niptables -C FORWARD -j TNC-ISOLATION 2>/dev/null || iptables -I FORWARD 1 -j TNC-ISOLATION\niptables -C FORWARD -i tnc+ -j ACCEPT 2>/dev/null || iptables -A FORWARD -i tnc+ -j ACCEPT\niptables -C FORWARD -o tnc+ -j ACCEPT 2>/dev/null || iptables -A FORWARD -o tnc+ -j ACCEPT\n",BRIDGE_SETUP=`\n${BASE_SETUP}\nbr="$1"; gw="$2"; plen="$3"; subnet="$4"; chain="$5"; internal="$6"\nip link show "$br" >/dev/null 2>&1 || ip link add "$br" type bridge\nip -4 addr show dev "$br" | grep -q "$gw/$plen" || ip addr add "$gw/$plen" dev "$br"\nip link set "$br" up\nif [ "$internal" = 1 ]; then\n iptables -t nat -D POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null || true\nelse\n iptables -t nat -C POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE\nfi\nif ! iptables -L "$chain" -n >/dev/null 2>&1; then\n iptables -N "$chain"\n iptables -A "$chain" -o "$br" -j RETURN\n iptables -A "$chain" -o tnc+ -j DROP\n if [ "$internal" = 1 ]; then iptables -A "$chain" -j DROP; fi\n iptables -A "$chain" -j RETURN\nfi\niptables -C TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null || iptables -A TNC-ISOLATION -i "$br" -j "$chain"\n# IPv6 (network create --ipv6): $7 subnet6, $8 gateway6\nsubnet6="$7"; gw6="$8"\nif [ -n "$gw6" ]; then\n echo 0 > "/proc/sys/net/ipv6/conf/$br/disable_ipv6"\n echo 1 > /proc/sys/net/ipv6/conf/all/forwarding\n ip -6 addr show dev "$br" | grep -q "$gw6/64" || ip -6 addr add "$gw6/64" dev "$br" nodad\n if command -v ip6tables >/dev/null 2>&1; then\n ip6tables -N TNC-ISOLATION 2>/dev/null || true\n ip6tables -C FORWARD -j TNC-ISOLATION 2>/dev/null || ip6tables -I FORWARD 1 -j TNC-ISOLATION\n ip6tables -C FORWARD -i tnc+ -j ACCEPT 2>/dev/null || ip6tables -A FORWARD -i tnc+ -j ACCEPT\n ip6tables -C FORWARD -o tnc+ -j ACCEPT 2>/dev/null || ip6tables -A FORWARD -o tnc+ -j ACCEPT\n if ! ip6tables -L "$chain" -n >/dev/null 2>&1; then\n ip6tables -N "$chain"\n ip6tables -A "$chain" -o "$br" -j RETURN\n ip6tables -A "$chain" -o tnc+ -j DROP\n if [ "$internal" = 1 ]; then ip6tables -A "$chain" -j DROP; fi\n ip6tables -A "$chain" -j RETURN\n fi\n ip6tables -C TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null || ip6tables -A TNC-ISOLATION -i "$br" -j "$chain"\n # outbound IPv6 through this machine's address; best effort (a kernel without IPv6 NAT keeps it inside)\n if [ "$internal" != 1 ]; then\n ip6tables -t nat -C POSTROUTING -s "$subnet6" ! -o "$br" -j MASQUERADE 2>/dev/null || ip6tables -t nat -A POSTROUTING -s "$subnet6" ! -o "$br" -j MASQUERADE 2>/dev/null || true\n fi\n fi\nfi\n`,BRIDGE_REMOVE='\nbr="$1"; subnet="$2"; chain="$3"; subnet6="$4"\niptables -D TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null\niptables -F "$chain" 2>/dev/null; iptables -X "$chain" 2>/dev/null\niptables -t nat -D POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null\nif [ -n "$subnet6" ] && command -v ip6tables >/dev/null 2>&1; then\n ip6tables -D TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null\n ip6tables -F "$chain" 2>/dev/null; ip6tables -X "$chain" 2>/dev/null\n ip6tables -t nat -D POSTROUTING -s "$subnet6" ! -o "$br" -j MASQUERADE 2>/dev/null\nfi\nip link del "$br" 2>/dev/null\ntrue\n';function ipv6Subnet(e,n={}){const t=(n.Config||[]).map(e=>e.Subnet).find(e=>e&&e.includes(":"));if(t){const[e,n]=t.split("/");if(64!==Number(n))throw httpError(400,`IPv6 subnet ${t}: only /64 subnets are supported`);const i=expand6(e);if(!i)throw httpError(400,`invalid IPv6 subnet ${t}`);return i.slice(0,4).map(e=>e.replace(/^0+(?=.)/,"")).join(":")}return`fd${e.slice(0,2)}:${e.slice(2,6)}:${e.slice(6,10)}:0`}function expand6(e){if(!/^[0-9a-fA-F:]+$/.test(e)||(e.match(/::/g)||[]).length>1)return null;const[n,t]=e.split("::"),i=n?n.split(":"):[],r=void 0!==t&&t?t.split(":"):[],s=e.includes("::")?8-i.length-r.length:0,a=[...i,...Array(Math.max(0,s)).fill("0"),...r];return 8!==a.length||a.some(e=>!/^[0-9a-fA-F]{1,4}$/.test(e))?null:a.map(e=>e.toLowerCase().padStart(4,"0"))}const ATTACH=`\nset -e\nns="$1"; br="$2"; vh="$3"; vp="$4"; ifn="$5"; ip="$6"; plen="$7"; mac="$8"; gw="$9"; route="\${10}"\nmkdir -p /var/run/netns\n[ -e "/var/run/netns/$ns" ] || { ip netns add "$ns"; ip -n "$ns" link set lo up; ${NETNS_DEFAULTS.replace(/\$NS/g,"$ns")}; }\nip link del "$vh" 2>/dev/null || true\nip link add "$vh" type veth peer name "$vp"\nip link set "$vh" master "$br"\nip link set "$vh" up\nip link set "$vp" netns "$ns"\nip -n "$ns" link set "$vp" name "$ifn"\nip -n "$ns" link set "$ifn" address "$mac"\nip -n "$ns" addr add "$ip/$plen" dev "$ifn"\n# an address may have belonged to a container that just left: drop the bridge's\n# stale neighbour entry and announce the new MAC (gratuitous ARP on link up)\nip neigh flush to "$ip" dev "$br" 2>/dev/null || true\nip netns exec "$ns" sh -c "echo 1 > /proc/sys/net/ipv4/conf/$ifn/arp_notify" 2>/dev/null || true\nip -n "$ns" link set "$ifn" up\nif [ "$route" = 1 ]; then ip -n "$ns" route replace default via "$gw"; fi\n# \${13}: MTU of an overlay network (the tunnel takes 50 bytes of every packet)\nif [ -n "\${13}" ]; then ip link set "$vh" mtu "\${13}"; ip -n "$ns" link set "$ifn" mtu "\${13}"; fi\n# IPv6: \${11} address, \${12} gateway (empty: an IPv4-only network)\nip6="\${11}"; gw6="\${12}"\nif [ -n "$ip6" ]; then\n ip netns exec "$ns" sh -c "echo 0 > /proc/sys/net/ipv6/conf/$ifn/disable_ipv6"\n ip -n "$ns" -6 addr add "$ip6/64" dev "$ifn" nodad\n if [ "$route" = 1 ]; then ip -n "$ns" -6 route replace default via "$gw6" dev "$ifn"; fi\nfi\n`,LAN_ATTACH=`\nset -e\nns="$1"; parent="$2"; kind="$3"; mode="$4"; vp="$5"; ifn="$6"; ip="$7"; plen="$8"; gw="$9"; route="\${10}"; mac="\${11}"\nmkdir -p /var/run/netns\n[ -e "/var/run/netns/$ns" ] || { ip netns add "$ns"; ip -n "$ns" link set lo up; ${NETNS_DEFAULTS.replace(/\$NS/g,"$ns")}; }\n[ -n "$parent" ] || parent=$(ip -4 route show default | awk '{print $5; exit}')\nip link show "$parent" >/dev/null 2>&1 || { echo "parent interface \\"$parent\\" does not exist on this machine" >&2; exit 1; }\nip link del "$vp" 2>/dev/null || true\nif [ "$kind" = macvlan ]; then ip link add "$vp" link "$parent" type macvlan mode "$mode"; else ip link add "$vp" link "$parent" type ipvlan mode "$mode"; fi\nip link set "$vp" netns "$ns"\nip -n "$ns" link set "$vp" name "$ifn"\nif [ "$kind" = macvlan ]; then ip -n "$ns" link set "$ifn" address "$mac"; fi\nip -n "$ns" addr add "$ip/$plen" dev "$ifn"\nip -n "$ns" link set "$ifn" up\nif [ "$route" = 1 ] && [ -n "$gw" ]; then ip -n "$ns" route replace default via "$gw"; fi\n`,VXLAN_SETUP='\nset -e\nbr="$1"; vx="$2"; vni="$3"; port="$4"; local="$5"; mtu="$6"; shift 6\nif ! ip link show "$vx" >/dev/null 2>&1; then\n if [ -n "$local" ]; then ip link add "$vx" type vxlan id "$vni" dstport "$port" local "$local"; else ip link add "$vx" type vxlan id "$vni" dstport "$port"; fi\nfi\nip link set "$vx" mtu "$mtu"\nip link set "$br" mtu "$mtu"\nip link set "$vx" master "$br"\nip link set "$vx" up\nfor peer; do\n bridge fdb show dev "$vx" | grep -q "dst $peer " || bridge fdb append 00:00:00:00:00:00 dev "$vx" dst "$peer"\ndone\niptables -C INPUT -p udp --dport "$port" -j ACCEPT 2>/dev/null || iptables -I INPUT -p udp --dport "$port" -j ACCEPT\n# the engines\' name announcements on the overlay itself (runtime/overlaydns.js)\niptables -C INPUT -i "$br" -p udp --dport 7946 -j ACCEPT 2>/dev/null || iptables -I INPUT -i "$br" -p udp --dport 7946 -j ACCEPT\n',NETNS_ONLY=`set -e; mkdir -p /var/run/netns; [ -e "/var/run/netns/$1" ] || { ip netns add "$1"; ip -n "$1" link set lo up; ${NETNS_DEFAULTS.replace(/\$NS/g,"$1")}; }`,DETACH_ONE='ip -n "$1" link del "$2" 2>/dev/null; ip link del "$3" 2>/dev/null; true',DETACH_ALL='ip netns del "$1" 2>/dev/null; true';class Network{constructor({host:e,store:n}){this.host=e,this.store=n,this.reserved=new Set,this.lastIp=new Map}builtins(){return[DEFAULT_BRIDGE,HOST,NONE]}resolve(e){if(!e||"default"===e||"bridge"===e)return DEFAULT_BRIDGE;if("host"===e)return HOST;if("none"===e)return NONE;const n=this.store.findNetwork(e);if(!n)throw httpError(404,`network ${e} not found`);return{...n,dns:!0,prefixLen:n.prefixLen||24}}list(){return[...this.builtins(),...this.store.listNetworks().map(e=>({...e,dns:!0,prefixLen:e.prefixLen||24}))]}create({Name:e,Driver:n,Labels:t,Internal:i=!1,Options:r,EnableIPv6:s=!1,IPAM:a}){if(n=n||"bridge",t=t||{},r=r||{},!e)throw httpError(400,"network name is required");if(!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(e))throw httpError(400,`invalid network name "${e}", only [a-zA-Z0-9][a-zA-Z0-9_.-] are allowed`);if(["bridge","host","none","default"].includes(e))throw httpError(403,`${e} is a pre-defined network and cannot be created`);if(this.store.findNetwork(e)?.name===e)throw httpError(409,`network with name ${e} already exists`);if("macvlan"===n||"ipvlan"===n)return this._createLan({Name:e,Driver:n,Labels:t,Internal:i,Options:r,IPAM:a});if("overlay"===n)return this._createOverlay({Name:e,Labels:t,Internal:i,Options:r,IPAM:a});if("bridge"!==n)throw httpError(400,`network driver "${n}" is not supported; use bridge, macvlan, ipvlan or overlay`);const o=new Set(this.store.listNetworks().map(e=>e.subnet));let l=null;for(let e=USER_POOL.first;e<=USER_POOL.last;e++){const n=`${USER_POOL.base}.${e}.0/24`;if(!o.has(n)){l=n;break}}if(!l)throw httpError(500,"no free subnets left in 172.29.0.0/16");const p=crypto.randomBytes(32).toString("hex"),d=l.split("/")[0].split(".").slice(0,3).join("."),h=s||(a?.Config||[]).some(e=>e.Subnet?.includes(":"))?ipv6Subnet(p,a):null;return this.store.insertNetwork({id:p,name:e,driver:"bridge",subnet:l,gateway:`${d}.1`,poolStart:`${d}.2`,poolEnd:`${d}.254`,...h?{subnet6:`${h}::/64`,gateway6:`${h}::1`,prefix6:h}:{},bridgeIf:`tncb-${p.slice(0,8)}`,chain:`TNC-ISO-${p.slice(0,8)}`,labels:t,internal:!!i,options:r})}_needLinuxRoot(e,n){if("local"!==this.host.kind)throw httpError(400,`${e} networks need dock on Linux as root: here containers run behind ${{vm:"the Linux VM's NAT",wsl:"WSL's NAT",rootless:"rootless user-mode networking"}[this.host.kind]||"NAT"}, so ${n}`)}_createLan({Name:e,Driver:n,Labels:t,Internal:i,Options:r,IPAM:s}){this._needLinuxRoot(n,"there is no LAN interface to put them on");const a=(s?.Config||[]).find(e=>e.Subnet&&!e.Subnet.includes(":"));if(!a)throw httpError(400,`a ${n} network needs the LAN's subnet and gateway, e.g. --subnet 192.168.1.0/24 --gateway 192.168.1.1`);const{netInt:o,plen:l,lo:p,hi:d}=ipv4Range(a),h=a.Gateway||intToIp(o+1),$="macvlan"===n?r.macvlan_mode||"bridge":r.ipvlan_mode||"l2";if(!("macvlan"===n?["bridge","private","vepa","passthru"]:["l2","l3","l3s"]).includes($))throw httpError(400,`invalid ${n} mode ${$}`);if(r.parent&&!/^[A-Za-z0-9_.@-]{1,15}$/.test(r.parent))throw httpError(400,`invalid parent interface ${r.parent}`);const u=crypto.randomBytes(32).toString("hex");return this.store.insertNetwork({id:u,name:e,driver:n,subnet:`${intToIp(o)}/${l}`,gateway:h,prefixLen:l,poolStart:intToIp(p),poolEnd:intToIp(d),parent:r.parent||"",mode:$,labels:t,internal:!!i,options:r})}_createOverlay({Name:e,Labels:n,Internal:t,Options:i,IPAM:r}){this._needLinuxRoot("overlay","other machines cannot reach its tunnel");const s=(r?.Config||[]).find(e=>e.Subnet&&!e.Subnet.includes(":"));if(!s)throw httpError(400,"an overlay network needs its subnet (the same on every machine), e.g. --subnet 10.20.0.0/16 --ip-range 10.20.1.0/24");const a=String(i.peers||"").split(",").map(e=>e.trim()).filter(Boolean);for(const e of a)if(!require("net").isIPv4(e))throw httpError(400,`peer ${e} is not an IPv4 address`);if(a.length&&!s.IPRange)throw httpError(400,"an overlay network across machines needs --ip-range: a different part of the subnet on each machine, so their containers never get the same address");const{netInt:o,plen:l,hi:p}=ipv4Range(s);let{lo:d}=ipv4Range(s);if(255&d||d++,d>=p)throw httpError(400,`ip range ${s.IPRange||s.Subnet} is too small`);const h=s.Gateway||intToIp(d),$=2**(32-l);for(const e of[DEFAULT_BRIDGE,...this.store.listNetworks()]){const[n,t]=String(e.subnet||"").split("/");if(!n||n.includes(":"))continue;const i=ipToInt(n);if(o<i+2**(32-Number(t))&&i<o+$)throw httpError(400,`subnet ${s.Subnet} overlaps network ${e.name} (${e.subnet})`)}const u=void 0!==i.vni?Number(i.vni):parseInt(crypto.createHash("sha256").update(e).digest("hex").slice(0,6),16)||1;if(!Number.isInteger(u)||u<1||u>16777215)throw httpError(400,"vni must be a number from 1 to 16777215");const c=Number(i.port||4789),v=Number(i.mtu||1450);if(!Number.isInteger(c)||c<1||c>65535)throw httpError(400,"port must be a UDP port number");if(!Number.isInteger(v)||v<1280||v>9e3)throw httpError(400,"mtu must be from 1280 to 9000");if(i.local&&!require("net").isIPv4(i.local))throw httpError(400,`local ${i.local} is not an IPv4 address`);const f=crypto.randomBytes(32).toString("hex");return this.store.insertNetwork({id:f,name:e,driver:"overlay",subnet:`${intToIp(o)}/${l}`,gateway:h,prefixLen:l,poolStart:intToIp(s.Gateway?d:d+1),poolEnd:intToIp(p),bridgeIf:`tnco-${f.slice(0,8)}`,vxlanIf:`tncx-${f.slice(0,8)}`,chain:`TNC-ISO-${f.slice(0,8)}`,vni:u,port:c,mtu:v,peers:a,local:i.local||"",labels:n,internal:!!t,options:i})}_allocate6(e,n){if(!e.prefix6)return null;const t=new Set(this.reserved);for(const e of this.store.listContainers())if(e.id!==n)for(const n of Object.values(e.runtime.networks||{}))n.ip6&&t.add(n.ip6);for(let n=2;n<65535;n++){const i=`${e.prefix6}::${n.toString(16)}`;if(!t.has(i))return this.reserved.add(i),i}throw httpError(500,`no free IPv6 addresses left in network ${e.name}`)}async remove(e){if(e.builtin)throw httpError(403,`${e.name} is a pre-defined network and cannot be removed`);"bridge"!==e.driver&&"overlay"!==e.driver||!await this.host.isUp().catch(()=>!1)||(e.vxlanIf&&await this.host.sh('ip link del "$1" 2>/dev/null; true',[e.vxlanIf]),await this.host.sh(BRIDGE_REMOVE,[e.bridgeIf,e.subnet,e.chain,e.subnet6||""])),this.store.deleteNetwork(e.id)}_allocate(e,n){const t=new Set(this.reserved);e.gateway&&t.add(e.gateway);for(const e of this.store.listContainers())if(e.id!==n){for(const n of Object.values(e.runtime.networks||{}))n.ip&&t.add(n.ip);e.runtime.ip&&t.add(e.runtime.ip)}const i=ipToInt(e.poolStart),r=ipToInt(e.poolEnd)-i+1,s=this.lastIp.get(e.id),a=s?(ipToInt(s)-i+1)%r:0;for(let n=0;n<r;n++){const s=intToIp(i+(a+n)%r);if(!t.has(s))return this.reserved.add(s),this.lastIp.set(e.id,s),s}throw httpError(500,`no free addresses left in network ${e.name}`)}names(e,n){const t=/^[0-9a-f]{12}/.test(e)?e:crypto.createHash("sha256").update(e).digest("hex");return{ns:`tnc-${t.slice(0,12)}`,vh:`vt${t.slice(0,8)}${n}`,vp:`vp${t.slice(0,8)}${n}`}}async _ensureBridge(e){const n=e.builtin?"TNC-ISO-default":e.chain;await this.host.sh(BRIDGE_SETUP,[e.bridgeIf,e.gateway,e.prefixLen,e.subnet,n,e.internal?1:0,e.subnet6||"",e.gateway6||""]),"overlay"===e.driver&&await this.host.sh(VXLAN_SETUP,[e.bridgeIf,e.vxlanIf,String(e.vni),String(e.port),e.local||"",String(e.mtu),...e.peers||[]]).catch(n=>{throw httpError(500,`overlay network ${e.name}: ${/vxlan|Unknown device type|not supported/i.test(n.message)?"this kernel has no VXLAN support (modprobe vxlan)":n.message}`)})}async attachOne(e,n,t,i=[]){const{ns:r,vh:s,vp:a}=this.names(e,t);if("null"===n.driver)return await this.host.sh(NETNS_ONLY,[r]),{networkId:"none",ifname:null,aliases:i};if("macvlan"===n.driver||"ipvlan"===n.driver){const s=this._allocate(n,e),o="02:42:"+crypto.createHash("sha1").update(`${e}:${n.id}`).digest("hex").slice(0,8).match(/../g).join(":");try{await this.host.sh(LAN_ATTACH,[r,n.parent||"",n.driver,n.mode,a,`eth${t}`,s,n.prefixLen,n.gateway,0===t?1:0,o])}catch(e){throw this.reserved.delete(s),e}return{networkId:n.id,ifname:`eth${t}`,idx:t,ip:s,mac:"macvlan"===n.driver?o:"",gateway:n.gateway,prefixLen:n.prefixLen,aliases:i}}await this._ensureBridge(n);const o=this._allocate(n,e),l=this._allocate6(n,e),p="02:42:"+crypto.createHash("sha1").update(`${e}:${n.id}`).digest("hex").slice(0,8).match(/../g).join(":");try{await this.host.sh(ATTACH,[r,n.bridgeIf,s,a,`eth${t}`,o,n.prefixLen,p,n.gateway,0===t?1:0,l||"",n.gateway6||"","overlay"===n.driver?String(n.mtu):""])}catch(e){throw this.reserved.delete(o),l&&this.reserved.delete(l),e}return{networkId:n.id,ifname:`eth${t}`,idx:t,ip:o,mac:p,gateway:n.gateway,prefixLen:n.prefixLen,aliases:i,...l?{ip6:l,gateway6:n.gateway6,prefixLen6:64}:{}}}async attachAll(e,n){const t=n.map(e=>({net:this.resolve(e.name),aliases:e.aliases||[]}));if("host"===t[0].net.driver)return{nsPath:null,endpoints:{host:{networkId:"host"}}};const i={};try{for(let n=0;n<t.length;n++)i[t[n].net.name]=await this.attachOne(e,t[n].net,n,t[n].aliases)}catch(n){throw await this.detachAll(e,i),n}return{nsPath:`/var/run/netns/${this.names(e,0).ns}`,endpoints:i}}async detachOne(e,n){if(!n||void 0===n.idx)return;const{ns:t,vh:i}=this.names(e,n.idx);await this.host.sh(DETACH_ONE,[t,n.ifname,i]).catch(()=>{}),n.ip&&this.reserved.delete(n.ip),n.ip6&&this.reserved.delete(n.ip6)}async detachAll(e,n={}){if(!n.host){await this.host.sh(DETACH_ALL,[this.names(e,0).ns]).catch(()=>{});for(const e of Object.values(n))e.ip&&this.reserved.delete(e.ip),e.ip6&&this.reserved.delete(e.ip6)}}}module.exports={Network,DEFAULT_BRIDGE,ipToInt};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
+ * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
+ * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
+ * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
+ */
7
+ "use strict";const dgram=require("dgram"),{ipToInt}=require("./network"),GOSSIP_PORT=7946,TICK=3e3,HEARTBEAT=1e4,EXPIRE=35e3,intToIp=t=>[t>>>24,t>>>16&255,t>>>8&255,255&t].join(".");function inSubnet(t,e){const[s,n]=e.split("/"),r=-1<<32-Number(n)>>>0;return(ipToInt(t)&r)>>>0==(ipToInt(s)&r)>>>0}function broadcastOf(t){const[e,s]=t.split("/"),n=-1<<32-Number(s)>>>0;return intToIp((ipToInt(e)&n|~n)>>>0)}const NAME=/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,252}$/;class OverlayNames{constructor(t,{port:e=7946}={}){this.engine=t,this.port=e,this.sock=null,this.timer=null,this.remote=new Map,this.sent=new Map}nets(){return this.engine.store.listNetworks().filter(t=>"overlay"===t.driver&&(t.peers||[]).length)}sync(){const t="local"===this.engine.host.kind&&this.nets().length>0;t&&!this.sock?this._start():!t&&this.sock&&this.stop()}_start(){const t=dgram.createSocket({type:"udp4",reuseAddr:!0});t.on("error",t=>{this.engine.log(`overlay names: ${t.message}`),this.stop()}),t.on("message",(t,e)=>this._receive(t,e)),t.bind(this.port,()=>{try{t.setBroadcast(!0)}catch(t){}this.timer=setInterval(()=>this.announce(),3e3),this.timer.unref?.(),this.announce()}),this.sock=t}stop(){clearInterval(this.timer),this.timer=null;try{this.sock?.close()}catch(t){}this.sock=null}localRecords(t){const e=[];for(const s of this.engine.store.listContainers()){if("running"!==s.state)continue;const n=s.runtime.networks?.[t.name];if(n?.ip)for(const t of new Set([s.name,...n.aliases||[]]))NAME.test(t)&&e.push([t,n.ip])}return e.sort((t,e)=>(t[0]+t[1]).localeCompare(e[0]+e[1]))}announce(){if(!this.sock)return;const t=Date.now();for(const e of this.nets()){const s=this.localRecords(e),n=JSON.stringify(s),r=this.sent.get(e.vni);if(r&&r.key===n&&t-r.at<1e4)continue;this.sent.set(e.vni,{at:t,key:n});const i=Buffer.from(JSON.stringify({t:"tnc-overlay",v:1,vni:e.vni,from:e.gateway,records:s}));i.length>6e4||this.sock.send(i,this.port,broadcastOf(e.subnet),()=>{})}this._expire(t)}_receive(t,e){let s;try{s=JSON.parse(t.toString("utf8"))}catch(t){return}if("tnc-overlay"!==s?.t||1!==s.v||!Array.isArray(s.records))return;const n=this.nets().find(t=>t.vni===s.vni);if(!n||s.from===n.gateway||e.address!==s.from||!inSubnet(s.from,n.subnet))return;const r=s.records.filter(t=>Array.isArray(t)&&NAME.test(String(t[0]))&&"string"==typeof t[1]&&inSubnet(t[1],n.subnet)).slice(0,4096);this.remote.has(n.vni)||this.remote.set(n.vni,new Map);const i=this.remote.get(n.vni),o=JSON.stringify(i.get(s.from)?.records||[]);i.set(s.from,{at:Date.now(),records:r}),o!==JSON.stringify(r)&&this.engine._refreshDns().catch(()=>{})}_expire(t=Date.now()){let e=!1;for(const s of this.remote.values())for(const[n,r]of s)t-r.at>35e3&&(s.delete(n),e=!0);e&&this.engine._refreshDns().catch(()=>{})}dnsLines(){const t=[];for(const e of this.nets()){const s=e.id.slice(0,12);for(const n of this.remote.get(e.vni)?.values()||[])for(const[e,r]of n.records)t.push(`a ${s} ${e} ${r}`)}return t}}module.exports={OverlayNames,GOSSIP_PORT:7946,broadcastOf,inSubnet};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const net=require("net"),dgram=require("dgram"),UDP_IDLE_MS=6e4;function defaultAddress(){try{return require("../config").effective().publishAddress||"127.0.0.1"}catch(t){return"127.0.0.1"}}function parseBindings(t={},o=defaultAddress()){const r=[];for(const[e,s]of Object.entries(t||{})){const[t,n="tcp"]=e.split("/"),c=parseInt(t,10);if(Number.isInteger(c))for(const t of s||[{}]){const s=/^(\d+)-(\d+)$/.exec(String(t.HostPort||"")),i=s?Array.from({length:Number(s[2])-Number(s[1])+1},(t,o)=>Number(s[1])+o):[t.HostPort?parseInt(t.HostPort,10):0];r.push({key:e,containerPort:c,proto:n,hostIp:t.HostIp||o,hostPort:i[0],hostPorts:i})}}return r}function portError(t,o){const r=new Error("EADDRINUSE"===o.code?`driver failed programming external connectivity: Bind for ${t.hostIp}:${t.hostPorts.length>1?`${t.hostPorts[0]}-${t.hostPorts[t.hostPorts.length-1]}`:t.hostPort} failed: port is already allocated`:`cannot publish port ${t.hostPort}: ${o.message}`);return r.statusCode=500,r}class PortProxies{constructor({host:t,log:o=()=>{}}){this.host=t,this.log=o,this.byContainer=new Map}async start(t,o,r){const e=parseBindings(o),s=[],n={};try{for(const t of e){if("tcp"!==t.proto&&"udp"!==t.proto){const o=new Error(`publishing ${t.proto} ports is not supported; use tcp or udp`);throw o.statusCode=400,o}let o=null,e=null;for(const s of t.hostPorts)try{o="udp"===t.proto?await this._listenUdp({...t,hostPort:s},r):await this._listen({...t,hostPort:s},r);break}catch(t){if(e=t,"EADDRINUSE"!==t.code)break}if(!o)throw portError(t,e);s.push(o),(n[t.key]||=[]).push({HostIp:t.hostIp,HostPort:String(o.port)})}}catch(t){for(const t of s)t.close();throw t.statusCode?t:portError(e[0]||{},t)}return this.byContainer.set(t,s),n}_listen(t,o){return new Promise((r,e)=>{const s=new Set,n=net.createServer(r=>{const e=this.host.connect(o,t.containerPort);s.add(r),s.add(e);const n=()=>{r.destroy(),e.destroy(),s.delete(r),s.delete(e)};r.on("error",n),e.on("error",n),r.on("close",n),e.on("close",n),r.pipe(e),e.pipe(r)});n.once("error",e),n.listen(t.hostPort,t.hostIp,()=>r({port:n.address().port,close:()=>{n.close();for(const t of s)t.destroy()}}))})}_listenUdp(t,o){return new Promise((r,e)=>{const s=t.hostIp.includes(":"),n=dgram.createSocket({type:s?"udp6":"udp4",reuseAddr:!1}),c=new Map,i=r=>{const e=`${r.address}:${r.port}`;let s=c.get(e);if(s)return s;const i=t=>n.send(t,r.port,r.address,()=>{});return s="local"===this.host.kind?this._directUdp(o,t.containerPort,i):this._shimUdp(o,t.containerPort,i),s.seen=Date.now(),c.set(e,s),s},a=setInterval(()=>{for(const[t,o]of c)Date.now()-o.seen>6e4&&(o.close(),c.delete(t))},1e4);a.unref(),n.on("message",(t,o)=>{const r=i(o);r.seen=Date.now(),r.send(t)}),n.once("error",t=>{clearInterval(a),e(t)}),n.bind(t.hostPort,t.hostIp,()=>{n.removeAllListeners("error"),n.on("error",o=>this.log(`udp ${t.hostPort}: ${o.message}`)),r({port:n.address().port,close:()=>{clearInterval(a);for(const t of c.values())t.close();c.clear();try{n.close()}catch(t){}}})})})}_directUdp(t,o,r){const e=dgram.createSocket("udp4");e.on("message",r),e.on("error",()=>{}),e.connect(o,t);const s=[];return e.once("connect",()=>{for(const t of s.splice(0))e.send(t)}),{send:t=>{try{e.send(t)}catch(o){s.push(t)}},close:()=>{try{e.close()}catch(t){}}}}_shimUdp(t,o,r){const e=this.host.spawn([this.host.binaries().shim,"udp",t,String(o)]);let s=Buffer.alloc(0);return e.stdout.on("data",t=>{for(s=s.length?Buffer.concat([s,t]):t;s.length>=2;){const t=s.readUInt16BE(0);if(s.length<2+t)break;r(Buffer.from(s.subarray(2,2+t))),s=s.subarray(2+t)}}),e.stderr.resume(),{send:t=>{const o=Buffer.alloc(2);o.writeUInt16BE(Math.min(t.length,65535)),e.write(Buffer.concat([o,t.subarray(0,65535)]))},close:()=>{e.end(),e.kill("SIGTERM")}}}stop(t){for(const o of this.byContainer.get(t)||[])o.close();this.byContainer.delete(t)}stopAll(){for(const t of[...this.byContainer.keys()])this.stop(t)}}module.exports={PortProxies,parseBindings};
7
+ "use strict";const net=require("net"),dgram=require("dgram"),UDP_IDLE_MS=6e4;function defaultAddress(){try{return require("../config").effective().publishAddress||"127.0.0.1"}catch(t){return"127.0.0.1"}}function parseBindings(t={},o=defaultAddress()){const r=[];for(const[e,s]of Object.entries(t||{})){const[t,n="tcp"]=e.split("/"),c=parseInt(t,10);if(Number.isInteger(c))for(const t of s||[{}]){const s=/^(\d+)-(\d+)$/.exec(String(t.HostPort||"")),i=s?Array.from({length:Number(s[2])-Number(s[1])+1},(t,o)=>Number(s[1])+o):[t.HostPort?parseInt(t.HostPort,10):0];r.push({key:e,containerPort:c,proto:n,hostIp:t.HostIp||o,hostPort:i[0],hostPorts:i})}}return r}function portError(t,o){const r=new Error("EADDRINUSE"===o.code?`driver failed programming external connectivity: Bind for ${t.hostIp}:${t.hostPorts.length>1?`${t.hostPorts[0]}-${t.hostPorts[t.hostPorts.length-1]}`:t.hostPort} failed: port is already allocated`:"EACCES"===o.code&&Number(t.hostPort)<1024&&"linux"===process.platform&&0!==process.getuid?.()?`cannot publish port ${t.hostPort}: ports below 1024 need root; use a higher one (-p 8080:80), or allow it once: sudo sysctl -w net.ipv4.ip_unprivileged_port_start=${t.hostPort}`:`cannot publish port ${t.hostPort}: ${o.message}`);return r.statusCode=500,r}class PortProxies{constructor({host:t,log:o=()=>{}}){this.host=t,this.log=o,this.byContainer=new Map}async start(t,o,r){const e=parseBindings(o),s=[],n={};try{for(const t of e){if("tcp"!==t.proto&&"udp"!==t.proto){const o=new Error(`publishing ${t.proto} ports is not supported; use tcp or udp`);throw o.statusCode=400,o}let o=null,e=null;for(const s of t.hostPorts)try{o="udp"===t.proto?await this._listenUdp({...t,hostPort:s},r):await this._listen({...t,hostPort:s},r);break}catch(t){if(e=t,"EADDRINUSE"!==t.code)break}if(!o)throw portError(t,e);s.push(o),(n[t.key]||=[]).push({HostIp:t.hostIp,HostPort:String(o.port)})}}catch(t){for(const t of s)t.close();throw t.statusCode?t:portError(e[0]||{},t)}return this.byContainer.set(t,s),n}_listen(t,o){return new Promise((r,e)=>{const s=new Set,n=net.createServer(r=>{const e=this.host.connect(o,t.containerPort);s.add(r),s.add(e);const n=()=>{r.destroy(),e.destroy(),s.delete(r),s.delete(e)};r.on("error",n),e.on("error",n),r.on("close",n),e.on("close",n),r.pipe(e),e.pipe(r)});n.once("error",e),n.listen(t.hostPort,t.hostIp,()=>r({port:n.address().port,close:()=>{n.close();for(const t of s)t.destroy()}}))})}_listenUdp(t,o){return new Promise((r,e)=>{const s=t.hostIp.includes(":"),n=dgram.createSocket({type:s?"udp6":"udp4",reuseAddr:!1}),c=new Map,i=r=>{const e=`${r.address}:${r.port}`;let s=c.get(e);if(s)return s;const i=t=>n.send(t,r.port,r.address,()=>{});return s="local"===this.host.kind?this._directUdp(o,t.containerPort,i):this._shimUdp(o,t.containerPort,i),s.seen=Date.now(),c.set(e,s),s},a=setInterval(()=>{for(const[t,o]of c)Date.now()-o.seen>6e4&&(o.close(),c.delete(t))},1e4);a.unref(),n.on("message",(t,o)=>{const r=i(o);r.seen=Date.now(),r.send(t)}),n.once("error",t=>{clearInterval(a),e(t)}),n.bind(t.hostPort,t.hostIp,()=>{n.removeAllListeners("error"),n.on("error",o=>this.log(`udp ${t.hostPort}: ${o.message}`)),r({port:n.address().port,close:()=>{clearInterval(a);for(const t of c.values())t.close();c.clear();try{n.close()}catch(t){}}})})})}_directUdp(t,o,r){const e=dgram.createSocket("udp4");e.on("message",r),e.on("error",()=>{}),e.connect(o,t);const s=[];return e.once("connect",()=>{for(const t of s.splice(0))e.send(t)}),{send:t=>{try{e.send(t)}catch(o){s.push(t)}},close:()=>{try{e.close()}catch(t){}}}}_shimUdp(t,o,r){const e=this.host.spawn([this.host.binaries().shim,"udp",t,String(o)]);let s=Buffer.alloc(0);return e.stdout.on("data",t=>{for(s=s.length?Buffer.concat([s,t]):t;s.length>=2;){const t=s.readUInt16BE(0);if(s.length<2+t)break;r(Buffer.from(s.subarray(2,2+t))),s=s.subarray(2+t)}}),e.stderr.resume(),{send:t=>{const o=Buffer.alloc(2);o.writeUInt16BE(Math.min(t.length,65535)),e.write(Buffer.concat([o,t.subarray(0,65535)]))},close:()=>{e.end(),e.kill("SIGTERM")}}}stop(t){for(const o of this.byContainer.get(t)||[])o.close();this.byContainer.delete(t)}stopAll(){for(const t of[...this.byContainer.keys()])this.stop(t)}}module.exports={PortProxies,parseBindings};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const IO_CGROUP='\nif [ "$DOCK_IO_CGROUP" = 1 ] && [ -d /sys/fs/cgroup ]; then\n mkdir -p /sys/fs/cgroup/tnc-io 2>/dev/null\n echo +memory > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null\n echo 96M > /sys/fs/cgroup/tnc-io/memory.high 2>/dev/null\n echo $$ > /sys/fs/cgroup/tnc-io/cgroup.procs 2>/dev/null\nfi\ntrue\n',PREPARE=`\nset -e\nroot="$1"; tmpfs="$2"; shim="$3"; cid="$4"; workdir="$5"; shift 5\nDOCK_IO_CGROUP="$tmpfs"\n${IO_CGROUP}\nmkdir -p "$root"\nif [ "$tmpfs" = 1 ] && ! mountpoint -q "$root"; then mount -t tmpfs -o mode=0711 tnc-dock "$root"; fi\nmkdir -p "$root/layers" "$root/containers"\nlower=""\nwhile [ $# -gt 0 ]; do\n hex="$1"; blob="$2"; comp="$3"; shift 3\n d="$root/layers/$hex"\n if [ ! -f "$d.done" ]; then\n # containers that start together may share a layer: each unpacks into its\n # own folder, and only the first to finish moves it in (under a lock)\n x="$d.tmp.$$"\n rm -rf "$x"; mkdir -p "$x"\n if [ "$comp" = gzip ]; then tar -xzf "$blob" -C "$x"; else tar -xf "$blob" -C "$x"; fi\n find "$x" -name '.wh..wh..opq' -type f | while IFS= read -r f; do\n rm -f "$f"; "$shim" setxattr "$(dirname "$f")" trusted.overlay.opaque y\n done\n find "$x" -name '.wh.*' | while IFS= read -r f; do\n b=$(basename "$f"); t="$(dirname "$f")/\${b#.wh.}"\n rm -f "$f"; rm -rf "$t"; mknod "$t" c 0 0\n done\n n=0\n until mkdir "$d.lock" 2>/dev/null; do\n n=$((n+1)); [ "$n" -gt 600 ] && { rm -rf "$d.lock"; continue; } # a holder that died\n sleep 0.1\n done\n if [ -f "$d.done" ]; then rm -rf "$x"; else rm -rf "$d"; mv "$x" "$d"; touch "$d.done"; fi\n rmdir "$d.lock"\n fi\n lower="$d\${lower:+:$lower}"\ndone\nif [ -z "$lower" ]; then mkdir -p "$root/layers/empty"; lower="$root/layers/empty"; fi\nc="$root/containers/$cid"\nmkdir -p "$c/upper" "$c/work" "$c/merged"\nrm -f "$c/stdin" "$c/ctl" "$c/console.sock"; mkfifo "$c/stdin" "$c/ctl"\nmountpoint -q "$c/merged" || mount -t overlay overlay -o "lowerdir=$lower,upperdir=$c/upper,workdir=$c/work" "$c/merged"\nmkdir -p "$c/merged$workdir"\n`,UNMOUNT='c="$1/containers/$2"; mountpoint -q "$c/merged" && umount "$c/merged" || true',REMOVE='c="$1/containers/$2"; mountpoint -q "$c/merged" && umount -l "$c/merged"; rm -rf "$c"';function layerCompression(o){return/gzip/.test(o||"")?"gzip":"tar"}class Rootfs{constructor({host:o,blobsDir:t,root:r="/var/lib/tnc-dock"}){this.host=o,this.blobsDir=t,this.root=r}mergedPath(o){return`${this.root}/containers/${o}/merged`}stdinFifo(o){return`${this.root}/containers/${o}/stdin`}ctlFifo(o){return`${this.root}/containers/${o}/ctl`}consoleSock(o){return`${this.root}/containers/${o}/console.sock`}async prepare(o,t,r="/"){const{shim:n}=this.host.binaries(),e=[this.root,"vm"===this.host.kind?1:0,n,o,r];for(const o of t.layers||[]){const t=o.digest.replace(/^sha256:/,"");e.push(t,this.host.guestPath(`${this.blobsDir}/${t}`),layerCompression(o.mediaType))}return await this.host.sh(PREPARE,e),this.mergedPath(o)}unmount(o){return this.host.sh(UNMOUNT,[this.root,o])}remove(o){return this.host.sh(REMOVE,[this.root,o])}async readFile(o,t){const r=await this.host.run(["cat",`${this.mergedPath(o)}${t}`]);return 0===r.code?r.stdout:""}}module.exports={Rootfs,IO_CGROUP};
7
+ "use strict";const fs=require("fs"),crypto=require("crypto"),{pipeline}=require("stream/promises"),IO_CGROUP='\nif [ "$DOCK_IO_CGROUP" = 1 ] && [ -d /sys/fs/cgroup ]; then\n mkdir -p /sys/fs/cgroup/tnc-io 2>/dev/null\n echo +memory > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null\n echo 96M > /sys/fs/cgroup/tnc-io/memory.high 2>/dev/null\n echo $$ > /sys/fs/cgroup/tnc-io/cgroup.procs 2>/dev/null\nfi\ntrue\n',PREPARE=`\nset -e\nroot="$1"; tmpfs="$2"; shim="$3"; cid="$4"; workdir="$5"; shift 5\nDOCK_IO_CGROUP="$tmpfs"\n${IO_CGROUP}\nmkdir -p "$root"\nif [ "$tmpfs" = 1 ] && ! mountpoint -q "$root"; then mount -t tmpfs -o mode=0711 tnc-dock "$root"; fi\n# userns-remap: layers unpacked once more for each id range, owners moved into it\nlayers="$root/layers"\nif [ -n "\${REMAP:-}" ]; then layers="$root/layers-\${REMAP%% *}"; fi\nmkdir -p "$layers" "$root/containers"\nlower=""\nwhile [ $# -gt 0 ]; do\n hex="$1"; blob="$2"; comp="$3"; shift 3\n d="$layers/$hex"\n if [ ! -f "$d.done" ]; then\n # containers that start together may share a layer: each unpacks into its\n # own folder, and only the first to finish moves it in (under a lock)\n x="$d.tmp.$$"\n rm -rf "$x"; mkdir -p "$x"\n if [ "$comp" = gzip ]; then tar -xzf "$blob" -C "$x"; else tar -xf "$blob" -C "$x"; fi\n opq="$x.opq"; find "$x" -name '.wh..wh..opq' -type f > "$opq"\n while IFS= read -r f; do rm -f "$f"; done < "$opq"\n if [ "$TNC_ROOTLESS" = 1 ]; then\n # rootless: mknod is not allowed in a user namespace, but overlayfs makes the\n # whiteouts itself when the paths are deleted through a mount with this layer as upper\n wl="$x.wl"; list="$x.whl"; rm -rf "$wl" "$x.ww" "$x.wm"; mkdir -p "$wl" "$x.ww" "$x.wm"; : > "$list"\n find "$x" -name '.wh.*' | while IFS= read -r f; do\n b=$(basename "$f"); rel="\${f#"$x"/}"; rel="$(dirname "$rel")/\${b#.wh.}"\n rm -f "$f"; rm -rf "$x/$rel"; mkdir -p "$wl/$(dirname "$rel")"; touch "$wl/$rel"; echo "$rel" >> "$list"\n done\n if [ -s "$list" ]; then\n mount -t overlay tnc-wh -o "lowerdir=$wl,upperdir=$x,workdir=$x.ww,userxattr" "$x.wm"\n while IFS= read -r rel; do rm -rf "$x.wm/$rel"; done < "$list"\n umount "$x.wm"\n fi\n rm -rf "$wl" "$x.ww" "$x.wm" "$list"\n else\n find "$x" -name '.wh.*' | while IFS= read -r f; do\n b=$(basename "$f"); t="$(dirname "$f")/\${b#.wh.}"\n rm -f "$f"; rm -rf "$t"; mknod "$t" c 0 0\n done\n fi\n # opaque folders last: an opaque upper hides the lower entries the whiteouts above are made against\n xns=trusted; [ "$TNC_ROOTLESS" = 1 ] && xns=user\n while IFS= read -r f; do "$shim" setxattr "$(dirname "$f")" "$xns.overlay.opaque" y; done < "$opq"\n rm -f "$opq"\n if [ -n "\${REMAP:-}" ]; then "$shim" idshift "$x" $REMAP to; fi\n n=0\n until mkdir "$d.lock" 2>/dev/null; do\n n=$((n+1)); [ "$n" -gt 600 ] && { rm -rf "$d.lock"; continue; } # a holder that died\n sleep 0.1\n done\n if [ -f "$d.done" ]; then rm -rf "$x"; else rm -rf "$d"; mv "$x" "$d"; touch "$d.done"; fi\n rmdir "$d.lock"\n fi\n lower="$d\${lower:+:$lower}"\ndone\nif [ -z "$lower" ]; then mkdir -p "$layers/empty"; lower="$layers/empty"; fi\nc="$root/containers/$cid"\nmkdir -p "$c/upper" "$c/work" "$c/merged"\nif [ -n "\${REMAP:-}" ]; then\n set -- $REMAP\n # the container's root owns / (the upper dir), and may reach its folder on the way to it\n [ "$(stat -c %u "$c/upper")" = "$1" ] || chown "$1:$2" "$c/upper"\n chmod 0711 "$root" "$root/containers" "$c"\nfi\nrm -f "$c/stdin" "$c/ctl" "$c/console.sock"; mkfifo "$c/stdin" "$c/ctl"\nmountpoint -q "$c/merged" || mount -t overlay overlay -o "lowerdir=$lower,upperdir=$c/upper,workdir=$c/work\${TNC_ROOTLESS:+,userxattr}" "$c/merged"\nif [ ! -e "$c/merged$workdir" ]; then\n # a WORKDIR the image lacks: made for the container's root (remap: the range's)\n p="$workdir"; top=""; while [ "$p" != / ] && [ ! -e "$c/merged$p" ]; do top="$p"; p=$(dirname "$p"); done\n mkdir -p "$c/merged$workdir"\n if [ -n "\${REMAP:-}" ] && [ -n "$top" ]; then set -- $REMAP; chown -R "$1:$2" "$c/merged$top"; fi\nfi\n`,UNMOUNT='c="$1/containers/$2"; mountpoint -q "$c/merged" && umount "$c/merged" || true',REMOVE='c="$1/containers/$2"; mountpoint -q "$c/merged" && umount -l "$c/merged"; rm -rf "$c"';function layerCompression(e){return/gzip/.test(e||"")?"gzip":/zstd/.test(e||"")?"zstd":"tar"}async function zstdToTar(e){const r=`${e}.tar`;if(fs.existsSync(r))return r;const t=require("zlib");if("function"!=typeof t.createZstdDecompress){const e=new Error(`zstd-compressed layers need Node 22.15+ or 23.8+ (this is ${process.version})`);throw e.statusCode=501,e}const o=`${r}.${crypto.randomBytes(6).toString("hex")}.part`;try{await pipeline(fs.createReadStream(e),t.createZstdDecompress(),fs.createWriteStream(o)),fs.renameSync(o,r)}catch(e){throw fs.rmSync(o,{force:!0}),e}return r}class Rootfs{constructor({host:e,blobsDir:r,root:t="/var/lib/tnc-dock"}){this.host=e,this.blobsDir=r,this.root=t}mergedPath(e){return`${this.root}/containers/${e}/merged`}stdinFifo(e){return`${this.root}/containers/${e}/stdin`}ctlFifo(e){return`${this.root}/containers/${e}/ctl`}consoleSock(e){return`${this.root}/containers/${e}/console.sock`}async prepare(e,r,t="/",{userns:o=null}={}){const{shim:n}=this.host.binaries(),s=[this.root,"vm"===this.host.kind?1:0,n,e,t];for(const e of r.layers||[]){const r=e.digest.replace(/^sha256:/,""),t=layerCompression(e.mediaType),o="zstd"===t?await zstdToTar(`${this.blobsDir}/${r}`):`${this.blobsDir}/${r}`;s.push(r,this.host.guestPath(o),"zstd"===t?"tar":t)}return await this.host.sh(o?`REMAP="${Number(o.uid)} ${Number(o.gid)} ${Number(o.size)}"\n${PREPARE}`:PREPARE,s),this.mergedPath(e)}unmount(e){return this.host.sh(UNMOUNT,[this.root,e])}remove(e){return this.host.sh(REMOVE,[this.root,e])}async readFile(e,r){const t=await this.host.run(["cat",`${this.mergedPath(e)}${r}`]);return 0===t.code?t.stdout:""}}module.exports={Rootfs,IO_CGROUP,PREPARE,layerCompression,zstdToTar};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const{defaultSeccomp}=require("./seccomp"),ALL_CAPS=["CAP_CHOWN","CAP_DAC_OVERRIDE","CAP_DAC_READ_SEARCH","CAP_FOWNER","CAP_FSETID","CAP_KILL","CAP_SETGID","CAP_SETUID","CAP_SETPCAP","CAP_LINUX_IMMUTABLE","CAP_NET_BIND_SERVICE","CAP_NET_BROADCAST","CAP_NET_ADMIN","CAP_NET_RAW","CAP_IPC_LOCK","CAP_IPC_OWNER","CAP_SYS_MODULE","CAP_SYS_RAWIO","CAP_SYS_CHROOT","CAP_SYS_PTRACE","CAP_SYS_PACCT","CAP_SYS_ADMIN","CAP_SYS_BOOT","CAP_SYS_NICE","CAP_SYS_RESOURCE","CAP_SYS_TIME","CAP_SYS_TTY_CONFIG","CAP_MKNOD","CAP_LEASE","CAP_AUDIT_WRITE","CAP_AUDIT_CONTROL","CAP_SETFCAP","CAP_MAC_OVERRIDE","CAP_MAC_ADMIN","CAP_SYSLOG","CAP_WAKE_ALARM","CAP_BLOCK_SUSPEND","CAP_AUDIT_READ"],DEFAULT_CAPS=["CAP_CHOWN","CAP_DAC_OVERRIDE","CAP_FSETID","CAP_FOWNER","CAP_MKNOD","CAP_NET_RAW","CAP_SETGID","CAP_SETUID","CAP_SETFCAP","CAP_SETPCAP","CAP_NET_BIND_SERVICE","CAP_SYS_CHROOT","CAP_KILL","CAP_AUDIT_WRITE"],MASKED=["/proc/asound","/proc/acpi","/proc/kcore","/proc/keys","/proc/latency_stats","/proc/timer_list","/proc/timer_stats","/proc/sched_debug","/proc/scsi","/sys/firmware","/sys/devices/virtual/powercap"],READONLY=["/proc/bus","/proc/fs","/proc/irq","/proc/sys","/proc/sysrq-trigger"],DEFAULT_PATH="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";function mergeEnv(e=[],s=[]){const t=new Map;for(const o of[...e,...s]){const e=o.indexOf("=");t.set(-1===e?o:o.slice(0,e),o)}return t.has("PATH")||t.set("PATH",DEFAULT_PATH),[...t.values()]}function processArgs(e,s){const t=Array.isArray(e.Entrypoint);return[...(t?e.Entrypoint.filter(e=>""!==e):s.Entrypoint||[])||[],...(Array.isArray(e.Cmd)&&e.Cmd.length?e.Cmd:t?[]:s.Cmd||[])||[]]}function resolveUser(e,s="",t=""){if(!e)return{uid:0,gid:0};const[o,r]=e.split(":"),i=s.split("\n").map(e=>e.split(":")).filter(e=>e.length>=4),n=t.split("\n").map(e=>e.split(":")).filter(e=>e.length>=3);let c,p;if(/^\d+$/.test(o)){c=Number(o);const e=i.find(e=>Number(e[2])===c);p=e?Number(e[3]):0}else{const e=i.find(e=>e[0]===o);if(!e){const e=new Error(`unable to find user ${o}: no matching entries in passwd file`);throw e.statusCode=400,e}c=Number(e[2]),p=Number(e[3])}if(void 0!==r)if(/^\d+$/.test(r))p=Number(r);else{const e=n.find(e=>e[0]===r);if(!e){const e=new Error(`unable to find group ${r}: no matching entries in group file`);throw e.statusCode=400,e}p=Number(e[2])}return{uid:c,gid:p}}function additionalGids(e=[],s=""){const t=s.split("\n").map(e=>e.split(":")).filter(e=>e.length>=3);return(e||[]).map(e=>{if(/^\d+$/.test(String(e)))return Number(e);const s=t.find(s=>s[0]===e);if(!s){const s=new Error(`unable to find group ${e}: no matching entries in group file`);throw s.statusCode=400,s}return Number(s[2])})}function resources(e={}){const s={},t={};e.Memory>0&&(t.limit=e.Memory),e.MemorySwap&&t.limit&&(t.swap=e.MemorySwap),e.MemoryReservation>0&&(t.reservation=e.MemoryReservation),Object.keys(t).length&&(s.memory=t);const o={};e.NanoCpus>0&&Object.assign(o,{quota:Math.round(e.NanoCpus/1e9*1e5),period:1e5}),e.CpuQuota>0&&Object.assign(o,{quota:e.CpuQuota,period:e.CpuPeriod>0?e.CpuPeriod:1e5}),e.CpuShares>0&&(o.shares=e.CpuShares),e.CpusetCpus&&(o.cpus=e.CpusetCpus),e.CpusetMems&&(o.mems=e.CpusetMems),Object.keys(o).length&&(s.cpu=o),e.PidsLimit>0&&(s.pids={limit:e.PidsLimit}),e.BlkioWeight>0&&(s.blockIO={weight:e.BlkioWeight});const r=e.Devices||[];return s.devices=e.Privileged?[{allow:!0,access:"rwm"}]:[{allow:!1,access:"rwm"},...DEFAULT_DEVICE_RULES,...r.map(e=>({allow:!0,type:e.type||"c",major:e.major,minor:e.minor,access:e.CgroupPermissions||"rwm"}))],s}const DEFAULT_DEVICE_RULES=[{allow:!0,type:"c",access:"m"},{allow:!0,type:"b",access:"m"},{allow:!0,type:"c",major:10,minor:200,access:"rwm"},{allow:!0,type:"c",major:1,minor:3,access:"rwm"},{allow:!0,type:"c",major:1,minor:5,access:"rwm"},{allow:!0,type:"c",major:1,minor:7,access:"rwm"},{allow:!0,type:"c",major:1,minor:8,access:"rwm"},{allow:!0,type:"c",major:1,minor:9,access:"rwm"},{allow:!0,type:"c",major:5,minor:0,access:"rwm"},{allow:!0,type:"c",major:5,minor:1,access:"rwm"},{allow:!0,type:"c",major:5,minor:2,access:"rwm"},{allow:!0,type:"c",major:136,access:"rwm"}];function rlimits(e=[]){const s=new Map([["RLIMIT_NOFILE",{type:"RLIMIT_NOFILE",hard:1048576,soft:1048576}]]);for(const t of e||[]){const e=`RLIMIT_${String(t.Name).toUpperCase()}`;s.set(e,{type:e,soft:t.Soft,hard:t.Hard})}return[...s.values()]}const NAMESPACED_SYSCTL=/^(net\.|kernel\.(shm|msg|sem)|fs\.mqueue\.)/;function sysctls(e={}){for(const s of Object.keys(e||{}))if(!NAMESPACED_SYSCTL.test(s)){const e=new Error(`sysctl "${s}" is not allowed: only namespaced ones (net.*, kernel.shm*, kernel.msg*, kernel.sem, fs.mqueue.*)`);throw e.statusCode=400,e}return e||{}}function buildSpec({id:e,rootfs:s,args:t,env:o,cwd:r,user:i,hostname:n,nsPath:c,mounts:p,hostConfig:a,terminal:m=!1,devices:u=[]}){const A=[{type:"pid"},{type:"ipc"},{type:"uts"},{type:"mount"}];"host"!==a.CgroupnsMode&&A.push({type:"cgroup"}),c&&A.push({type:"network",path:c});const l=a.ShmSize>0?a.ShmSize:67108864,C=e=>"CAP_"+String(e).toUpperCase().replace(/^CAP_/,""),_=!!a.Privileged,d=(a.CapAdd||[]).map(C),P=(a.CapDrop||[]).map(C),S=_||d.includes("CAP_ALL")?ALL_CAPS:DEFAULT_CAPS,E=P.includes("CAP_ALL")?[]:S.filter(e=>!P.includes(e)),y=[...new Set([...E,...d.filter(e=>"CAP_ALL"!==e)])],f=a.SecurityOpt||[],g=_||f.some(e=>/^seccomp[=:]unconfined$/.test(e)),w=f.some(e=>/^no-new-privileges(:true|=true)?$/.test(e));return{ociVersion:"1.0.2",process:{terminal:m,user:i,args:t,env:o,cwd:r,capabilities:{bounding:y,effective:y,permitted:y},rlimits:rlimits(a.Ulimits),noNewPrivileges:w},root:{path:s,readonly:!!a.ReadonlyRootfs},hostname:n,mounts:[{destination:"/proc",type:"proc",source:"proc"},{destination:"/dev",type:"tmpfs",source:"tmpfs",options:["nosuid","strictatime","mode=755","size=65536k"]},{destination:"/dev/pts",type:"devpts",source:"devpts",options:["nosuid","noexec","newinstance","ptmxmode=0666","mode=0620","gid=5"]},{destination:"/dev/shm",type:"tmpfs",source:"shm",options:["nosuid","noexec","nodev","mode=1777",`size=${l}`]},{destination:"/dev/mqueue",type:"mqueue",source:"mqueue",options:["nosuid","noexec","nodev"]},{destination:"/sys",type:"sysfs",source:"sysfs",options:["nosuid","noexec","nodev",_?"rw":"ro"]},{destination:"/sys/fs/cgroup",type:"cgroup",source:"cgroup",options:["nosuid","noexec","nodev","relatime","ro"]},...p],linux:{namespaces:A,cgroupsPath:`/tnc-dock/${e}`,resources:resources({...a,Devices:u}),...u.length?{devices:u.map(({path:e,type:s,major:t,minor:o,fileMode:r,uid:i,gid:n})=>({path:e,type:s,major:t,minor:o,fileMode:r,uid:i,gid:n}))}:{},...Object.keys(a.Sysctls||{}).length?{sysctl:sysctls(a.Sysctls)}:{},maskedPaths:_?[]:MASKED,readonlyPaths:_?[]:READONLY,...g?{}:{seccomp:defaultSeccomp(process.arch,y)}}}}module.exports={buildSpec,mergeEnv,processArgs,resolveUser,additionalGids,rlimits,sysctls,DEFAULT_CAPS,ALL_CAPS};
7
+ "use strict";const{defaultSeccomp}=require("./seccomp"),ALL_CAPS=["CAP_CHOWN","CAP_DAC_OVERRIDE","CAP_DAC_READ_SEARCH","CAP_FOWNER","CAP_FSETID","CAP_KILL","CAP_SETGID","CAP_SETUID","CAP_SETPCAP","CAP_LINUX_IMMUTABLE","CAP_NET_BIND_SERVICE","CAP_NET_BROADCAST","CAP_NET_ADMIN","CAP_NET_RAW","CAP_IPC_LOCK","CAP_IPC_OWNER","CAP_SYS_MODULE","CAP_SYS_RAWIO","CAP_SYS_CHROOT","CAP_SYS_PTRACE","CAP_SYS_PACCT","CAP_SYS_ADMIN","CAP_SYS_BOOT","CAP_SYS_NICE","CAP_SYS_RESOURCE","CAP_SYS_TIME","CAP_SYS_TTY_CONFIG","CAP_MKNOD","CAP_LEASE","CAP_AUDIT_WRITE","CAP_AUDIT_CONTROL","CAP_SETFCAP","CAP_MAC_OVERRIDE","CAP_MAC_ADMIN","CAP_SYSLOG","CAP_WAKE_ALARM","CAP_BLOCK_SUSPEND","CAP_AUDIT_READ"],DEFAULT_CAPS=["CAP_CHOWN","CAP_DAC_OVERRIDE","CAP_FSETID","CAP_FOWNER","CAP_MKNOD","CAP_NET_RAW","CAP_SETGID","CAP_SETUID","CAP_SETFCAP","CAP_SETPCAP","CAP_NET_BIND_SERVICE","CAP_SYS_CHROOT","CAP_KILL","CAP_AUDIT_WRITE"],MASKED=["/proc/asound","/proc/acpi","/proc/kcore","/proc/keys","/proc/latency_stats","/proc/timer_list","/proc/timer_stats","/proc/sched_debug","/proc/scsi","/sys/firmware","/sys/devices/virtual/powercap"],READONLY=["/proc/bus","/proc/fs","/proc/irq","/proc/sys","/proc/sysrq-trigger"],DEFAULT_PATH="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";function mergeEnv(e=[],s=[]){const t=new Map;for(const o of[...e,...s]){const e=o.indexOf("=");t.set(-1===e?o:o.slice(0,e),o)}return t.has("PATH")||t.set("PATH",DEFAULT_PATH),[...t.values()]}function processArgs(e,s){const t=Array.isArray(e.Entrypoint);return[...(t?e.Entrypoint.filter(e=>""!==e):s.Entrypoint||[])||[],...(Array.isArray(e.Cmd)&&e.Cmd.length?e.Cmd:t?[]:s.Cmd||[])||[]]}function resolveUser(e,s="",t=""){if(!e)return{uid:0,gid:0};const[o,r]=e.split(":"),i=s.split("\n").map(e=>e.split(":")).filter(e=>e.length>=4),n=t.split("\n").map(e=>e.split(":")).filter(e=>e.length>=3);let c,p;if(/^\d+$/.test(o)){c=Number(o);const e=i.find(e=>Number(e[2])===c);p=e?Number(e[3]):0}else{const e=i.find(e=>e[0]===o);if(!e){const e=new Error(`unable to find user ${o}: no matching entries in passwd file`);throw e.statusCode=400,e}c=Number(e[2]),p=Number(e[3])}if(void 0!==r)if(/^\d+$/.test(r))p=Number(r);else{const e=n.find(e=>e[0]===r);if(!e){const e=new Error(`unable to find group ${r}: no matching entries in group file`);throw e.statusCode=400,e}p=Number(e[2])}return{uid:c,gid:p}}function additionalGids(e=[],s=""){const t=s.split("\n").map(e=>e.split(":")).filter(e=>e.length>=3);return(e||[]).map(e=>{if(/^\d+$/.test(String(e)))return Number(e);const s=t.find(s=>s[0]===e);if(!s){const s=new Error(`unable to find group ${e}: no matching entries in group file`);throw s.statusCode=400,s}return Number(s[2])})}function resources(e={}){const s={},t={};e.Memory>0&&(t.limit=e.Memory),e.MemorySwap&&t.limit&&(t.swap=e.MemorySwap),e.MemoryReservation>0&&(t.reservation=e.MemoryReservation),Object.keys(t).length&&(s.memory=t);const o={};e.NanoCpus>0&&Object.assign(o,{quota:Math.round(e.NanoCpus/1e9*1e5),period:1e5}),e.CpuQuota>0&&Object.assign(o,{quota:e.CpuQuota,period:e.CpuPeriod>0?e.CpuPeriod:1e5}),e.CpuShares>0&&(o.shares=e.CpuShares),e.CpusetCpus&&(o.cpus=e.CpusetCpus),e.CpusetMems&&(o.mems=e.CpusetMems),Object.keys(o).length&&(s.cpu=o),e.PidsLimit>0&&(s.pids={limit:e.PidsLimit}),e.BlkioWeight>0&&(s.blockIO={weight:e.BlkioWeight});const r=e.Devices||[];return s.devices=e.Privileged?[{allow:!0,access:"rwm"}]:[{allow:!1,access:"rwm"},...DEFAULT_DEVICE_RULES,...r.map(e=>({allow:!0,type:e.type||"c",major:e.major,minor:e.minor,access:e.CgroupPermissions||"rwm"}))],s}const DEFAULT_DEVICE_RULES=[{allow:!0,type:"c",access:"m"},{allow:!0,type:"b",access:"m"},{allow:!0,type:"c",major:10,minor:200,access:"rwm"},{allow:!0,type:"c",major:1,minor:3,access:"rwm"},{allow:!0,type:"c",major:1,minor:5,access:"rwm"},{allow:!0,type:"c",major:1,minor:7,access:"rwm"},{allow:!0,type:"c",major:1,minor:8,access:"rwm"},{allow:!0,type:"c",major:1,minor:9,access:"rwm"},{allow:!0,type:"c",major:5,minor:0,access:"rwm"},{allow:!0,type:"c",major:5,minor:1,access:"rwm"},{allow:!0,type:"c",major:5,minor:2,access:"rwm"},{allow:!0,type:"c",major:136,access:"rwm"}];function rlimits(e=[]){const s=new Map([["RLIMIT_NOFILE",{type:"RLIMIT_NOFILE",hard:1048576,soft:1048576}]]);for(const t of e||[]){const e=`RLIMIT_${String(t.Name).toUpperCase()}`;s.set(e,{type:e,soft:t.Soft,hard:t.Hard})}return[...s.values()]}const NAMESPACED_SYSCTL=/^(net\.|kernel\.(shm|msg|sem)|fs\.mqueue\.)/;function sysctls(e={}){for(const s of Object.keys(e||{}))if(!NAMESPACED_SYSCTL.test(s)){const e=new Error(`sysctl "${s}" is not allowed: only namespaced ones (net.*, kernel.shm*, kernel.msg*, kernel.sem, fs.mqueue.*)`);throw e.statusCode=400,e}return e||{}}function buildSpec({id:e,rootfs:s,args:t,env:o,cwd:r,user:i,hostname:n,nsPath:c,mounts:p,hostConfig:a,terminal:m=!1,devices:u=[],cgroupParent:A="/tnc-dock",userns:l=null}){const C=[{type:"pid"},{type:"ipc"},{type:"uts"},{type:"mount"}];"host"!==a.CgroupnsMode&&C.push({type:"cgroup"}),c&&C.push({type:"network",path:c}),l&&C.push({type:"user"});const d=a.ShmSize>0?a.ShmSize:67108864,_=e=>"CAP_"+String(e).toUpperCase().replace(/^CAP_/,""),P=!!a.Privileged,S=(a.CapAdd||[]).map(_),E=(a.CapDrop||[]).map(_),y=P||S.includes("CAP_ALL")?ALL_CAPS:DEFAULT_CAPS,f=E.includes("CAP_ALL")?[]:y.filter(e=>!E.includes(e)),g=[...new Set([...f,...S.filter(e=>"CAP_ALL"!==e)])],w=a.SecurityOpt||[],T=P||w.some(e=>/^seccomp[=:]unconfined$/.test(e)),h=w.some(e=>/^no-new-privileges(:true|=true)?$/.test(e));return{ociVersion:"1.0.2",process:{terminal:m,user:i,args:t,env:o,cwd:r,capabilities:{bounding:g,effective:g,permitted:g},rlimits:rlimits(a.Ulimits),noNewPrivileges:h},root:{path:s,readonly:!!a.ReadonlyRootfs},hostname:n,mounts:[{destination:"/proc",type:"proc",source:"proc"},{destination:"/dev",type:"tmpfs",source:"tmpfs",options:["nosuid","strictatime","mode=755","size=65536k"]},{destination:"/dev/pts",type:"devpts",source:"devpts",options:["nosuid","noexec","newinstance","ptmxmode=0666","mode=0620","gid=5"]},{destination:"/dev/shm",type:"tmpfs",source:"shm",options:["nosuid","noexec","nodev","mode=1777",`size=${d}`]},{destination:"/dev/mqueue",type:"mqueue",source:"mqueue",options:["nosuid","noexec","nodev"]},{destination:"/sys",type:"sysfs",source:"sysfs",options:["nosuid","noexec","nodev",P?"rw":"ro"]},{destination:"/sys/fs/cgroup",type:"cgroup",source:"cgroup",options:["nosuid","noexec","nodev","relatime",P?"rw":"ro"]},...p],linux:{namespaces:C,...l?require("./userns").idMappings(l):{},cgroupsPath:`${A}/${e}`,resources:resources({...a,Devices:u}),...u.length?{devices:u.map(({path:e,type:s,major:t,minor:o,fileMode:r,uid:i,gid:n})=>({path:e,type:s,major:t,minor:o,fileMode:r,uid:i,gid:n}))}:{},...Object.keys(a.Sysctls||{}).length?{sysctl:sysctls(a.Sysctls)}:{},maskedPaths:P?[]:MASKED,readonlyPaths:P?[]:READONLY,...T?{}:{seccomp:defaultSeccomp(process.arch,g)}}}}module.exports={buildSpec,mergeEnv,processArgs,resolveUser,additionalGids,rlimits,sysctls,DEFAULT_CAPS,ALL_CAPS};