<# Instalador do servidor local do Orvion (Windows). Roda no servidor DENTRO da empresa do cliente. Ele: 1. pareia com a VPS usando o código de instalação (uso único) 2. baixa o pacote da versão publicada e confere o hash 3. monta as pastas, gera a configuração e registra o serviço 4. sobe o servidor, que baixa a base da empresa sozinho no primeiro boot Layout criado (pensado pra atualização trocar só a pasta da versão): C:\Orvion\ node\ runtime do Node (baixado se não houver) versoes\1.2.3\ a aplicação - uma pasta por versão atual.json qual versão está ativa config.json host, porta e segredo desta instalação dados\ DADOS DO CLIENTE - atualização nunca encosta aqui logs\ iniciar.ps1 o que o serviço executa Uso: .\instalar-orvion.ps1 -Codigo ABCDE-FGHIJ-KLMNO-PQRST .\instalar-orvion.ps1 -Codigo ... -Porta 8080 -Destino D:\Orvion .\instalar-orvion.ps1 -Codigo ... -SemServico (não registra o serviço) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Codigo, [string]$HostUrl = 'https://orvionsystem.online', [string]$Destino = 'C:\Orvion', [int]$Porta = 3000, [string]$VersaoNode = '22.14.0', [switch]$SemServico, [switch]$SemFirewall ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # senão o Invoke-WebRequest fica lentíssimo # Set-Content -Encoding utf8 no PowerShell 5.1 grava BOM, e JSON.parse do Node # estoura com BOM no começo do arquivo. Todo JSON que o Node vai ler passa por # aqui. function GravarJson($caminho, $objeto) { $texto = $objeto | ConvertTo-Json [System.IO.File]::WriteAllText($caminho, $texto, (New-Object System.Text.UTF8Encoding($false))) } function Passo($texto) { Write-Host "`n>> $texto" -ForegroundColor Cyan } function Ok($texto) { Write-Host " OK $texto" -ForegroundColor Green } function Aviso($texto) { Write-Host " ! $texto" -ForegroundColor Yellow } function Abortar($texto) { Write-Host "`nERRO: $texto" -ForegroundColor Red exit 1 } # --------------------------------------------------------------------------- Passo 'Conferindo o ambiente' $ehAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $ehAdmin -and -not ($SemServico -and $SemFirewall)) { Abortar 'Abra o PowerShell como Administrador - registrar o serviço e a regra de firewall exige isso. (Para testar sem essas duas etapas: -SemServico -SemFirewall)' } if (Test-Path (Join-Path $Destino 'atual.json')) { Abortar "Já existe uma instalação em $Destino. Para reinstalar: faça backup de '$Destino\dados', remova a pasta, e gere um código NOVO no painel - o código é de uso único e o anterior já foi consumido." } $portaEmUso = Get-NetTCPConnection -LocalPort $Porta -State Listen -ErrorAction SilentlyContinue if ($portaEmUso) { Abortar "A porta $Porta já está em uso nesta máquina. Escolha outra com -Porta." } Ok "Porta $Porta livre" # --------------------------------------------------------------------------- Passo 'Pareando com a VPS' # Feito ANTES de baixar qualquer coisa: se o código estiver errado ou vencido, # o técnico descobre em 2 segundos, sem esperar 8 MB de download. try { $pareamento = Invoke-RestMethod -Method Post -Uri "$HostUrl/api/sync/parear" ` -ContentType 'application/json' -Body (@{ codigo = $Codigo } | ConvertTo-Json) } catch { $detalhe = $_.ErrorDetails.Message if ($detalhe) { Abortar "A VPS recusou o código: $detalhe" } Abortar "Não consegui falar com a VPS em $HostUrl. Verifique a internet e o endereço. ($($_.Exception.Message))" } Ok "Empresa: $($pareamento.empresaNome)" # --------------------------------------------------------------------------- Passo 'Descobrindo a versão a instalar' $cabecalhos = @{ Authorization = "Bearer $($pareamento.syncToken)" } $infoPacote = Invoke-RestMethod -Uri "$HostUrl/api/sync/pacote" -Headers $cabecalhos if (-not $infoPacote.versaoParaInstalar) { Abortar 'A VPS não tem nenhuma versão publicada. Rode "node scripts/empacotar.js" na VPS antes de instalar.' } $versao = $infoPacote.versaoParaInstalar Ok "Versão $versao ($([math]::Round($infoPacote.tamanho / 1MB, 1)) MB)" # --------------------------------------------------------------------------- Passo 'Preparando as pastas' $pastaVersao = Join-Path $Destino "versoes\$versao" $pastaDados = Join-Path $Destino 'dados' foreach ($p in @($Destino, (Join-Path $Destino 'versoes'), $pastaDados, (Join-Path $Destino 'logs'))) { New-Item -ItemType Directory -Force -Path $p | Out-Null } Ok $Destino # --------------------------------------------------------------------------- Passo 'Verificando o Node' $nodeExe = $null $nodeDoSistema = Get-Command node -ErrorAction SilentlyContinue if ($nodeDoSistema) { $versaoInstalada = (& $nodeDoSistema.Source --version).TrimStart('v') if ([version]$versaoInstalada -ge [version]'18.0.0') { $nodeExe = $nodeDoSistema.Source Ok "Node $versaoInstalada já instalado" } else { Aviso "Node $versaoInstalada é antigo demais - vou baixar uma cópia própria" } } if (-not $nodeExe) { # Cópia própria dentro da pasta do Orvion: não mexe no Node que a empresa # já usa pra outra coisa, e uma atualização do sistema não quebra o serviço. $pastaNode = Join-Path $Destino 'node' $zipNode = Join-Path $env:TEMP "node-v$VersaoNode-win-x64.zip" $urlNode = "https://nodejs.org/dist/v$VersaoNode/node-v$VersaoNode-win-x64.zip" Write-Host " baixando $urlNode" Invoke-WebRequest -Uri $urlNode -OutFile $zipNode Expand-Archive -Path $zipNode -DestinationPath $pastaNode -Force Remove-Item $zipNode -Force $nodeExe = Join-Path $pastaNode "node-v$VersaoNode-win-x64\node.exe" if (-not (Test-Path $nodeExe)) { Abortar 'O download do Node não trouxe o node.exe esperado.' } Ok "Node $VersaoNode instalado em $pastaNode" } # --------------------------------------------------------------------------- Passo 'Baixando a aplicação' $pacote = Join-Path $env:TEMP "orvion-local-$versao.tar.gz" Invoke-WebRequest -Uri "$HostUrl/api/sync/pacote/$versao/arquivo" -Headers $cabecalhos -OutFile $pacote # O pacote é código que vai rodar dentro da rede do cliente. Se o hash não # bater, não executa - melhor falhar a instalação do que rodar algo alterado. $hashBaixado = (Get-FileHash -Path $pacote -Algorithm SHA256).Hash.ToLower() if ($hashBaixado -ne $infoPacote.sha256.ToLower()) { Remove-Item $pacote -Force Abortar "O pacote baixado não confere com o hash publicado pela VPS. Download corrompido ou adulterado - instalação cancelada." } Ok 'Hash confere' New-Item -ItemType Directory -Force -Path $pastaVersao | Out-Null & "$env:SystemRoot\System32\tar.exe" -xzf $pacote -C $pastaVersao if ($LASTEXITCODE -ne 0) { Abortar 'Falha ao extrair o pacote.' } Remove-Item $pacote -Force Ok "Aplicação em $pastaVersao" # --------------------------------------------------------------------------- Passo 'Gravando a configuração' # Segredo próprio desta instalação: as sessões do servidor local são dele. $bytes = New-Object byte[] 48 [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) $segredo = [Convert]::ToBase64String($bytes) GravarJson (Join-Path $Destino 'config.json') @{ hostUrl = $HostUrl; porta = $Porta; jwtSecret = $segredo; empresaNome = $pareamento.empresaNome; nodeExe = $nodeExe } # O syncToken já vem gravado aqui pelo instalador: no primeiro boot o servidor # vê que já está pareado, pula essa etapa e vai direto baixar a base da empresa. GravarJson (Join-Path $pastaDados 'instalacao.json') @{ hostUrl = $HostUrl empresaId = $pareamento.empresaId empresaNome = $pareamento.empresaNome syncToken = $pareamento.syncToken pareadoEm = (Get-Date).ToUniversalTime().ToString('o') cargaConcluida = $false } GravarJson (Join-Path $Destino 'atual.json') @{ versao = $versao } # config.json e dados\ carregam a credencial de sincronização - só o dono da # máquina e os administradores enxergam. # SIDs e não nomes: em Windows português o grupo é "Administradores", e # /grant:r "Administrators" falharia calado na máquina do cliente. # # Pasta leva as flags de herança; arquivo NÃO. Aplicar (OI)(CI) num # arquivo faz o icacls recusar a concessão depois de /inheritance:r já ter # removido o que era herdado - e aí nem o SYSTEM consegue ler, o serviço # sobe sem configuração nenhuma e nada sincroniza. icacls $pastaDados /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null if ($LASTEXITCODE -ne 0) { Abortar "Não consegui proteger a pasta de dados ($pastaDados)." } icacls (Join-Path $Destino 'config.json') /inheritance:r /grant:r '*S-1-5-18:F' '*S-1-5-32-544:F' | Out-Null if ($LASTEXITCODE -ne 0) { Abortar 'Não consegui proteger o config.json.' } # Esconde a pasta de dados: oculta + sistema, que some da visao normal do # Explorer mesmo com "mostrar arquivos ocultos" ligado. Nao e seguranca (a # permissao acima e que barra o acesso) - e pra ninguem esbarrar, abrir um # .json "so pra ver" e deixar o banco corrompido. # # Marca a PASTA, nunca os arquivos dentro. Arquivo com atributo oculto no # Windows faz a regravacao falhar (o Node abre com CREATE_ALWAYS e leva # EPERM), e o servidor precisa reescrever esses arquivos o tempo todo. $itemDados = Get-Item $pastaDados -Force $itemDados.Attributes = $itemDados.Attributes -bor [IO.FileAttributes]::Hidden -bor [IO.FileAttributes]::System # Recado pra quem for fucar na pasta assim mesmo. $aviso = @" Orvion - servidor local Esta pasta guarda o banco de dados desta empresa. NAO edite, mova nem apague nada aqui dentro. O sistema grava estes arquivos o tempo todo, e mexer neles corrompe o banco da portaria. Nada editado na mao chega ao servidor da Orvion: a sincronizacao so envia o que passou pelo proprio sistema. Ou seja, mexer aqui nao "conserta" nada la - so quebra o que funciona aqui. Precisa de alguma coisa? Fale com o suporte da Orvion. "@ $avisoPath = Join-Path $Destino 'LEIA-ME.txt' [System.IO.File]::WriteAllText($avisoPath, $aviso, (New-Object System.Text.UTF8Encoding($false))) Ok 'Configuração gravada, pasta de dados protegida e oculta' # --------------------------------------------------------------------------- Passo 'Criando o iniciador' # Lê a versão ativa toda vez que sobe: é isso que faz a atualização ser só # "troca o atual.json e reinicia". $iniciador = @' # Supervisor do servidor local. # # Cada volta do laco RELE atual.json: e isso que faz a atualizacao ser so # "trocar o atual.json e sair". O servidor sai com codigo 0 quando acabou de # se atualizar; qualquer outra saida e queda, e ele sobe de novo. # # Volta atras: se a versao ativa e diferente da ultima que comprovadamente # funcionou, e ela cai 3 vezes seguidas sem completar 60 segundos de pe, o # atual.json volta pra versao boa. Um cliente nunca fica sem portaria por # causa de uma versao ruim. $base = Split-Path -Parent $MyInvocation.MyCommand.Path $arquivoBoa = Join-Path $base 'ultima-versao-boa.json' $logSup = Join-Path $base 'logs\supervisor.log' $falhasSeguidas = 0 # config.json e legivel so por SYSTEM e Administradores. Se a leitura # falhar, TODAS as variaveis ficam vazias e o servidor sobe sem saber onde # fica a VPS - parece que funcionou e nada sincroniza. Falhar alto aqui. $cfg = $null try { $cfg = Get-Content (Join-Path $base 'config.json') -Raw -ErrorAction Stop | ConvertFrom-Json } catch { "$(Get-Date -Format o) NAO CONSEGUI LER config.json: $($_.Exception.Message)" | Add-Content $logSup "$(Get-Date -Format o) O servico precisa rodar como SYSTEM ou Administrador. Servidor NAO iniciado." | Add-Content $logSup exit 1 } if (-not $cfg.hostUrl) { "$(Get-Date -Format o) config.json sem hostUrl. Servidor NAO iniciado - sem isso ele nunca sincronizaria." | Add-Content $logSup exit 1 } while ($true) { $versao = (Get-Content (Join-Path $base 'atual.json') -Raw | ConvertFrom-Json).versao $app = Join-Path $base "versoes\$versao" # O caminho do Node vem gravado da instalacao. Redescobrir por # 'Get-Command node' aqui era um bug real: o servico roda como SYSTEM, # que nao enxerga um Node instalado so pro usuario. O resultado era # $node nulo, o servidor nunca subindo e este laco girando calado. $node = $cfg.nodeExe if (-not $node -or -not (Test-Path $node)) { $node = Join-Path $base 'node\node-v__VERSAO_NODE__-win-x64\node.exe' } if (-not (Test-Path $node)) { $doPath = (Get-Command node -ErrorAction SilentlyContinue).Source if ($doPath) { $node = $doPath } } if (-not $node -or -not (Test-Path $node)) { "$(Get-Date -Format o) SEM NODE: nao achei o node.exe. Procurei em config.json (nodeExe), na pasta node\ da instalacao e no PATH." | Add-Content (Join-Path $base 'logs\supervisor.log') Start-Sleep -Seconds 30 continue } $env:ORVION_MODE = 'local' $env:ORVION_HOST_URL = $cfg.hostUrl $env:DATA_DIR = Join-Path $base 'dados' $env:JWT_SECRET = $cfg.jwtSecret $env:PORT = $cfg.porta $env:ORVION_RAIZ = $base $env:ORVION_VERSAO = $versao $comecou = Get-Date Set-Location $app & $node (Join-Path $app 'server.js') *>> (Join-Path $base 'logs\orvion.log') $codigo = $LASTEXITCODE $segundosDePe = ((Get-Date) - $comecou).TotalSeconds if ($segundosDePe -ge 60) { # aguentou de pe: essa versao e confiavel @{ versao = $versao } | ConvertTo-Json | Set-Content -Path $arquivoBoa -Encoding utf8 $falhasSeguidas = 0 } if ($codigo -eq 0) { # saida limpa = acabou de se atualizar; sobe de novo ja na versao nova "$(Get-Date -Format o) atualizado, reiniciando na versao nova" | Add-Content (Join-Path $base 'logs\supervisor.log') continue } $falhasSeguidas++ "$(Get-Date -Format o) servidor caiu (codigo $codigo) apos $([int]$segundosDePe)s - falha $falhasSeguidas" | Add-Content (Join-Path $base 'logs\supervisor.log') if ($falhasSeguidas -ge 3 -and $segundosDePe -lt 60 -and (Test-Path $arquivoBoa)) { $boa = (Get-Content $arquivoBoa -Raw | ConvertFrom-Json).versao if ($boa -and $boa -ne $versao -and (Test-Path (Join-Path $base "versoes\$boa"))) { @{ versao = $boa } | ConvertTo-Json | Set-Content -Path (Join-Path $base 'atual.json') -Encoding utf8 "$(Get-Date -Format o) VOLTANDO da versao $versao para $boa" | Add-Content (Join-Path $base 'logs\supervisor.log') $falhasSeguidas = 0 } } Start-Sleep -Seconds 5 } '@ $iniciador = $iniciador.Replace('__VERSAO_NODE__', $VersaoNode) Set-Content -Path (Join-Path $Destino 'iniciar.ps1') -Value $iniciador -Encoding utf8 Ok 'iniciar.ps1' # --------------------------------------------------------------------------- if (-not $SemFirewall) { Passo 'Liberando a porta no firewall' $regra = "Orvion Servidor Local (porta $Porta)" if (Get-NetFirewallRule -DisplayName $regra -ErrorAction SilentlyContinue) { Remove-NetFirewallRule -DisplayName $regra } # Só rede privada/domínio: a ideia é atender a rede interna da empresa, # não ficar exposto se a máquina cair numa rede pública. New-NetFirewallRule -DisplayName $regra -Direction Inbound -Action Allow ` -Protocol TCP -LocalPort $Porta -Profile Private,Domain | Out-Null Ok $regra } # --------------------------------------------------------------------------- Passo 'Baixando o painel de status' # Não crítico pro servidor funcionar — se a VPS ainda não tiver o painel # publicado (instalação feita antes dessa versão do instalador), segue sem # travar o resto. $painelExe = Join-Path $Destino 'OrvionPainel.exe' try { Invoke-WebRequest -Uri "$HostUrl/OrvionPainel.exe" -OutFile $painelExe Ok 'OrvionPainel.exe' } catch { Aviso "Não consegui baixar o painel de status ($($_.Exception.Message)) - seguindo sem ele." $painelExe = $null } if ($painelExe) { Passo 'Criando os atalhos' # Atalho comum (todos os usuários da máquina, mesmo raciocínio da pasta # de dados: a portaria costuma ter mais de uma conta usando a mesma # máquina) na área de trabalho, pra reabrir na mão; e na pasta de # Inicialização, pra abrir sozinho no login sem precisar lembrar. # # .url (Internet Shortcut), não .lnk: mesmo formato que o backfill em # sync/painelLocal.js usa pros servidores já instalados antes dessa # feature — um .lnk gerado via WScript.Shell rodando como SYSTEM (tarefa # agendada) dava EPERM em produção (bloqueio de antivírus pro padrão # "app spawna PowerShell"), e o .url não depende de COM nem de processo # nenhum, só um arquivo texto. Usa o mesmo formato aqui pra instalação # nova não divergir da instalação já existente. function CriarAtalho($caminhoUrl, $alvo) { $urlAlvo = 'file:///' + ($alvo -replace '\\', '/') -replace ' ', '%20' $conteudo = "[InternetShortcut]`r`nURL=$urlAlvo`r`nIconFile=$alvo`r`nIconIndex=0`r`n" [System.IO.File]::WriteAllText($caminhoUrl, $conteudo, [System.Text.Encoding]::ASCII) } $pastaDesktop = [Environment]::GetFolderPath('CommonDesktopDirectory') $pastaStartup = [Environment]::GetFolderPath('CommonStartup') CriarAtalho (Join-Path $pastaDesktop 'Orvion - Status do servidor.url') $painelExe CriarAtalho (Join-Path $pastaStartup 'Orvion - Status do servidor.url') $painelExe Ok 'Atalho na área de trabalho e na Inicialização' } # --------------------------------------------------------------------------- if (-not $SemServico) { Passo 'Registrando o serviço' # Tarefa agendada em vez de serviço do Windows: não depende de baixar # NSSM nem de nenhum binário de terceiro, funciona igual no Windows 10/11 # e no Server, e reinicia sozinho se cair. $nomeTarefa = 'Orvion Servidor Local' if (Get-ScheduledTask -TaskName $nomeTarefa -ErrorAction SilentlyContinue) { Unregister-ScheduledTask -TaskName $nomeTarefa -Confirm:$false } $acao = New-ScheduledTaskAction -Execute 'powershell.exe' ` -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Destino\iniciar.ps1`"" $gatilho = New-ScheduledTaskTrigger -AtStartup $conta = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest $opcoes = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Seconds 0) Register-ScheduledTask -TaskName $nomeTarefa -Action $acao -Trigger $gatilho ` -Principal $conta -Settings $opcoes | Out-Null Start-ScheduledTask -TaskName $nomeTarefa Ok "$nomeTarefa (sobe junto com o Windows)" } # --------------------------------------------------------------------------- Passo 'Conferindo se subiu' if ($SemServico) { Aviso "Serviço não registrado (-SemServico). Suba na mão com: powershell -NoProfile -File `"$Destino\iniciar.ps1`"" } else { $subiu = $false foreach ($i in 1..30) { Start-Sleep -Seconds 2 try { Invoke-WebRequest -Uri "http://127.0.0.1:$Porta/" -UseBasicParsing -TimeoutSec 3 | Out-Null $subiu = $true; break } catch { if ($_.Exception.Response) { $subiu = $true; break } # respondeu, mesmo que 401 } } if (-not $subiu) { Abortar "O servidor não respondeu na porta $Porta. Veja $Destino\logs\orvion.log" } Ok 'Servidor respondendo' # A carga inicial roda no primeiro boot e pode demorar, conforme o tamanho # da base (fotos de NF e de carga são as pesadas). Write-Host ' baixando a base da empresa...' $pronto = $false foreach ($i in 1..90) { Start-Sleep -Seconds 2 $inst = Get-Content (Join-Path $pastaDados 'instalacao.json') -Raw | ConvertFrom-Json if ($inst.cargaConcluida) { $pronto = $true; break } } if ($pronto) { Ok 'Base da empresa baixada' } else { Aviso "A carga ainda não terminou. Acompanhe em $Destino\logs\orvion.log" } } $ips = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' }).IPAddress Write-Host "`n============================================================" -ForegroundColor Green Write-Host " Orvion instalado - $($pareamento.empresaNome)" -ForegroundColor Green Write-Host "============================================================" -ForegroundColor Green Write-Host " Versao: $versao" Write-Host " Pasta: $Destino" Write-Host " Acesso na rede interna:" foreach ($ip in $ips) { Write-Host " http://${ip}:$Porta" } Write-Host "" Write-Host " Camera e GPS pelo navegador so funcionam em HTTPS." -ForegroundColor Yellow Write-Host " Pelo IP acima o navegador bloqueia os dois. Quem precisa" -ForegroundColor Yellow Write-Host " disso (celular, captura facial) deve usar a VPS por" -ForegroundColor Yellow Write-Host " enquanto: $HostUrl" -ForegroundColor Yellow Write-Host "" # Abre o painel já mostrando "Online" — a instalação acabou de confirmar que # o servidor respondeu, então é a primeira coisa que o técnico vê. if ($painelExe -and -not $SemServico) { Start-Process -FilePath $painelExe -ArgumentList "`"$Destino`"" }