A complete guide for developers to set up, understand, and recreate the ZeroProxy Smart Attendance system on a Raspberry Pi from scratch.
- System Architecture
- Installation Prerequisites
- Network Configuration
- Boot Scripts
- Systemd Services
- Database Architecture
- Node.js Server Setup
- Environment Variables (.env)
- Duplicating the Raspberry Pi
The Raspberry Pi acts as a dedicated server and local gateway. Here is how the network is wired:
[University Wi-Fi / Export]
|
wlan0 ← Pi gets internet here (for Google Sheets sync)
[Pi 172.16.0.1]
eth0 ← Pi hands out IPs here
|
[Router WAN port] ← Cable plugged into WAN port, NOT LAN
[TP-Link Router]
|
[Students' phones & Admin laptop via Wi-Fi]
Key Design Rules:
wlan0is the internet uplink for the Pi only (for cloud syncing). Students do NOT get internet.eth0always has the static IP172.16.0.1. The router's WAN port connects here and receives172.16.0.2.- The router manages the student-facing Wi-Fi (its own subnet, e.g.,
192.168.0.x). - The Node.js server is accessible at
172.16.0.1from devices on the router's LAN.
Install these on the Raspberry Pi OS (Lite or Desktop):
# System packages
sudo apt update && sudo apt upgrade -y
sudo apt install -y hostapd dnsmasq wpa_supplicant iptables net-tools iw dhcpcd5
# Node.js (via NodeSource)
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install -y nodejs
# PM2 process manager
sudo npm install -g pm2
# PostgreSQL
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable postgresql
# Redis
sudo apt install -y redis-server
sudo systemctl enable redis-serverThis assigns IP addresses to devices plugged into eth0 (i.e., the router's WAN port or a developer's laptop).
Config file: /etc/dnsmasq.conf
# Uncomment to suppress dnsmasq reading /etc/resolv.conf and from the host
# Use standard DNS
server=8.8.8.8
# DHCP for devices connected via Ethernet (eth0)
interface=eth0
dhcp-range=172.16.0.2,172.16.1.245,255.255.254.0,12h
dhcp-option=option:router,172.16.0.1To apply changes:
sudo systemctl restart dnsmasq
sudo systemctl enable dnsmasqThis handles connecting the Pi's wlan0 to the university Wi-Fi for internet access.
Config file: /etc/wpa_supplicant/wpa_supplicant.conf
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=IN
# Option 1: Simple WPA2 Personal Network (e.g., a hotspot or portable router)
network={
ssid="Export"
psk="Net@1234"
}
# Option 2: WPA2 Enterprise (University like DAIICT)
network={
ssid="DAIICT_Student"
key_mgmt=WPA-EAP
eap=PEAP
identity="YOUR_STUDENT_ID"
password="YOUR_PASSWORD"
phase2="auth=MSCHAPV2"
}
⚠️ Security Note: Never commit yourwpa_supplicant.confwith real credentials to a public repository. Keep it on the Pi only.
The external router acts purely as a Wi-Fi access point for students. The Pi's dnsmasq controls the IP handed to the router itself. The router then manages a separate subnet (192.168.0.x) for students internally.
Wiring: The network cable connects from the Pi's eth0 port → the Router's WAN/Internet port (NOT a LAN port).
Critical Router Settings (Advanced → Network → LAN Settings):
- DHCP → IP Address:
192.168.0.1 - DHCP → IP Address Pool:
192.168.0.1to192.168.0.200 - Default Gateway:
192.168.0.1 - Primary / Secondary DNS:
0.0.0.0(leave empty — students will use the Pi's DNS via the WAN)
Note: "Operation Mode" should be set to Router (NOT Access Point mode), which causes its WAN port to request an IP from the Pi's
dnsmasq(receiving172.16.0.2).
Wireless Settings:
- Set SSID (e.g.,
ZeroproxyAP) and a strong password. - This is the network students will connect to.
These scripts live in /usr/local/bin/ and are executed by systemd at every boot.
The main boot-time orchestration script. It always brings up eth0 as a static LAN (172.16.0.1) first, then attempts to connect wlan0 to the internet.
Install location: /usr/local/bin/ethernet-bridge.sh
#!/bin/bash
# /usr/local/bin/ethernet-bridge.sh
# Boot-time Auto Network Decision Script for ZeroProxy
# 1. eth0 is ALWAYS a static local network (172.16.0.1).
# It runs a DHCP server to assign an IP to a Router's WAN port or a PC.
# 2. wlan0 is ALWAYS a client attempting to get internet.
# 3. Internet is NOT shared to eth0 (no NAT). Students only reach the Node.js server.
WIFI_DEV="wlan0"
ETH_DEV="eth0"
WIFI_SSID="DAIICT_Student"
GATEWAY_IP="172.16.0.1/23"
LOG_FILE="/var/log/ethernet-bridge.log"
SCAN_ATTEMPTS=2
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S'): $1" >> "$LOG_FILE"
echo "$1"
}
log "=== ZeroProxy Network Bridge Script Started ==="
# Clean up any conflicting services
sudo killall hostapd wpa_supplicant 2>/dev/null
sudo systemctl stop hostapd 2>/dev/null
# === STEP 1: INITIALIZE ETH0 (The Gateway) ===
log "Initializing $ETH_DEV as Gateway at $GATEWAY_IP..."
sudo ip link set "$ETH_DEV" up
sudo ip addr flush dev "$ETH_DEV" 2>/dev/null
sudo ip addr add $GATEWAY_IP broadcast + dev "$ETH_DEV"
sudo systemctl restart dnsmasq
log "Downstream network ready. Devices connected to eth0 will get an IP from dnsmasq."
# === STEP 2: INITIALIZE WLAN0 (The Internet Uplink) ===
log "Scanning for Wi-Fi network: '$WIFI_SSID' on $WIFI_DEV..."
sudo ip link set "$WIFI_DEV" up 2>/dev/null
sudo ip addr flush dev "$WIFI_DEV" 2>/dev/null
FOUND_EXPORT=0
for i in $(seq 1 $SCAN_ATTEMPTS); do
SCAN_RESULT=$(timeout 5 sudo iwlist "$WIFI_DEV" scan 2>/dev/null)
if echo "$SCAN_RESULT" | grep -q "$WIFI_SSID"; then
FOUND_EXPORT=1
log "Found '$WIFI_SSID' on attempt $i"
break
fi
sleep 2
done
if [ $FOUND_EXPORT -eq 1 ]; then
log "Connecting to '$WIFI_SSID' as Wi-Fi client..."
sudo wpa_supplicant -B -c /etc/wpa_supplicant/wpa_supplicant.conf -i "$WIFI_DEV"
sleep 3
sudo dhclient -v "$WIFI_DEV"
sleep 5
if ip -4 addr show "$WIFI_DEV" | grep -q "inet "; then
CLIENT_IP=$(ip -4 addr show "$WIFI_DEV" | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)
log "SUCCESS: Connected to Internet on '$WIFI_SSID' (IP: $CLIENT_IP)"
log "NOTICE: Internet is restricted to this Pi. Devices on eth0 cannot reach the internet."
else
log "WARNING: Failed to get IP on '$WIFI_SSID'."
fi
else
log "NOTICE: '$WIFI_SSID' not found. Letting wpa_supplicant try other saved networks..."
sudo wpa_supplicant -B -c /etc/wpa_supplicant/wpa_supplicant.conf -i "$WIFI_DEV"
sleep 3
sudo dhclient -v "$WIFI_DEV"
fi
log "=== Script Complete ==="
exit 0To install:
sudo chmod +x /usr/local/bin/ethernet-bridge.shA continuously running background daemon that monitors the wlan0 internet connection every 5 seconds. If lost, it triggers a DHCP renewal. It writes to /dev/shm/wifi-status.json (RAM) so the Node.js server can query it instantly.
Install location: /usr/local/bin/wifi-watchdog.sh
#!/bin/bash
# /usr/local/bin/wifi-watchdog.sh
INTERFACE="wlan0"
PING_TARGET="8.8.8.8"
LOG_FILE="/var/log/wifi-watchdog.log"
STATUS_FILE="/dev/shm/wifi-status.json"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S'): $1" >> "$LOG_FILE"
}
update_status() {
echo "{\"online\": $1}" > "$STATUS_FILE"
}
log "=== Wi-Fi Watchdog Service Started ==="
while true; do
IS_ONLINE="false"
if ip link show "$INTERFACE" | grep -q "state UP"; then
if ip -4 addr show "$INTERFACE" | grep -q "inet "; then
if ping -c 1 -W 2 "$PING_TARGET" > /dev/null 2>&1; then
IS_ONLINE="true"
else
log "Ping failed. Internet unreachable. Renewing DHCP lease..."
sudo dhclient -r "$INTERFACE" 2>/dev/null
sudo dhclient -v "$INTERFACE"
log "DHCP Renewal triggered."
fi
fi
fi
update_status "$IS_ONLINE"
sleep 5
doneTo install:
sudo chmod +x /usr/local/bin/wifi-watchdog.shThese services ensure the correct startup order on every boot. Place them in /etc/systemd/system/.
Runs ethernet-bridge.sh once at boot. Brings up the local eth0 network and connects wlan0 to the internet.
# /etc/systemd/system/ethernet-bridge.service
[Unit]
Description=ZeroProxy Ethernet Bridge Initialization
After=network.target sys-subsystem-net-devices-wlan0.device
Wants=network.target
[Service]
Type=oneshot
ExecStartPre=/bin/sleep 2
ExecStart=/usr/local/bin/ethernet-bridge.sh
RemainAfterExit=yes
TimeoutStartSec=60
[Install]
WantedBy=multi-user.targetRuns wifi-watchdog.sh continuously after the bridge is established. Restarts automatically if it crashes.
# /etc/systemd/system/wifi-watchdog.service
[Unit]
Description=Wi-Fi Connectivity Watchdog
After=network.target ethernet-bridge.service
[Service]
Type=simple
ExecStart=/usr/local/bin/wifi-watchdog.sh
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetStarts the Node.js server via PM2 only after the network is up, ensuring the server is always accessible.
# /etc/systemd/system/node-server.service
[Unit]
Description=PM2 process manager for Node.js Server
After=ethernet-bridge.service
Requires=ethernet-bridge.service
Wants=ethernet-bridge.service
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/Server_Code
ExecStart=/usr/bin/pm2-runtime start /home/pi/Server_Code/main_server.js -i max --name node-server
Restart=always
Environment=NODE_ENV=production
LimitNOFILE=infinity
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetTo enable all services after creating them:
sudo systemctl daemon-reload
sudo systemctl enable ethernet-bridge.service
sudo systemctl enable wifi-watchdog.service
sudo systemctl enable node-server.serviceThe schema is in BCNF (Boyce-Codd Normal Form). Three violations present in an earlier version were fixed:
admission_yearandbranch_codewere removed fromstudents— they are derived fromstudent_id(e.g.2023CE001→ year2023, branchCE) and computed in the server JS. Storing derived data violates BCNF.subjects TEXT[]was extracted into a dedicatedenrollmentstable — a student:subject relationship is many-to-many and must not be collapsed into an array column.sessionsnow has aUNIQUE(course_id, session_date, start_time)constraint enforced at the DB level.
-- 1. Students (core identity only — no derived or multi-valued attributes)
CREATE TABLE students (
student_id VARCHAR(20) PRIMARY KEY,
password_hash TEXT NOT NULL,
face_embedding JSONB NOT NULL,
image_path TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 2. Enrollments (resolves the many-to-many: student <-> subject)
CREATE TABLE enrollments (
student_id VARCHAR(20) REFERENCES students(student_id) ON DELETE CASCADE,
subject_id VARCHAR(20) NOT NULL,
PRIMARY KEY (student_id, subject_id)
);
-- 3. Sessions (DB-enforced uniqueness)
CREATE TABLE sessions (
id SERIAL PRIMARY KEY,
course_id VARCHAR(20) NOT NULL,
session_date DATE NOT NULL,
start_time VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_session UNIQUE (course_id, session_date, start_time)
);
-- 4. Attendance (buffer — exported to Google Sheets, then cleared)
CREATE TABLE attendance (
id SERIAL PRIMARY KEY,
session_id INT REFERENCES sessions(id) ON DELETE CASCADE,
student_id VARCHAR(20) REFERENCES students(student_id) ON DELETE CASCADE,
timestamp TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_session_student UNIQUE (session_id, student_id)
);Derived attributes (computed in server JS, not stored in DB):
const admission_year = parseInt(student_id.slice(0, 4), 10); // e.g. 2023
const branch_code = student_id.slice(4, 6); // e.g. CEPostgreSQL setup commands:
sudo -u postgres psql
# Inside psql:
CREATE DATABASE zeroproxy;
CREATE USER zeroproxy_user WITH PASSWORD 'YOUR_PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE zeroproxy TO zeroproxy_user;
\qUsed for fast in-memory operations during an active session.
| Key Pattern | TTL | Purpose |
|---|---|---|
activeSession |
none | Currently running attendance session details |
sessionStudents:[sessionId] |
session duration | Cache of enrolled students' face embeddings |
[nonce] |
3 minutes | Cryptographic challenge to prevent replay attacks |
Redis setup:
sudo systemctl enable redis-server
# Verify Redis is running:
redis-cli ping # Should respond: PONG# Clone the repository onto the Pi
cd /home/pi
git clone https://github.com/YOUR_USERNAME/Zeroproxy_Server.git Server_Code
cd Server_Code
# Install dependencies
npm install
# Copy your .env file (see section 8 below)
nano .env
# Generate SSL certificates (or copy existing ones)
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout server.key -out server.crt -config openssl.cnf
# Test the server manually first
node main_server.jsCreate a .env file in /home/pi/Server_Code/. Never commit this file to GitHub.
# JWT Secrets
ADMIN_TOKEN=your_admin_jwt_secret_here
STUDENT_TOKEN=your_student_jwt_secret_here
JWT_SECRET=your_main_jwt_secret_here
# Email (for OTP / notifications via Nodemailer)
SMTP_USER=your_gmail_address@gmail.com
SMTP_PASS=your_gmail_app_password
# Redis
REDIS_URL=redis://127.0.0.1:6379
# PostgreSQL
USERX=zeroproxy_user
HOST=localhost
DATABASE=zeroproxy
PASSWORD=your_db_password
PORT=5432
# Network (used for Wi-Fi BSSID validation in attendance marking)
BSSID=XX:XX:XX:XX:XX:XX
# Google Sheets Integration
GOOGLE_CREDENTIALS_PATH=/home/pi/Server_Code/smart-attendance-system.json
GOOGLE_SHEET_ID=your_google_sheet_id_hereTo set up an identical Pi for another classroom, clone the SD card directly. This copies the OS, all software, databases, and configuration with zero reinstallation.
Steps:
- Power down the source Raspberry Pi.
- Remove the MicroSD card and plug it into a computer.
- Create the image:
- Windows: Use Win32 Disk Imager → Select SD card drive → Name file
ZeroProxy_Master.img→ Click Read. - Mac/Linux:
sudo dd if=/dev/mmcblk0 of=ZeroProxy_Master.img bs=4M status=progress
- Windows: Use Win32 Disk Imager → Select SD card drive → Name file
- Flash to a new card:
- Insert a blank MicroSD card (same size or larger).
- Flash using Raspberry Pi Imager or balenaEtcher with
ZeroProxy_Master.img.
- Boot and personalize:
- Boot the new Pi. Then run:
sudo raspi-config # → System Options → Hostname → Change to e.g. "zeroproxy-room202" - Update
wpa_supplicant.confif needed for the new room's Wi-Fi credentials. - Done! The new Pi is now a complete, independent server.
- Boot the new Pi. Then run:
