Switch GST operator agent to custom protocol launch
This commit is contained in:
@@ -1,27 +1,25 @@
|
||||
ARRR GST Operator Agent 1.0.1
|
||||
ARRR GST Operator Agent 1.1.0
|
||||
|
||||
Purpose
|
||||
- Runs only on the GST operator's Windows workstation.
|
||||
- Listens only on 127.0.0.1:8791.
|
||||
- Exposes a trusted local HTTPS endpoint at https://localhost:8791.
|
||||
- Opens visible Chrome/Edge for GST Portal authentication and return download.
|
||||
- Autofills the selected Credential Vault username/password supplied through the ERP short-lived job token.
|
||||
- CAPTCHA/OTP remains interactive with the operator.
|
||||
- Does not run Tally and does not hold the client storage.
|
||||
- Completed GST packages continue through the ERP to the already configured Local Storage Agent.
|
||||
-------
|
||||
Runs only on the GST operator workstation. It opens visible Chrome/Edge, redeems a short-lived GST job token from ARRR ERP, fills the selected Credential Vault username/password, waits for manual CAPTCHA/OTP, downloads selected GST return data, and uploads the package back to ERP for transfer to the configured Storage Agent.
|
||||
|
||||
Install
|
||||
1. Extract the downloaded ZIP.
|
||||
2. Run PowerShell in the extracted ARRR_GST_Operator_Agent folder:
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install_gst_operator_agent.ps1
|
||||
3. The installer creates a localhost TLS certificate and trusts it only for the current Windows user.
|
||||
4. Verify https://localhost:8791/api/status.
|
||||
5. Refresh the ERP GST Return Reconciliation page.
|
||||
Architecture
|
||||
------------
|
||||
ARRR ERP starts the agent through the Windows custom URL protocol arrrgst://. No browser-to-localhost HTTP/HTTPS request is used. No localhost TLS certificate, local web server, Tally access, or client storage access is required on the operator workstation.
|
||||
|
||||
Upgrade from 1.0.0
|
||||
- Download the current agent ZIP from ERP and run install_gst_operator_agent.ps1 again.
|
||||
- The installer stops the old HTTP listener, replaces the runtime, installs the trusted localhost certificate and starts v1.0.1 over HTTPS.
|
||||
Installation
|
||||
------------
|
||||
Run install_gst_operator_agent.ps1 as the Windows user who will operate GST downloads. Administrator rights are not required. Python 3.11+ and installed Chrome or Edge are required.
|
||||
|
||||
Browser prompt
|
||||
--------------
|
||||
The first time ARRR ERP opens arrrgst://, Chrome/Edge may ask whether to open ARRR GST Operator Agent. Choose Open/Allow.
|
||||
|
||||
Security
|
||||
--------
|
||||
The custom URL contains only a short-lived signed job token. GST username/password are redeemed by the local agent directly from ARRR ERP and are never inserted in ERP page HTML or in the custom URL. CAPTCHA/OTP remains manual.
|
||||
|
||||
Uninstall
|
||||
- Run uninstall_gst_operator_agent.ps1 from the installed/downloaded package.
|
||||
- The installer-created certificate is also removed from the current user's trusted root store.
|
||||
---------
|
||||
Run uninstall_gst_operator_agent.ps1. It removes the protocol registration and also cleans legacy v1.0.x localhost listener/startup/certificate state.
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
CERT_PEM = ROOT / "localhost-cert.pem"
|
||||
KEY_PEM = ROOT / "localhost-key.pem"
|
||||
CERT_DER = ROOT / "localhost-cert.cer"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
name = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "ARRR GST Operator Agent localhost"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "ARRR & Associates"),
|
||||
])
|
||||
now = datetime.now(timezone.utc)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(days=1))
|
||||
.not_valid_after(now + timedelta(days=825))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName("localhost"),
|
||||
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
|
||||
]),
|
||||
critical=False,
|
||||
)
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.add_extension(
|
||||
x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),
|
||||
critical=False,
|
||||
)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
KEY_PEM.write_bytes(
|
||||
key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
CERT_PEM.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
CERT_DER.write_bytes(cert.public_bytes(serialization.Encoding.DER))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,19 +3,17 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import requests
|
||||
|
||||
VERSION = "1.0.1"
|
||||
PORT = 8791
|
||||
VERSION = "1.1.0"
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
DATA = ROOT / "data"
|
||||
CONFIG_PATH = ROOT / "config.json"
|
||||
@@ -26,7 +24,6 @@ GSTR1_DOWNLOAD_URL = "https://return.gst.gov.in/returns/auth/api/offline/downloa
|
||||
GSTR2B_URL = "https://gstr2b.gst.gov.in/gstr2b/auth/api/gstr2b/getjson?rtnprd={period}"
|
||||
GSTR3B_SUMMARY_URL = "https://return.gst.gov.in/returns/auth/api/gstr3b/summary?rtn_prd={period}"
|
||||
GSTR3B_URL = "https://return.gst.gov.in/returns/auth/api/gstr3b/taxpayble?rtn_prd={period}"
|
||||
THREADS: dict[str, threading.Thread] = {}
|
||||
|
||||
|
||||
def now() -> str:
|
||||
@@ -37,7 +34,7 @@ def read_config() -> dict:
|
||||
try:
|
||||
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {"erp_base_url": "https://office.arrrassociates.com", "port": PORT, "certfile": str(ROOT / "localhost-cert.pem"), "keyfile": str(ROOT / "localhost-key.pem")}
|
||||
return {"erp_base_url": "https://office.arrrassociates.com", "protocol": "arrrgst"}
|
||||
|
||||
|
||||
def safe(value: str, fallback: str = "item") -> str:
|
||||
@@ -275,12 +272,40 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def start_job(token: str) -> dict:
|
||||
def _log(message: str) -> None:
|
||||
try:
|
||||
DATA.mkdir(parents=True, exist_ok=True)
|
||||
with (DATA / "operator_agent.log").open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"[{now()}] {message}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _show_error(message: str) -> None:
|
||||
_log("ERROR: " + message)
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
messagebox.showerror("ARRR GST Operator Agent", message)
|
||||
root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def redeem_job(token: str) -> dict:
|
||||
cfg = read_config()
|
||||
erp = str(cfg.get("erp_base_url") or "").rstrip("/")
|
||||
if not token:
|
||||
raise ValueError("GST operator token is required.")
|
||||
response = requests.post(erp + "/tools/accounting/gst-reconciliation/operator/redeem", json={"token": token}, timeout=30)
|
||||
if not erp.startswith("https://"):
|
||||
raise RuntimeError("ERP base URL must use HTTPS.")
|
||||
response = requests.post(
|
||||
erp + "/tools/accounting/gst-reconciliation/operator/redeem",
|
||||
json={"token": token},
|
||||
timeout=30,
|
||||
)
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
@@ -291,88 +316,75 @@ def start_job(token: str) -> dict:
|
||||
job_id = str(payload.get("jti") or "")
|
||||
if not job_id:
|
||||
raise RuntimeError("ERP did not return a GST job id.")
|
||||
return payload
|
||||
|
||||
|
||||
def run_job_foreground(token: str) -> None:
|
||||
payload = redeem_job(token)
|
||||
job_id = str(payload.get("jti") or "")
|
||||
path = job_path(job_id)
|
||||
current = read_json(path)
|
||||
if current.get("status") in {"queued", "running"}:
|
||||
return current
|
||||
initial = {
|
||||
"status": "queued", "percent": 1, "stage": "Queued", "message": "GST browser job queued.",
|
||||
"job_id": job_id, "periods": payload.get("periods") or [], "return_types": payload.get("return_types") or [],
|
||||
"status": "queued", "percent": 1, "stage": "Queued",
|
||||
"message": "GST browser job launched from ARRR ERP.",
|
||||
"job_id": job_id, "periods": payload.get("periods") or [],
|
||||
"return_types": payload.get("return_types") or [],
|
||||
"started_at_utc": now(), "updated_at_utc": now(),
|
||||
}
|
||||
write_json(path, initial)
|
||||
thread = threading.Thread(target=browser_worker, args=(payload, token, path), daemon=True, name=f"gst-{job_id}")
|
||||
THREADS[job_id] = thread
|
||||
thread.start()
|
||||
return initial
|
||||
_log(f"Starting GST job {job_id}")
|
||||
browser_worker(payload, token, path)
|
||||
final = read_json(path)
|
||||
if final.get("status") == "failed":
|
||||
_show_error(str(final.get("error") or final.get("message") or "GST download failed."))
|
||||
else:
|
||||
_log(f"Completed GST job {job_id}")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
def handle_protocol_url(raw_url: str) -> None:
|
||||
parsed = urlsplit(str(raw_url or "").strip())
|
||||
if parsed.scheme.lower() != "arrrgst":
|
||||
raise ValueError("Invalid ARRR GST protocol URL.")
|
||||
action = (parsed.netloc or parsed.path.lstrip("/") or "").lower()
|
||||
if action != "start":
|
||||
raise ValueError("Unsupported ARRR GST action.")
|
||||
token = str((parse_qs(parsed.query).get("token") or [""])[0]).strip()
|
||||
if not token:
|
||||
raise ValueError("GST operator token is missing from the launch request.")
|
||||
run_job_foreground(token)
|
||||
|
||||
def cors(self):
|
||||
cfg = read_config()
|
||||
origin = self.headers.get("Origin") or ""
|
||||
allowed = str(cfg.get("erp_base_url") or "").rstrip("/")
|
||||
if origin.rstrip("/") == allowed:
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
self.send_header("Vary", "Origin")
|
||||
if (self.headers.get("Access-Control-Request-Private-Network") or "").lower() == "true":
|
||||
self.send_header("Access-Control-Allow-Private-Network", "true")
|
||||
|
||||
def reply(self, payload, status=200):
|
||||
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.cors()
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.cors()
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
path = urlsplit(self.path)
|
||||
if path.path == "/api/status":
|
||||
return self.reply({"ok": True, "name": "ARRR GST Operator Agent", "version": VERSION, "port": PORT, "transport": "https", "host": "localhost"})
|
||||
if path.path == "/api/gst/status":
|
||||
job_id = str((parse_qs(path.query).get("job_id") or [""])[0])
|
||||
return self.reply({"ok": True, "job": read_json(job_path(job_id))})
|
||||
return self.reply({"ok": False, "error": "Not found"}, 404)
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
if urlsplit(self.path).path != "/api/gst/start":
|
||||
return self.reply({"ok": False, "error": "Not found"}, 404)
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = json.loads((self.rfile.read(length) if length else b"{}").decode("utf-8"))
|
||||
job = start_job(str(body.get("token") or ""))
|
||||
return self.reply({"ok": True, "job": job})
|
||||
except Exception as exc:
|
||||
return self.reply({"ok": False, "error": str(exc)}, 400)
|
||||
def self_test() -> int:
|
||||
cfg = read_config()
|
||||
erp = str(cfg.get("erp_base_url") or "")
|
||||
if not erp.startswith("https://"):
|
||||
print("ERROR: ERP base URL must use HTTPS.")
|
||||
return 1
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Playwright is unavailable: {exc}")
|
||||
return 1
|
||||
print(f"ARRR GST Operator Agent {VERSION} ready")
|
||||
print(f"ERP: {erp}")
|
||||
print("Protocol: arrrgst://start?token=<short-lived-token>")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
DATA.mkdir(parents=True, exist_ok=True)
|
||||
cfg = read_config()
|
||||
port = int(cfg.get("port") or PORT)
|
||||
certfile = Path(str(cfg.get("certfile") or (ROOT / "localhost-cert.pem")))
|
||||
keyfile = Path(str(cfg.get("keyfile") or (ROOT / "localhost-key.pem")))
|
||||
if not certfile.is_file() or not keyfile.is_file():
|
||||
raise RuntimeError("GST Operator Agent localhost TLS certificate is missing. Re-run the agent installer.")
|
||||
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.load_cert_chain(certfile=str(certfile), keyfile=str(keyfile))
|
||||
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||
server.serve_forever()
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "--self-test":
|
||||
raise SystemExit(self_test())
|
||||
if len(sys.argv) >= 2 and str(sys.argv[1]).lower().startswith("arrrgst:"):
|
||||
try:
|
||||
handle_protocol_url(sys.argv[1])
|
||||
except Exception as exc:
|
||||
_log(traceback.format_exc())
|
||||
_show_error(str(exc))
|
||||
raise SystemExit(1)
|
||||
return
|
||||
print(f"ARRR GST Operator Agent {VERSION}")
|
||||
print("This agent is launched by the arrrgst:// Windows protocol from ARRR ERP.")
|
||||
print("Run with --self-test to validate this installation.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,10 +7,25 @@ $InstallRoot = Join-Path $env:LOCALAPPDATA "ARRR\GSTOperatorAgent"
|
||||
$Venv = Join-Path $InstallRoot ".venv"
|
||||
$Startup = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Startup\ARRR GST Operator Agent.cmd"
|
||||
$ThumbprintFile = Join-Path $InstallRoot "cert_thumbprint.txt"
|
||||
$ProtocolKey = "HKCU:\Software\Classes\arrrgst"
|
||||
|
||||
Write-Host "ARRR GST Operator Agent 1.0.1" -ForegroundColor Cyan
|
||||
Write-Host "ARRR GST Operator Agent 1.1.0 - clean installation" -ForegroundColor Cyan
|
||||
Write-Host "Install root: $InstallRoot"
|
||||
|
||||
# Clean legacy v1.0.x listener/startup/certificate/protocol state first.
|
||||
try {
|
||||
Get-NetTCPConnection -LocalPort 8791 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
if ($_.OwningProcess) { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
} catch {}
|
||||
if (Test-Path $ThumbprintFile) {
|
||||
$Thumbprint = (Get-Content $ThumbprintFile -Raw -ErrorAction SilentlyContinue).Trim()
|
||||
if ($Thumbprint) { Remove-Item "Cert:\CurrentUser\Root\$Thumbprint" -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
Remove-Item $Startup -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $ProtocolKey -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $InstallRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$Python = $null
|
||||
if (Get-Command py -ErrorAction SilentlyContinue) { $Python = @("py", "-3") }
|
||||
elseif (Get-Command python -ErrorAction SilentlyContinue) { $Python = @("python") }
|
||||
@@ -19,7 +34,6 @@ else { throw "Python 3 is required on this workstation. Install Python 3.11+ and
|
||||
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
||||
$RuntimeFiles = @(
|
||||
"gst_operator_agent.py",
|
||||
"generate_localhost_cert.py",
|
||||
"requirements.txt",
|
||||
"README.txt",
|
||||
"uninstall_gst_operator_agent.ps1"
|
||||
@@ -32,58 +46,39 @@ foreach ($Name in $RuntimeFiles) {
|
||||
|
||||
$config = @{
|
||||
erp_base_url = $ErpBaseUrl.TrimEnd('/')
|
||||
port = 8791
|
||||
certfile = (Join-Path $InstallRoot "localhost-cert.pem")
|
||||
keyfile = (Join-Path $InstallRoot "localhost-key.pem")
|
||||
protocol = "arrrgst"
|
||||
} | ConvertTo-Json
|
||||
[System.IO.File]::WriteAllText((Join-Path $InstallRoot "config.json"), $config, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
if (-not (Test-Path (Join-Path $Venv "Scripts\python.exe"))) {
|
||||
if ($Python.Count -eq 2) { & $Python[0] $Python[1] -m venv $Venv }
|
||||
else { & $Python[0] -m venv $Venv }
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not create GST Operator Agent virtual environment." }
|
||||
}
|
||||
if ($Python.Count -eq 2) { & $Python[0] $Python[1] -m venv $Venv }
|
||||
else { & $Python[0] -m venv $Venv }
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not create GST Operator Agent virtual environment." }
|
||||
|
||||
$VenvPython = Join-Path $Venv "Scripts\python.exe"
|
||||
$Pythonw = Join-Path $Venv "Scripts\pythonw.exe"
|
||||
& $VenvPython -m pip install --disable-pip-version-check --upgrade pip
|
||||
if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed." }
|
||||
& $VenvPython -m pip install --disable-pip-version-check -r (Join-Path $InstallRoot "requirements.txt")
|
||||
if ($LASTEXITCODE -ne 0) { throw "GST Operator Agent dependencies could not be installed." }
|
||||
|
||||
# Remove only the certificate previously installed by this agent instance.
|
||||
if (Test-Path $ThumbprintFile) {
|
||||
$OldThumbprint = (Get-Content $ThumbprintFile -Raw -ErrorAction SilentlyContinue).Trim()
|
||||
if ($OldThumbprint) {
|
||||
Remove-Item "Cert:\CurrentUser\Root\$OldThumbprint" -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
# Register per-user custom protocol. No administrator rights, localhost server, TLS certificate, or startup task is required.
|
||||
New-Item -Path $ProtocolKey -Force | Out-Null
|
||||
Set-ItemProperty -Path $ProtocolKey -Name "(Default)" -Value "URL:ARRR GST Operator Agent"
|
||||
New-ItemProperty -Path $ProtocolKey -Name "URL Protocol" -Value "" -PropertyType String -Force | Out-Null
|
||||
New-Item -Path "$ProtocolKey\DefaultIcon" -Force | Out-Null
|
||||
Set-ItemProperty -Path "$ProtocolKey\DefaultIcon" -Name "(Default)" -Value "`"$Pythonw`",0"
|
||||
New-Item -Path "$ProtocolKey\shell\open\command" -Force | Out-Null
|
||||
$Command = "`"$Pythonw`" `"$InstallRoot\gst_operator_agent.py`" `"%1`""
|
||||
Set-ItemProperty -Path "$ProtocolKey\shell\open\command" -Name "(Default)" -Value $Command
|
||||
|
||||
# Create a localhost-only TLS certificate and trust it for the current Windows user.
|
||||
& $VenvPython (Join-Path $InstallRoot "generate_localhost_cert.py")
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not create the GST Operator Agent localhost certificate." }
|
||||
$CertFile = Join-Path $InstallRoot "localhost-cert.cer"
|
||||
$ImportedCert = Import-Certificate -FilePath $CertFile -CertStoreLocation "Cert:\CurrentUser\Root"
|
||||
if (-not $ImportedCert -or -not $ImportedCert.Thumbprint) { throw "Could not trust the GST Operator Agent localhost certificate." }
|
||||
[System.IO.File]::WriteAllText($ThumbprintFile, $ImportedCert.Thumbprint, [System.Text.ASCIIEncoding]::new())
|
||||
& $VenvPython (Join-Path $InstallRoot "gst_operator_agent.py") --self-test
|
||||
if ($LASTEXITCODE -ne 0) { throw "GST Operator Agent self-test failed." }
|
||||
|
||||
$Pythonw = Join-Path $Venv "Scripts\pythonw.exe"
|
||||
$Cmd = "@echo off`r`nstart `"`" `"$Pythonw`" `"$InstallRoot\gst_operator_agent.py`"`r`n"
|
||||
[System.IO.File]::WriteAllText($Startup, $Cmd, [System.Text.ASCIIEncoding]::new())
|
||||
$Registered = (Get-ItemProperty "$ProtocolKey\shell\open\command" -ErrorAction Stop)."(default)"
|
||||
if (-not $Registered) { throw "ARRR GST custom protocol registration failed." }
|
||||
|
||||
try {
|
||||
$Connections = Get-NetTCPConnection -LocalPort 8791 -State Listen -ErrorAction SilentlyContinue
|
||||
foreach ($Connection in $Connections) {
|
||||
if ($Connection.OwningProcess) { Stop-Process -Id $Connection.OwningProcess -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
} catch {}
|
||||
|
||||
Start-Process -FilePath $Pythonw -ArgumentList @((Join-Path $InstallRoot "gst_operator_agent.py")) -WorkingDirectory $InstallRoot
|
||||
Start-Sleep -Seconds 3
|
||||
try {
|
||||
$Status = Invoke-RestMethod -Uri "https://localhost:8791/api/status" -TimeoutSec 8
|
||||
Write-Host "Agent status: $($Status.name) v$($Status.version) - Online via HTTPS" -ForegroundColor Green
|
||||
} catch {
|
||||
throw "GST Operator Agent was installed but did not answer on https://localhost:8791. $($_.Exception.Message)"
|
||||
}
|
||||
Write-Host "Installed successfully. It will start automatically when this Windows user logs in." -ForegroundColor Green
|
||||
Write-Host "Local status URL: https://localhost:8791/api/status" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Installed successfully." -ForegroundColor Green
|
||||
Write-Host "Protocol registered: arrrgst://" -ForegroundColor Green
|
||||
Write-Host "No localhost web server or trusted localhost certificate is used." -ForegroundColor DarkGray
|
||||
Write-Host "Chrome/Edge may ask once whether ARRR GST Operator Agent can be opened. Choose Open/Allow." -ForegroundColor Yellow
|
||||
|
||||
@@ -2,19 +2,18 @@ $ErrorActionPreference = "Stop"
|
||||
$InstallRoot = Join-Path $env:LOCALAPPDATA "ARRR\GSTOperatorAgent"
|
||||
$Startup = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Startup\ARRR GST Operator Agent.cmd"
|
||||
$ThumbprintFile = Join-Path $InstallRoot "cert_thumbprint.txt"
|
||||
$ProtocolKey = "HKCU:\Software\Classes\arrrgst"
|
||||
|
||||
try {
|
||||
Get-NetTCPConnection -LocalPort 8791 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
if ($_.OwningProcess) { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (Test-Path $ThumbprintFile) {
|
||||
$Thumbprint = (Get-Content $ThumbprintFile -Raw -ErrorAction SilentlyContinue).Trim()
|
||||
if ($Thumbprint) {
|
||||
Remove-Item "Cert:\CurrentUser\Root\$Thumbprint" -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($Thumbprint) { Remove-Item "Cert:\CurrentUser\Root\$Thumbprint" -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
Remove-Item $Startup -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $ProtocolKey -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $InstallRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "ARRR GST Operator Agent removed." -ForegroundColor Green
|
||||
Write-Host "ARRR GST Operator Agent removed, including legacy localhost certificate/startup state." -ForegroundColor Green
|
||||
|
||||
@@ -144,12 +144,12 @@ def extract_gstr3b_itc(data) -> dict[str, float]:
|
||||
return totals
|
||||
|
||||
router = APIRouter(prefix="/tools/accounting/gst-reconciliation", tags=["accounting-gst-reconciliation-ui"])
|
||||
_TOKEN_PURPOSE = "gst_lightweight_operator_v2"
|
||||
_TOKEN_PURPOSE = "gst_lightweight_operator_v3"
|
||||
_TOKEN_MINUTES = 15
|
||||
_UPLOAD_ROOT = Path(tempfile.gettempdir()) / "audit_firm_gst_operator_uploads"
|
||||
_OPERATOR_AGENT_VERSION = "1.0.1"
|
||||
_OPERATOR_AGENT_PORT = 8791
|
||||
_OPERATOR_AGENT_VERSION = "1.1.0"
|
||||
_OPERATOR_RUNTIME_ROOT = Path(__file__).resolve().parent / "gst_operator_agent_runtime"
|
||||
_OPERATOR_PACKAGE_FILES = ("gst_operator_agent.py", "requirements.txt", "README.txt", "install_gst_operator_agent.ps1", "uninstall_gst_operator_agent.ps1")
|
||||
|
||||
|
||||
def _fy_bounds(fy: str) -> tuple[date, date]:
|
||||
@@ -414,7 +414,7 @@ def page(request: Request, client_id: int | None = None, registration_id: int |
|
||||
"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request),
|
||||
"clients":clients,"selected_client":selected,"registrations":registrations,"selected_registration":selected_reg,"credentials":credentials,"node":node,"node_online":_node_online(node) if node else False,
|
||||
"period":period,"financial_year":financial_year,"download_mode":download_mode,"operator_job":operator_job,"gst_login_url":GST_LOGIN_URL,
|
||||
"operator_agent_version":_OPERATOR_AGENT_VERSION,"operator_agent_port":_OPERATOR_AGENT_PORT,
|
||||
"operator_agent_version":_OPERATOR_AGENT_VERSION,
|
||||
"message":message,"error":error,"title":"GST Return Reconciliation",
|
||||
})
|
||||
finally:
|
||||
@@ -432,9 +432,11 @@ def download_operator_agent(request: Request):
|
||||
return JSONResponse({"ok": False, "error": "GST Operator Agent runtime is missing from this ERP build."}, status_code=404)
|
||||
memory = io.BytesIO()
|
||||
with zipfile.ZipFile(memory, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
||||
for path in sorted(_OPERATOR_RUNTIME_ROOT.rglob("*")):
|
||||
if path.is_file() and "__pycache__" not in path.parts:
|
||||
archive.write(path, Path("ARRR_GST_Operator_Agent") / path.relative_to(_OPERATOR_RUNTIME_ROOT))
|
||||
for name in _OPERATOR_PACKAGE_FILES:
|
||||
path = _OPERATOR_RUNTIME_ROOT / name
|
||||
if not path.is_file():
|
||||
return JSONResponse({"ok": False, "error": f"GST Operator Agent package file is missing: {name}"}, status_code=500)
|
||||
archive.write(path, Path("ARRR_GST_Operator_Agent") / name)
|
||||
memory.seek(0)
|
||||
headers = {"Content-Disposition": f'attachment; filename="ARRR_GST_Operator_Agent_{_OPERATOR_AGENT_VERSION}.zip"'}
|
||||
return StreamingResponse(memory, media_type="application/zip", headers=headers)
|
||||
@@ -484,8 +486,8 @@ def start_download(
|
||||
})
|
||||
log_access(db,request,user,cred,"use_for_gst_download",reason=f"GST returns {financial_year}: {','.join(return_types)}",fields="username,secret",success=True)
|
||||
db.commit()
|
||||
request.session["gst_operator_job"]={"token":token,"job_id":jti,"periods":periods,"return_types":return_types}
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,message="GST download prepared. The lightweight GST Operator Agent on this computer will open the visible GST browser and autofill the selected Credential Vault login. Complete CAPTCHA/OTP there; downloaded return data will then be transferred to the configured client storage.")
|
||||
request.session["gst_operator_job"]={"job_id":jti,"periods":periods,"return_types":return_types,"launch_url":"arrrgst://start?"+urlencode({"token":token})}
|
||||
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,message="GST download prepared. Windows will launch the lightweight GST Operator Agent through the ARRR GST protocol. The agent will open the visible GST browser and autofill the selected Credential Vault login. Complete CAPTCHA/OTP there; downloaded return data will then be transferred to the configured client storage.")
|
||||
except Exception as exc:
|
||||
db.rollback(); return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error=str(exc))
|
||||
finally:
|
||||
|
||||
@@ -55,10 +55,11 @@
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 rounded-lg border bg-slate-50 p-3 text-sm">
|
||||
<span>GST Operator Agent:</span>
|
||||
<span id="gst-agent-status" class="font-semibold text-slate-600">Checking…</span>
|
||||
<span class="font-semibold text-slate-700">Windows protocol launcher</span>
|
||||
<span class="text-slate-400">v{{ operator_agent_version }}</span>
|
||||
<a href="/tools/accounting/gst-reconciliation/operator-agent/download" class="ml-auto rounded border bg-white px-3 py-1.5 text-xs font-medium">Download / Install Agent</a>
|
||||
<a href="/tools/accounting/gst-reconciliation/operator-agent/download" class="ml-auto rounded border bg-white px-3 py-1.5 text-xs font-medium">Download / Clean Install Agent</a>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500">No browser-to-localhost connection is required. When you start a GST download, Windows opens the installed agent through <b>arrrgst://</b>. Chrome/Edge may ask once for permission to open the ARRR GST Operator Agent.</p>
|
||||
|
||||
<label class="text-sm">Credential Vault Entry
|
||||
<select name="credential_id" required class="mt-1 w-full rounded border p-2" {% if not credentials %}disabled{% endif %}>
|
||||
@@ -83,7 +84,7 @@
|
||||
|
||||
<button {% if not node_online or not credentials %}disabled{% endif %} class="rounded bg-indigo-600 px-4 py-2 text-white disabled:opacity-50">Start GST Download</button>
|
||||
<div class="text-xs">Configured Storage Agent: <b>{{ 'Online' if node_online else 'Offline' }}</b></div>
|
||||
<p class="text-xs text-slate-500">For Full Financial Year, the job processes April through March and reports progress month by month. The password is redeemed directly by the localhost agent and is never placed in this page's HTML or URL.</p>
|
||||
<p class="text-xs text-slate-500">For Full Financial Year, the job processes April through March and reports progress month by month. The custom protocol contains only a short-lived signed job token. GST username/password are redeemed directly by the agent and are never placed in this page's HTML or protocol URL.</p>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/tools/accounting/gst-reconciliation/import" enctype="multipart/form-data" class="rounded-xl border bg-white p-4 space-y-3">
|
||||
@@ -106,10 +107,11 @@
|
||||
</div>
|
||||
|
||||
<div id="gst-job-panel" class="hidden rounded-xl border bg-white p-4 space-y-2">
|
||||
<div class="flex items-center justify-between"><h2 class="font-semibold">Interactive GST Download</h2><span id="gst-job-percent" class="text-sm font-medium">0%</span></div>
|
||||
<div class="h-2 overflow-hidden rounded bg-slate-200"><div id="gst-job-bar" class="h-full bg-indigo-600" style="width:0%"></div></div>
|
||||
<h2 class="font-semibold">Interactive GST Download</h2>
|
||||
<div id="gst-job-stage" class="text-sm font-medium"></div>
|
||||
<div id="gst-job-message" class="text-sm text-slate-600"></div>
|
||||
<a id="gst-agent-open" href="#" class="hidden inline-block rounded bg-indigo-600 px-4 py-2 text-sm text-white">Open GST Operator Agent</a>
|
||||
<p class="text-xs text-slate-500">If Chrome/Edge asks whether to open ARRR GST Operator Agent, choose Open/Allow. The GST browser then opens separately and the operator completes CAPTCHA/OTP there.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/tools/accounting/gst-reconciliation/analyze" class="rounded-xl border bg-white p-4 space-y-3">
|
||||
@@ -135,78 +137,23 @@
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const port = {{ operator_agent_port|int }};
|
||||
const base = `https://localhost:${port}`;
|
||||
const statusEl = document.getElementById('gst-agent-status');
|
||||
const panel = document.getElementById('gst-job-panel');
|
||||
const bar = document.getElementById('gst-job-bar');
|
||||
const pct = document.getElementById('gst-job-percent');
|
||||
const stage = document.getElementById('gst-job-stage');
|
||||
const msg = document.getElementById('gst-job-message');
|
||||
|
||||
async function agentStatus() {
|
||||
if (!statusEl) return false;
|
||||
try {
|
||||
const r = await fetch(base + '/api/status', {cache:'no-store'});
|
||||
const b = await r.json();
|
||||
if (!r.ok || !b.ok) throw new Error(b.error || 'Agent unavailable');
|
||||
statusEl.textContent = `Online · v${b.version}`;
|
||||
statusEl.className = 'font-semibold text-emerald-700';
|
||||
return true;
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Browser cannot reach local agent';
|
||||
statusEl.className = 'font-semibold text-amber-700';
|
||||
statusEl.title = e && e.message ? e.message : 'Local HTTPS connection failed';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderJob(job) {
|
||||
panel?.classList.remove('hidden');
|
||||
const total = Number(job.period_total || (job.periods || []).length || 1);
|
||||
const index = Number(job.period_index || 0);
|
||||
let percent = Number(job.percent || 0);
|
||||
if (!percent && total) percent = Math.min(95, Math.round((index / total) * 100));
|
||||
if (job.status === 'completed') percent = 100;
|
||||
if (bar) bar.style.width = `${percent}%`;
|
||||
if (pct) pct.textContent = `${percent}%`;
|
||||
if (stage) stage.textContent = job.stage || job.status || '';
|
||||
if (msg) msg.textContent = job.message || job.error || '';
|
||||
}
|
||||
|
||||
async function poll(jobId) {
|
||||
try {
|
||||
const r = await fetch(base + '/api/gst/status?job_id=' + encodeURIComponent(jobId), {cache:'no-store'});
|
||||
const b = await r.json();
|
||||
if (b.job) renderJob(b.job);
|
||||
if (b.job && !['completed','failed'].includes(b.job.status)) setTimeout(() => poll(jobId), 1800);
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = 'Could not read GST Operator Agent progress: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
agentStatus();
|
||||
const openLink = document.getElementById('gst-agent-open');
|
||||
|
||||
{% if operator_job %}
|
||||
const prepared = {{ operator_job|tojson }};
|
||||
(async () => {
|
||||
panel?.classList.remove('hidden');
|
||||
if (stage) stage.textContent = 'Connecting to GST Operator Agent';
|
||||
if (msg) msg.textContent = 'Starting visible GST login on this workstation…';
|
||||
try {
|
||||
const online = await agentStatus();
|
||||
if (!online) throw new Error('The GST Operator Agent is not reachable over local HTTPS. Install/update the agent using the button above, then refresh this page.');
|
||||
const r = await fetch(base + '/api/gst/start', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({token:prepared.token})
|
||||
});
|
||||
const b = await r.json();
|
||||
if (!r.ok || !b.ok) throw new Error(b.error || 'Agent could not start GST browser.');
|
||||
renderJob(b.job || {status:'queued',stage:'Queued',message:'GST browser job queued.'});
|
||||
poll(prepared.job_id);
|
||||
} catch (e) {
|
||||
renderJob({status:'failed',percent:100,stage:'Could not start GST browser',message:e.message});
|
||||
}
|
||||
})();
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
if (stage) stage.textContent = 'Launching GST Operator Agent';
|
||||
if (msg) msg.textContent = 'Windows is being asked to open the lightweight GST agent. If the browser shows an external-app confirmation, choose Open/Allow.';
|
||||
if (openLink) {
|
||||
openLink.href = prepared.launch_url;
|
||||
openLink.classList.remove('hidden');
|
||||
}
|
||||
// The protocol launch replaces the previous browser-to-localhost HTTPS fetch.
|
||||
// A short delay lets the ERP page finish rendering before Chrome/Edge opens the registered Windows handler.
|
||||
window.setTimeout(() => { window.location.href = prepared.launch_url; }, 250);
|
||||
{% endif %}
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user