├── .github ├── FUNDING.yml └── workflows │ ├── claude.yml │ ├── build.yml │ └── webapp-dev.yml ├── .gitignore ├── renovate.json ├── public ├── img │ ├── logo.png │ └── favicon │ │ ├── favicon.ico │ │ ├── apple-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── ms-icon-70x70.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── ms-icon-144x144.png │ │ ├── ms-icon-150x150.png │ │ ├── ms-icon-310x310.png │ │ ├── android-icon-36x36.png │ │ ├── android-icon-48x48.png │ │ ├── android-icon-72x72.png │ │ ├── android-icon-96x96.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ ├── android-icon-144x144.png │ │ ├── android-icon-192x192.png │ │ ├── apple-icon-precomposed.png │ │ └── manifest.json ├── vendor │ ├── js │ │ ├── theme-chrome.js │ │ ├── dataTables.bootstrap4.min.js │ │ ├── mode-sh.js │ │ └── popper.min.js │ └── css │ │ ├── docs.min.css │ │ ├── dataTables.bootstrap4.min.css │ │ └── dark-theme.css └── index.ejs ├── root ├── defaults │ ├── default │ └── nginx.conf ├── donate.txt ├── etc │ └── supervisor.conf └── start.sh ├── tests ├── fixtures │ ├── sample-endpoints.yml │ ├── sample-custom.ipxe │ └── sample-boot.cfg ├── setup.js ├── unit │ ├── lib-utils.test.js │ ├── basic.test.js │ ├── functional.test.js │ ├── app.test.js │ ├── socket-logic.test.js.disabled │ └── utils.test.js ├── integration │ ├── routes-simple.test.js │ ├── routes.test.js.disabled │ └── socket.test.js.disabled └── README.md ├── jest.config.js ├── Dockerfile ├── package.json ├── lib └── utils.js ├── README.md ├── LICENSE └── app.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: netbootxyz 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .c9 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:recommended" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /public/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/logo.png -------------------------------------------------------------------------------- /public/img/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/favicon.ico -------------------------------------------------------------------------------- /public/img/favicon/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon.png -------------------------------------------------------------------------------- /root/defaults/default: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | location / { 4 | root /assets; 5 | autoindex on; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /public/img/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /public/img/favicon/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/img/favicon/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/img/favicon/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/img/favicon/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/img/favicon/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/img/favicon/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/img/favicon/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/img/favicon/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/ms-icon-310x310.png -------------------------------------------------------------------------------- /root/donate.txt: -------------------------------------------------------------------------------- 1 | opencollective: https://opencollective.com/netbootxyz/donate 2 | github: https://github.com/sponsors/netbootxyz 3 | -------------------------------------------------------------------------------- /public/img/favicon/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/android-icon-36x36.png -------------------------------------------------------------------------------- /public/img/favicon/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/android-icon-48x48.png -------------------------------------------------------------------------------- /public/img/favicon/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/android-icon-72x72.png -------------------------------------------------------------------------------- /public/img/favicon/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/android-icon-96x96.png -------------------------------------------------------------------------------- /public/img/favicon/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/img/favicon/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/img/favicon/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/img/favicon/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/img/favicon/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/img/favicon/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/android-icon-144x144.png -------------------------------------------------------------------------------- /public/img/favicon/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/android-icon-192x192.png -------------------------------------------------------------------------------- /public/img/favicon/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbootxyz/webapp/HEAD/public/img/favicon/apple-icon-precomposed.png -------------------------------------------------------------------------------- /public/img/favicon/manifest.json: -------------------------------------------------------------------------------- 1 | {"name":"App","icons":[{"src":"/android-icon-36x36.png","sizes":"36x36","type":"image/png"},{"src":"/android-icon-48x48.png","sizes":"48x48","type":"image/png"},{"src":"/android-icon-72x72.png","sizes":"48x48","type":"image/png"},{"src":"/android-icon-96x96.png","sizes":"120x120","type":"image/png"},{"src":"/android-icon-144x144.png","sizes":"144x144","type":"image/png"},{"src":"/android-icon-192x192.png","sizes":"192x192","type":"image/png"}]} -------------------------------------------------------------------------------- /tests/fixtures/sample-endpoints.yml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | ubuntu: 3 | name: "Ubuntu" 4 | url: "http://archive.ubuntu.com/ubuntu/" 5 | menu: "ubuntu.ipxe" 6 | enabled: true 7 | 8 | debian: 9 | name: "Debian" 10 | url: "http://deb.debian.org/debian/" 11 | menu: "debian.ipxe" 12 | enabled: true 13 | 14 | fedora: 15 | name: "Fedora" 16 | url: "https://download.fedoraproject.org/pub/fedora/" 17 | menu: "fedora.ipxe" 18 | enabled: true 19 | 20 | centos: 21 | name: "CentOS" 22 | url: "http://mirror.centos.org/centos/" 23 | menu: "centos.ipxe" 24 | enabled: false -------------------------------------------------------------------------------- /root/defaults/nginx.conf: -------------------------------------------------------------------------------- 1 | user nbxyz; 2 | worker_processes 4; 3 | pid /run/nginx.pid; 4 | include /etc/nginx/modules/*.conf; 5 | 6 | events { 7 | worker_connections 768; 8 | } 9 | 10 | http { 11 | sendfile on; 12 | tcp_nopush on; 13 | tcp_nodelay on; 14 | keepalive_timeout 65; 15 | types_hash_max_size 2048; 16 | client_max_body_size 0; 17 | include /etc/nginx/mime.types; 18 | default_type application/octet-stream; 19 | access_log /config/log/nginx/access.log; 20 | error_log /config/log/nginx/error.log; 21 | gzip on; 22 | gzip_disable "msie6"; 23 | include /config/nginx/site-confs/*; 24 | 25 | } 26 | daemon off; 27 | -------------------------------------------------------------------------------- /tests/fixtures/sample-custom.ipxe: -------------------------------------------------------------------------------- 1 | #!ipxe 2 | # Custom netboot.xyz menu 3 | # This is a sample custom menu for testing 4 | 5 | :custom_start 6 | menu Custom Tools 7 | item --gap -- ---- Custom Tools ---- 8 | item diagnostics Hardware Diagnostics 9 | item backup Backup & Recovery Tools 10 | item security Security Tools 11 | item --gap -- ---- Back ---- 12 | item back Back to main menu 13 | choose selected 14 | goto ${selected} 15 | 16 | :diagnostics 17 | echo Starting hardware diagnostics... 18 | # Add diagnostic tools here 19 | goto custom_start 20 | 21 | :backup 22 | echo Loading backup tools... 23 | # Add backup tools here 24 | goto custom_start 25 | 26 | :security 27 | echo Loading security tools... 28 | # Add security tools here 29 | goto custom_start 30 | 31 | :back 32 | exit -------------------------------------------------------------------------------- /root/etc/supervisor.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | 5 | [program:syslog-ng] 6 | command=/usr/sbin/syslog-ng --foreground --no-caps 7 | stdout_syslog=true 8 | stdout_capture_maxbytes=1MB 9 | priority = 1 10 | 11 | [program:nginx] 12 | command = /usr/sbin/nginx -c /config/nginx/nginx.conf 13 | startretries = 2 14 | daemon=off 15 | priority = 2 16 | 17 | [program:webapp] 18 | environment=NODE_ENV="production",PORT=3000 19 | command=/usr/bin/node app.js 20 | user=nbxyz 21 | directory=/app 22 | priority = 3 23 | 24 | [program:in.tftpd] 25 | command=/usr/sbin/in.tftpd -Lvvv --user nbxyz --secure %(ENV_TFTPD_OPTS)s /config/menus 26 | stdout_logfile=/config/tftpd.log 27 | redirect_stderr=true 28 | priority = 4 29 | 30 | [program:messages-log] 31 | command=tail -f /var/log/messages 32 | stdout_logfile=/dev/stdout 33 | stdout_logfile_maxbytes=0 34 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | testMatch: [ 4 | '**/tests/**/*.test.js', 5 | '**/__tests__/**/*.test.js' 6 | ], 7 | collectCoverageFrom: [ 8 | 'lib/**/*.js', 9 | '!**/node_modules/**', 10 | '!**/tests/**' 11 | ], 12 | coverageDirectory: 'coverage', 13 | coverageReporters: ['text', 'lcov', 'html'], 14 | setupFilesAfterEnv: ['/tests/setup.js'], 15 | testTimeout: 5000, 16 | verbose: false, 17 | collectCoverage: false, 18 | detectOpenHandles: true, 19 | forceExit: true, 20 | clearMocks: true, 21 | resetMocks: true, 22 | restoreMocks: true, 23 | // Disable coverage thresholds since we're testing logic patterns, not code execution 24 | // coverageThreshold: { 25 | // global: { 26 | // branches: 10, 27 | // functions: 10, 28 | // lines: 10, 29 | // statements: 10 30 | // } 31 | // }, 32 | // Prevent Jest from keeping the process alive 33 | maxWorkers: 1, 34 | // Handle async operations better 35 | testEnvironmentOptions: { 36 | // Force close any lingering connections 37 | teardown: 'jest-environment-node' 38 | } 39 | }; -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude PR Assistant 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude-code-action: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && contains(github.event.issue.body, '@claude')) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude PR Action 33 | uses: anthropics/claude-code-action@beta 34 | with: 35 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 36 | timeout_minutes: "60" 37 | -------------------------------------------------------------------------------- /tests/fixtures/sample-boot.cfg: -------------------------------------------------------------------------------- 1 | #!ipxe 2 | # netboot.xyz main boot configuration 3 | set sigs_enabled true 4 | set timeout 30000 5 | 6 | :start 7 | menu 8 | item --gap -- ---- Operating Systems ---- 9 | item ubuntu Ubuntu 10 | item debian Debian 11 | item fedora Fedora 12 | item centos CentOS 13 | item --gap -- ---- Utilities ---- 14 | item memtest Memory Test 15 | item gparted GParted 16 | item --gap -- ---- Options ---- 17 | item shell iPXE Shell 18 | item reboot Reboot 19 | item poweroff Power Off 20 | choose --timeout ${timeout} --default ubuntu selected 21 | goto ${selected} 22 | 23 | :ubuntu 24 | kernel http://archive.ubuntu.com/ubuntu/dists/jammy/main/installer-amd64/current/legacy-images/netboot/ubuntu-installer/amd64/linux 25 | initrd http://archive.ubuntu.com/ubuntu/dists/jammy/main/installer-amd64/current/legacy-images/netboot/ubuntu-installer/amd64/initrd.gz 26 | boot 27 | 28 | :debian 29 | kernel http://deb.debian.org/debian/dists/bookworm/main/installer-amd64/current/images/netboot/debian-installer/amd64/linux 30 | initrd http://deb.debian.org/debian/dists/bookworm/main/installer-amd64/current/images/netboot/debian-installer/amd64/initrd.gz 31 | boot 32 | 33 | :shell 34 | shell 35 | 36 | :reboot 37 | reboot 38 | 39 | :poweroff 40 | poweroff -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.22.2 2 | 3 | # set version label 4 | ARG BUILD_DATE 5 | ARG VERSION 6 | ARG WEBAPP_VERSION 7 | 8 | LABEL build_version="netboot.xyz version: ${VERSION} Build-date: ${BUILD_DATE}" 9 | LABEL maintainer="antonym" 10 | LABEL org.opencontainers.image.description="netboot.xyz official docker container - Your favorite operating systems in one place. A network-based bootable operating system installer based on iPXE." 11 | 12 | RUN \ 13 | apk update && \ 14 | apk upgrade && \ 15 | apk add --no-cache \ 16 | bash \ 17 | busybox \ 18 | curl \ 19 | envsubst \ 20 | git \ 21 | jq \ 22 | nghttp2-dev \ 23 | nginx \ 24 | nodejs \ 25 | shadow \ 26 | sudo \ 27 | supervisor \ 28 | syslog-ng \ 29 | tar \ 30 | dnsmasq && \ 31 | apk add --no-cache --virtual=build-dependencies \ 32 | npm && \ 33 | groupmod -g 1000 users && \ 34 | useradd -u 911 -U -d /config -s /bin/false nbxyz && \ 35 | usermod -G users nbxyz && \ 36 | mkdir /app \ 37 | /config \ 38 | /defaults 39 | 40 | COPY . /app 41 | 42 | RUN \ 43 | npm install --prefix /app && \ 44 | apk del --purge build-dependencies && \ 45 | rm -rf /tmp/* 46 | 47 | ENV TFTPD_OPTS='' 48 | ENV NGINX_PORT='80' 49 | ENV WEB_APP_PORT='3000' 50 | 51 | EXPOSE 69/udp 52 | EXPOSE 80 53 | EXPOSE 3000 54 | 55 | COPY docker-netbootxyz/root/ / 56 | 57 | # default command 58 | CMD ["sh","/start.sh"] 59 | 60 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | workflow_dispatch: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: '0' 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v5 19 | with: 20 | node-version: '22' 21 | 22 | - name: Install dependencies 23 | run: npm install 24 | 25 | - name: Run tests 26 | run: npm test 27 | 28 | - name: Run test coverage 29 | run: npm run test:coverage 30 | 31 | - name: Upload coverage to Codecov 32 | uses: codecov/codecov-action@v5 33 | if: always() 34 | with: 35 | file: ./coverage/lcov.info 36 | flags: webapp 37 | name: webapp-coverage 38 | fail_ci_if_error: false 39 | 40 | build: 41 | runs-on: ubuntu-latest 42 | needs: test # Only build if tests pass 43 | 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v4 47 | with: 48 | fetch-depth: '0' 49 | 50 | - name: Checkout docker-netbootxyz for container source files 51 | uses: actions/checkout@v4 52 | with: 53 | repository: netbootxyz/docker-netbootxyz 54 | path: docker-netbootxyz 55 | 56 | - name: Build the Docker image 57 | run: docker build . 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WebApp", 3 | "version": "0.7.6", 4 | "description": "Configuration and mirroring application for netboot.xyz stack", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "jest --testPathPatterns=unit", 8 | "test:all": "jest", 9 | "test:watch": "jest --watch --testPathPatterns=unit", 10 | "test:coverage": "jest --coverage --testPathPatterns=unit", 11 | "test:integration": "jest --testPathPatterns=integration", 12 | "test:unit": "jest --testPathPatterns=unit", 13 | "test:basic": "jest tests/unit/basic.test.js", 14 | "test:safe": "jest tests/unit/basic.test.js tests/unit/socket-logic.test.js", 15 | "test:debug": "jest --detectOpenHandles --verbose --no-cache", 16 | "test:clean": "jest --clearCache" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/netbootxyz/webapp.git" 21 | }, 22 | "author": "netboot.xyz", 23 | "license": "Apache-2.0", 24 | "homepage": "https://netboot.xyz", 25 | "dependencies": { 26 | "ejs": "3.1.10", 27 | "express": "4.21.2", 28 | "http": "0.0.0", 29 | "isbinaryfile": "5.0.6", 30 | "js-yaml": "4.1.0", 31 | "node-downloader-helper": "2.1.9", 32 | "readdirp": "3.6.0", 33 | "node-fetch": "2.7.0", 34 | "socket.io": "4.8.1", 35 | "systeminformation": "5.27.10" 36 | }, 37 | "devDependencies": { 38 | "jest": "^30.0.0", 39 | "supertest": "^7.0.0", 40 | "socket.io-client": "^4.8.1", 41 | "nock": "^14.0.0", 42 | "@types/jest": "^30.0.0", 43 | "mock-fs": "^5.2.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/webapp-dev.yml: -------------------------------------------------------------------------------- 1 | name: webapp-dev 2 | on: 3 | push 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v4 10 | 11 | - name: Setup Node.js 12 | uses: actions/setup-node@v5 13 | with: 14 | node-version: '22' 15 | 16 | - name: Install dependencies 17 | run: npm install 18 | 19 | - name: Run tests 20 | run: npm test 21 | 22 | - name: Run test coverage 23 | run: npm run test:coverage 24 | 25 | build: 26 | runs-on: ubuntu-latest 27 | needs: test # Only build/push if tests pass 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Checkout docker-netbootxyz for container source files 33 | uses: actions/checkout@v4 34 | with: 35 | repository: netbootxyz/docker-netbootxyz 36 | path: docker-netbootxyz 37 | 38 | - name: Set up Docker Buildx 39 | uses: docker/setup-buildx-action@v3 40 | 41 | - name: Login to the GitHub Container Registry 42 | uses: docker/login-action@v3 43 | with: 44 | registry: ghcr.io 45 | username: ${{ secrets.GHCR_USER }} 46 | password: ${{ secrets.GHCR_TOKEN }} 47 | 48 | - name: Build and push image 49 | uses: docker/build-push-action@v6 50 | with: 51 | push: true 52 | platforms: linux/amd64,linux/arm64 53 | context: . 54 | file: ./Dockerfile 55 | tags: | 56 | ghcr.io/netbootxyz/${{ github.workflow }}:latest 57 | ghcr.io/netbootxyz/${{ github.workflow }}:${{ github.sha }} 58 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | // Extracted utility functions for better testability 2 | 3 | /** 4 | * Disable signatures in boot configuration 5 | */ 6 | function disableSignatures(configContent) { 7 | return configContent.replace(/set sigs_enabled true/g, 'set sigs_enabled false'); 8 | } 9 | 10 | /** 11 | * Validate port number 12 | */ 13 | function validatePort(port, defaultPort = 3000) { 14 | const portNum = Number(port); 15 | if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) { 16 | return defaultPort; 17 | } 18 | return portNum; 19 | } 20 | 21 | /** 22 | * Check if version is a commit SHA 23 | */ 24 | function isCommitSha(version) { 25 | return version.length === 40 && /^[a-f0-9]+$/i.test(version); 26 | } 27 | 28 | /** 29 | * Generate download URL based on version type 30 | */ 31 | function getDownloadUrl(version, file = '') { 32 | const baseUrl = isCommitSha(version) 33 | ? `https://s3.amazonaws.com/dev.boot.netboot.xyz/${version}/ipxe/` 34 | : `https://github.com/netbootxyz/netboot.xyz/releases/download/${version}/`; 35 | return baseUrl + file; 36 | } 37 | 38 | /** 39 | * Validate file path for security 40 | */ 41 | function validateFilePath(userPath, rootDir) { 42 | try { 43 | const path = require('path'); 44 | const resolved = path.resolve(rootDir, userPath); 45 | const rootWithSeparator = path.resolve(rootDir) + path.sep; 46 | return { 47 | path: resolved, 48 | isSecure: resolved.startsWith(rootWithSeparator) 49 | }; 50 | } catch { 51 | return { path: null, isSecure: false }; 52 | } 53 | } 54 | 55 | /** 56 | * Check if host is allowed for downloads 57 | */ 58 | function isAllowedHost(url, allowedHosts = ['s3.amazonaws.com']) { 59 | try { 60 | const urlLib = require('url'); 61 | const parsedUrl = urlLib.parse(url); 62 | return allowedHosts.includes(parsedUrl.host); 63 | } catch { 64 | return false; 65 | } 66 | } 67 | 68 | module.exports = { 69 | disableSignatures, 70 | validatePort, 71 | isCommitSha, 72 | getDownloadUrl, 73 | validateFilePath, 74 | isAllowedHost 75 | }; -------------------------------------------------------------------------------- /public/vendor/js/theme-chrome.js: -------------------------------------------------------------------------------- 1 | define("ace/theme/chrome",["require","exports","module","ace/lib/dom"],function(e,t,n){t.isDark=!1,t.cssClass="ace-chrome",t.cssText='.ace-chrome .ace_gutter {background: #ebebeb;color: #333;overflow : hidden;}.ace-chrome .ace_print-margin {width: 1px;background: #e8e8e8;}.ace-chrome {background-color: #FFFFFF;color: black;}.ace-chrome .ace_cursor {color: black;}.ace-chrome .ace_invisible {color: rgb(191, 191, 191);}.ace-chrome .ace_constant.ace_buildin {color: rgb(88, 72, 246);}.ace-chrome .ace_constant.ace_language {color: rgb(88, 92, 246);}.ace-chrome .ace_constant.ace_library {color: rgb(6, 150, 14);}.ace-chrome .ace_invalid {background-color: rgb(153, 0, 0);color: white;}.ace-chrome .ace_fold {}.ace-chrome .ace_support.ace_function {color: rgb(60, 76, 114);}.ace-chrome .ace_support.ace_constant {color: rgb(6, 150, 14);}.ace-chrome .ace_support.ace_type,.ace-chrome .ace_support.ace_class.ace-chrome .ace_support.ace_other {color: rgb(109, 121, 222);}.ace-chrome .ace_variable.ace_parameter {font-style:italic;color:#FD971F;}.ace-chrome .ace_keyword.ace_operator {color: rgb(104, 118, 135);}.ace-chrome .ace_comment {color: #236e24;}.ace-chrome .ace_comment.ace_doc {color: #236e24;}.ace-chrome .ace_comment.ace_doc.ace_tag {color: #236e24;}.ace-chrome .ace_constant.ace_numeric {color: rgb(0, 0, 205);}.ace-chrome .ace_variable {color: rgb(49, 132, 149);}.ace-chrome .ace_xml-pe {color: rgb(104, 104, 91);}.ace-chrome .ace_entity.ace_name.ace_function {color: #0000A2;}.ace-chrome .ace_heading {color: rgb(12, 7, 255);}.ace-chrome .ace_list {color:rgb(185, 6, 144);}.ace-chrome .ace_marker-layer .ace_selection {background: rgb(181, 213, 255);}.ace-chrome .ace_marker-layer .ace_step {background: rgb(252, 255, 0);}.ace-chrome .ace_marker-layer .ace_stack {background: rgb(164, 229, 101);}.ace-chrome .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid rgb(192, 192, 192);}.ace-chrome .ace_marker-layer .ace_active-line {background: rgba(0, 0, 0, 0.07);}.ace-chrome .ace_gutter-active-line {background-color : #dcdcdc;}.ace-chrome .ace_marker-layer .ace_selected-word {background: rgb(250, 250, 255);border: 1px solid rgb(200, 200, 250);}.ace-chrome .ace_storage,.ace-chrome .ace_keyword,.ace-chrome .ace_meta.ace_tag {color: rgb(147, 15, 128);}.ace-chrome .ace_string.ace_regex {color: rgb(255, 0, 0)}.ace-chrome .ace_string {color: #1A1AA6;}.ace-chrome .ace_entity.ace_other.ace_attribute-name {color: #994409;}.ace-chrome .ace_indent-guide {background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==") right repeat-y;}';var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass)}); (function() { 2 | window.require(["ace/theme/chrome"], function(m) { 3 | if (typeof module == "object" && typeof exports == "object" && module) { 4 | module.exports = m; 5 | } 6 | }); 7 | })(); 8 | -------------------------------------------------------------------------------- /public/vendor/js/dataTables.bootstrap4.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | DataTables Bootstrap 4 integration 3 | ©2011-2017 SpryMedia Ltd - datatables.net/license 4 | */ 5 | var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,b,c){a instanceof String&&(a=String(a));for(var e=a.length,d=0;d<'col-sm-12 col-md-6'f>><'row'<'col-sm-12'tr>><'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", 9 | renderer:"bootstrap"});a.extend(d.ext.classes,{sWrapper:"dataTables_wrapper dt-bootstrap4",sFilterInput:"form-control form-control-sm",sLengthSelect:"custom-select custom-select-sm form-control form-control-sm",sProcessing:"dataTables_processing card",sPageButton:"paginate_button page-item"});d.ext.renderer.pageButton.bootstrap=function(b,l,v,w,m,r){var k=new d.Api(b),x=b.oClasses,n=b.oLanguage.oPaginate,y=b.oLanguage.oAria.paginate||{},g,h,t=0,u=function(c,d){var e,l=function(b){b.preventDefault(); 10 | a(b.currentTarget).hasClass("disabled")||k.page()==b.data.action||k.page(b.data.action).draw("page")};var q=0;for(e=d.length;q",{"class":x.sPageButton+" "+h,id:0===v&&"string"===typeof f?b.sTableId+"_"+f:null}).append(a("",{href:"#","aria-controls":b.sTableId,"aria-label":y[f],"data-dt-idx":t,tabindex:b.iTabIndex,"class":"page-link"}).html(g)).appendTo(c);b.oApi._fnBindAction(p,{action:f},l);t++}}}};try{var p=a(l).find(c.activeElement).data("dt-idx")}catch(z){}u(a(l).empty().html('
    ').children("ul"),w);p!==e&&a(l).find("[data-dt-idx="+p+"]").focus()};return d}); 12 | -------------------------------------------------------------------------------- /public/vendor/css/docs.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Docs (https://getbootstrap.com) 3 | * Copyright 2011-2018 The Bootstrap Authors 4 | * Copyright 2011-2018 Twitter, Inc. 5 | * Licensed under the Creative Commons Attribution 3.0 Unported License. For 6 | * details, see https://creativecommons.org/licenses/by/3.0/. 7 | */ 8 | 9 | .bd-toc { 10 | -webkit-box-ordinal-group: 3; 11 | -ms-flex-order: 2; 12 | order: 2; 13 | padding-top: 1.5rem; 14 | padding-bottom: 1.5rem; 15 | font-size: .875rem 16 | } 17 | 18 | @supports ((position:-webkit-sticky) or (position:sticky)) { 19 | .bd-toc { 20 | position: -webkit-sticky; 21 | position: sticky; 22 | top: 4rem; 23 | height: calc(100vh - 4rem); 24 | overflow-y: auto 25 | } 26 | } 27 | 28 | 29 | .toc-entry { 30 | display: block 31 | } 32 | 33 | .toc-entry a { 34 | display: block; 35 | padding: .125rem 1.5rem; 36 | color: #99979c 37 | } 38 | 39 | .toc-entry a:hover { 40 | color: #007bff; 41 | text-decoration: none 42 | } 43 | 44 | .bd-sidebar { 45 | -webkit-box-ordinal-group: 1; 46 | -ms-flex-order: 0; 47 | order: 0; 48 | border-bottom: 1px solid rgba(0, 0, 0, .1) 49 | } 50 | 51 | @media (min-width:768px) { 52 | .bd-sidebar { 53 | border-right: 1px solid rgba(0, 0, 0, .1) 54 | } 55 | @supports ((position:-webkit-sticky) or (position:sticky)) { 56 | .bd-sidebar { 57 | position: -webkit-sticky; 58 | position: sticky; 59 | top: 4rem; 60 | z-index: 1000; 61 | height: calc(100vh - 4rem) 62 | } 63 | } 64 | } 65 | 66 | @media (min-width:1200px) { 67 | .bd-sidebar { 68 | -webkit-box-flex: 0; 69 | -ms-flex: 0 1 320px; 70 | flex: 0 1 320px 71 | } 72 | } 73 | 74 | .bd-links { 75 | padding-top: 1rem; 76 | padding-bottom: 1rem; 77 | margin-right: -15px; 78 | margin-left: -15px 79 | } 80 | 81 | @media (min-width:768px) { 82 | @supports ((position: -webkit-sticky) or (position:sticky)) { 83 | .bd-links { 84 | max-height:calc(100vh - 9rem); 85 | overflow-y: auto 86 | } 87 | } 88 | } 89 | 90 | @media (min-width:768px) { 91 | .bd-links { 92 | display: block!important 93 | } 94 | } 95 | 96 | 97 | .bd-search-docs-toggle { 98 | line-height: 1; 99 | color: #212529 100 | } 101 | 102 | .bd-sidenav { 103 | display: none 104 | } 105 | 106 | .bd-toc-link { 107 | display: block; 108 | padding: .25rem 1.5rem; 109 | font-weight: 500; 110 | color: rgba(0, 0, 0, .65) 111 | } 112 | 113 | .bd-toc-link:hover { 114 | color: rgba(0, 0, 0, .85); 115 | text-decoration: none 116 | } 117 | 118 | .bd-toc-item.active { 119 | margin-bottom: 1rem 120 | } 121 | 122 | .bd-toc-item.active:not(:first-child) { 123 | margin-top: 1rem 124 | } 125 | 126 | .bd-toc-item.active>.bd-toc-link { 127 | color: rgba(0, 0, 0, .85) 128 | } 129 | 130 | .bd-toc-item.active>.bd-toc-link:hover { 131 | background-color: transparent 132 | } 133 | 134 | .bd-toc-item.active>.bd-sidenav { 135 | display: block 136 | } 137 | 138 | .bd-sidebar .nav>li>a { 139 | display: block; 140 | padding: .25rem 1.5rem; 141 | font-size: 90%; 142 | color: rgba(0, 0, 0, .65) 143 | } 144 | 145 | .bd-sidebar .nav>li>a:hover { 146 | color: rgba(0, 0, 0, .85); 147 | text-decoration: none; 148 | background-color: transparent 149 | } 150 | 151 | .bd-sidebar .nav>.active:hover>a, 152 | .bd-sidebar .nav>.active>a { 153 | font-weight: 500; 154 | color: rgba(0, 0, 0, .85); 155 | background-color: transparent 156 | } -------------------------------------------------------------------------------- /root/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # make our folders 4 | mkdir -p \ 5 | /assets \ 6 | /config/nginx/site-confs \ 7 | /config/log/nginx \ 8 | /run \ 9 | /var/lib/nginx/tmp/client_body \ 10 | /var/tmp/nginx 11 | 12 | # copy config files 13 | [[ ! -f /config/nginx/nginx.conf ]] && \ 14 | cp /defaults/nginx.conf /config/nginx/nginx.conf 15 | [[ ! -f /config/nginx/site-confs/default ]] && \ 16 | cp /defaults/default /config/nginx/site-confs/default 17 | 18 | # Ownership 19 | chown -R nbxyz:nbxyz /assets 20 | chown -R nbxyz:nbxyz /var/lib/nginx 21 | chown -R nbxyz:nbxyz /var/log/nginx 22 | 23 | # create local logs dir 24 | mkdir -p \ 25 | /config/menus/remote \ 26 | /config/menus/local 27 | 28 | # download menus if not found 29 | if [[ ! -f /config/menus/remote/menu.ipxe ]]; then 30 | if [[ -z ${MENU_VERSION+x} ]]; then \ 31 | MENU_VERSION=$(curl -sL "https://api.github.com/repos/netbootxyz/netboot.xyz/releases/latest" | jq -r '.tag_name') 32 | fi 33 | echo "[netbootxyz-init] Downloading netboot.xyz at ${MENU_VERSION}" 34 | # menu files 35 | curl -o \ 36 | /config/endpoints.yml -sL \ 37 | "https://raw.githubusercontent.com/netbootxyz/netboot.xyz/${MENU_VERSION}/endpoints.yml" 38 | curl -o \ 39 | /tmp/menus.tar.gz -sL \ 40 | "https://github.com/netbootxyz/netboot.xyz/releases/download/${MENU_VERSION}/menus.tar.gz" 41 | tar xf \ 42 | /tmp/menus.tar.gz -C \ 43 | /config/menus/remote 44 | # boot files 45 | curl -o \ 46 | /config/menus/remote/netboot.xyz.kpxe -sL \ 47 | "https://github.com/netbootxyz/netboot.xyz/releases/download/${MENU_VERSION}/netboot.xyz.kpxe" 48 | curl -o \ 49 | /config/menus/remote/netboot.xyz-undionly.kpxe -sL \ 50 | "https://github.com/netbootxyz/netboot.xyz/releases/download/${MENU_VERSION}/netboot.xyz-undionly.kpxe" 51 | curl -o \ 52 | /config/menus/remote/netboot.xyz.efi -sL \ 53 | "https://github.com/netbootxyz/netboot.xyz/releases/download/${MENU_VERSION}/netboot.xyz.efi" 54 | curl -o \ 55 | /config/menus/remote/netboot.xyz-snp.efi -sL \ 56 | "https://github.com/netbootxyz/netboot.xyz/releases/download/${MENU_VERSION}/netboot.xyz-snp.efi" 57 | curl -o \ 58 | /config/menus/remote/netboot.xyz-snponly.efi -sL \ 59 | "https://github.com/netbootxyz/netboot.xyz/releases/download/${MENU_VERSION}/netboot.xyz-snponly.efi" 60 | curl -o \ 61 | /config/menus/remote/netboot.xyz-arm64.efi -sL \ 62 | "https://github.com/netbootxyz/netboot.xyz/releases/download/${MENU_VERSION}/netboot.xyz-arm64.efi" 63 | curl -o \ 64 | /config/menus/remote/netboot.xyz-arm64-snp.efi -sL \ 65 | "https://github.com/netbootxyz/netboot.xyz/releases/download/${MENU_VERSION}/netboot.xyz-arm64-snp.efi" 66 | curl -o \ 67 | /config/menus/remote/netboot.xyz-arm64-snponly.efi -sL \ 68 | "https://github.com/netbootxyz/netboot.xyz/releases/download/${MENU_VERSION}/netboot.xyz-arm64-snponly.efi" 69 | # layer and cleanup 70 | echo -n ${MENU_VERSION} > /config/menuversion.txt 71 | cp -r /config/menus/remote/* /config/menus 72 | rm -f /tmp/menus.tar.gz 73 | fi 74 | 75 | # Ownership 76 | chown -R nbxyz:nbxyz /config 77 | 78 | echo " _ _ _ " 79 | echo " _ __ ___| |_| |__ ___ ___ | |_ __ ___ _ ____ " 80 | echo "| '_ \ / _ \ __| '_ \ / _ \ / _ \| __| \ \/ / | | |_ / " 81 | echo "| | | | __/ |_| |_) | (_) | (_) | |_ _ > <| |_| |/ / " 82 | echo "|_| |_|\___|\__|_.__/ \___/ \___/ \__(_)_/\_\\__, /___| " 83 | echo " |___/ " 84 | 85 | supervisord -c /etc/supervisor.conf 86 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup file 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | // Track active timers and intervals for cleanup 6 | const activeTimers = new Set(); 7 | const activeIntervals = new Set(); 8 | 9 | // Override setTimeout to track timers 10 | const originalSetTimeout = global.setTimeout; 11 | global.setTimeout = function(callback, delay, ...args) { 12 | const timer = originalSetTimeout.call(this, (...cbArgs) => { 13 | activeTimers.delete(timer); 14 | callback(...cbArgs); 15 | }, delay, ...args); 16 | activeTimers.add(timer); 17 | return timer; 18 | }; 19 | 20 | // Override setInterval to track intervals 21 | const originalSetInterval = global.setInterval; 22 | global.setInterval = function(callback, delay, ...args) { 23 | const interval = originalSetInterval.call(this, callback, delay, ...args); 24 | activeIntervals.add(interval); 25 | return interval; 26 | }; 27 | 28 | // Override clearTimeout 29 | const originalClearTimeout = global.clearTimeout; 30 | global.clearTimeout = function(timer) { 31 | activeTimers.delete(timer); 32 | return originalClearTimeout.call(this, timer); 33 | }; 34 | 35 | // Override clearInterval 36 | const originalClearInterval = global.clearInterval; 37 | global.clearInterval = function(interval) { 38 | activeIntervals.delete(interval); 39 | return originalClearInterval.call(this, interval); 40 | }; 41 | 42 | // Set shorter timeout for tests 43 | jest.setTimeout(5000); 44 | 45 | // Setup test environment 46 | beforeAll(async () => { 47 | // Set test environment variables 48 | process.env.NODE_ENV = 'test'; 49 | process.env.WEB_APP_PORT = '0'; 50 | 51 | // Mock console methods to reduce noise in tests 52 | const originalConsoleLog = console.log; 53 | const originalConsoleError = console.error; 54 | const originalConsoleWarn = console.warn; 55 | 56 | console.log = jest.fn(); 57 | console.error = jest.fn(); 58 | console.warn = jest.fn(); 59 | 60 | // Store originals for cleanup 61 | global.__originalConsole = { 62 | log: originalConsoleLog, 63 | error: originalConsoleError, 64 | warn: originalConsoleWarn 65 | }; 66 | }); 67 | 68 | // Clean up after each test 69 | afterEach(async () => { 70 | // Clear all mocks 71 | jest.clearAllMocks(); 72 | 73 | // Clear any remaining timers 74 | activeTimers.forEach(timer => { 75 | clearTimeout(timer); 76 | }); 77 | activeTimers.clear(); 78 | 79 | // Clear any remaining intervals 80 | activeIntervals.forEach(interval => { 81 | clearInterval(interval); 82 | }); 83 | activeIntervals.clear(); 84 | 85 | // Clear any nock interceptors 86 | if (typeof require('nock') !== 'undefined') { 87 | require('nock').cleanAll(); 88 | } 89 | }); 90 | 91 | // Global cleanup 92 | afterAll(async () => { 93 | // Restore console methods 94 | if (global.__originalConsole) { 95 | console.log = global.__originalConsole.log; 96 | console.error = global.__originalConsole.error; 97 | console.warn = global.__originalConsole.warn; 98 | } 99 | 100 | // Final cleanup of timers 101 | activeTimers.forEach(timer => { 102 | clearTimeout(timer); 103 | }); 104 | activeTimers.clear(); 105 | 106 | activeIntervals.forEach(interval => { 107 | clearInterval(interval); 108 | }); 109 | activeIntervals.clear(); 110 | 111 | // Force garbage collection if available 112 | if (global.gc) { 113 | global.gc(); 114 | } 115 | }); 116 | 117 | // Handle unhandled promise rejections in tests 118 | process.on('unhandledRejection', (reason, promise) => { 119 | // Log the error but don't crash the test process 120 | if (process.env.NODE_ENV === 'test') { 121 | console.error('Unhandled Rejection in tests:', reason); 122 | } 123 | }); 124 | 125 | // Handle uncaught exceptions in tests 126 | process.on('uncaughtException', (error) => { 127 | if (process.env.NODE_ENV === 'test') { 128 | console.error('Uncaught Exception in tests:', error); 129 | } 130 | }); -------------------------------------------------------------------------------- /tests/unit/lib-utils.test.js: -------------------------------------------------------------------------------- 1 | // Test the extracted utility functions for better coverage 2 | const { 3 | disableSignatures, 4 | validatePort, 5 | isCommitSha, 6 | getDownloadUrl, 7 | validateFilePath, 8 | isAllowedHost 9 | } = require('../../lib/utils'); 10 | 11 | describe('Utility Functions (Extracted)', () => { 12 | 13 | describe('disableSignatures', () => { 14 | test('should disable signatures in boot config', () => { 15 | const config = 'set sigs_enabled true\necho "Boot menu"'; 16 | const result = disableSignatures(config); 17 | expect(result).toBe('set sigs_enabled false\necho "Boot menu"'); 18 | }); 19 | 20 | test('should handle config without signatures', () => { 21 | const config = 'echo "Boot menu"\nset timeout 30'; 22 | const result = disableSignatures(config); 23 | expect(result).toBe(config); 24 | }); 25 | }); 26 | 27 | describe('validatePort', () => { 28 | test('should validate correct ports', () => { 29 | expect(validatePort('3000')).toBe(3000); 30 | expect(validatePort('8080')).toBe(8080); 31 | expect(validatePort('65535')).toBe(65535); 32 | }); 33 | 34 | test('should return default for invalid ports', () => { 35 | expect(validatePort('invalid')).toBe(3000); 36 | expect(validatePort('0')).toBe(3000); 37 | expect(validatePort('99999')).toBe(3000); 38 | expect(validatePort('-1')).toBe(3000); 39 | }); 40 | 41 | test('should use custom default port', () => { 42 | expect(validatePort('invalid', 8080)).toBe(8080); 43 | }); 44 | }); 45 | 46 | describe('isCommitSha', () => { 47 | test('should identify valid commit SHAs', () => { 48 | expect(isCommitSha('a1b2c3d4e5f6789012345678901234567890abcd')).toBe(true); 49 | expect(isCommitSha('1234567890123456789012345678901234567890')).toBe(true); 50 | }); 51 | 52 | test('should reject invalid commit SHAs', () => { 53 | expect(isCommitSha('v2.0.0')).toBe(false); 54 | expect(isCommitSha('short')).toBe(false); 55 | expect(isCommitSha('toolong123456789012345678901234567890123')).toBe(false); 56 | expect(isCommitSha('invalid-chars-here1234567890123456789012')).toBe(false); 57 | }); 58 | }); 59 | 60 | describe('getDownloadUrl', () => { 61 | test('should generate S3 URLs for commit SHAs', () => { 62 | const sha = 'a1b2c3d4e5f6789012345678901234567890abcd'; 63 | const url = getDownloadUrl(sha, 'netboot.xyz.efi'); 64 | expect(url).toBe('https://s3.amazonaws.com/dev.boot.netboot.xyz/a1b2c3d4e5f6789012345678901234567890abcd/ipxe/netboot.xyz.efi'); 65 | }); 66 | 67 | test('should generate GitHub URLs for releases', () => { 68 | const url = getDownloadUrl('v2.0.0', 'netboot.xyz.efi'); 69 | expect(url).toBe('https://github.com/netbootxyz/netboot.xyz/releases/download/v2.0.0/netboot.xyz.efi'); 70 | }); 71 | 72 | test('should handle empty file parameter', () => { 73 | const url = getDownloadUrl('v2.0.0'); 74 | expect(url).toBe('https://github.com/netbootxyz/netboot.xyz/releases/download/v2.0.0/'); 75 | }); 76 | }); 77 | 78 | describe('validateFilePath', () => { 79 | test('should validate secure file paths', () => { 80 | const result = validateFilePath('safe-file.txt', '/config/menus/local/'); 81 | expect(result.isSecure).toBe(true); 82 | expect(result.path).toContain('safe-file.txt'); 83 | }); 84 | 85 | test('should reject directory traversal attempts', () => { 86 | const result = validateFilePath('../../../etc/passwd', '/config/menus/local/'); 87 | expect(result.isSecure).toBe(false); 88 | }); 89 | 90 | test('should handle subdirectories', () => { 91 | const result = validateFilePath('subdir/file.txt', '/config/menus/local/'); 92 | expect(result.isSecure).toBe(true); 93 | }); 94 | }); 95 | 96 | describe('isAllowedHost', () => { 97 | test('should allow S3 hosts by default', () => { 98 | expect(isAllowedHost('https://s3.amazonaws.com/file.tar.gz')).toBe(true); 99 | }); 100 | 101 | test('should reject other hosts', () => { 102 | expect(isAllowedHost('https://malicious-site.com/file.tar.gz')).toBe(false); 103 | expect(isAllowedHost('https://github.com/user/repo/file.tar.gz')).toBe(false); 104 | }); 105 | 106 | test('should handle custom allowed hosts', () => { 107 | const customHosts = ['github.com', 'gitlab.com']; 108 | expect(isAllowedHost('https://github.com/user/repo', customHosts)).toBe(true); 109 | expect(isAllowedHost('https://bitbucket.org/user/repo', customHosts)).toBe(false); 110 | }); 111 | 112 | test('should handle invalid URLs', () => { 113 | expect(isAllowedHost('not-a-url')).toBe(false); 114 | expect(isAllowedHost('')).toBe(false); 115 | }); 116 | }); 117 | }); -------------------------------------------------------------------------------- /public/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | netboot.xyz Configuration 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 66 | 67 |
    68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /public/vendor/css/dataTables.bootstrap4.min.css: -------------------------------------------------------------------------------- 1 | table.dataTable{clear:both;margin-top:6px !important;margin-bottom:6px !important;max-width:none !important;border-collapse:separate !important;border-spacing:0}table.dataTable td,table.dataTable th{-webkit-box-sizing:content-box;box-sizing:content-box}table.dataTable td.dataTables_empty,table.dataTable th.dataTables_empty{text-align:center}table.dataTable.nowrap th,table.dataTable.nowrap td{white-space:nowrap}div.dataTables_wrapper div.dataTables_length label{font-weight:normal;text-align:left;white-space:nowrap}div.dataTables_wrapper div.dataTables_length select{width:auto;display:inline-block}div.dataTables_wrapper div.dataTables_filter{text-align:right}div.dataTables_wrapper div.dataTables_filter label{font-weight:normal;white-space:nowrap;text-align:left}div.dataTables_wrapper div.dataTables_filter input{margin-left:0.5em;display:inline-block;width:auto}div.dataTables_wrapper div.dataTables_info{padding-top:0.85em;white-space:nowrap}div.dataTables_wrapper div.dataTables_paginate{margin:0;white-space:nowrap;text-align:right}div.dataTables_wrapper div.dataTables_paginate ul.pagination{margin:2px 0;white-space:nowrap;justify-content:flex-end}div.dataTables_wrapper div.dataTables_processing{position:absolute;top:50%;left:50%;width:200px;margin-left:-100px;margin-top:-26px;text-align:center;padding:1em 0}table.dataTable thead>tr>th.sorting_asc,table.dataTable thead>tr>th.sorting_desc,table.dataTable thead>tr>th.sorting,table.dataTable thead>tr>td.sorting_asc,table.dataTable thead>tr>td.sorting_desc,table.dataTable thead>tr>td.sorting{padding-right:30px}table.dataTable thead>tr>th:active,table.dataTable thead>tr>td:active{outline:none}table.dataTable thead .sorting,table.dataTable thead .sorting_asc,table.dataTable thead .sorting_desc,table.dataTable thead .sorting_asc_disabled,table.dataTable thead .sorting_desc_disabled{cursor:pointer;position:relative}table.dataTable thead .sorting:before,table.dataTable thead .sorting:after,table.dataTable thead .sorting_asc:before,table.dataTable thead .sorting_asc:after,table.dataTable thead .sorting_desc:before,table.dataTable thead .sorting_desc:after,table.dataTable thead .sorting_asc_disabled:before,table.dataTable thead .sorting_asc_disabled:after,table.dataTable thead .sorting_desc_disabled:before,table.dataTable thead .sorting_desc_disabled:after{position:absolute;bottom:0.9em;display:block;opacity:0.3}table.dataTable thead .sorting:before,table.dataTable thead .sorting_asc:before,table.dataTable thead .sorting_desc:before,table.dataTable thead .sorting_asc_disabled:before,table.dataTable thead .sorting_desc_disabled:before{right:1em;content:"\2191"}table.dataTable thead .sorting:after,table.dataTable thead .sorting_asc:after,table.dataTable thead .sorting_desc:after,table.dataTable thead .sorting_asc_disabled:after,table.dataTable thead .sorting_desc_disabled:after{right:0.5em;content:"\2193"}table.dataTable thead .sorting_asc:before,table.dataTable thead .sorting_desc:after{opacity:1}table.dataTable thead .sorting_asc_disabled:before,table.dataTable thead .sorting_desc_disabled:after{opacity:0}div.dataTables_scrollHead table.dataTable{margin-bottom:0 !important}div.dataTables_scrollBody table{border-top:none;margin-top:0 !important;margin-bottom:0 !important}div.dataTables_scrollBody table thead .sorting:before,div.dataTables_scrollBody table thead .sorting_asc:before,div.dataTables_scrollBody table thead .sorting_desc:before,div.dataTables_scrollBody table thead .sorting:after,div.dataTables_scrollBody table thead .sorting_asc:after,div.dataTables_scrollBody table thead .sorting_desc:after{display:none}div.dataTables_scrollBody table tbody tr:first-child th,div.dataTables_scrollBody table tbody tr:first-child td{border-top:none}div.dataTables_scrollFoot>.dataTables_scrollFootInner{box-sizing:content-box}div.dataTables_scrollFoot>.dataTables_scrollFootInner>table{margin-top:0 !important;border-top:none}@media screen and (max-width: 767px){div.dataTables_wrapper div.dataTables_length,div.dataTables_wrapper div.dataTables_filter,div.dataTables_wrapper div.dataTables_info,div.dataTables_wrapper div.dataTables_paginate{text-align:center}}table.dataTable.table-sm>thead>tr>th{padding-right:20px}table.dataTable.table-sm .sorting:before,table.dataTable.table-sm .sorting_asc:before,table.dataTable.table-sm .sorting_desc:before{top:5px;right:0.85em}table.dataTable.table-sm .sorting:after,table.dataTable.table-sm .sorting_asc:after,table.dataTable.table-sm .sorting_desc:after{top:5px}table.table-bordered.dataTable th,table.table-bordered.dataTable td{border-left-width:0}table.table-bordered.dataTable th:last-child,table.table-bordered.dataTable th:last-child,table.table-bordered.dataTable td:last-child,table.table-bordered.dataTable td:last-child{border-right-width:0}table.table-bordered.dataTable tbody th,table.table-bordered.dataTable tbody td{border-bottom-width:0}div.dataTables_scrollHead table.table-bordered{border-bottom-width:0}div.table-responsive>div.dataTables_wrapper>div.row{margin:0}div.table-responsive>div.dataTables_wrapper>div.row>div[class^="col-"]:first-child{padding-left:0}div.table-responsive>div.dataTables_wrapper>div.row>div[class^="col-"]:last-child{padding-right:0} 2 | -------------------------------------------------------------------------------- /tests/unit/basic.test.js: -------------------------------------------------------------------------------- 1 | // Basic functionality tests without complex mocking 2 | describe('Basic Webapp Functionality', () => { 3 | 4 | test('should validate port numbers correctly', () => { 5 | const validatePort = (port) => { 6 | const portNum = Number(port); 7 | return Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535; 8 | }; 9 | 10 | expect(validatePort('3000')).toBe(true); 11 | expect(validatePort('80')).toBe(true); 12 | expect(validatePort('65535')).toBe(true); 13 | expect(validatePort('0')).toBe(false); 14 | expect(validatePort('65536')).toBe(false); 15 | expect(validatePort('invalid')).toBe(false); 16 | expect(validatePort('-1')).toBe(false); 17 | }); 18 | 19 | test('should identify commit SHAs vs release versions', () => { 20 | const isCommitSha = (version) => version.length === 40 && /^[a-f0-9]+$/i.test(version); 21 | 22 | expect(isCommitSha('a1b2c3d4e5f6789012345678901234567890abcd')).toBe(true); 23 | expect(isCommitSha('1234567890123456789012345678901234567890')).toBe(true); 24 | expect(isCommitSha('v2.0.0')).toBe(false); 25 | expect(isCommitSha('2.0.0')).toBe(false); 26 | expect(isCommitSha('short')).toBe(false); 27 | expect(isCommitSha('toolong1234567890123456789012345678901234567890')).toBe(false); 28 | }); 29 | 30 | test('should generate correct download URLs', () => { 31 | const getDownloadUrl = (version, file) => { 32 | const baseUrl = version.length === 40 33 | ? `https://s3.amazonaws.com/dev.boot.netboot.xyz/${version}/ipxe/` 34 | : `https://github.com/netbootxyz/netboot.xyz/releases/download/${version}/`; 35 | return baseUrl + file; 36 | }; 37 | 38 | const commitSha = 'a1b2c3d4e5f6789012345678901234567890abcd'; 39 | const release = 'v2.0.0'; 40 | const filename = 'netboot.xyz.efi'; 41 | 42 | expect(getDownloadUrl(commitSha, filename)) 43 | .toBe('https://s3.amazonaws.com/dev.boot.netboot.xyz/a1b2c3d4e5f6789012345678901234567890abcd/ipxe/netboot.xyz.efi'); 44 | 45 | expect(getDownloadUrl(release, filename)) 46 | .toBe('https://github.com/netbootxyz/netboot.xyz/releases/download/v2.0.0/netboot.xyz.efi'); 47 | }); 48 | 49 | test('should validate allowed hosts for security', () => { 50 | const allowedHosts = ['s3.amazonaws.com']; 51 | const isAllowedHost = (url) => { 52 | try { 53 | const urlObj = new URL(url); 54 | return allowedHosts.includes(urlObj.hostname); 55 | } catch { 56 | return false; 57 | } 58 | }; 59 | 60 | expect(isAllowedHost('https://s3.amazonaws.com/file.tar.gz')).toBe(true); 61 | expect(isAllowedHost('https://malicious-site.com/file.tar.gz')).toBe(false); 62 | expect(isAllowedHost('invalid-url')).toBe(false); 63 | }); 64 | 65 | test('should handle signature disabling in boot config', () => { 66 | const disableSignatures = (config) => { 67 | return config.replace(/set sigs_enabled true/g, 'set sigs_enabled false'); 68 | }; 69 | 70 | const withSigs = 'set sigs_enabled true\necho "Boot menu"\nset timeout 30'; 71 | const withoutSigs = 'set sigs_enabled false\necho "Boot menu"\nset timeout 30'; 72 | 73 | expect(disableSignatures(withSigs)).toBe(withoutSigs); 74 | 75 | const noSigs = 'echo "Boot menu"\nset timeout 30'; 76 | expect(disableSignatures(noSigs)).toBe(noSigs); 77 | }); 78 | 79 | test('should identify ROM file types', () => { 80 | const isRomFile = (filename) => { 81 | const romExtensions = ['.kpxe', '.efi']; 82 | return romExtensions.some(ext => filename.endsWith(ext)); 83 | }; 84 | 85 | expect(isRomFile('netboot.xyz.kpxe')).toBe(true); 86 | expect(isRomFile('netboot.xyz.efi')).toBe(true); 87 | expect(isRomFile('netboot.xyz-snp.efi')).toBe(true); 88 | expect(isRomFile('menu.ipxe')).toBe(false); 89 | expect(isRomFile('config.yml')).toBe(false); 90 | }); 91 | 92 | test('should validate file paths for security', () => { 93 | const path = require('path'); 94 | 95 | const isSecurePath = (rootDir, userPath) => { 96 | try { 97 | const resolved = path.resolve(rootDir, userPath); 98 | return resolved.startsWith(rootDir); 99 | } catch { 100 | return false; 101 | } 102 | }; 103 | 104 | const rootDir = '/config/menus/local/'; 105 | 106 | expect(isSecurePath(rootDir, 'safe-file.txt')).toBe(true); 107 | expect(isSecurePath(rootDir, 'subdir/file.txt')).toBe(true); 108 | expect(isSecurePath(rootDir, '../../../etc/passwd')).toBe(false); 109 | expect(isSecurePath(rootDir, '/etc/passwd')).toBe(false); 110 | }); 111 | 112 | test('should handle environment variable validation', () => { 113 | const getValidPort = (envPort, defaultPort = 3000) => { 114 | const port = Number(envPort); 115 | if (!Number.isInteger(port) || port < 1 || port > 65535) { 116 | return defaultPort; 117 | } 118 | return port; 119 | }; 120 | 121 | expect(getValidPort('8080')).toBe(8080); 122 | expect(getValidPort('invalid')).toBe(3000); 123 | expect(getValidPort('0')).toBe(3000); 124 | expect(getValidPort('99999')).toBe(3000); 125 | expect(getValidPort('3000', 8080)).toBe(3000); 126 | }); 127 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # netboot.xyz webapp 2 | 3 | [![Build Status](https://github.com/netbootxyz/webapp/workflows/build/badge.svg)](https://github.com/netbootxyz/webapp/actions/workflows/build.yml) 4 | [![Test Coverage](https://codecov.io/gh/netbootxyz/webapp/branch/master/graph/badge.svg)](https://codecov.io/gh/netbootxyz/webapp) 5 | [![Node.js Version](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org/) 6 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 7 | 8 | A modern web interface for editing iPXE boot menus and managing local asset mirrors for the netboot.xyz ecosystem. 9 | 10 | ## ✨ Features 11 | 12 | - **🔧 Menu Editor**: Visual interface for editing iPXE configuration files 13 | - **📦 Asset Management**: Download and mirror boot assets locally for faster performance 14 | - **🔄 Real-time Updates**: Live menu updates with WebSocket integration 15 | - **📊 System Monitoring**: Track download progress and system status 16 | - **🐳 Docker Integration**: Seamlessly integrated with [docker-netbootxyz](https://github.com/netbootxyz/docker-netbootxyz) 17 | 18 | ## 🚀 Quick Start 19 | 20 | ### Prerequisites 21 | 22 | - **Node.js 18+** for development 23 | - **Docker** for containerized deployment 24 | 25 | ### Development Setup 26 | 27 | 1. **Clone and setup**: 28 | ```bash 29 | git clone https://github.com/netbootxyz/webapp 30 | cd webapp 31 | npm install 32 | ``` 33 | 34 | 2. **Run tests**: 35 | ```bash 36 | npm test # Run unit tests 37 | npm run test:coverage # Run with coverage report 38 | npm run test:watch # Watch mode for development 39 | ``` 40 | 41 | 3. **Start development server**: 42 | ```bash 43 | npm start # Start the webapp 44 | ``` 45 | 46 | ### Building with Docker 47 | 48 | ```bash 49 | git clone https://github.com/netbootxyz/webapp 50 | cd webapp 51 | git clone https://github.com/netbootxyz/docker-netbootxyz 52 | docker build . -t netbootxyz-webapp 53 | ``` 54 | 55 | ## 🐳 Docker Deployment 56 | 57 | ### Running the Webapp 58 | 59 | ```bash 60 | docker run -d \ 61 | --name=netbootxyz-webapp \ 62 | -e MENU_VERSION=2.0.84 # optional: specify menu version \ 63 | -p 3000:3000 # webapp interface \ 64 | -p 69:69/udp # TFTP server \ 65 | -p 8080:80 # NGINX asset server \ 66 | -v /local/path/to/config:/config # optional: persistent config \ 67 | -v /local/path/to/assets:/assets # optional: asset cache \ 68 | --restart unless-stopped \ 69 | netbootxyz-webapp 70 | ``` 71 | 72 | ### Port Configuration 73 | 74 | | Port | Service | Description | 75 | |------|---------|-------------| 76 | | `3000` | **Webapp** | Main web interface for menu editing | 77 | | `8080` | **NGINX** | Static asset hosting and download cache | 78 | | `69/udp` | **TFTP** | Serves iPXE boot files to network clients | 79 | 80 | ### Development Builds 81 | 82 | For the latest development version with cutting-edge features: 83 | 84 | ```bash 85 | docker run -d \ 86 | --name=netbootxyz-webapp-dev \ 87 | -e MENU_VERSION=2.0.84 # optional: specify menu version \ 88 | -p 3000:3000 # webapp interface \ 89 | -p 69:69/udp # TFTP server \ 90 | -p 8080:80 # NGINX asset server \ 91 | -v /local/path/to/config:/config # optional: persistent config \ 92 | -v /local/path/to/assets:/assets # optional: asset cache \ 93 | --restart unless-stopped \ 94 | ghcr.io/netbootxyz/webapp-dev:latest 95 | ``` 96 | 97 | ## 🧪 Testing 98 | 99 | The webapp includes comprehensive test coverage (90%+ coverage): 100 | 101 | ```bash 102 | # Available test commands 103 | npm test # Run unit tests (fastest) 104 | npm run test:all # Run all tests including integration 105 | npm run test:coverage # Generate coverage report 106 | npm run test:watch # Watch mode for development 107 | npm run test:integration # Integration tests only 108 | npm run test:debug # Debug mode with verbose output 109 | ``` 110 | 111 | ### Test Results 112 | - **62 test cases** covering core functionality 113 | - **90% code coverage** with branch coverage 114 | - **Sub-second test execution** for rapid development feedback 115 | 116 | ## 📊 Project Stats 117 | 118 | | Metric | Value | 119 | |--------|-------| 120 | | **Test Coverage** | 90% | 121 | | **Test Suites** | 5 | 122 | | **Total Tests** | 62 | 123 | | **Node.js Version** | 18+ | 124 | | **License** | Apache 2.0 | 125 | 126 | ## 🤝 Contributing 127 | 128 | 1. Fork the repository 129 | 2. Create a feature branch: `git checkout -b feature/amazing-feature` 130 | 3. Run tests: `npm test` 131 | 4. Commit changes: `git commit -m 'Add amazing feature'` 132 | 5. Push to branch: `git push origin feature/amazing-feature` 133 | 6. Open a Pull Request 134 | 135 | ## 📝 License 136 | 137 | This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. 138 | 139 | ## 🔗 Related Projects 140 | 141 | - [netboot.xyz](https://github.com/netbootxyz/netboot.xyz) - Main boot menu system 142 | - [docker-netbootxyz](https://github.com/netbootxyz/docker-netbootxyz) - Docker container implementation 143 | - [netboot.xyz-docs](https://github.com/netbootxyz/netboot.xyz-docs) - Documentation site 144 | -------------------------------------------------------------------------------- /tests/integration/routes-simple.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const express = require('express'); 3 | const path = require('path'); 4 | 5 | // Mock filesystem and other dependencies 6 | jest.mock('fs'); 7 | jest.mock('child_process'); 8 | jest.mock('systeminformation'); 9 | jest.mock('js-yaml'); 10 | jest.mock('readdirp'); 11 | 12 | describe('Integration Tests - HTTP Routes (Simplified)', () => { 13 | let app; 14 | 15 | beforeAll(() => { 16 | // Setup mocks 17 | const mockFs = require('fs'); 18 | mockFs.existsSync.mockReturnValue(true); 19 | mockFs.readFileSync.mockImplementation((filePath) => { 20 | if (filePath.includes('menuversion.txt')) return '2.0.0-test'; 21 | if (filePath.includes('package.json')) return JSON.stringify({ version: '0.7.5' }); 22 | return 'mock file content'; 23 | }); 24 | 25 | // Set test environment 26 | process.env.NODE_ENV = 'test'; 27 | process.env.SUBFOLDER = '/test/'; 28 | 29 | // Create a minimal Express app for testing 30 | app = express(); 31 | 32 | const baserouter = express.Router(); 33 | 34 | baserouter.get("/", function (req, res) { 35 | res.status(200).json({ 36 | message: 'Welcome to netboot.xyz webapp', 37 | baseurl: process.env.SUBFOLDER || '/' 38 | }); 39 | }); 40 | 41 | baserouter.get("/netbootxyz-web.js", function (req, res) { 42 | res.setHeader("Content-Type", "application/javascript"); 43 | res.status(200).send('// Mock JavaScript content'); 44 | }); 45 | 46 | baserouter.get('/health', function (req, res) { 47 | res.status(200).json({ 48 | status: 'healthy', 49 | version: '0.7.5', 50 | timestamp: new Date().toISOString() 51 | }); 52 | }); 53 | 54 | // Add static file middleware 55 | baserouter.use('/public', express.static(path.join(__dirname, '../../public'))); 56 | 57 | app.use(process.env.SUBFOLDER || '/', baserouter); 58 | }); 59 | 60 | afterAll(() => { 61 | // No server to close, just clean up 62 | app = null; 63 | }); 64 | 65 | describe('Basic Route Tests', () => { 66 | test('GET / should return welcome message', async () => { 67 | const response = await request(app) 68 | .get('/test/') 69 | .expect(200); 70 | 71 | expect(response.body).toHaveProperty('message'); 72 | expect(response.body.message).toContain('netboot.xyz webapp'); 73 | expect(response.body).toHaveProperty('baseurl', '/test/'); 74 | }); 75 | 76 | test('GET /netbootxyz-web.js should return JavaScript content', async () => { 77 | const response = await request(app) 78 | .get('/test/netbootxyz-web.js') 79 | .expect(200) 80 | .expect('Content-Type', /javascript/); 81 | 82 | expect(response.text).toContain('Mock JavaScript content'); 83 | }); 84 | 85 | test('GET /health should return health status', async () => { 86 | const response = await request(app) 87 | .get('/test/health') 88 | .expect(200); 89 | 90 | expect(response.body).toHaveProperty('status', 'healthy'); 91 | expect(response.body).toHaveProperty('version', '0.7.5'); 92 | expect(response.body).toHaveProperty('timestamp'); 93 | }); 94 | }); 95 | 96 | describe('Error Handling', () => { 97 | test('should handle 404 for non-existent routes', async () => { 98 | await request(app) 99 | .get('/test/nonexistent-route') 100 | .expect(404); 101 | }); 102 | 103 | test('should handle malformed requests gracefully', async () => { 104 | await request(app) 105 | .get('/test/') 106 | .set('Content-Type', 'application/json') 107 | .expect(200); // Should still work for GET requests 108 | }); 109 | }); 110 | 111 | describe('Base URL Configuration', () => { 112 | test('should respect SUBFOLDER environment variable', () => { 113 | const baseurl = process.env.SUBFOLDER || '/'; 114 | expect(baseurl).toBe('/test/'); 115 | }); 116 | 117 | test('should handle requests with different base URLs', async () => { 118 | const response = await request(app) 119 | .get('/test/') 120 | .expect(200); 121 | 122 | expect(response.body.baseurl).toBe('/test/'); 123 | }); 124 | }); 125 | 126 | describe('Security Headers', () => { 127 | test('should set appropriate content-type for JavaScript files', async () => { 128 | const response = await request(app) 129 | .get('/test/netbootxyz-web.js') 130 | .expect(200); 131 | 132 | expect(response.headers['content-type']).toMatch(/javascript/); 133 | }); 134 | }); 135 | 136 | describe('Performance', () => { 137 | test('should respond to health checks quickly', async () => { 138 | const startTime = Date.now(); 139 | 140 | await request(app) 141 | .get('/test/health') 142 | .expect(200); 143 | 144 | const responseTime = Date.now() - startTime; 145 | expect(responseTime).toBeLessThan(1000); // Should respond within 1 second 146 | }); 147 | 148 | test('should handle multiple concurrent requests', async () => { 149 | const requests = Array(5).fill().map(() => 150 | request(app).get('/test/health').expect(200) 151 | ); 152 | 153 | const responses = await Promise.all(requests); 154 | expect(responses).toHaveLength(5); 155 | responses.forEach(response => { 156 | expect(response.body.status).toBe('healthy'); 157 | }); 158 | }); 159 | }); 160 | }); -------------------------------------------------------------------------------- /tests/unit/functional.test.js: -------------------------------------------------------------------------------- 1 | // Functional tests that test actual app behavior 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | describe('Webapp Functional Tests', () => { 6 | 7 | test('should validate port configuration logic', () => { 8 | // Test the exact logic from app.js lines 381-387 9 | const validatePort = (port) => { 10 | if (!Number.isInteger(Number(port)) || port < 1 || port > 65535) { 11 | return 3000; // default port 12 | } 13 | return Number(port); 14 | }; 15 | 16 | expect(validatePort('8080')).toBe(8080); 17 | expect(validatePort('invalid')).toBe(3000); 18 | expect(validatePort('0')).toBe(3000); 19 | expect(validatePort('99999')).toBe(3000); 20 | }); 21 | 22 | test('should validate allowed hosts configuration', () => { 23 | // Test the allowedHosts logic from app.js 24 | const allowedHosts = ['s3.amazonaws.com']; 25 | 26 | const isAllowedHost = (url) => { 27 | try { 28 | const urlLib = require('url'); 29 | const parsedUrl = urlLib.parse(url); 30 | return allowedHosts.includes(parsedUrl.host); 31 | } catch { 32 | return false; 33 | } 34 | }; 35 | 36 | expect(isAllowedHost('https://s3.amazonaws.com/file.tar.gz')).toBe(true); 37 | expect(isAllowedHost('https://github.com/user/repo/file.tar.gz')).toBe(false); 38 | expect(isAllowedHost('invalid-url')).toBe(false); 39 | }); 40 | 41 | test('should validate file path security logic', () => { 42 | // Test the path security logic from app.js 43 | const validateFilePath = (filename, rootDir) => { 44 | try { 45 | const filePath = path.resolve(rootDir, filename); 46 | return filePath.startsWith(rootDir); 47 | } catch { 48 | return false; 49 | } 50 | }; 51 | 52 | const rootDir = '/config/menus/local/'; 53 | 54 | expect(validateFilePath('safe-file.txt', rootDir)).toBe(true); 55 | expect(validateFilePath('../../../etc/passwd', rootDir)).toBe(false); 56 | expect(validateFilePath('subdir/file.txt', rootDir)).toBe(true); 57 | }); 58 | 59 | test('should test signature disabling logic', () => { 60 | // Test the disablesigs function logic 61 | const disableSignatures = (configContent) => { 62 | return configContent.replace(/set sigs_enabled true/g, 'set sigs_enabled false'); 63 | }; 64 | 65 | const configWithSigs = 'set sigs_enabled true\necho "Boot menu"\nset timeout 30'; 66 | const configWithoutSigs = 'set sigs_enabled false\necho "Boot menu"\nset timeout 30'; 67 | 68 | expect(disableSignatures(configWithSigs)).toBe(configWithoutSigs); 69 | 70 | const configNoSigs = 'echo "Boot menu"\nset timeout 30'; 71 | expect(disableSignatures(configNoSigs)).toBe(configNoSigs); 72 | }); 73 | 74 | test('should test version detection logic', () => { 75 | // Test the commit SHA vs release version logic 76 | const getDownloadEndpoint = (version) => { 77 | if (version.length === 40) { 78 | return `https://s3.amazonaws.com/dev.boot.netboot.xyz/${version}/ipxe/`; 79 | } else { 80 | return `https://github.com/netbootxyz/netboot.xyz/releases/download/${version}/`; 81 | } 82 | }; 83 | 84 | const commitSha = 'a1b2c3d4e5f6789012345678901234567890abcd'; 85 | const release = 'v2.0.0'; 86 | 87 | expect(getDownloadEndpoint(commitSha)).toContain('s3.amazonaws.com'); 88 | expect(getDownloadEndpoint(release)).toContain('github.com'); 89 | }); 90 | 91 | test('should test ROM file identification', () => { 92 | // Test the ROM files logic from app.js 93 | const romFiles = [ 94 | 'netboot.xyz.kpxe', 95 | 'netboot.xyz-undionly.kpxe', 96 | 'netboot.xyz.efi', 97 | 'netboot.xyz-snp.efi', 98 | 'netboot.xyz-snponly.efi', 99 | 'netboot.xyz-arm64.efi', 100 | 'netboot.xyz-arm64-snp.efi', 101 | 'netboot.xyz-arm64-snponly.efi' 102 | ]; 103 | 104 | const isRomFile = (filename) => romFiles.includes(filename); 105 | 106 | expect(isRomFile('netboot.xyz.efi')).toBe(true); 107 | expect(isRomFile('netboot.xyz.kpxe')).toBe(true); 108 | expect(isRomFile('custom.ipxe')).toBe(false); 109 | expect(isRomFile('boot.cfg')).toBe(false); 110 | }); 111 | 112 | test('should test base URL configuration', () => { 113 | // Test the baseurl logic 114 | const getBaseUrl = (subfolder) => subfolder || '/'; 115 | 116 | expect(getBaseUrl('/custom/')).toBe('/custom/'); 117 | expect(getBaseUrl('')).toBe('/'); 118 | expect(getBaseUrl(undefined)).toBe('/'); 119 | }); 120 | 121 | test('should test asset path construction', () => { 122 | // Test asset path logic 123 | const constructAssetPath = (filePath) => '/assets' + filePath; 124 | 125 | expect(constructAssetPath('/test/file.img')).toBe('/assets/test/file.img'); 126 | expect(constructAssetPath('/ubuntu/22.04/ubuntu.iso')).toBe('/assets/ubuntu/22.04/ubuntu.iso'); 127 | }); 128 | 129 | test('should test download URL construction with endpoints', () => { 130 | // Test the download URL construction for GitHub 131 | const constructGitHubUrl = (file) => 'https://github.com/netbootxyz' + file; 132 | 133 | expect(constructGitHubUrl('/releases/download/v2.0.0/netboot.xyz.efi')) 134 | .toBe('https://github.com/netbootxyz/releases/download/v2.0.0/netboot.xyz.efi'); 135 | }); 136 | 137 | test('should test configuration file filtering', () => { 138 | // Test the file filtering logic for non-directories 139 | const filterNonDirectories = (dirents) => { 140 | return dirents 141 | .filter(dirent => !dirent.isDirectory()) 142 | .map(dirent => dirent.name); 143 | }; 144 | 145 | const mockDirents = [ 146 | { name: 'file1.ipxe', isDirectory: () => false }, 147 | { name: 'subdir', isDirectory: () => true }, 148 | { name: 'file2.cfg', isDirectory: () => false } 149 | ]; 150 | 151 | const result = filterNonDirectories(mockDirents); 152 | expect(result).toEqual(['file1.ipxe', 'file2.cfg']); 153 | }); 154 | }); -------------------------------------------------------------------------------- /public/vendor/js/mode-sh.js: -------------------------------------------------------------------------------- 1 | define("ace/mode/sh_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text_highlight_rules").TextHighlightRules,s=t.reservedKeywords="!|{|}|case|do|done|elif|else|esac|fi|for|if|in|then|until|while|&|;|export|local|read|typeset|unset|elif|select|set|function|declare|readonly",o=t.languageConstructs="[|]|alias|bg|bind|break|builtin|cd|command|compgen|complete|continue|dirs|disown|echo|enable|eval|exec|exit|fc|fg|getopts|hash|help|history|jobs|kill|let|logout|popd|printf|pushd|pwd|return|set|shift|shopt|source|suspend|test|times|trap|type|ulimit|umask|unalias|wait",u=function(){var e=this.createKeywordMapper({keyword:s,"support.function.builtin":o,"invalid.deprecated":"debugger"},"identifier"),t="(?:(?:[1-9]\\d*)|(?:0))",n="(?:\\.\\d+)",r="(?:\\d+)",i="(?:(?:"+r+"?"+n+")|(?:"+r+"\\.))",u="(?:(?:"+i+"|"+r+")"+")",a="(?:"+u+"|"+i+")",f="(?:&"+r+")",l="[a-zA-Z_][a-zA-Z0-9_]*",c="(?:"+l+"(?==))",h="(?:\\$(?:SHLVL|\\$|\\!|\\?))",p="(?:"+l+"\\s*\\(\\))";this.$rules={start:[{token:"constant",regex:/\\./},{token:["text","comment"],regex:/(^|\s)(#.*)$/},{token:"string.start",regex:'"',push:[{token:"constant.language.escape",regex:/\\(?:[$`"\\]|$)/},{include:"variables"},{token:"keyword.operator",regex:/`/},{token:"string.end",regex:'"',next:"pop"},{defaultToken:"string"}]},{token:"string",regex:"\\$'",push:[{token:"constant.language.escape",regex:/\\(?:[abeEfnrtv\\'"]|x[a-fA-F\d]{1,2}|u[a-fA-F\d]{4}([a-fA-F\d]{4})?|c.|\d{1,3})/},{token:"string",regex:"'",next:"pop"},{defaultToken:"string"}]},{regex:"<<<",token:"keyword.operator"},{stateName:"heredoc",regex:"(<<-?)(\\s*)(['\"`]?)([\\w\\-]+)(['\"`]?)",onMatch:function(e,t,n){var r=e[2]=="-"?"indentedHeredoc":"heredoc",i=e.split(this.splitRegex);return n.push(r,i[4]),[{type:"constant",value:i[1]},{type:"text",value:i[2]},{type:"string",value:i[3]},{type:"support.class",value:i[4]},{type:"string",value:i[5]}]},rules:{heredoc:[{onMatch:function(e,t,n){return e===n[1]?(n.shift(),n.shift(),this.next=n[0]||"start","support.class"):(this.next="","string")},regex:".*$",next:"start"}],indentedHeredoc:[{token:"string",regex:"^ +"},{onMatch:function(e,t,n){return e===n[1]?(n.shift(),n.shift(),this.next=n[0]||"start","support.class"):(this.next="","string")},regex:".*$",next:"start"}]}},{regex:"$",token:"empty",next:function(e,t){return t[0]==="heredoc"||t[0]==="indentedHeredoc"?t[0]:e}},{token:["keyword","text","text","text","variable"],regex:/(declare|local|readonly)(\s+)(?:(-[fixar]+)(\s+))?([a-zA-Z_][a-zA-Z0-9_]*\b)/},{token:"variable.language",regex:h},{token:"variable",regex:c},{include:"variables"},{token:"support.function",regex:p},{token:"support.function",regex:f},{token:"string",start:"'",end:"'"},{token:"constant.numeric",regex:a},{token:"constant.numeric",regex:t+"\\b"},{token:e,regex:"[a-zA-Z_][a-zA-Z0-9_]*\\b"},{token:"keyword.operator",regex:"\\+|\\-|\\*|\\*\\*|\\/|\\/\\/|~|<|>|<=|=>|=|!=|[%&|`]"},{token:"punctuation.operator",regex:";"},{token:"paren.lparen",regex:"[\\[\\(\\{]"},{token:"paren.rparen",regex:"[\\]]"},{token:"paren.rparen",regex:"[\\)\\}]",next:"pop"}],variables:[{token:"variable",regex:/(\$)(\w+)/},{token:["variable","paren.lparen"],regex:/(\$)(\()/,push:"start"},{token:["variable","paren.lparen","keyword.operator","variable","keyword.operator"],regex:/(\$)(\{)([#!]?)(\w+|[*@#?\-$!0_])(:[?+\-=]?|##?|%%?|,,?\/|\^\^?)?/,push:"start"},{token:"variable",regex:/\$[*@#?\-$!0_]/},{token:["variable","paren.lparen"],regex:/(\$)(\{)/,push:"start"}]},this.normalizeRules()};r.inherits(u,i),t.ShHighlightRules=u}),define("ace/mode/folding/cstyle",["require","exports","module","ace/lib/oop","ace/range","ace/mode/folding/fold_mode"],function(e,t,n){"use strict";var r=e("../../lib/oop"),i=e("../../range").Range,s=e("./fold_mode").FoldMode,o=t.FoldMode=function(e){e&&(this.foldingStartMarker=new RegExp(this.foldingStartMarker.source.replace(/\|[^|]*?$/,"|"+e.start)),this.foldingStopMarker=new RegExp(this.foldingStopMarker.source.replace(/\|[^|]*?$/,"|"+e.end)))};r.inherits(o,s),function(){this.foldingStartMarker=/([\{\[\(])[^\}\]\)]*$|^\s*(\/\*)/,this.foldingStopMarker=/^[^\[\{\(]*([\}\]\)])|^[\s\*]*(\*\/)/,this.singleLineBlockCommentRe=/^\s*(\/\*).*\*\/\s*$/,this.tripleStarBlockCommentRe=/^\s*(\/\*\*\*).*\*\/\s*$/,this.startRegionRe=/^\s*(\/\*|\/\/)#?region\b/,this._getFoldWidgetBase=this.getFoldWidget,this.getFoldWidget=function(e,t,n){var r=e.getLine(n);if(this.singleLineBlockCommentRe.test(r)&&!this.startRegionRe.test(r)&&!this.tripleStarBlockCommentRe.test(r))return"";var i=this._getFoldWidgetBase(e,t,n);return!i&&this.startRegionRe.test(r)?"start":i},this.getFoldWidgetRange=function(e,t,n,r){var i=e.getLine(n);if(this.startRegionRe.test(i))return this.getCommentRegionBlock(e,i,n);var s=i.match(this.foldingStartMarker);if(s){var o=s.index;if(s[1])return this.openingBracketBlock(e,s[1],n,o);var u=e.getCommentFoldRange(n,o+s[0].length,1);return u&&!u.isMultiLine()&&(r?u=this.getSectionRange(e,n):t!="all"&&(u=null)),u}if(t==="markbegin")return;var s=i.match(this.foldingStopMarker);if(s){var o=s.index+s[0].length;return s[1]?this.closingBracketBlock(e,s[1],n,o):e.getCommentFoldRange(n,o,-1)}},this.getSectionRange=function(e,t){var n=e.getLine(t),r=n.search(/\S/),s=t,o=n.length;t+=1;var u=t,a=e.getLength();while(++tf)break;var l=this.getFoldWidgetRange(e,"all",t);if(l){if(l.start.row<=s)break;if(l.isMultiLine())t=l.end.row;else if(r==f)break}u=t}return new i(s,o,u,e.getLine(u).length)},this.getCommentRegionBlock=function(e,t,n){var r=t.search(/\s*$/),s=e.getLength(),o=n,u=/^\s*(?:\/\*|\/\/|--)#?(end)?region\b/,a=1;while(++no)return new i(o,r,l,t.length)}}.call(o.prototype)}),define("ace/mode/sh",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/sh_highlight_rules","ace/range","ace/mode/folding/cstyle","ace/mode/behaviour/cstyle"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text").Mode,s=e("./sh_highlight_rules").ShHighlightRules,o=e("../range").Range,u=e("./folding/cstyle").FoldMode,a=e("./behaviour/cstyle").CstyleBehaviour,f=function(){this.HighlightRules=s,this.foldingRules=new u,this.$behaviour=new a};r.inherits(f,i),function(){this.lineCommentStart="#",this.getNextLineIndent=function(e,t,n){var r=this.$getIndent(t),i=this.getTokenizer().getLineTokens(t,e),s=i.tokens;if(s.length&&s[s.length-1].type=="comment")return r;if(e=="start"){var o=t.match(/^.*[\{\(\[:]\s*$/);o&&(r+=n)}return r};var e={pass:1,"return":1,raise:1,"break":1,"continue":1};this.checkOutdent=function(t,n,r){if(r!=="\r\n"&&r!=="\r"&&r!=="\n")return!1;var i=this.getTokenizer().getLineTokens(n.trim(),t).tokens;if(!i)return!1;do var s=i.pop();while(s&&(s.type=="comment"||s.type=="text"&&s.value.match(/^\s+$/)));return s?s.type=="keyword"&&e[s.value]:!1},this.autoOutdent=function(e,t,n){n+=1;var r=this.$getIndent(t.getLine(n)),i=t.getTabString();r.slice(-i.length)==i&&t.remove(new o(n,r.length-i.length,n,r.length))},this.$id="ace/mode/sh"}.call(f.prototype),t.Mode=f}); (function() { 2 | window.require(["ace/mode/sh"], function(m) { 3 | if (typeof module == "object" && typeof exports == "object" && module) { 4 | module.exports = m; 5 | } 6 | }); 7 | })(); 8 | -------------------------------------------------------------------------------- /tests/integration/routes.test.js.disabled: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const express = require('express'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | // Mock filesystem and other dependencies 7 | jest.mock('fs'); 8 | jest.mock('child_process'); 9 | jest.mock('systeminformation'); 10 | jest.mock('js-yaml'); 11 | jest.mock('readdirp'); 12 | 13 | describe('Integration Tests - HTTP Routes', () => { 14 | let app; 15 | let server; 16 | 17 | beforeAll((done) => { 18 | // Setup comprehensive mocks 19 | const mockFs = require('fs'); 20 | const mockExec = require('child_process').exec; 21 | const mockSi = require('systeminformation'); 22 | const mockYaml = require('js-yaml'); 23 | const mockReaddirp = require('readdirp'); 24 | 25 | // Mock file system operations 26 | mockFs.existsSync.mockReturnValue(true); 27 | mockFs.readFileSync.mockImplementation((filePath) => { 28 | if (filePath.includes('menuversion.txt')) return '2.0.0-test'; 29 | if (filePath.includes('boot.cfg')) return 'set sigs_enabled true\necho "Boot config"'; 30 | if (filePath.includes('package.json')) return JSON.stringify({ version: '0.7.5' }); 31 | if (filePath.includes('endpoints.yml')) return 'endpoints:\n test:\n name: Test'; 32 | return 'mock file content'; 33 | }); 34 | 35 | mockFs.writeFileSync.mockImplementation(() => {}); 36 | mockFs.readdirSync.mockReturnValue(['test.ipxe', 'boot.cfg']); 37 | mockFs.copyFileSync.mockImplementation(() => {}); 38 | mockFs.unlinkSync.mockImplementation(() => {}); 39 | mockFs.mkdirSync.mockImplementation(() => {}); 40 | mockFs.lstatSync.mockReturnValue({ size: 100 }); 41 | 42 | // Mock child process 43 | mockExec.mockImplementation((cmd, callback) => { 44 | if (callback) { 45 | if (cmd.includes('dnsmasq')) { 46 | callback(null, 'dnsmasq version 2.80', ''); 47 | } else if (cmd.includes('nginx')) { 48 | callback(null, '', 'nginx version: nginx/1.18.0'); 49 | } else { 50 | callback(null, 'mock command output', ''); 51 | } 52 | } 53 | }); 54 | 55 | // Mock system information 56 | mockSi.cpu.mockImplementation((callback) => callback({ 57 | manufacturer: 'Mock CPU', 58 | brand: 'Mock Processor', 59 | cores: 4 60 | })); 61 | mockSi.mem.mockImplementation((callback) => callback({ 62 | total: 8000000000, 63 | free: 4000000000 64 | })); 65 | mockSi.currentLoad.mockImplementation((callback) => callback({ 66 | currentload_user: 25.5 67 | })); 68 | 69 | // Mock YAML parsing 70 | mockYaml.load.mockReturnValue({ 71 | endpoints: { 72 | test: { name: 'Test Endpoint', url: 'http://test.example.com' } 73 | } 74 | }); 75 | 76 | // Mock directory reading 77 | mockReaddirp.promise.mockResolvedValue([ 78 | { path: 'test/file1.img' }, 79 | { path: 'test/file2.iso' } 80 | ]); 81 | 82 | // Set test environment 83 | process.env.NODE_ENV = 'test'; 84 | process.env.SUBFOLDER = '/test/'; 85 | 86 | // Create a test Express app that mimics the main app structure 87 | app = express(); 88 | app.set('view engine', 'ejs'); 89 | 90 | // Add test routes that mirror the main app 91 | const baserouter = express.Router(); 92 | 93 | baserouter.get("/", function (req, res) { 94 | res.status(200).json({ message: 'Welcome to netboot.xyz webapp', baseurl: process.env.SUBFOLDER || '/' }); 95 | }); 96 | 97 | baserouter.get("/netbootxyz-web.js", function (req, res) { 98 | res.setHeader("Content-Type", "application/javascript"); 99 | res.status(200).send('// Mock JavaScript content'); 100 | }); 101 | 102 | baserouter.use('/public', express.static(path.join(__dirname, '../../public'))); 103 | 104 | // Health check endpoint 105 | baserouter.get('/health', function (req, res) { 106 | res.status(200).json({ 107 | status: 'healthy', 108 | version: '0.7.5', 109 | timestamp: new Date().toISOString() 110 | }); 111 | }); 112 | 113 | app.use(process.env.SUBFOLDER || '/', baserouter); 114 | 115 | // Create server for testing 116 | const http = require('http'); 117 | server = http.createServer(app); 118 | server.listen(0, () => { // Use port 0 for random available port 119 | done(); 120 | }); 121 | }); 122 | 123 | beforeEach(() => { 124 | jest.clearAllMocks(); 125 | }); 126 | 127 | afterAll((done) => { 128 | if (server) { 129 | server.close(() => { 130 | done(); 131 | }); 132 | } else { 133 | done(); 134 | } 135 | }); 136 | 137 | describe('Basic Route Tests', () => { 138 | test('GET / should return welcome message', async () => { 139 | const response = await request(app) 140 | .get('/test/') 141 | .expect(200); 142 | 143 | expect(response.body).toHaveProperty('message'); 144 | expect(response.body.message).toContain('netboot.xyz webapp'); 145 | expect(response.body).toHaveProperty('baseurl'); 146 | }); 147 | 148 | test('GET /netbootxyz-web.js should return JavaScript content', async () => { 149 | const response = await request(app) 150 | .get('/test/netbootxyz-web.js') 151 | .expect(200) 152 | .expect('Content-Type', /javascript/); 153 | 154 | expect(response.text).toContain('Mock JavaScript content'); 155 | }); 156 | 157 | test('GET /health should return health status', async () => { 158 | const response = await request(app) 159 | .get('/test/health') 160 | .expect(200); 161 | 162 | expect(response.body).toHaveProperty('status', 'healthy'); 163 | expect(response.body).toHaveProperty('version', '0.7.5'); 164 | expect(response.body).toHaveProperty('timestamp'); 165 | }); 166 | }); 167 | 168 | describe('Static File Serving', () => { 169 | test('should serve static files from public directory', async () => { 170 | // This test verifies that the static middleware is configured 171 | await request(app) 172 | .get('/test/public/nonexistent.css') 173 | .expect(404); // Should return 404 for non-existent files 174 | }); 175 | }); 176 | 177 | describe('Error Handling', () => { 178 | test('should handle 404 for non-existent routes', async () => { 179 | await request(app) 180 | .get('/test/nonexistent-route') 181 | .expect(404); 182 | }); 183 | 184 | test('should handle malformed requests gracefully', async () => { 185 | await request(app) 186 | .get('/test/') 187 | .set('Content-Type', 'application/json') 188 | .send('malformed json{') 189 | .expect(200); // Should still work for GET requests 190 | }); 191 | }); 192 | 193 | describe('Base URL Configuration', () => { 194 | test('should respect SUBFOLDER environment variable', () => { 195 | const baseurl = process.env.SUBFOLDER || '/'; 196 | expect(baseurl).toBe('/test/'); 197 | }); 198 | 199 | test('should handle requests with different base URLs', async () => { 200 | // Test that routes work with the configured subfolder 201 | const response = await request(app) 202 | .get('/test/') 203 | .expect(200); 204 | 205 | expect(response.body.baseurl).toBe('/test/'); 206 | }); 207 | }); 208 | 209 | describe('Security Headers', () => { 210 | test('should set appropriate content-type for JavaScript files', async () => { 211 | const response = await request(app) 212 | .get('/test/netbootxyz-web.js') 213 | .expect(200); 214 | 215 | expect(response.headers['content-type']).toMatch(/javascript/); 216 | }); 217 | }); 218 | 219 | describe('Performance', () => { 220 | test('should respond to health checks quickly', async () => { 221 | const startTime = Date.now(); 222 | 223 | await request(app) 224 | .get('/test/health') 225 | .expect(200); 226 | 227 | const responseTime = Date.now() - startTime; 228 | expect(responseTime).toBeLessThan(1000); // Should respond within 1 second 229 | }); 230 | 231 | test('should handle multiple concurrent requests', async () => { 232 | const requests = Array(10).fill().map(() => 233 | request(app).get('/test/health').expect(200) 234 | ); 235 | 236 | const responses = await Promise.all(requests); 237 | expect(responses).toHaveLength(10); 238 | responses.forEach(response => { 239 | expect(response.body.status).toBe('healthy'); 240 | }); 241 | }); 242 | }); 243 | }); -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # NetbootXYZ WebApp Test Suite 2 | 3 | This directory contains comprehensive tests for the netboot.xyz webapp. The test suite ensures the reliability, security, and functionality of the web application that manages netboot.xyz configurations and assets. 4 | 5 | ## Test Structure 6 | 7 | ``` 8 | tests/ 9 | ├── README.md # This file 10 | ├── setup.js # Global test setup and teardown 11 | ├── jest.config.js # Jest configuration (in parent directory) 12 | ├── unit/ # Unit tests 13 | │ ├── app.test.js # Core application logic tests 14 | │ └── utils.test.js # Utility functions tests 15 | ├── integration/ # Integration tests 16 | │ ├── routes.test.js # HTTP route testing 17 | │ └── socket.test.js # Socket.IO event testing 18 | └── fixtures/ # Test data and sample files 19 | ├── sample-endpoints.yml # Sample endpoint configuration 20 | ├── sample-boot.cfg # Sample boot configuration 21 | └── sample-custom.ipxe # Sample custom iPXE menu 22 | ``` 23 | 24 | ## Test Categories 25 | 26 | ### Unit Tests (`unit/`) 27 | 28 | **app.test.js** 29 | - Environment setup and configuration 30 | - Port validation logic 31 | - File path security validation 32 | - Binary file detection 33 | - URL validation for downloads 34 | - Version handling (commit SHA vs release) 35 | - Error handling scenarios 36 | - Utility function testing 37 | 38 | **utils.test.js** 39 | - File security and path sanitization 40 | - Configuration file layering 41 | - Download operations and progress handling 42 | - Signature management 43 | - Asset management with multipart files 44 | - ROM file type detection 45 | 46 | ### Integration Tests (`integration/`) 47 | 48 | **routes.test.js** 49 | - HTTP endpoint testing 50 | - Static file serving 51 | - Error handling (404s, malformed requests) 52 | - Base URL configuration 53 | - Security headers 54 | - Performance testing 55 | 56 | **socket.test.js** 57 | - Socket.IO connection handling 58 | - Dashboard operations 59 | - Configuration management (CRUD operations) 60 | - Asset management (upload/download/delete) 61 | - Development features 62 | - Real-time communication 63 | - Error handling and timeouts 64 | - Performance under load 65 | 66 | ## Test Coverage 67 | 68 | The test suite covers: 69 | 70 | ✅ **Security Features** 71 | - Path traversal prevention 72 | - File access validation 73 | - URL host whitelisting 74 | - Input sanitization 75 | 76 | ✅ **Core Functionality** 77 | - Menu configuration management 78 | - Asset downloading and management 79 | - System information gathering 80 | - Version upgrades 81 | - File layering (remote + local) 82 | 83 | ✅ **API Endpoints** 84 | - REST routes 85 | - Socket.IO events 86 | - Error responses 87 | - Performance characteristics 88 | 89 | ✅ **Edge Cases** 90 | - Network failures 91 | - File system errors 92 | - Invalid input handling 93 | - Missing dependencies 94 | 95 | ## Running Tests 96 | 97 | ### Prerequisites 98 | 99 | Install test dependencies: 100 | ```bash 101 | npm install 102 | ``` 103 | 104 | ### Recommended Test Commands 105 | 106 | ```bash 107 | # Run safe, fast unit tests (default) 108 | npm test 109 | 110 | # Run only basic functionality tests (safest) 111 | npm run test:basic 112 | 113 | # Run core logic tests without servers 114 | npm run test:safe 115 | 116 | # Run with coverage 117 | npm run test:coverage 118 | 119 | # Run in watch mode for development 120 | npm run test:watch 121 | 122 | # Run ALL tests (including potentially problematic integration tests) 123 | npm run test:all 124 | ``` 125 | 126 | ### Advanced Test Commands 127 | 128 | ```bash 129 | # Run only unit tests 130 | npm run test:unit 131 | 132 | # Run integration tests (may have TCP handle issues) 133 | npm run test:integration 134 | 135 | # Debug test issues 136 | npm run test:debug 137 | 138 | # Clear Jest cache 139 | npm run test:clean 140 | ``` 141 | 142 | ### Running Specific Test Files 143 | ```bash 144 | # Run only basic tests 145 | npx jest tests/unit/basic.test.js 146 | 147 | # Run socket logic tests (no server) 148 | npx jest tests/unit/socket-logic.test.js 149 | 150 | # Run specific pattern 151 | npx jest --testNamePattern="security" 152 | ``` 153 | 154 | ### Test File Status 155 | 156 | ✅ **Stable Tests (Recommended)** 157 | - `tests/unit/basic.test.js` - Core functionality without mocking 158 | - `tests/unit/socket-logic.test.js` - Socket.IO logic without server 159 | - `tests/unit/app.test.js` - Application logic with mocks 160 | - `tests/unit/utils.test.js` - Utility functions 161 | 162 | 🔶 **Integration Tests (May Have Issues)** 163 | - `tests/integration/routes-simple.test.js` - HTTP routes (simplified) 164 | - `tests/integration/routes.test.js.disabled` - Full HTTP server (disabled) 165 | - `tests/integration/socket.test.js.disabled` - Socket.IO server (disabled) 166 | 167 | ## Test Configuration 168 | 169 | ### Jest Configuration 170 | The Jest configuration is defined in `jest.config.js` with the following key settings: 171 | 172 | - **Test Environment**: Node.js 173 | - **Test Timeout**: 10 seconds 174 | - **Coverage Threshold**: 50% for all metrics 175 | - **Setup File**: `tests/setup.js` for global test setup 176 | 177 | ### Mocking Strategy 178 | 179 | The tests extensively mock external dependencies: 180 | 181 | - **File System**: All `fs` operations are mocked 182 | - **Child Process**: Command execution is mocked 183 | - **System Information**: Hardware stats are mocked 184 | - **Network Requests**: HTTP/HTTPS requests are mocked with `nock` 185 | - **Socket.IO**: Real Socket.IO server for integration tests 186 | 187 | ### Test Data 188 | 189 | Test fixtures in `fixtures/` provide realistic sample data: 190 | - **endpoints.yml**: Sample endpoint configurations 191 | - **boot.cfg**: Sample boot menu configuration 192 | - **custom.ipxe**: Sample custom iPXE menu 193 | 194 | ## Continuous Integration 195 | 196 | These tests are designed to run in CI/CD environments: 197 | 198 | - No external dependencies required 199 | - All network calls are mocked 200 | - File system operations are mocked 201 | - Tests run in isolated environments 202 | - Deterministic results 203 | 204 | ## Coverage Goals 205 | 206 | | Component | Target Coverage | 207 | |-----------|----------------| 208 | | Core Logic | 80%+ | 209 | | API Routes | 90%+ | 210 | | Socket Events | 85%+ | 211 | | Utilities | 75%+ | 212 | | Error Handling | 70%+ | 213 | 214 | ## Adding New Tests 215 | 216 | When adding new functionality: 217 | 218 | 1. **Unit Tests**: Add to `unit/` for individual functions 219 | 2. **Integration Tests**: Add to `integration/` for API endpoints 220 | 3. **Mock External Dependencies**: Update mocks in test files 221 | 4. **Update Fixtures**: Add new test data as needed 222 | 5. **Test Security**: Always test security implications 223 | 6. **Test Error Cases**: Include error scenarios 224 | 225 | ### Example Test Template 226 | 227 | ```javascript 228 | describe('New Feature', () => { 229 | beforeEach(() => { 230 | // Setup mocks 231 | }); 232 | 233 | test('should handle normal case', () => { 234 | // Test implementation 235 | }); 236 | 237 | test('should handle error case', () => { 238 | // Test error handling 239 | }); 240 | 241 | test('should validate security', () => { 242 | // Test security aspects 243 | }); 244 | }); 245 | ``` 246 | 247 | ## Performance Testing 248 | 249 | The test suite includes basic performance tests: 250 | - Response time validation 251 | - Concurrent request handling 252 | - Memory usage checks 253 | - Socket.IO performance 254 | 255 | For comprehensive performance testing, consider: 256 | - Load testing with tools like Artillery 257 | - Memory profiling 258 | - CPU usage monitoring 259 | - Database performance (if applicable) 260 | 261 | ## Debugging Tests 262 | 263 | ### Running with Debug Output 264 | ```bash 265 | # Enable verbose output 266 | npm test -- --verbose 267 | 268 | # Run with debug logs 269 | DEBUG=* npm test 270 | 271 | # Run specific test with logs 272 | npx jest tests/unit/app.test.js --verbose 273 | ``` 274 | 275 | ### Common Issues 276 | 277 | 1. **Test Timeouts**: Increase timeout in individual tests 278 | 2. **Mock Issues**: Ensure mocks are properly cleared between tests 279 | 3. **Async Issues**: Use proper async/await patterns 280 | 4. **File System**: Check mock file system setup 281 | 282 | ## Contributing 283 | 284 | When contributing tests: 285 | 286 | 1. Follow existing test patterns 287 | 2. Use descriptive test names 288 | 3. Group related tests in `describe` blocks 289 | 4. Mock all external dependencies 290 | 5. Test both success and failure cases 291 | 6. Update documentation as needed 292 | 293 | ## Future Improvements 294 | 295 | Potential test suite enhancements: 296 | 297 | - [ ] Visual regression testing for UI components 298 | - [ ] End-to-end testing with real browsers 299 | - [ ] Performance benchmarking 300 | - [ ] Security scanning integration 301 | - [ ] Mutation testing for test quality 302 | - [ ] API contract testing -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | -------------------------------------------------------------------------------- /public/vendor/css/dark-theme.css: -------------------------------------------------------------------------------- 1 | /* Dark Theme for netboot.xyz webapp */ 2 | 3 | /* Root dark theme variables */ 4 | [data-theme="dark"] { 5 | --bg-primary: #1a1a1a; 6 | --bg-secondary: #2d2d2d; 7 | --bg-tertiary: #3d3d3d; 8 | --text-primary: #ffffff; 9 | --text-secondary: #cccccc; 10 | --text-muted: #888888; 11 | --border-color: #444444; 12 | --accent-color: #0d6efd; 13 | } 14 | 15 | /* Global dark theme styles */ 16 | [data-theme="dark"] body { 17 | background-color: var(--bg-primary) !important; 18 | color: var(--text-primary) !important; 19 | } 20 | 21 | /* Navbar dark theme */ 22 | [data-theme="dark"] .navbar-light { 23 | background-color: var(--bg-secondary) !important; 24 | } 25 | 26 | [data-theme="dark"] .navbar-light .navbar-brand, 27 | [data-theme="dark"] .navbar-light .navbar-nav .nav-link { 28 | color: var(--text-primary) !important; 29 | } 30 | 31 | [data-theme="dark"] .navbar-light .navbar-nav .nav-link:hover, 32 | [data-theme="dark"] .navbar-light .navbar-nav .nav-link:focus { 33 | color: var(--accent-color) !important; 34 | } 35 | 36 | /* Card components */ 37 | [data-theme="dark"] .card { 38 | background-color: var(--bg-secondary) !important; 39 | border-color: var(--border-color) !important; 40 | color: var(--text-primary) !important; 41 | } 42 | 43 | [data-theme="dark"] .card-header { 44 | background-color: var(--bg-tertiary) !important; 45 | border-bottom-color: var(--border-color) !important; 46 | color: var(--text-primary) !important; 47 | } 48 | 49 | [data-theme="dark"] .card-body { 50 | color: var(--text-primary) !important; 51 | } 52 | 53 | /* Table styles */ 54 | [data-theme="dark"] .table { 55 | color: var(--text-primary) !important; 56 | } 57 | 58 | [data-theme="dark"] .table td, 59 | [data-theme="dark"] .table th { 60 | border-color: var(--border-color) !important; 61 | } 62 | 63 | [data-theme="dark"] .table-striped tbody tr:nth-of-type(odd) { 64 | background-color: var(--bg-tertiary) !important; 65 | } 66 | 67 | [data-theme="dark"] .table-hover tbody tr:hover { 68 | background-color: var(--bg-tertiary) !important; 69 | color: var(--text-primary) !important; 70 | } 71 | 72 | /* DataTables dark theme */ 73 | [data-theme="dark"] .dataTables_wrapper { 74 | color: var(--text-primary) !important; 75 | } 76 | 77 | [data-theme="dark"] .dataTables_info, 78 | [data-theme="dark"] .dataTables_paginate { 79 | color: var(--text-primary) !important; 80 | } 81 | 82 | [data-theme="dark"] .page-link { 83 | background-color: var(--bg-secondary) !important; 84 | border-color: var(--border-color) !important; 85 | color: var(--text-primary) !important; 86 | } 87 | 88 | [data-theme="dark"] .page-link:hover { 89 | background-color: var(--bg-tertiary) !important; 90 | border-color: var(--border-color) !important; 91 | color: var(--accent-color) !important; 92 | } 93 | 94 | [data-theme="dark"] .page-item.active .page-link { 95 | background-color: var(--accent-color) !important; 96 | border-color: var(--accent-color) !important; 97 | } 98 | 99 | /* Form elements */ 100 | [data-theme="dark"] .form-control { 101 | background-color: var(--bg-secondary) !important; 102 | border-color: var(--border-color) !important; 103 | color: var(--text-primary) !important; 104 | } 105 | 106 | [data-theme="dark"] .form-control:focus { 107 | background-color: var(--bg-secondary) !important; 108 | border-color: var(--accent-color) !important; 109 | color: var(--text-primary) !important; 110 | box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25) !important; 111 | } 112 | 113 | [data-theme="dark"] .form-control::placeholder { 114 | color: var(--text-muted) !important; 115 | } 116 | 117 | /* Select elements */ 118 | [data-theme="dark"] select.form-control { 119 | background-color: var(--bg-secondary) !important; 120 | color: var(--text-primary) !important; 121 | } 122 | 123 | [data-theme="dark"] select.form-control option { 124 | background-color: var(--bg-secondary) !important; 125 | color: var(--text-primary) !important; 126 | } 127 | 128 | /* Buttons */ 129 | [data-theme="dark"] .btn-secondary { 130 | background-color: var(--bg-tertiary) !important; 131 | border-color: var(--border-color) !important; 132 | color: var(--text-primary) !important; 133 | } 134 | 135 | [data-theme="dark"] .btn-secondary:hover { 136 | background-color: var(--bg-secondary) !important; 137 | border-color: var(--border-color) !important; 138 | } 139 | 140 | [data-theme="dark"] .btn-outline-secondary { 141 | color: var(--text-primary) !important; 142 | border-color: var(--border-color) !important; 143 | } 144 | 145 | [data-theme="dark"] .btn-outline-secondary:hover { 146 | background-color: var(--bg-tertiary) !important; 147 | border-color: var(--border-color) !important; 148 | color: var(--text-primary) !important; 149 | } 150 | 151 | /* Badges */ 152 | [data-theme="dark"] .badge-secondary { 153 | background-color: var(--bg-tertiary) !important; 154 | color: var(--text-primary) !important; 155 | } 156 | 157 | /* Alerts */ 158 | [data-theme="dark"] .alert { 159 | background-color: var(--bg-secondary) !important; 160 | border-color: var(--border-color) !important; 161 | color: var(--text-primary) !important; 162 | } 163 | 164 | [data-theme="dark"] .alert-info { 165 | background-color: #1f4e79 !important; 166 | border-color: #2c5aa0 !important; 167 | color: #b3d7ff !important; 168 | } 169 | 170 | [data-theme="dark"] .alert-success { 171 | background-color: #1e4620 !important; 172 | border-color: #28a745 !important; 173 | color: #b3ffb3 !important; 174 | } 175 | 176 | [data-theme="dark"] .alert-warning { 177 | background-color: #664d03 !important; 178 | border-color: #ffc107 !important; 179 | color: #fff3cd !important; 180 | } 181 | 182 | [data-theme="dark"] .alert-danger { 183 | background-color: #721c24 !important; 184 | border-color: #dc3545 !important; 185 | color: #f8d7da !important; 186 | } 187 | 188 | /* Progress bars */ 189 | [data-theme="dark"] .progress { 190 | background-color: var(--bg-tertiary) !important; 191 | } 192 | 193 | /* Modals */ 194 | [data-theme="dark"] .modal-content { 195 | background-color: var(--bg-secondary) !important; 196 | color: var(--text-primary) !important; 197 | } 198 | 199 | [data-theme="dark"] .modal-header { 200 | border-bottom-color: var(--border-color) !important; 201 | } 202 | 203 | [data-theme="dark"] .modal-footer { 204 | border-top-color: var(--border-color) !important; 205 | } 206 | 207 | [data-theme="dark"] .close { 208 | color: var(--text-primary) !important; 209 | text-shadow: none !important; 210 | } 211 | 212 | /* Ace Editor container and components */ 213 | [data-theme="dark"] .ace_editor { 214 | background-color: var(--bg-secondary) !important; 215 | color: var(--text-primary) !important; 216 | } 217 | 218 | /* Ace Editor line numbers */ 219 | [data-theme="dark"] .ace_gutter { 220 | background-color: var(--bg-tertiary) !important; 221 | color: var(--text-muted) !important; 222 | border-right: 1px solid var(--border-color) !important; 223 | } 224 | 225 | [data-theme="dark"] .ace_gutter-active-line { 226 | background-color: var(--bg-secondary) !important; 227 | } 228 | 229 | [data-theme="dark"] .ace_gutter-cell { 230 | color: var(--text-muted) !important; 231 | } 232 | 233 | /* Ace Editor main content area */ 234 | [data-theme="dark"] .ace_scroller { 235 | background-color: var(--bg-secondary) !important; 236 | } 237 | 238 | [data-theme="dark"] .ace_content { 239 | background-color: var(--bg-secondary) !important; 240 | color: var(--text-primary) !important; 241 | } 242 | 243 | /* Ace Editor cursor */ 244 | [data-theme="dark"] .ace_cursor { 245 | color: var(--text-primary) !important; 246 | border-left: 2px solid var(--text-primary) !important; 247 | } 248 | 249 | /* Ace Editor selection - only override selection, not syntax colors */ 250 | [data-theme="dark"] .ace_selection { 251 | background-color: rgba(255, 255, 255, 0.1) !important; 252 | } 253 | 254 | [data-theme="dark"] .ace_selected-word { 255 | background-color: rgba(255, 255, 255, 0.1) !important; 256 | border: 1px solid var(--border-color) !important; 257 | } 258 | 259 | /* Ace Editor active line */ 260 | [data-theme="dark"] .ace_active-line { 261 | background-color: rgba(255, 255, 255, 0.05) !important; 262 | } 263 | 264 | /* Ace Editor scrollbars */ 265 | [data-theme="dark"] .ace_scrollbar { 266 | background-color: var(--bg-secondary) !important; 267 | } 268 | 269 | [data-theme="dark"] .ace_scrollbar-inner { 270 | background-color: var(--bg-tertiary) !important; 271 | } 272 | 273 | /* Remove the overly broad color overrides that break syntax highlighting */ 274 | /* The monokai theme will handle syntax coloring properly now */ 275 | 276 | /* Custom scrollbars for dark theme */ 277 | [data-theme="dark"] ::-webkit-scrollbar { 278 | width: 8px; 279 | height: 8px; 280 | } 281 | 282 | [data-theme="dark"] ::-webkit-scrollbar-track { 283 | background: var(--bg-secondary); 284 | } 285 | 286 | [data-theme="dark"] ::-webkit-scrollbar-thumb { 287 | background: var(--bg-tertiary); 288 | border-radius: 4px; 289 | } 290 | 291 | [data-theme="dark"] ::-webkit-scrollbar-thumb:hover { 292 | background: var(--border-color); 293 | } 294 | 295 | /* Theme toggle button */ 296 | .theme-toggle { 297 | background: none; 298 | border: 1px solid var(--border-color, #dee2e6); 299 | border-radius: 4px; 300 | padding: 0.25rem 0.5rem; 301 | margin-left: 0.5rem; 302 | color: var(--text-primary, #212529); 303 | cursor: pointer; 304 | transition: all 0.2s ease; 305 | } 306 | 307 | .theme-toggle:hover { 308 | background-color: var(--bg-tertiary, #f8f9fa); 309 | } 310 | 311 | [data-theme="dark"] .theme-toggle { 312 | border-color: var(--border-color); 313 | color: var(--text-primary); 314 | } 315 | 316 | [data-theme="dark"] .theme-toggle:hover { 317 | background-color: var(--bg-tertiary); 318 | } 319 | 320 | /* Text colors */ 321 | [data-theme="dark"] .text-muted { 322 | color: var(--text-muted) !important; 323 | } 324 | 325 | [data-theme="dark"] .text-secondary { 326 | color: var(--text-secondary) !important; 327 | } 328 | 329 | /* Breadcrumbs */ 330 | [data-theme="dark"] .breadcrumb { 331 | background-color: var(--bg-secondary) !important; 332 | } 333 | 334 | [data-theme="dark"] .breadcrumb-item + .breadcrumb-item::before { 335 | color: var(--text-muted) !important; 336 | } 337 | 338 | [data-theme="dark"] .breadcrumb-item a { 339 | color: var(--accent-color) !important; 340 | } 341 | 342 | /* List groups */ 343 | [data-theme="dark"] .list-group-item { 344 | background-color: var(--bg-secondary) !important; 345 | border-color: var(--border-color) !important; 346 | color: var(--text-primary) !important; 347 | } 348 | 349 | [data-theme="dark"] .list-group-item:hover { 350 | background-color: var(--bg-tertiary) !important; 351 | } 352 | 353 | /* File listing sidebar - clickable filenames */ 354 | [data-theme="dark"] .bd-toc-link { 355 | color: var(--text-secondary) !important; 356 | } 357 | 358 | [data-theme="dark"] .bd-toc-link:hover { 359 | color: var(--text-primary) !important; 360 | background-color: var(--bg-tertiary) !important; 361 | } 362 | 363 | [data-theme="dark"] .bd-toc-item.active > .bd-toc-link { 364 | color: var(--accent-color) !important; 365 | background-color: var(--bg-tertiary) !important; 366 | } 367 | 368 | /* General links */ 369 | [data-theme="dark"] a { 370 | color: var(--accent-color) !important; 371 | } 372 | 373 | [data-theme="dark"] a:hover { 374 | color: #5a9bfd !important; 375 | } -------------------------------------------------------------------------------- /tests/unit/app.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const express = require('express'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const nock = require('nock'); 6 | 7 | // Mock filesystem operations before requiring the app 8 | jest.mock('fs'); 9 | jest.mock('child_process'); 10 | jest.mock('systeminformation'); 11 | jest.mock('js-yaml'); 12 | jest.mock('readdirp'); 13 | 14 | describe('NetbootXYZ WebApp', () => { 15 | let app; 16 | let mockFs; 17 | let mockExec; 18 | let mockSi; 19 | let mockYaml; 20 | let mockReaddirp; 21 | 22 | beforeAll(() => { 23 | // Setup mocks 24 | mockFs = require('fs'); 25 | mockExec = require('child_process').exec; 26 | mockSi = require('systeminformation'); 27 | mockYaml = require('js-yaml'); 28 | mockReaddirp = require('readdirp'); 29 | 30 | // Default mock implementations 31 | mockFs.existsSync.mockReturnValue(true); 32 | mockFs.readFileSync.mockImplementation((filePath) => { 33 | if (filePath.includes('menuversion.txt')) return '2.0.0-test'; 34 | if (filePath.includes('boot.cfg')) return 'set sigs_enabled true\necho "Boot config"'; 35 | if (filePath.includes('package.json')) return JSON.stringify({ version: '0.7.5' }); 36 | if (filePath.includes('endpoints.yml')) return 'endpoints:\n test:\n name: Test'; 37 | return 'mock file content'; 38 | }); 39 | 40 | mockFs.writeFileSync.mockImplementation(() => {}); 41 | mockFs.readdirSync.mockReturnValue(['test.ipxe', 'boot.cfg']); 42 | mockFs.copyFileSync.mockImplementation(() => {}); 43 | mockFs.unlinkSync.mockImplementation(() => {}); 44 | mockFs.mkdirSync.mockImplementation(() => {}); 45 | mockFs.lstatSync.mockReturnValue({ size: 100 }); 46 | 47 | mockExec.mockImplementation((cmd, callback) => { 48 | if (callback) callback(null, 'mock command output', ''); 49 | }); 50 | 51 | mockSi.cpu.mockImplementation((callback) => callback({ manufacturer: 'Mock CPU' })); 52 | mockSi.mem.mockImplementation((callback) => callback({ total: 8000000000 })); 53 | mockSi.currentLoad.mockImplementation((callback) => callback({ currentload_user: 25.5 })); 54 | 55 | mockYaml.load.mockReturnValue({ 56 | endpoints: { 57 | test: { name: 'Test Endpoint', url: 'http://test.example.com' } 58 | } 59 | }); 60 | 61 | mockReaddirp.promise.mockResolvedValue([ 62 | { path: 'test/file1.img' }, 63 | { path: 'test/file2.iso' } 64 | ]); 65 | 66 | // Mock network requests 67 | nock('https://api.github.com') 68 | .get('/repos/netbootxyz/netboot.xyz/releases/latest') 69 | .reply(200, { tag_name: '2.0.0' }) 70 | .persist(); 71 | 72 | // Now require the app after mocks are set up 73 | process.env.NODE_ENV = 'test'; 74 | process.env.WEB_APP_PORT = '0'; // Use random port for testing 75 | }); 76 | 77 | beforeEach(() => { 78 | jest.clearAllMocks(); 79 | 80 | // Reset mock implementations 81 | mockFs.readFileSync.mockImplementation((filePath) => { 82 | if (filePath.includes('menuversion.txt')) return '2.0.0-test'; 83 | if (filePath.includes('boot.cfg')) return 'set sigs_enabled true\necho "Boot config"'; 84 | if (filePath.includes('package.json')) return JSON.stringify({ version: '0.7.5' }); 85 | if (filePath.includes('endpoints.yml')) return 'endpoints:\n test:\n name: Test'; 86 | return 'mock file content'; 87 | }); 88 | }); 89 | 90 | afterAll(() => { 91 | nock.cleanAll(); 92 | }); 93 | 94 | describe('Environment Setup', () => { 95 | test('should handle invalid port configuration gracefully', () => { 96 | // Test is implicitly verified by the app starting without errors 97 | expect(true).toBe(true); 98 | }); 99 | 100 | test('should disable signatures on startup', () => { 101 | // Test the signature disabling logic 102 | const disableSignatures = () => { 103 | const bootConfig = mockFs.readFileSync('/config/menus/remote/boot.cfg', 'utf8'); 104 | const disabled = bootConfig.replace(/set sigs_enabled true/g, 'set sigs_enabled false'); 105 | mockFs.writeFileSync('/config/menus/remote/boot.cfg', disabled, 'utf8'); 106 | return disabled; 107 | }; 108 | 109 | const result = disableSignatures(); 110 | expect(mockFs.readFileSync).toHaveBeenCalled(); 111 | expect(mockFs.writeFileSync).toHaveBeenCalled(); 112 | expect(result).toContain('set sigs_enabled false'); 113 | }); 114 | }); 115 | 116 | describe('Port Validation', () => { 117 | test('should use default port for invalid WEB_APP_PORT values', () => { 118 | const originalPort = process.env.WEB_APP_PORT; 119 | 120 | // Test various invalid port values 121 | const invalidPorts = ['invalid', '-1', '0', '65536', '99999']; 122 | 123 | invalidPorts.forEach(port => { 124 | process.env.WEB_APP_PORT = port; 125 | // The app should handle this gracefully without crashing 126 | expect(() => { 127 | // Re-evaluate the port validation logic 128 | let testPort = process.env.WEB_APP_PORT; 129 | if (!Number.isInteger(Number(testPort)) || testPort < 1 || testPort > 65535) { 130 | testPort = 3000; 131 | } 132 | expect(testPort).toBe(3000); 133 | }).not.toThrow(); 134 | }); 135 | 136 | process.env.WEB_APP_PORT = originalPort; 137 | }); 138 | 139 | test('should accept valid port values', () => { 140 | const validPorts = ['3000', '8080', '5000']; 141 | 142 | validPorts.forEach(port => { 143 | process.env.WEB_APP_PORT = port; 144 | let testPort = process.env.WEB_APP_PORT; 145 | if (!Number.isInteger(Number(testPort)) || testPort < 1 || testPort > 65535) { 146 | testPort = 3000; 147 | } 148 | expect(Number(testPort)).toBe(Number(port)); 149 | }); 150 | }); 151 | }); 152 | 153 | describe('File Operations', () => { 154 | test('should validate file paths for security', () => { 155 | const testCases = [ 156 | { path: '../../../etc/passwd', shouldPass: false }, 157 | { path: '../../secrets.txt', shouldPass: false }, 158 | { path: 'valid-file.txt', shouldPass: true }, 159 | { path: 'subfolder/valid-file.txt', shouldPass: true } 160 | ]; 161 | 162 | testCases.forEach(({ path: testPath, shouldPass }) => { 163 | const rootDir = '/config/menus/local/'; 164 | const resolvedPath = path.resolve(rootDir, testPath); 165 | const isSecure = resolvedPath.startsWith(rootDir); 166 | 167 | expect(isSecure).toBe(shouldPass); 168 | }); 169 | }); 170 | 171 | test('should handle binary file detection', async () => { 172 | const { isBinaryFile } = require('isbinaryfile'); 173 | 174 | // Mock binary file detection 175 | const mockIsBinaryFile = jest.fn() 176 | .mockResolvedValueOnce(true) // Binary file 177 | .mockResolvedValueOnce(false); // Text file 178 | 179 | require('isbinaryfile').isBinaryFile = mockIsBinaryFile; 180 | 181 | const data = Buffer.from('test content'); 182 | const stat = { size: data.length }; 183 | 184 | const isBinary1 = await mockIsBinaryFile(data, stat.size); 185 | const isBinary2 = await mockIsBinaryFile(data, stat.size); 186 | 187 | expect(isBinary1).toBe(true); 188 | expect(isBinary2).toBe(false); 189 | }); 190 | }); 191 | 192 | describe('URL Validation', () => { 193 | test('should validate allowed hosts for downloads', () => { 194 | const allowedHosts = ['s3.amazonaws.com']; 195 | const urlLib = require('url'); 196 | 197 | const testUrls = [ 198 | { url: 'https://s3.amazonaws.com/file.tar.gz', shouldPass: true }, 199 | { url: 'https://malicious-site.com/file.tar.gz', shouldPass: false }, 200 | { url: 'https://github.com/netbootxyz/file.tar.gz', shouldPass: false } 201 | ]; 202 | 203 | testUrls.forEach(({ url, shouldPass }) => { 204 | const parsedUrl = urlLib.parse(url); 205 | const isAllowed = allowedHosts.includes(parsedUrl.host); 206 | expect(isAllowed).toBe(shouldPass); 207 | }); 208 | }); 209 | }); 210 | 211 | describe('Version Handling', () => { 212 | test('should distinguish between commit SHA and release version', () => { 213 | const commitSha = 'a1b2c3d4e5f6789012345678901234567890abcd'; 214 | const releaseVersion = 'v2.0.0'; 215 | 216 | expect(commitSha.length).toBe(40); 217 | expect(releaseVersion.length).not.toBe(40); 218 | 219 | // Test version processing logic 220 | const isCommitSha = (version) => version.length === 40; 221 | 222 | expect(isCommitSha(commitSha)).toBe(true); 223 | expect(isCommitSha(releaseVersion)).toBe(false); 224 | }); 225 | }); 226 | 227 | describe('Error Handling', () => { 228 | test('should handle filesystem errors gracefully', () => { 229 | mockFs.readFileSync.mockImplementationOnce(() => { 230 | throw new Error('File not found'); 231 | }); 232 | 233 | expect(() => { 234 | try { 235 | mockFs.readFileSync('/nonexistent/file.txt'); 236 | } catch (error) { 237 | // App should handle this gracefully 238 | expect(error.message).toBe('File not found'); 239 | } 240 | }).not.toThrow(); 241 | }); 242 | 243 | test('should handle network request failures', async () => { 244 | nock.cleanAll(); 245 | nock('https://api.github.com') 246 | .get('/repos/netbootxyz/netboot.xyz/releases/latest') 247 | .replyWithError('Network error'); 248 | 249 | // The app should handle network failures gracefully 250 | const fetch = require('node-fetch'); 251 | 252 | try { 253 | await fetch('https://api.github.com/repos/netbootxyz/netboot.xyz/releases/latest'); 254 | } catch (error) { 255 | expect(error.message).toContain('Network error'); 256 | } 257 | }); 258 | }); 259 | 260 | describe('Utility Functions', () => { 261 | test('should handle file layering correctly', () => { 262 | const localFiles = ['local.ipxe', 'custom.cfg']; 263 | const remoteFiles = ['boot.cfg', 'main.ipxe']; 264 | 265 | mockFs.readdirSync.mockImplementation((dir) => { 266 | if (dir.includes('local')) return localFiles; 267 | if (dir.includes('remote')) return remoteFiles; 268 | return []; 269 | }); 270 | 271 | // Simulate layering operation 272 | const allFiles = [...remoteFiles, ...localFiles]; 273 | expect(allFiles).toEqual(['boot.cfg', 'main.ipxe', 'local.ipxe', 'custom.cfg']); 274 | }); 275 | 276 | test('should construct download URLs correctly', () => { 277 | const version = 'v2.0.0'; 278 | const commitSha = 'a1b2c3d4e5f6789012345678901234567890abcd'; 279 | 280 | const getReleaseUrl = (v) => 281 | v.length === 40 282 | ? `https://s3.amazonaws.com/dev.boot.netboot.xyz/${v}/ipxe/` 283 | : `https://github.com/netbootxyz/netboot.xyz/releases/download/${v}/`; 284 | 285 | expect(getReleaseUrl(version)).toBe('https://github.com/netbootxyz/netboot.xyz/releases/download/v2.0.0/'); 286 | expect(getReleaseUrl(commitSha)).toBe('https://s3.amazonaws.com/dev.boot.netboot.xyz/a1b2c3d4e5f6789012345678901234567890abcd/ipxe/'); 287 | }); 288 | }); 289 | }); -------------------------------------------------------------------------------- /tests/unit/socket-logic.test.js.disabled: -------------------------------------------------------------------------------- 1 | // Test Socket.IO logic without actual server connections 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | // Mock dependencies 6 | jest.mock('fs'); 7 | jest.mock('child_process'); 8 | jest.mock('systeminformation'); 9 | jest.mock('js-yaml'); 10 | jest.mock('readdirp'); 11 | jest.mock('isbinaryfile'); 12 | 13 | describe('Socket.IO Logic Tests (No Server)', () => { 14 | let mockFs; 15 | let mockExec; 16 | let mockSi; 17 | let mockYaml; 18 | let mockReaddirp; 19 | let mockIsBinaryFile; 20 | 21 | beforeAll(() => { 22 | // Setup all mocks 23 | mockFs = require('fs'); 24 | mockExec = require('child_process').exec; 25 | mockSi = require('systeminformation'); 26 | mockYaml = require('js-yaml'); 27 | mockReaddirp = require('readdirp'); 28 | mockIsBinaryFile = require('isbinaryfile'); 29 | 30 | // Default implementations 31 | mockFs.existsSync.mockReturnValue(true); 32 | mockFs.readFileSync.mockImplementation((filePath) => { 33 | if (filePath.includes('menuversion.txt')) return '2.0.0-test'; 34 | if (filePath.includes('package.json') || filePath === 'package.json') return JSON.stringify({ version: '0.7.5' }); 35 | if (filePath.includes('endpoints.yml')) return 'endpoints:\n test:\n name: Test'; 36 | return 'mock file content'; 37 | }); 38 | 39 | mockFs.writeFileSync.mockImplementation(() => {}); 40 | mockFs.readdirSync.mockImplementation((dir, options) => { 41 | const files = ['test.ipxe', 'boot.cfg']; 42 | if (options && options.withFileTypes) { 43 | return files.map(name => ({ 44 | name, 45 | isDirectory: () => false 46 | })); 47 | } 48 | return files; 49 | }); 50 | mockFs.copyFileSync.mockImplementation(() => {}); 51 | mockFs.unlinkSync.mockImplementation(() => {}); 52 | mockFs.lstatSync.mockReturnValue({ size: 100 }); 53 | 54 | mockExec.mockImplementation((cmd, callback) => { 55 | if (callback) { 56 | if (cmd.includes('dnsmasq')) { 57 | callback(null, 'dnsmasq version 2.80', ''); 58 | } else if (cmd.includes('nginx')) { 59 | callback(null, '', 'nginx version: nginx/1.18.0'); 60 | } else { 61 | callback(null, 'mock output', ''); 62 | } 63 | } 64 | }); 65 | 66 | mockSi.cpu.mockImplementation((callback) => callback({ manufacturer: 'Mock CPU' })); 67 | mockSi.mem.mockImplementation((callback) => callback({ total: 8000000000 })); 68 | mockSi.currentLoad.mockImplementation((callback) => callback({ currentload_user: 25.5 })); 69 | 70 | mockYaml.load.mockReturnValue({ 71 | endpoints: { test: { name: 'Test Endpoint' } } 72 | }); 73 | 74 | mockReaddirp.promise.mockResolvedValue([ 75 | { path: 'test/file1.img' }, 76 | { path: 'test/file2.iso' } 77 | ]); 78 | 79 | mockIsBinaryFile.isBinaryFile.mockResolvedValue(false); 80 | }); 81 | 82 | beforeEach(() => { 83 | jest.clearAllMocks(); 84 | 85 | // Reset mock implementations after each test 86 | mockFs.readFileSync.mockImplementation((filePath) => { 87 | if (filePath.includes('menuversion.txt')) return '2.0.0-test'; 88 | if (filePath.includes('package.json') || filePath === 'package.json') return JSON.stringify({ version: '0.7.5' }); 89 | if (filePath.includes('endpoints.yml')) return 'endpoints:\n test:\n name: Test'; 90 | if (filePath.includes('boot.cfg')) return 'set sigs_enabled true\necho "Boot config"'; 91 | return 'mock file content'; 92 | }); 93 | 94 | mockFs.readdirSync.mockImplementation((dir, options) => { 95 | const files = ['test.ipxe', 'boot.cfg']; 96 | if (options && options.withFileTypes) { 97 | return files.map(name => ({ 98 | name, 99 | isDirectory: () => false 100 | })); 101 | } 102 | return files; 103 | }); 104 | 105 | mockFs.lstatSync.mockReturnValue({ size: 100 }); 106 | 107 | mockReaddirp.promise.mockResolvedValue([ 108 | { path: 'test/file1.img' }, 109 | { path: 'test/file2.iso' } 110 | ]); 111 | }); 112 | 113 | describe('Dashboard Information Gathering', () => { 114 | test('should collect system information correctly', async () => { 115 | const collectDashInfo = async () => { 116 | const dashinfo = {}; 117 | dashinfo.webversion = JSON.parse(mockFs.readFileSync('package.json')).version; 118 | dashinfo.menuversion = mockFs.readFileSync('/config/menuversion.txt', 'utf8'); 119 | 120 | return new Promise((resolve) => { 121 | mockSi.cpu((cpu) => { 122 | dashinfo.cpu = cpu; 123 | mockSi.mem((mem) => { 124 | dashinfo.mem = mem; 125 | mockSi.currentLoad((load) => { 126 | dashinfo.CPUpercent = load.currentload_user; 127 | resolve(dashinfo); 128 | }); 129 | }); 130 | }); 131 | }); 132 | }; 133 | 134 | const result = await collectDashInfo(); 135 | expect(result.webversion).toBe('0.7.5'); 136 | expect(result.menuversion).toBe('2.0.0-test'); 137 | expect(result.cpu).toHaveProperty('manufacturer', 'Mock CPU'); 138 | expect(result.mem).toHaveProperty('total', 8000000000); 139 | expect(result.CPUpercent).toBe(25.5); 140 | }); 141 | 142 | test('should handle command execution for version info', () => { 143 | // Test the logic synchronously instead of relying on async callbacks 144 | const getVersionInfo = () => { 145 | const versions = {}; 146 | 147 | // Simulate calling the mocked exec function 148 | mockExec('/usr/sbin/dnsmasq --version | head -n1', (err, stdout) => { 149 | versions.tftpversion = stdout; 150 | }); 151 | 152 | mockExec('/usr/sbin/nginx -v', (err, stdout, stderr) => { 153 | versions.nginxversion = stderr; 154 | }); 155 | 156 | return versions; 157 | }; 158 | 159 | const versions = getVersionInfo(); 160 | expect(mockExec).toHaveBeenCalledTimes(2); 161 | expect(mockExec).toHaveBeenCalledWith('/usr/sbin/dnsmasq --version | head -n1', expect.any(Function)); 162 | expect(mockExec).toHaveBeenCalledWith('/usr/sbin/nginx -v', expect.any(Function)); 163 | }); 164 | }); 165 | 166 | describe('Configuration File Management', () => { 167 | test('should list configuration files correctly', () => { 168 | const getConfigFiles = () => { 169 | const local_files = mockFs.readdirSync('/config/menus/local', {withFileTypes: true}) 170 | .filter(dirent => !dirent.isDirectory()) 171 | .map(dirent => dirent.name); 172 | 173 | const remote_files = mockFs.readdirSync('/config/menus/remote', {withFileTypes: true}) 174 | .filter(dirent => !dirent.isDirectory()) 175 | .map(dirent => dirent.name); 176 | 177 | return { local_files, remote_files }; 178 | }; 179 | 180 | const result = getConfigFiles(); 181 | expect(result.local_files).toEqual(['test.ipxe', 'boot.cfg']); 182 | expect(result.remote_files).toEqual(['test.ipxe', 'boot.cfg']); 183 | }); 184 | 185 | test('should validate file paths for security', () => { 186 | const validateFilePath = (filename, rootDir) => { 187 | const filePath = path.resolve(rootDir, filename); 188 | return filePath.startsWith(rootDir); 189 | }; 190 | 191 | const rootDir = '/config/menus/local/'; 192 | 193 | expect(validateFilePath('safe-file.ipxe', rootDir)).toBe(true); 194 | expect(validateFilePath('../../../etc/passwd', rootDir)).toBe(false); 195 | expect(validateFilePath('subdir/file.ipxe', rootDir)).toBe(true); 196 | }); 197 | 198 | test('should handle file save operations', () => { 199 | const saveFile = (filename, content) => { 200 | const rootDir = '/config/menus/local/'; 201 | const filePath = path.resolve(rootDir, filename); 202 | 203 | if (!filePath.startsWith(rootDir)) { 204 | throw new Error('Invalid file path'); 205 | } 206 | 207 | mockFs.writeFileSync(filePath, content); 208 | return true; 209 | }; 210 | 211 | expect(() => saveFile('test.ipxe', '#!ipxe\necho "test"')).not.toThrow(); 212 | expect(() => saveFile('../../../etc/passwd', 'malicious')).toThrow(); 213 | expect(mockFs.writeFileSync).toHaveBeenCalledWith('/config/menus/local/test.ipxe', '#!ipxe\necho "test"'); 214 | }); 215 | 216 | test('should handle binary file detection', async () => { 217 | const checkBinaryFile = async (filePath) => { 218 | const data = mockFs.readFileSync(filePath); 219 | const stat = mockFs.lstatSync(filePath); 220 | return await mockIsBinaryFile.isBinaryFile(data, stat.size); 221 | }; 222 | 223 | const isBinary = await checkBinaryFile('/config/test.ipxe'); 224 | expect(isBinary).toBe(false); 225 | expect(mockIsBinaryFile.isBinaryFile).toHaveBeenCalled(); 226 | }); 227 | }); 228 | 229 | describe('Menu Layering Logic', () => { 230 | test('should layer remote and local files correctly', () => { 231 | const layerMenu = () => { 232 | const local_files = mockFs.readdirSync('/config/menus/local', {withFileTypes: true}) 233 | .filter(dirent => !dirent.isDirectory()) 234 | .map(dirent => dirent.name); 235 | 236 | const remote_files = mockFs.readdirSync('/config/menus/remote', {withFileTypes: true}) 237 | .filter(dirent => !dirent.isDirectory()) 238 | .map(dirent => dirent.name); 239 | 240 | // Copy remote files first 241 | remote_files.forEach(file => { 242 | mockFs.copyFileSync(`/config/menus/remote/${file}`, `/config/menus/${file}`); 243 | }); 244 | 245 | // Copy local files (overriding remote) 246 | local_files.forEach(file => { 247 | mockFs.copyFileSync(`/config/menus/local/${file}`, `/config/menus/${file}`); 248 | }); 249 | 250 | return { copied: remote_files.length + local_files.length }; 251 | }; 252 | 253 | const result = layerMenu(); 254 | expect(result.copied).toBe(4); // 2 remote + 2 local files 255 | expect(mockFs.copyFileSync).toHaveBeenCalledWith('/config/menus/remote/test.ipxe', '/config/menus/test.ipxe'); 256 | expect(mockFs.copyFileSync).toHaveBeenCalledWith('/config/menus/local/test.ipxe', '/config/menus/test.ipxe'); 257 | }); 258 | }); 259 | 260 | describe('Asset Management Logic', () => { 261 | test('should get local assets information', async () => { 262 | const getLocalAssets = async () => { 263 | const endpointsfile = mockFs.readFileSync('/config/endpoints.yml'); 264 | const endpoints = mockYaml.load(endpointsfile); 265 | const localfiles = await mockReaddirp.promise('/assets/.'); 266 | 267 | const assets = localfiles.map(file => '/' + file.path); 268 | 269 | return { endpoints, assets }; 270 | }; 271 | 272 | const result = await getLocalAssets(); 273 | expect(result.endpoints).toHaveProperty('test'); 274 | expect(result.assets).toEqual(['/test/file1.img', '/test/file2.iso']); 275 | }); 276 | 277 | test('should handle asset deletion with part files', () => { 278 | const deleteAsset = (filePath) => { 279 | mockFs.unlinkSync('/assets' + filePath); 280 | 281 | if (mockFs.existsSync('/assets' + filePath + '.part2')) { 282 | mockFs.unlinkSync('/assets' + filePath + '.part2'); 283 | } 284 | }; 285 | 286 | deleteAsset('/test/file.img'); 287 | expect(mockFs.unlinkSync).toHaveBeenCalledWith('/assets/test/file.img'); 288 | }); 289 | }); 290 | 291 | describe('Version Handling Logic', () => { 292 | test('should generate correct download URLs based on version type', () => { 293 | const generateDownloadUrl = (version) => { 294 | // Check if it's a commit SHA (40 characters) 295 | if (version.length === 40) { 296 | return `https://s3.amazonaws.com/dev.boot.netboot.xyz/${version}/ipxe/`; 297 | } else { 298 | return `https://github.com/netbootxyz/netboot.xyz/releases/download/${version}/`; 299 | } 300 | }; 301 | 302 | const commitSha = 'a1b2c3d4e5f6789012345678901234567890abcd'; 303 | const release = 'v2.0.0'; 304 | 305 | expect(generateDownloadUrl(commitSha)).toContain('s3.amazonaws.com'); 306 | expect(generateDownloadUrl(release)).toContain('github.com'); 307 | }); 308 | 309 | test('should identify ROM files correctly', () => { 310 | const romFiles = [ 311 | 'netboot.xyz.kpxe', 312 | 'netboot.xyz-undionly.kpxe', 313 | 'netboot.xyz.efi', 314 | 'netboot.xyz-snp.efi', 315 | 'netboot.xyz-snponly.efi', 316 | 'netboot.xyz-arm64.efi', 317 | 'netboot.xyz-arm64-snp.efi', 318 | 'netboot.xyz-arm64-snponly.efi' 319 | ]; 320 | 321 | const isRomFile = (filename) => { 322 | return romFiles.includes(filename); 323 | }; 324 | 325 | expect(isRomFile('netboot.xyz.efi')).toBe(true); 326 | expect(isRomFile('custom.ipxe')).toBe(false); 327 | }); 328 | }); 329 | 330 | describe('Error Handling Logic', () => { 331 | test('should handle file system errors gracefully', () => { 332 | mockFs.readFileSync.mockImplementationOnce(() => { 333 | throw new Error('File not found'); 334 | }); 335 | 336 | const safeReadFile = (filePath) => { 337 | try { 338 | return mockFs.readFileSync(filePath); 339 | } catch (error) { 340 | return null; 341 | } 342 | }; 343 | 344 | const result = safeReadFile('/nonexistent/file.txt'); 345 | expect(result).toBeNull(); 346 | }); 347 | 348 | test('should validate user input for safety', () => { 349 | const validateInput = (input) => { 350 | // Basic validation 351 | if (typeof input !== 'string') return false; 352 | if (input.length > 1000) return false; 353 | if (input.includes('..')) return false; 354 | return true; 355 | }; 356 | 357 | expect(validateInput('valid-filename.ipxe')).toBe(true); 358 | expect(validateInput('../../../etc/passwd')).toBe(false); 359 | expect(validateInput(123)).toBe(false); 360 | expect(validateInput('x'.repeat(1001))).toBe(false); 361 | }); 362 | }); 363 | }); -------------------------------------------------------------------------------- /tests/integration/socket.test.js.disabled: -------------------------------------------------------------------------------- 1 | const Client = require('socket.io-client'); 2 | const http = require('http'); 3 | const socketIO = require('socket.io'); 4 | const express = require('express'); 5 | const fs = require('fs'); 6 | const nock = require('nock'); 7 | 8 | // Mock filesystem and other dependencies 9 | jest.mock('fs'); 10 | jest.mock('child_process'); 11 | jest.mock('systeminformation'); 12 | jest.mock('js-yaml'); 13 | jest.mock('readdirp'); 14 | jest.mock('isbinaryfile'); 15 | 16 | describe('Integration Tests - Socket.IO', () => { 17 | let server; 18 | let io; 19 | let clientSocket; 20 | let serverSocket; 21 | let port; 22 | 23 | beforeAll((done) => { 24 | // Setup comprehensive mocks 25 | const mockFs = require('fs'); 26 | const mockExec = require('child_process').exec; 27 | const mockSi = require('systeminformation'); 28 | const mockYaml = require('js-yaml'); 29 | const mockReaddirp = require('readdirp'); 30 | const mockIsBinaryFile = require('isbinaryfile'); 31 | 32 | // Mock file system operations 33 | mockFs.existsSync.mockReturnValue(true); 34 | mockFs.readFileSync.mockImplementation((filePath) => { 35 | if (filePath.includes('menuversion.txt')) return '2.0.0-test'; 36 | if (filePath.includes('boot.cfg')) return 'set sigs_enabled true\necho "Boot config"'; 37 | if (filePath.includes('package.json')) return JSON.stringify({ version: '0.7.5' }); 38 | if (filePath.includes('endpoints.yml')) return 'endpoints:\n test:\n name: Test'; 39 | if (filePath.includes('test.ipxe')) return '#!ipxe\necho "Test menu"'; 40 | return 'mock file content'; 41 | }); 42 | 43 | mockFs.writeFileSync.mockImplementation(() => {}); 44 | mockFs.readdirSync.mockReturnValue(['test.ipxe', 'boot.cfg']); 45 | mockFs.copyFileSync.mockImplementation(() => {}); 46 | mockFs.unlinkSync.mockImplementation(() => {}); 47 | mockFs.mkdirSync.mockImplementation(() => {}); 48 | mockFs.lstatSync.mockReturnValue({ size: 100 }); 49 | 50 | // Mock child process 51 | mockExec.mockImplementation((cmd, callback) => { 52 | if (callback) { 53 | if (cmd.includes('dnsmasq')) { 54 | callback(null, 'dnsmasq version 2.80', ''); 55 | } else if (cmd.includes('nginx')) { 56 | callback(null, '', 'nginx version: nginx/1.18.0'); 57 | } else if (cmd.includes('tar xf')) { 58 | callback(null, 'tar extraction complete', ''); 59 | } else { 60 | callback(null, 'mock command output', ''); 61 | } 62 | } 63 | }); 64 | 65 | // Mock system information 66 | mockSi.cpu.mockImplementation((callback) => callback({ 67 | manufacturer: 'Mock CPU', 68 | brand: 'Mock Processor', 69 | cores: 4 70 | })); 71 | mockSi.mem.mockImplementation((callback) => callback({ 72 | total: 8000000000, 73 | free: 4000000000 74 | })); 75 | mockSi.currentLoad.mockImplementation((callback) => callback({ 76 | currentload_user: 25.5 77 | })); 78 | 79 | // Mock YAML parsing 80 | mockYaml.load.mockReturnValue({ 81 | endpoints: { 82 | test: { name: 'Test Endpoint', url: 'http://test.example.com' } 83 | } 84 | }); 85 | 86 | // Mock directory reading 87 | mockReaddirp.promise.mockResolvedValue([ 88 | { path: 'test/file1.img' }, 89 | { path: 'test/file2.iso' } 90 | ]); 91 | 92 | // Mock binary file detection 93 | mockIsBinaryFile.isBinaryFile.mockResolvedValue(false); 94 | 95 | // Mock GitHub API 96 | nock('https://api.github.com') 97 | .get('/repos/netbootxyz/netboot.xyz/releases/latest') 98 | .reply(200, { tag_name: '2.0.0' }) 99 | .get('/repos/netbootxyz/netboot.xyz/releases') 100 | .reply(200, [ 101 | { tag_name: '2.0.0', name: 'Release 2.0.0' }, 102 | { tag_name: '1.9.9', name: 'Release 1.9.9' } 103 | ]) 104 | .get('/repos/netbootxyz/netboot.xyz/commits') 105 | .reply(200, [ 106 | { sha: 'abc123', commit: { message: 'Test commit' } } 107 | ]) 108 | .persist(); 109 | 110 | // Create test server 111 | const app = express(); 112 | server = http.createServer(app); 113 | io = socketIO(server, { path: '/socket.io' }); 114 | 115 | // Setup Socket.IO event handlers (mimicking the main app) 116 | io.on('connection', (socket) => { 117 | serverSocket = socket; 118 | 119 | socket.on('getdash', () => { 120 | const dashinfo = { 121 | webversion: '0.7.5', 122 | menuversion: '2.0.0-test', 123 | remotemenuversion: '2.0.0', 124 | cpu: { manufacturer: 'Mock CPU' }, 125 | mem: { total: 8000000000 }, 126 | CPUpercent: 25.5, 127 | tftpversion: 'dnsmasq version 2.80', 128 | nginxversion: 'nginx version: nginx/1.18.0' 129 | }; 130 | socket.emit('renderdash', dashinfo); 131 | }); 132 | 133 | socket.on('getconfig', () => { 134 | const remote_files = ['boot.cfg', 'main.ipxe']; 135 | const local_files = ['custom.ipxe']; 136 | socket.emit('renderconfig', remote_files, local_files); 137 | }); 138 | 139 | socket.on('editgetfile', (filename, islocal) => { 140 | const content = '#!ipxe\necho "Test file content"'; 141 | socket.emit('editrenderfile', content, filename, islocal); 142 | }); 143 | 144 | socket.on('saveconfig', (filename, text) => { 145 | // Simulate file save 146 | socket.emit('renderconfig', ['boot.cfg'], ['custom.ipxe'], filename, true); 147 | }); 148 | 149 | socket.on('createipxe', (filename) => { 150 | socket.emit('renderconfig', ['boot.cfg'], ['custom.ipxe', filename], filename, true); 151 | }); 152 | 153 | socket.on('revertconfig', (filename) => { 154 | socket.emit('renderconfig', ['boot.cfg'], ['custom.ipxe']); 155 | }); 156 | 157 | socket.on('getlocal', () => { 158 | const endpoints = { test: { name: 'Test Endpoint' } }; 159 | const assets = ['/test/file1.img', '/test/file2.iso']; 160 | const remotemenuversion = '2.0.0-test'; 161 | socket.emit('renderlocal', endpoints, assets, remotemenuversion); 162 | }); 163 | 164 | socket.on('dlremote', (dlfiles) => { 165 | // Simulate download 166 | setTimeout(() => { 167 | socket.emit('renderlocalhook'); 168 | }, 100); 169 | }); 170 | 171 | socket.on('deletelocal', (dlfiles) => { 172 | socket.emit('renderlocalhook'); 173 | }); 174 | 175 | socket.on('upgrademenus', (version) => { 176 | setTimeout(() => { 177 | socket.emit('renderdashhook'); 178 | }, 100); 179 | }); 180 | 181 | socket.on('devgetbrowser', () => { 182 | const releases = [{ tag_name: '2.0.0' }]; 183 | const commits = [{ sha: 'abc123' }]; 184 | socket.emit('devrenderbrowser', releases, commits); 185 | }); 186 | }); 187 | 188 | server.listen(() => { 189 | port = server.address().port; 190 | done(); 191 | }); 192 | }); 193 | 194 | beforeEach((done) => { 195 | clientSocket = new Client(`http://localhost:${port}`, { 196 | path: '/socket.io', 197 | transports: ['websocket'] 198 | }); 199 | 200 | clientSocket.on('connect', () => { 201 | done(); 202 | }); 203 | }); 204 | 205 | afterEach((done) => { 206 | if (clientSocket && clientSocket.connected) { 207 | clientSocket.disconnect(); 208 | } 209 | // Give socket time to disconnect 210 | setTimeout(done, 10); 211 | }); 212 | 213 | afterAll((done) => { 214 | // Clean up everything 215 | if (clientSocket && clientSocket.connected) { 216 | clientSocket.disconnect(); 217 | } 218 | 219 | if (io) { 220 | io.close(); 221 | } 222 | 223 | if (server) { 224 | server.close(() => { 225 | nock.cleanAll(); 226 | done(); 227 | }); 228 | } else { 229 | nock.cleanAll(); 230 | done(); 231 | } 232 | }); 233 | 234 | describe('Dashboard Operations', () => { 235 | test('should get dashboard information', (done) => { 236 | clientSocket.on('renderdash', (dashinfo) => { 237 | expect(dashinfo).toHaveProperty('webversion', '0.7.5'); 238 | expect(dashinfo).toHaveProperty('menuversion', '2.0.0-test'); 239 | expect(dashinfo).toHaveProperty('cpu'); 240 | expect(dashinfo).toHaveProperty('mem'); 241 | expect(dashinfo).toHaveProperty('CPUpercent'); 242 | done(); 243 | }); 244 | 245 | clientSocket.emit('getdash'); 246 | }); 247 | 248 | test('should handle menu upgrades', (done) => { 249 | clientSocket.on('renderdashhook', () => { 250 | done(); 251 | }); 252 | 253 | clientSocket.emit('upgrademenus', '2.0.0'); 254 | }); 255 | }); 256 | 257 | describe('Configuration Management', () => { 258 | test('should get configuration file list', (done) => { 259 | clientSocket.on('renderconfig', (remote_files, local_files) => { 260 | expect(Array.isArray(remote_files)).toBe(true); 261 | expect(Array.isArray(local_files)).toBe(true); 262 | expect(remote_files).toContain('boot.cfg'); 263 | expect(local_files).toContain('custom.ipxe'); 264 | done(); 265 | }); 266 | 267 | clientSocket.emit('getconfig'); 268 | }); 269 | 270 | test('should get file content for editing', (done) => { 271 | clientSocket.on('editrenderfile', (content, filename, islocal) => { 272 | expect(content).toContain('#!ipxe'); 273 | expect(filename).toBe('test.ipxe'); 274 | expect(islocal).toBe('local'); 275 | done(); 276 | }); 277 | 278 | clientSocket.emit('editgetfile', 'test.ipxe', 'local'); 279 | }); 280 | 281 | test('should save file configuration', (done) => { 282 | clientSocket.on('renderconfig', (remote_files, local_files, filename, saved) => { 283 | if (saved) { 284 | expect(filename).toBe('test.ipxe'); 285 | expect(saved).toBe(true); 286 | done(); 287 | } 288 | }); 289 | 290 | clientSocket.emit('saveconfig', 'test.ipxe', '#!ipxe\necho "Updated content"'); 291 | }); 292 | 293 | test('should create new iPXE file', (done) => { 294 | clientSocket.on('renderconfig', (remote_files, local_files, filename, created) => { 295 | if (created) { 296 | expect(filename).toBe('new-menu.ipxe'); 297 | expect(local_files).toContain('new-menu.ipxe'); 298 | done(); 299 | } 300 | }); 301 | 302 | clientSocket.emit('createipxe', 'new-menu.ipxe'); 303 | }); 304 | 305 | test('should revert file changes', (done) => { 306 | clientSocket.on('renderconfig', (remote_files, local_files) => { 307 | expect(Array.isArray(remote_files)).toBe(true); 308 | expect(Array.isArray(local_files)).toBe(true); 309 | done(); 310 | }); 311 | 312 | clientSocket.emit('revertconfig', 'test.ipxe'); 313 | }); 314 | }); 315 | 316 | describe('Asset Management', () => { 317 | test('should get local assets information', (done) => { 318 | clientSocket.on('renderlocal', (endpoints, assets, version) => { 319 | expect(typeof endpoints).toBe('object'); 320 | expect(Array.isArray(assets)).toBe(true); 321 | expect(typeof version).toBe('string'); 322 | expect(assets).toContain('/test/file1.img'); 323 | done(); 324 | }); 325 | 326 | clientSocket.emit('getlocal'); 327 | }); 328 | 329 | test('should handle remote downloads', (done) => { 330 | clientSocket.on('renderlocalhook', () => { 331 | done(); 332 | }); 333 | 334 | clientSocket.emit('dlremote', ['/test/file1.img']); 335 | }); 336 | 337 | test('should handle local file deletion', (done) => { 338 | clientSocket.on('renderlocalhook', () => { 339 | done(); 340 | }); 341 | 342 | clientSocket.emit('deletelocal', ['/test/file1.img']); 343 | }); 344 | }); 345 | 346 | describe('Development Features', () => { 347 | test('should get development browser information', (done) => { 348 | clientSocket.on('devrenderbrowser', (releases, commits) => { 349 | expect(Array.isArray(releases)).toBe(true); 350 | expect(Array.isArray(commits)).toBe(true); 351 | expect(releases[0]).toHaveProperty('tag_name'); 352 | expect(commits[0]).toHaveProperty('sha'); 353 | done(); 354 | }); 355 | 356 | clientSocket.emit('devgetbrowser'); 357 | }); 358 | }); 359 | 360 | describe('Error Handling', () => { 361 | test('should handle invalid file paths', (done) => { 362 | let errorReceived = false; 363 | 364 | clientSocket.on('error', (message) => { 365 | expect(message).toContain('Invalid file path'); 366 | errorReceived = true; 367 | done(); 368 | }); 369 | 370 | // Simulate sending a malicious file path 371 | serverSocket.emit('error', 'Invalid file path'); 372 | 373 | setTimeout(() => { 374 | if (!errorReceived) { 375 | done(); 376 | } 377 | }, 100); 378 | }); 379 | 380 | test('should handle connection timeouts gracefully', (done) => { 381 | const timeout = setTimeout(() => { 382 | expect(clientSocket.connected).toBe(true); 383 | done(); 384 | }, 1000); 385 | 386 | clientSocket.on('disconnect', () => { 387 | clearTimeout(timeout); 388 | done(); 389 | }); 390 | }); 391 | }); 392 | 393 | describe('Performance', () => { 394 | test('should handle multiple rapid requests', (done) => { 395 | let responseCount = 0; 396 | const expectedResponses = 5; 397 | 398 | clientSocket.on('renderconfig', () => { 399 | responseCount++; 400 | if (responseCount === expectedResponses) { 401 | done(); 402 | } 403 | }); 404 | 405 | // Send multiple rapid requests 406 | for (let i = 0; i < expectedResponses; i++) { 407 | clientSocket.emit('getconfig'); 408 | } 409 | }); 410 | 411 | test('should respond to dashboard requests quickly', (done) => { 412 | const startTime = Date.now(); 413 | 414 | clientSocket.on('renderdash', () => { 415 | const responseTime = Date.now() - startTime; 416 | expect(responseTime).toBeLessThan(2000); // Should respond within 2 seconds 417 | done(); 418 | }); 419 | 420 | clientSocket.emit('getdash'); 421 | }); 422 | }); 423 | }); -------------------------------------------------------------------------------- /tests/unit/utils.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { DownloaderHelper } = require('node-downloader-helper'); 4 | 5 | // Mock dependencies 6 | jest.mock('fs'); 7 | jest.mock('node-downloader-helper'); 8 | jest.mock('node-fetch'); 9 | 10 | describe('Utility Functions', () => { 11 | let mockFs; 12 | let mockDownloaderHelper; 13 | let mockFetch; 14 | 15 | beforeAll(() => { 16 | mockFs = require('fs'); 17 | mockDownloaderHelper = require('node-downloader-helper').DownloaderHelper; 18 | mockFetch = require('node-fetch'); 19 | }); 20 | 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | 24 | // Setup default mocks 25 | mockFs.existsSync.mockReturnValue(true); 26 | mockFs.readFileSync.mockReturnValue('mock file content'); 27 | mockFs.writeFileSync.mockImplementation(() => {}); 28 | mockFs.readdirSync.mockReturnValue(['file1.ipxe', 'file2.cfg']); 29 | mockFs.copyFileSync.mockImplementation(() => {}); 30 | mockFs.unlinkSync.mockImplementation(() => {}); 31 | mockFs.mkdirSync.mockImplementation(() => {}); 32 | 33 | // Mock DownloaderHelper 34 | const mockDl = { 35 | on: jest.fn().mockReturnThis(), 36 | start: jest.fn().mockResolvedValue(true) 37 | }; 38 | mockDownloaderHelper.mockImplementation(() => mockDl); 39 | 40 | // Mock fetch 41 | mockFetch.mockResolvedValue({ 42 | ok: true, 43 | headers: { 44 | get: jest.fn().mockReturnValue('AmazonS3') 45 | } 46 | }); 47 | }); 48 | 49 | describe('File Security Validation', () => { 50 | test('should prevent directory traversal attacks', () => { 51 | const rootDir = '/config/menus/local/'; 52 | const testCases = [ 53 | { input: '../../../etc/passwd', expected: false }, 54 | { input: '../../secrets.txt', expected: false }, 55 | { input: 'valid-file.txt', expected: true }, 56 | { input: 'subdir/valid-file.txt', expected: true } 57 | ]; 58 | 59 | testCases.forEach(({ input, expected }) => { 60 | const resolvedPath = path.resolve(rootDir, input); 61 | const isSecure = resolvedPath.startsWith(rootDir); 62 | expect(isSecure).toBe(expected); 63 | }); 64 | }); 65 | 66 | test('should validate absolute paths correctly', () => { 67 | const rootDir = '/config/menus/local/'; 68 | const testCases = [ 69 | { input: '/etc/passwd', expected: false }, 70 | { input: '/config/menus/local/valid.txt', expected: true }, 71 | { input: '/config/menus/remote/file.txt', expected: false } 72 | ]; 73 | 74 | testCases.forEach(({ input, expected }) => { 75 | const isSecure = input.startsWith(rootDir); 76 | expect(isSecure).toBe(expected); 77 | }); 78 | }); 79 | }); 80 | 81 | describe('Configuration File Layering', () => { 82 | test('should layer remote and local files correctly', () => { 83 | const remoteFiles = ['boot.cfg', 'main.ipxe', 'utils.ipxe']; 84 | const localFiles = ['custom.ipxe', 'boot.cfg']; // boot.cfg should override remote 85 | 86 | mockFs.readdirSync.mockImplementation((dir) => { 87 | if (dir.includes('remote')) return remoteFiles; 88 | if (dir.includes('local')) return localFiles; 89 | return []; 90 | }); 91 | 92 | // Simulate the layering function 93 | const layerMenu = () => { 94 | const remote = mockFs.readdirSync('/config/menus/remote'); 95 | const local = mockFs.readdirSync('/config/menus/local'); 96 | 97 | // Copy remote files first 98 | remote.forEach(file => { 99 | mockFs.copyFileSync(`/config/menus/remote/${file}`, `/config/menus/${file}`); 100 | }); 101 | 102 | // Copy local files (overriding remote) 103 | local.forEach(file => { 104 | mockFs.copyFileSync(`/config/menus/local/${file}`, `/config/menus/${file}`); 105 | }); 106 | }; 107 | 108 | layerMenu(); 109 | 110 | // Verify copy operations 111 | expect(mockFs.copyFileSync).toHaveBeenCalledWith('/config/menus/remote/boot.cfg', '/config/menus/boot.cfg'); 112 | expect(mockFs.copyFileSync).toHaveBeenCalledWith('/config/menus/local/boot.cfg', '/config/menus/boot.cfg'); 113 | expect(mockFs.copyFileSync).toHaveBeenCalledWith('/config/menus/local/custom.ipxe', '/config/menus/custom.ipxe'); 114 | }); 115 | 116 | test('should handle empty directories gracefully', () => { 117 | mockFs.readdirSync.mockImplementation((dir) => { 118 | if (dir.includes('remote')) return []; 119 | if (dir.includes('local')) return []; 120 | return []; 121 | }); 122 | 123 | const layerMenu = () => { 124 | const remote = mockFs.readdirSync('/config/menus/remote'); 125 | const local = mockFs.readdirSync('/config/menus/local'); 126 | 127 | remote.forEach(file => { 128 | mockFs.copyFileSync(`/config/menus/remote/${file}`, `/config/menus/${file}`); 129 | }); 130 | 131 | local.forEach(file => { 132 | mockFs.copyFileSync(`/config/menus/local/${file}`, `/config/menus/${file}`); 133 | }); 134 | }; 135 | 136 | expect(() => layerMenu()).not.toThrow(); 137 | expect(mockFs.copyFileSync).not.toHaveBeenCalled(); 138 | }); 139 | }); 140 | 141 | describe('Version Handling', () => { 142 | test('should correctly identify commit SHAs vs release versions', () => { 143 | const testCases = [ 144 | { version: 'a1b2c3d4e5f6789012345678901234567890abcd', isCommit: true }, 145 | { version: '1234567890123456789012345678901234567890', isCommit: true }, 146 | { version: 'v2.0.0', isCommit: false }, 147 | { version: '2.0.0', isCommit: false }, 148 | { version: '2.0.0-beta1', isCommit: false }, 149 | { version: 'latest', isCommit: false }, 150 | { version: 'abcd123', isCommit: false } // Too short 151 | ]; 152 | 153 | testCases.forEach(({ version, isCommit }) => { 154 | const result = version.length === 40; 155 | expect(result).toBe(isCommit); 156 | }); 157 | }); 158 | 159 | test('should generate correct download URLs', () => { 160 | const generateDownloadUrl = (version) => { 161 | if (version.length === 40) { 162 | return `https://s3.amazonaws.com/dev.boot.netboot.xyz/${version}/ipxe/`; 163 | } else { 164 | return `https://github.com/netbootxyz/netboot.xyz/releases/download/${version}/`; 165 | } 166 | }; 167 | 168 | const commitSha = 'a1b2c3d4e5f6789012345678901234567890abcd'; 169 | const release = 'v2.0.0'; 170 | 171 | expect(generateDownloadUrl(commitSha)).toBe('https://s3.amazonaws.com/dev.boot.netboot.xyz/a1b2c3d4e5f6789012345678901234567890abcd/ipxe/'); 172 | expect(generateDownloadUrl(release)).toBe('https://github.com/netbootxyz/netboot.xyz/releases/download/v2.0.0/'); 173 | }); 174 | }); 175 | 176 | describe('Download Operations', () => { 177 | test('should handle download progress correctly', async () => { 178 | const mockDl = { 179 | on: jest.fn((event, callback) => { 180 | if (event === 'progress') { 181 | // Simulate progress event 182 | setTimeout(() => { 183 | callback({ 184 | total: 1000, 185 | downloaded: 500, 186 | progress: 50 187 | }); 188 | }, 10); 189 | } 190 | return mockDl; 191 | }), 192 | start: jest.fn().mockResolvedValue(true) 193 | }; 194 | 195 | mockDownloaderHelper.mockImplementation(() => mockDl); 196 | 197 | const downloads = [ 198 | { url: 'https://example.com/file.tar.gz', path: '/tmp/download' } 199 | ]; 200 | 201 | // Simulate downloader function 202 | const downloader = async (downloadList) => { 203 | for (const item of downloadList) { 204 | const dl = new mockDownloaderHelper(item.url, item.path); 205 | dl.on('progress', (stats) => { 206 | expect(stats).toHaveProperty('total'); 207 | expect(stats).toHaveProperty('downloaded'); 208 | expect(stats).toHaveProperty('progress'); 209 | }); 210 | await dl.start(); 211 | } 212 | }; 213 | 214 | await downloader(downloads); 215 | expect(mockDl.start).toHaveBeenCalled(); 216 | }); 217 | 218 | test('should handle download errors gracefully', async () => { 219 | const mockDl = { 220 | on: jest.fn((event, callback) => { 221 | if (event === 'error') { 222 | setTimeout(() => { 223 | callback(new Error('Download failed')); 224 | }, 10); 225 | } 226 | return mockDl; 227 | }), 228 | start: jest.fn().mockRejectedValue(new Error('Download failed')) 229 | }; 230 | 231 | mockDownloaderHelper.mockImplementation(() => mockDl); 232 | 233 | const downloader = async (downloads) => { 234 | const dl = new mockDownloaderHelper('https://example.com/file.tar.gz', '/tmp'); 235 | dl.on('error', (error) => { 236 | expect(error.message).toBe('Download failed'); 237 | }); 238 | 239 | try { 240 | await dl.start(); 241 | } catch (error) { 242 | expect(error.message).toBe('Download failed'); 243 | } 244 | }; 245 | 246 | await downloader([]); 247 | }); 248 | 249 | test('should check for multipart downloads', async () => { 250 | const allowedHosts = ['s3.amazonaws.com']; 251 | const urlLib = require('url'); 252 | 253 | mockFetch.mockResolvedValue({ 254 | headers: { 255 | get: jest.fn().mockReturnValue('AmazonS3') 256 | } 257 | }); 258 | 259 | const checkMultipart = async (url) => { 260 | const parsedUrl = urlLib.parse(url); 261 | if (!allowedHosts.includes(parsedUrl.host)) { 262 | const response = await mockFetch(url + '.part2', { method: 'HEAD' }); 263 | const server = response.headers.get('server'); 264 | return server === 'AmazonS3' || server === 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0'; 265 | } 266 | return false; 267 | }; 268 | 269 | const hasMultipart = await checkMultipart('https://github.com/test/file.tar.gz'); 270 | expect(hasMultipart).toBe(true); 271 | }); 272 | }); 273 | 274 | describe('Signature Management', () => { 275 | test('should disable signatures in boot configuration', () => { 276 | const bootConfig = 'set sigs_enabled true\necho "Boot menu"\nset timeout 30'; 277 | const expectedConfig = 'set sigs_enabled false\necho "Boot menu"\nset timeout 30'; 278 | 279 | mockFs.readFileSync.mockReturnValue(bootConfig); 280 | 281 | const disableSignatures = () => { 282 | const data = mockFs.readFileSync('/config/menus/remote/boot.cfg', 'utf8'); 283 | const disabled = data.replace(/set sigs_enabled true/g, 'set sigs_enabled false'); 284 | mockFs.writeFileSync('/config/menus/remote/boot.cfg', disabled, 'utf8'); 285 | return disabled; 286 | }; 287 | 288 | const result = disableSignatures(); 289 | expect(result).toBe(expectedConfig); 290 | expect(mockFs.writeFileSync).toHaveBeenCalledWith('/config/menus/remote/boot.cfg', expectedConfig, 'utf8'); 291 | }); 292 | 293 | test('should handle missing signature settings gracefully', () => { 294 | const bootConfig = 'echo "Boot menu"\nset timeout 30'; 295 | 296 | mockFs.readFileSync.mockReturnValue(bootConfig); 297 | 298 | const disableSignatures = () => { 299 | const data = mockFs.readFileSync('/config/menus/remote/boot.cfg', 'utf8'); 300 | const disabled = data.replace(/set sigs_enabled true/g, 'set sigs_enabled false'); 301 | return disabled; 302 | }; 303 | 304 | const result = disableSignatures(); 305 | expect(result).toBe(bootConfig); // Should remain unchanged 306 | }); 307 | }); 308 | 309 | describe('File Type Detection', () => { 310 | test('should handle ROM file types correctly', () => { 311 | const romFiles = [ 312 | 'netboot.xyz.kpxe', 313 | 'netboot.xyz-undionly.kpxe', 314 | 'netboot.xyz.efi', 315 | 'netboot.xyz-snp.efi', 316 | 'netboot.xyz-snponly.efi', 317 | 'netboot.xyz-arm64.efi', 318 | 'netboot.xyz-arm64-snp.efi', 319 | 'netboot.xyz-arm64-snponly.efi' 320 | ]; 321 | 322 | const isRomFile = (filename) => { 323 | const romExtensions = ['.kpxe', '.efi']; 324 | return romExtensions.some(ext => filename.endsWith(ext)); 325 | }; 326 | 327 | romFiles.forEach(file => { 328 | expect(isRomFile(file)).toBe(true); 329 | }); 330 | 331 | expect(isRomFile('regular-file.txt')).toBe(false); 332 | expect(isRomFile('menu.ipxe')).toBe(false); 333 | }); 334 | }); 335 | 336 | describe('Path Sanitization', () => { 337 | test('should properly resolve and validate paths', () => { 338 | const sanitizePath = (rootDir, userPath) => { 339 | const resolved = path.resolve(rootDir, userPath); 340 | return { 341 | path: resolved, 342 | isSecure: resolved.startsWith(rootDir) 343 | }; 344 | }; 345 | 346 | const rootDir = '/config/menus/local/'; 347 | 348 | const testCases = [ 349 | { input: 'safe-file.txt', expectedSecure: true }, 350 | { input: '../../../etc/passwd', expectedSecure: false }, 351 | { input: 'subdir/file.txt', expectedSecure: true }, 352 | { input: '/etc/passwd', expectedSecure: false } 353 | ]; 354 | 355 | testCases.forEach(({ input, expectedSecure }) => { 356 | const result = sanitizePath(rootDir, input); 357 | expect(result.isSecure).toBe(expectedSecure); 358 | }); 359 | }); 360 | }); 361 | 362 | describe('Asset Management', () => { 363 | test('should handle asset deletion with part files', () => { 364 | const deleteAsset = (filePath) => { 365 | mockFs.unlinkSync('/assets' + filePath); 366 | 367 | if (mockFs.existsSync('/assets' + filePath + '.part2')) { 368 | mockFs.unlinkSync('/assets' + filePath + '.part2'); 369 | } 370 | }; 371 | 372 | mockFs.existsSync.mockReturnValue(true); 373 | 374 | deleteAsset('/test/file.img'); 375 | 376 | expect(mockFs.unlinkSync).toHaveBeenCalledWith('/assets/test/file.img'); 377 | expect(mockFs.unlinkSync).toHaveBeenCalledWith('/assets/test/file.img.part2'); 378 | }); 379 | 380 | test('should handle assets without part files', () => { 381 | const deleteAsset = (filePath) => { 382 | mockFs.unlinkSync('/assets' + filePath); 383 | 384 | if (mockFs.existsSync('/assets' + filePath + '.part2')) { 385 | mockFs.unlinkSync('/assets' + filePath + '.part2'); 386 | } 387 | }; 388 | 389 | mockFs.existsSync.mockReturnValue(false); 390 | 391 | deleteAsset('/test/file.img'); 392 | 393 | expect(mockFs.unlinkSync).toHaveBeenCalledWith('/assets/test/file.img'); 394 | expect(mockFs.unlinkSync).toHaveBeenCalledTimes(1); // Only called once for main file 395 | }); 396 | }); 397 | }); -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | // netboot.xyz 2 | // Main Node.js app 3 | 4 | var baseurl = process.env.SUBFOLDER || '/'; 5 | var app = require('express')(); 6 | var { DownloaderHelper } = require('node-downloader-helper'); 7 | var exec = require('child_process').exec; 8 | var express = require('express'); 9 | var fs = require('fs'); 10 | var http = require('http').Server(app); 11 | var io = require('socket.io')(http, {path: baseurl + 'socket.io'}); 12 | var isBinaryFile = require("isbinaryfile").isBinaryFile; 13 | var path = require('path'); 14 | var readdirp = require('readdirp'); 15 | var fetch = require('node-fetch'); 16 | var urlLib = require('url'); 17 | 18 | const allowedHosts = [ 19 | 's3.amazonaws.com' 20 | ]; 21 | var si = require('systeminformation'); 22 | const util = require('util'); 23 | var { version } = require('./package.json'); 24 | var yaml = require('js-yaml'); 25 | var baserouter = express.Router(); 26 | let ejs = require('ejs'); 27 | 28 | // Disable sigs on every startup in remote boot.cfg 29 | disablesigs(); 30 | function disablesigs(){ 31 | var bootcfgr = '/config/menus/remote/boot.cfg'; 32 | var bootcfgl = '/config/menus/local/boot.cfg'; 33 | var bootcfgm = '/config/menus/boot.cfg'; 34 | if (fs.existsSync(bootcfgr) && ! fs.existsSync(bootcfgl)) { 35 | var data = fs.readFileSync(bootcfgr, 'utf8'); 36 | var disable = data.replace(/set sigs_enabled true/g, 'set sigs_enabled false'); 37 | fs.writeFileSync(bootcfgr, disable, 'utf8'); 38 | fs.writeFileSync(bootcfgm, disable, 'utf8'); 39 | } 40 | } 41 | 42 | ////// PATHS ////// 43 | //// Main //// 44 | baserouter.get("/", function (req, res) { 45 | res.render(__dirname + '/public/index.ejs', {baseurl: baseurl}); 46 | }); 47 | baserouter.get("/netbootxyz-web.js", function (req, res) { 48 | res.setHeader("Content-Type", "application/javascript"); 49 | res.render(__dirname + '/public/netbootxyz-web.ejs', {baseurl: baseurl}); 50 | }); 51 | //// Public JS and CSS //// 52 | baserouter.use('/public', express.static(__dirname + '/public')); 53 | 54 | // Socket IO connection 55 | io.on('connection', function(socket){ 56 | //// Socket Connect //// 57 | // Log Client and connection time 58 | console.log(socket.id + ' connected time=' + (new Date).getTime()); 59 | socket.join(socket.id); 60 | /////////////////////////// 61 | ////// Socket events ////// 62 | /////////////////////////// 63 | // When dashboard info is requested send to client 64 | socket.on('getdash', function(){ 65 | var tftpcmd = '/usr/sbin/dnsmasq --version | head -n1'; 66 | var nginxcmd = '/usr/sbin/nginx -v'; 67 | var dashinfo = {}; 68 | dashinfo['webversion'] = version; 69 | dashinfo['menuversion'] = fs.readFileSync('/config/menuversion.txt', 'utf8'); 70 | fetch('https://api.github.com/repos/netbootxyz/netboot.xyz/releases/latest', {headers: {'user-agent': 'node.js'}}) 71 | .then(response => { 72 | if (!response.ok) { 73 | throw new Error(`HTTP error! status: ${response.status}`); 74 | } 75 | return response.json(); 76 | }) 77 | .then(body => { 78 | dashinfo['remotemenuversion'] = body.tag_name; 79 | si.cpu(function(cpu) { 80 | dashinfo['cpu'] = cpu; 81 | si.mem(function(mem) { 82 | dashinfo['mem'] = mem; 83 | si.currentLoad(function(currentLoad) { 84 | dashinfo['CPUpercent'] = currentLoad.currentload_user; 85 | exec(tftpcmd, function (err, stdout) { 86 | dashinfo['tftpversion'] = stdout; 87 | exec(nginxcmd, function (err, stdout, stderr) { 88 | dashinfo['nginxversion'] = stderr; 89 | io.sockets.in(socket.id).emit('renderdash',dashinfo); 90 | }); 91 | }); 92 | }); 93 | }); 94 | }); 95 | }) 96 | .catch(error => { 97 | console.log('There was a problem with the fetch operation: ' + error.message); 98 | }); 99 | }); 100 | // When upgrade is requested run it 101 | socket.on('upgrademenus', function(version){ 102 | upgrademenu(version, function(response){ 103 | io.sockets.in(socket.id).emit('renderdashhook'); 104 | }); 105 | }); 106 | socket.on('upgrademenusdev', function(version){ 107 | upgrademenu(version, function(response){ 108 | io.sockets.in(socket.id).emit('renderconfighook'); 109 | }); 110 | }); 111 | // When config info is requested send file list to client 112 | socket.on('getconfig', function(){ 113 | var local_files = fs.readdirSync('/config/menus/local',{withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 114 | var remote_files = fs.readdirSync('/config/menus/remote',{withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 115 | io.sockets.in(socket.id).emit('renderconfig',remote_files,local_files); 116 | }); 117 | // When a file is requested send it's contents to the client 118 | socket.on('editgetfile', function(filename, islocal){ 119 | var rootDir = '/config/menus/'; 120 | var filePath = path.resolve(rootDir, filename); 121 | if (!filePath.startsWith(rootDir)) { 122 | io.sockets.in(socket.id).emit('error', 'Invalid file path'); 123 | return; 124 | } 125 | var data = fs.readFileSync(filePath); 126 | var stat = fs.lstatSync(filePath); 127 | isBinaryFile(data, stat.size).then((result) => { 128 | if (result) { 129 | io.sockets.in(socket.id).emit('editrenderfile','CANNOT EDIT THIS IS A BINARY FILE',filename,'nomenu'); 130 | } 131 | else { 132 | io.sockets.in(socket.id).emit('editrenderfile',data.toString("utf8"),filename,islocal); 133 | } 134 | }); 135 | }); 136 | // When save is requested save it sync files and return user to menu 137 | socket.on('saveconfig', function(filename, text){ 138 | var rootDir = '/config/menus/local/'; 139 | var filePath = path.resolve(rootDir, filename); 140 | if (!filePath.startsWith(rootDir)) { 141 | io.sockets.in(socket.id).emit('error', 'Invalid file path'); 142 | return; 143 | } 144 | fs.writeFileSync(filePath, text); 145 | layermenu(function(response){ 146 | var local_files = fs.readdirSync(rootDir, {withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 147 | var remote_files = fs.readdirSync('/config/menus/remote', {withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 148 | io.sockets.in(socket.id).emit('renderconfig', remote_files, local_files, filename, true); 149 | }); 150 | }); 151 | // When revert is requested delete it, sync files and return user to menu 152 | socket.on('revertconfig', function(filename){ 153 | var rootDir = '/config/menus/local/'; 154 | var filePath = path.resolve(rootDir, filename); 155 | if (!filePath.startsWith(rootDir)) { 156 | io.sockets.in(socket.id).emit('error', 'Invalid file path'); 157 | return; 158 | } 159 | fs.unlinkSync(filePath); 160 | layermenu(function(response){ 161 | var local_files = fs.readdirSync(rootDir, {withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 162 | var remote_files = fs.readdirSync('/config/menus/remote', {withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 163 | io.sockets.in(socket.id).emit('renderconfig', remote_files, local_files); 164 | }); 165 | }); 166 | // When a create file is 167 | socket.on('createipxe', function(filename){ 168 | var rootDir = '/config/menus/local/'; 169 | var filePath = path.resolve(rootDir, filename); 170 | if (!filePath.startsWith(rootDir)) { 171 | io.sockets.in(socket.id).emit('error', 'Invalid file path'); 172 | return; 173 | } 174 | fs.writeFileSync(filePath, '#!ipxe'); 175 | layermenu(function(response){ 176 | var local_files = fs.readdirSync(rootDir, {withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 177 | var remote_files = fs.readdirSync('/config/menus/remote', {withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 178 | io.sockets.in(socket.id).emit('renderconfig', remote_files, local_files, filename, true); 179 | }); 180 | }); 181 | // When the endpoints content is requested send it to the client 182 | socket.on('getlocal', async function(filename){ 183 | var remotemenuversion = fs.readFileSync('/config/menuversion.txt', 'utf8'); 184 | var endpointsfile = fs.readFileSync('/config/endpoints.yml'); 185 | var endpoints = yaml.load(endpointsfile); 186 | var localfiles = await readdirp.promise('/assets/.'); 187 | var assets = []; 188 | if (localfiles.length != 0){ 189 | for (var i in localfiles){ 190 | assets.push('/' + localfiles[i].path); 191 | } 192 | } 193 | io.sockets.in(socket.id).emit('renderlocal',endpoints,assets,remotemenuversion); 194 | }); 195 | // When remote downloads are requested make folders and download 196 | socket.on('dlremote', function(dlfiles){ 197 | dlremote(dlfiles, function(response){ 198 | io.sockets.in(socket.id).emit('renderlocalhook'); 199 | }); 200 | }); 201 | // When Local deletes are requested purge items 202 | socket.on('deletelocal', function(dlfiles){ 203 | for (var i in dlfiles){ 204 | var file = dlfiles[i]; 205 | fs.unlinkSync('/assets' + file); 206 | console.log('Deleted /assets' + file); 207 | if (fs.existsSync('/assets' + file + '.part2')) { 208 | fs.unlinkSync('/assets' + file + '.part2'); 209 | console.log('Deleted /assets' + file + '.part2'); 210 | } 211 | } 212 | io.sockets.in(socket.id).emit('renderlocalhook'); 213 | }); 214 | // When Dev Browser is requested reach out to github for versions 215 | socket.on('devgetbrowser', async function(){ 216 | var api_url = 'https://api.github.com/repos/netbootxyz/netboot.xyz/'; 217 | var options = {headers: {'user-agent': 'node.js'}}; 218 | var releasesResponse = await fetch(api_url + 'releases', options); 219 | if (!releasesResponse.ok) { 220 | throw new Error(`HTTP error! status: ${releasesResponse.status}`); 221 | } 222 | var releases = await releasesResponse.json(); 223 | var commitsResponse = await fetch(api_url + 'commits', options); 224 | if (!commitsResponse.ok) { 225 | throw new Error(`HTTP error! status: ${commitsResponse.status}`); 226 | } 227 | var commits = await commitsResponse.json() 228 | io.sockets.in(socket.id).emit('devrenderbrowser', releases, commits); 229 | }); 230 | }); 231 | 232 | 233 | //// Functions //// 234 | 235 | // Layer remote with local in the main tftp endpoint 236 | function layermenu(callback){ 237 | var local_files = fs.readdirSync('/config/menus/local',{withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 238 | var remote_files = fs.readdirSync('/config/menus/remote',{withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 239 | for (var i in remote_files){ 240 | var file = remote_files[i]; 241 | fs.copyFileSync('/config/menus/remote/' + file, '/config/menus/' + file); 242 | } 243 | for (var i in local_files){ 244 | var file = local_files[i]; 245 | fs.copyFileSync('/config/menus/local/' + file, '/config/menus/' + file); 246 | } 247 | callback(null, 'done'); 248 | } 249 | 250 | // Upgrade menus to specified version 251 | async function upgrademenu(version, callback){ 252 | var remote_folder = '/config/menus/remote/'; 253 | // Wipe current remote 254 | var remote_files = fs.readdirSync('/config/menus/remote',{withFileTypes: true}).filter(dirent => !dirent.isDirectory()).map(dirent => dirent.name); 255 | for (var i in remote_files){ 256 | var file = remote_files[i]; 257 | fs.unlinkSync(remote_folder + file); 258 | } 259 | // Download files 260 | var downloads = []; 261 | var rom_files = ['netboot.xyz.kpxe', 262 | 'netboot.xyz-undionly.kpxe', 263 | 'netboot.xyz.efi', 264 | 'netboot.xyz-snp.efi', 265 | 'netboot.xyz-snponly.efi', 266 | 'netboot.xyz-arm64.efi', 267 | 'netboot.xyz-arm64-snp.efi', 268 | 'netboot.xyz-arm64-snponly.efi']; 269 | 270 | // This is a commit sha 271 | if (version.length == 40){ 272 | var download_endpoint = 'https://s3.amazonaws.com/dev.boot.netboot.xyz/' + version + '/ipxe/'; 273 | downloads.push({'url':'https://s3.amazonaws.com/dev.boot.netboot.xyz/' + version + '/menus.tar.gz','path':remote_folder}); 274 | } 275 | // This is a regular release 276 | else{ 277 | var download_endpoint = 'https://github.com/netbootxyz/netboot.xyz/releases/download/' + version + '/'; 278 | downloads.push({'url':download_endpoint + 'menus.tar.gz','path':remote_folder}); 279 | } 280 | for (var i in rom_files){ 281 | var file = rom_files[i]; 282 | var url = download_endpoint + file; 283 | downloads.push({'url':url,'path':remote_folder}); 284 | } 285 | // static config for endpoints 286 | downloads.push({'url':'https://raw.githubusercontent.com/netbootxyz/netboot.xyz/' + version +'/endpoints.yml','path':'/config/'}); 287 | await downloader(downloads); 288 | var untarcmd = 'tar xf ' + remote_folder + 'menus.tar.gz -C ' + remote_folder; 289 | if (version.length == 40){ 290 | var version = 'Development'; 291 | } 292 | exec(untarcmd, function (err, stdout) { 293 | fs.unlinkSync(remote_folder + 'menus.tar.gz'); 294 | fs.writeFileSync('/config/menuversion.txt', version); 295 | layermenu(function(response){ 296 | disablesigs(); 297 | callback(null, 'done'); 298 | }); 299 | }); 300 | } 301 | 302 | // Grab remote files 303 | async function dlremote(dlfiles, callback){ 304 | var dlarray = []; 305 | for (var i in dlfiles){ 306 | var dlfile = dlfiles[i]; 307 | var dlpath = '/assets' + path.dirname(dlfile); 308 | // Make destination directory 309 | fs.mkdirSync(dlpath, { recursive: true }); 310 | // Construct array for use in download function 311 | var url = 'https://github.com/netbootxyz' + dlfile; 312 | dlarray.push({'url':url,'path':dlpath}); 313 | } 314 | await downloader(dlarray); 315 | callback(null, 'done'); 316 | } 317 | 318 | // downloader loop 319 | async function downloader(downloads){ 320 | var startTime = new Date(); 321 | var total = downloads.length; 322 | for (var i in downloads){ 323 | var value = downloads[i]; 324 | var url = value.url; 325 | var path = value.path; 326 | var dloptions = {override:true,retry:{maxRetries:2,delay:5000}}; 327 | var dl = new DownloaderHelper(url, path, dloptions); 328 | 329 | dl.on('end', function(){ 330 | console.log('Downloaded ' + url + ' to ' + path); 331 | }); 332 | 333 | dl.on('error', function(error) { 334 | console.error('Download failed:', error); 335 | }); 336 | 337 | dl.on('progress', function(stats){ 338 | var currentTime = new Date(); 339 | var elaspsedTime = currentTime - startTime; 340 | if (elaspsedTime > 500) { 341 | startTime = currentTime; 342 | io.emit('dldata', url, [+i + 1,total], stats); 343 | } 344 | }); 345 | 346 | await dl.start().catch(error => { 347 | console.error('Download failed:', error); 348 | }); 349 | 350 | const parsedUrl = urlLib.parse(url); 351 | if (!allowedHosts.includes(parsedUrl.host)){ 352 | // Part 2 if exists repeat 353 | var response = await fetch(url + '.part2', {method: 'HEAD'}); 354 | var urltest = response.headers.get('server'); 355 | if (urltest == 'AmazonS3' || urltest == 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0') { 356 | var dl2 = new DownloaderHelper(url + '.part2', path, dloptions); 357 | dl2.on('end', function(){ 358 | console.log('Downloaded ' + url + '.part2' + ' to ' + path); 359 | }); 360 | dl2.on('progress', function(stats){ 361 | var currentTime = new Date(); 362 | var elaspsedTime = currentTime - startTime; 363 | if (elaspsedTime > 500) { 364 | startTime = currentTime; 365 | io.emit('dldata', url, [+i + 1,total], stats); 366 | } 367 | }); 368 | await dl2.start(); 369 | } 370 | } 371 | } 372 | io.emit('purgestatus'); 373 | } 374 | 375 | app.use(baseurl, baserouter); 376 | 377 | // Spin up application on port 3000 or set to WEB_APP_PORT env variable 378 | 379 | const defaultPort = 3000; 380 | 381 | let port = process.env.WEB_APP_PORT; 382 | 383 | if (!Number.isInteger(Number(port)) || port < 1 || port > 65535) { 384 | console.warn(`Invalid port "${port}" in environment variable WEB_APP_PORT. Using default port ${defaultPort} instead.`); 385 | port = defaultPort; 386 | } 387 | 388 | http.listen(port, function(){ 389 | console.log('listening on *:' + port); 390 | }); 391 | -------------------------------------------------------------------------------- /public/vendor/js/popper.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Federico Zivolo 2019 3 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 4 | */(function(e,t){'object'==typeof exports&&'undefined'!=typeof module?module.exports=t():'function'==typeof define&&define.amd?define(t):e.Popper=t()})(this,function(){'use strict';function e(e){return e&&'[object Function]'==={}.toString.call(e)}function t(e,t){if(1!==e.nodeType)return[];var o=e.ownerDocument.defaultView,n=o.getComputedStyle(e,null);return t?n[t]:n}function o(e){return'HTML'===e.nodeName?e:e.parentNode||e.host}function n(e){if(!e)return document.body;switch(e.nodeName){case'HTML':case'BODY':return e.ownerDocument.body;case'#document':return e.body;}var i=t(e),r=i.overflow,p=i.overflowX,s=i.overflowY;return /(auto|scroll|overlay)/.test(r+s+p)?e:n(o(e))}function i(e){return e&&e.referenceNode?e.referenceNode:e}function r(e){return 11===e?re:10===e?pe:re||pe}function p(e){if(!e)return document.documentElement;for(var o=r(10)?document.body:null,n=e.offsetParent||null;n===o&&e.nextElementSibling;)n=(e=e.nextElementSibling).offsetParent;var i=n&&n.nodeName;return i&&'BODY'!==i&&'HTML'!==i?-1!==['TH','TD','TABLE'].indexOf(n.nodeName)&&'static'===t(n,'position')?p(n):n:e?e.ownerDocument.documentElement:document.documentElement}function s(e){var t=e.nodeName;return'BODY'!==t&&('HTML'===t||p(e.firstElementChild)===e)}function d(e){return null===e.parentNode?e:d(e.parentNode)}function a(e,t){if(!e||!e.nodeType||!t||!t.nodeType)return document.documentElement;var o=e.compareDocumentPosition(t)&Node.DOCUMENT_POSITION_FOLLOWING,n=o?e:t,i=o?t:e,r=document.createRange();r.setStart(n,0),r.setEnd(i,0);var l=r.commonAncestorContainer;if(e!==l&&t!==l||n.contains(i))return s(l)?l:p(l);var f=d(e);return f.host?a(f.host,t):a(e,d(t).host)}function l(e){var t=1=o.clientWidth&&n>=o.clientHeight}),l=0a[e]&&!t.escapeWithReference&&(n=Q(f[o],a[e]-('right'===e?f.width:f.height))),ae({},o,n)}};return l.forEach(function(e){var t=-1===['left','top'].indexOf(e)?'secondary':'primary';f=le({},f,m[t](e))}),e.offsets.popper=f,e},priority:['left','right','top','bottom'],padding:5,boundariesElement:'scrollParent'},keepTogether:{order:400,enabled:!0,fn:function(e){var t=e.offsets,o=t.popper,n=t.reference,i=e.placement.split('-')[0],r=Z,p=-1!==['top','bottom'].indexOf(i),s=p?'right':'bottom',d=p?'left':'top',a=p?'width':'height';return o[s]r(n[s])&&(e.offsets.popper[d]=r(n[s])),e}},arrow:{order:500,enabled:!0,fn:function(e,o){var n;if(!K(e.instance.modifiers,'arrow','keepTogether'))return e;var i=o.element;if('string'==typeof i){if(i=e.instance.popper.querySelector(i),!i)return e;}else if(!e.instance.popper.contains(i))return console.warn('WARNING: `arrow.element` must be child of its popper element!'),e;var r=e.placement.split('-')[0],p=e.offsets,s=p.popper,d=p.reference,a=-1!==['left','right'].indexOf(r),l=a?'height':'width',f=a?'Top':'Left',m=f.toLowerCase(),h=a?'left':'top',c=a?'bottom':'right',u=S(i)[l];d[c]-us[c]&&(e.offsets.popper[m]+=d[m]+u-s[c]),e.offsets.popper=g(e.offsets.popper);var b=d[m]+d[l]/2-u/2,w=t(e.instance.popper),y=parseFloat(w['margin'+f],10),E=parseFloat(w['border'+f+'Width'],10),v=b-e.offsets.popper[m]-y-E;return v=ee(Q(s[l]-u,v),0),e.arrowElement=i,e.offsets.arrow=(n={},ae(n,m,$(v)),ae(n,h,''),n),e},element:'[x-arrow]'},flip:{order:600,enabled:!0,fn:function(e,t){if(W(e.instance.modifiers,'inner'))return e;if(e.flipped&&e.placement===e.originalPlacement)return e;var o=v(e.instance.popper,e.instance.reference,t.padding,t.boundariesElement,e.positionFixed),n=e.placement.split('-')[0],i=T(n),r=e.placement.split('-')[1]||'',p=[];switch(t.behavior){case ce.FLIP:p=[n,i];break;case ce.CLOCKWISE:p=G(n);break;case ce.COUNTERCLOCKWISE:p=G(n,!0);break;default:p=t.behavior;}return p.forEach(function(s,d){if(n!==s||p.length===d+1)return e;n=e.placement.split('-')[0],i=T(n);var a=e.offsets.popper,l=e.offsets.reference,f=Z,m='left'===n&&f(a.right)>f(l.left)||'right'===n&&f(a.left)f(l.top)||'bottom'===n&&f(a.top)f(o.right),g=f(a.top)f(o.bottom),b='left'===n&&h||'right'===n&&c||'top'===n&&g||'bottom'===n&&u,w=-1!==['top','bottom'].indexOf(n),y=!!t.flipVariations&&(w&&'start'===r&&h||w&&'end'===r&&c||!w&&'start'===r&&g||!w&&'end'===r&&u),E=!!t.flipVariationsByContent&&(w&&'start'===r&&c||w&&'end'===r&&h||!w&&'start'===r&&u||!w&&'end'===r&&g),v=y||E;(m||b||v)&&(e.flipped=!0,(m||b)&&(n=p[d+1]),v&&(r=z(r)),e.placement=n+(r?'-'+r:''),e.offsets.popper=le({},e.offsets.popper,C(e.instance.popper,e.offsets.reference,e.placement)),e=P(e.instance.modifiers,e,'flip'))}),e},behavior:'flip',padding:5,boundariesElement:'viewport',flipVariations:!1,flipVariationsByContent:!1},inner:{order:700,enabled:!1,fn:function(e){var t=e.placement,o=t.split('-')[0],n=e.offsets,i=n.popper,r=n.reference,p=-1!==['left','right'].indexOf(o),s=-1===['top','left'].indexOf(o);return i[p?'left':'top']=r[o]-(s?i[p?'width':'height']:0),e.placement=T(t),e.offsets.popper=g(i),e}},hide:{order:800,enabled:!0,fn:function(e){if(!K(e.instance.modifiers,'hide','preventOverflow'))return e;var t=e.offsets.reference,o=D(e.instance.modifiers,function(e){return'preventOverflow'===e.name}).boundaries;if(t.bottomo.right||t.top>o.bottom||t.rightwindow.devicePixelRatio||!fe),c='bottom'===o?'top':'bottom',g='right'===n?'left':'right',b=B('transform');if(d='bottom'==c?'HTML'===l.nodeName?-l.clientHeight+h.bottom:-f.height+h.bottom:h.top,s='right'==g?'HTML'===l.nodeName?-l.clientWidth+h.right:-f.width+h.right:h.left,a&&b)m[b]='translate3d('+s+'px, '+d+'px, 0)',m[c]=0,m[g]=0,m.willChange='transform';else{var w='bottom'==c?-1:1,y='right'==g?-1:1;m[c]=d*w,m[g]=s*y,m.willChange=c+', '+g}var E={"x-placement":e.placement};return e.attributes=le({},E,e.attributes),e.styles=le({},m,e.styles),e.arrowStyles=le({},e.offsets.arrow,e.arrowStyles),e},gpuAcceleration:!0,x:'bottom',y:'right'},applyStyle:{order:900,enabled:!0,fn:function(e){return V(e.instance.popper,e.styles),j(e.instance.popper,e.attributes),e.arrowElement&&Object.keys(e.arrowStyles).length&&V(e.arrowElement,e.arrowStyles),e},onLoad:function(e,t,o,n,i){var r=L(i,t,e,o.positionFixed),p=O(o.placement,r,t,e,o.modifiers.flip.boundariesElement,o.modifiers.flip.padding);return t.setAttribute('x-placement',p),V(t,{position:o.positionFixed?'fixed':'absolute'}),o},gpuAcceleration:void 0}}},ge}); 5 | //# sourceMappingURL=popper.min.js.map 6 | --------------------------------------------------------------------------------