From 1be8dc8f7662a7e5027a2287d6ce68f3ae374ee1 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 11 Jun 2026 21:38:40 -0300 Subject: [PATCH] Adiciona limites diarios automaticos --- README.md | 34 ++++- install.sh | 2 + internal/blocker/firewall.go | 179 +++++++++++++++++++++++ internal/cli/control.go | 192 +++++++------------------ internal/cli/limit.go | 207 +++++++++++++++++++++++++++ internal/cli/limit_test.go | 44 ++++++ internal/daemon/daemon.go | 92 +----------- internal/db/db.go | 221 +++++++++++++++++++++++++++-- internal/db/db_test.go | 115 +++++++++++++++ internal/enforcer/enforcer.go | 173 ++++++++++++++++++++++ internal/enforcer/enforcer_test.go | 119 ++++++++++++++++ 11 files changed, 1140 insertions(+), 238 deletions(-) create mode 100644 internal/cli/limit.go create mode 100644 internal/cli/limit_test.go create mode 100644 internal/db/db_test.go create mode 100644 internal/enforcer/enforcer.go create mode 100644 internal/enforcer/enforcer_test.go diff --git a/README.md b/README.md index 6faf63a..f9667d4 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ It organizes blocking into **blocks** — named groups of: When you **activate** a block, a daemon continuously ensures the blocking layers stay applied. +Blocks can also have a **daily time limit**. A limited block stays available while it still has time left for the local day; Open Turkey monitors network traffic to the block's sites, counts traffic activity as usage, and automatically starts blocking the block once the daily quota is exhausted. + ## How it works — 4 enforcement layers Open Turkey doesn't rely on a single, easily-bypassed mechanism. Each active block is enforced on four independent layers, and a `systemd` daemon re-applies them every 5 seconds if anything is tampered with: @@ -106,7 +108,37 @@ Unlock a locked block (this also **deactivates** it): open-turkey unlock social-media ``` -### 3) Editing a block's lists +### 3) Daily time limits + +Configure a block to allow up to 30 minutes of site traffic per local day: + +```bash +open-turkey limit set social-media --daily 30m +open-turkey start social-media --lock +``` + +With a daily limit configured, you do not need to run a command to unlock time. While the block is active and still has quota left, its sites remain available and Open Turkey installs firewall counting rules. When traffic to those sites is detected, the daemon counts the block as active for 60 seconds. New traffic extends that activity window; once the daily quota is exhausted, the normal blocking layers are applied until the next local day. + +Show limit usage: + +```bash +open-turkey limit status +open-turkey limit status social-media +``` + +Remove a limit: + +```bash +open-turkey limit remove social-media +``` + +Notes: + +- The first version measures IPv4 network traffic, not browser tabs. Background traffic to a limited domain counts the same as deliberate use. +- Daily limits are meant for site blocks. A limited block must contain at least one site. +- Apps inside a limited block are allowed while the site quota remains and are killed once the quota is exhausted. + +### 4) Editing a block's lists Remove a domain from a block: diff --git a/install.sh b/install.sh index 6eae304..a47bbb9 100755 --- a/install.sh +++ b/install.sh @@ -43,6 +43,8 @@ echo "Comandos uteis:" echo " open-turkey block create - Cria um bloco" echo " open-turkey block add-site - Adiciona sites ao bloco" echo " open-turkey block remove-site - Remove sites do bloco" +echo " open-turkey limit set --daily 30m - Configura limite diario automatico" +echo " open-turkey limit status [bloco] - Mostra uso do limite diario" echo " open-turkey start - Ativa um bloco" echo " open-turkey stop - Desativa um bloco (se nao estiver travado)" echo " open-turkey status - Exibe o status dos blocos" diff --git a/internal/blocker/firewall.go b/internal/blocker/firewall.go index 6306e6a..8e7eedb 100644 --- a/internal/blocker/firewall.go +++ b/internal/blocker/firewall.go @@ -60,6 +60,7 @@ import ( "fmt" "net" "os/exec" + "strconv" "strings" ) @@ -69,6 +70,16 @@ import ( // ao Open Turkey. O hífen é permitido em nomes de chains. const chainName = "OPEN-TURKEY" +// limitChainPrefix identifica chains usadas apenas para contar uso de blocos +// com limite diário. Elas não bloqueiam tráfego; só incrementam contadores. +const limitChainPrefix = "OT-LIMIT-" + +// LimitTrackTarget descreve um bloco limitado que deve ter tráfego monitorado. +type LimitTrackTarget struct { + BlockID int + Domains []string +} + // dohServersIPv4 contém os endereços IPv4 dos servidores DNS-over-HTTPS // mais utilizados. Bloqueamos esses IPs na porta 443 (HTTPS) para impedir // que navegadores façam consultas DNS criptografadas, o que contornaria @@ -226,6 +237,86 @@ func RemoveFirewall() error { return nil } +// ApplyLimitTracking instala chains de contagem para blocos com limite diário. +func ApplyLimitTracking(targets []LimitTrackTarget) error { + if len(targets) == 0 { + return RemoveLimitTracking() + } + + desired := make(map[string]bool) + for _, target := range targets { + if target.BlockID <= 0 { + continue + } + + chain := limitChainName(target.BlockID) + desired[chain] = true + + _ = runIptables("-N", chain) + if err := runIptables("-F", chain); err != nil { + return fmt.Errorf("erro ao limpar a chain %s: %w", chain, err) + } + if err := ensureOutputJump(chain); err != nil { + return err + } + + for _, ip := range resolveDomainsIPv4(target.Domains) { + if err := runIptables("-A", chain, "-d", ip, "-j", "RETURN"); err != nil { + return fmt.Errorf("erro ao adicionar regra de contagem para %s: %w", ip, err) + } + } + } + + existing, err := listLimitChains() + if err != nil { + return err + } + for _, chain := range existing { + if !desired[chain] { + removeLimitChain(chain) + } + } + + return nil +} + +// RemoveLimitTracking remove todas as chains de contagem de uso diário. +func RemoveLimitTracking() error { + chains, err := listLimitChains() + if err != nil { + return err + } + for _, chain := range chains { + removeLimitChain(chain) + } + return nil +} + +// ReadLimitTrackingCounters soma os pacotes observados por bloco limitado. +func ReadLimitTrackingCounters() (map[int]uint64, error) { + counters := make(map[int]uint64) + + chains, err := listLimitChains() + if err != nil { + return counters, err + } + + for _, chain := range chains { + blockID, ok := parseLimitChainID(chain) + if !ok { + continue + } + + output, err := runIptablesOutput("-L", chain, "-v", "-x", "-n") + if err != nil { + return counters, err + } + counters[blockID] = parsePacketCounter(output) + } + + return counters, nil +} + // IsFirewallApplied verifica se o firewall do Open Turkey está ativo. // // Para considerar o firewall como "aplicado", duas condições precisam @@ -293,6 +384,94 @@ func BlockDoH() error { // Funções auxiliares (helpers) — uso interno do pacote // ============================================================================= +func ensureOutputJump(chain string) error { + if err := runIptables("-C", "OUTPUT", "-j", chain); err == nil { + return nil + } + if err := runIptables("-I", "OUTPUT", "-j", chain); err != nil { + return fmt.Errorf("erro ao inserir salto para a chain %s na OUTPUT: %w", chain, err) + } + return nil +} + +func removeLimitChain(chain string) { + _ = runIptables("-D", "OUTPUT", "-j", chain) + _ = runIptables("-F", chain) + _ = runIptables("-X", chain) +} + +func listLimitChains() ([]string, error) { + output, err := runIptablesOutput("-S") + if err != nil { + return nil, err + } + + var chains []string + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[0] == "-N" && strings.HasPrefix(fields[1], limitChainPrefix) { + chains = append(chains, fields[1]) + } + } + return chains, nil +} + +func limitChainName(blockID int) string { + return fmt.Sprintf("%s%d", limitChainPrefix, blockID) +} + +func parseLimitChainID(chain string) (int, bool) { + if !strings.HasPrefix(chain, limitChainPrefix) { + return 0, false + } + id, err := strconv.Atoi(strings.TrimPrefix(chain, limitChainPrefix)) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} + +func resolveDomainsIPv4(domains []string) []string { + seen := make(map[string]bool) + var ips []string + + for _, domain := range domains { + domain = NormalizarDominio(domain) + if domain == "" { + continue + } + + resolved, err := net.LookupHost(domain) + if err != nil { + continue + } + for _, ip := range resolved { + if strings.Contains(ip, ":") || seen[ip] { + continue + } + seen[ip] = true + ips = append(ips, ip) + } + } + + return ips +} + +func parsePacketCounter(output string) uint64 { + var total uint64 + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + packets, err := strconv.ParseUint(fields[0], 10, 64) + if err == nil { + total += packets + } + } + return total +} + // runIptables executa um comando iptables com os argumentos fornecidos. // // Por que encapsulamos isso em uma função separada? diff --git a/internal/cli/control.go b/internal/cli/control.go index ac5936a..36e6b9f 100644 --- a/internal/cli/control.go +++ b/internal/cli/control.go @@ -2,10 +2,10 @@ // // Este arquivo implementa os comandos que controlam o CICLO DE VIDA de um bloco: // -// open-turkey start → Ativa um bloco (começa a bloquear sites/apps) -// open-turkey stop → Desativa um bloco (para de bloquear) -// open-turkey unlock → Desbloqueia um bloco travado via desafio de digitação -// open-turkey status → Mostra o status de todos os blocos +// open-turkey start → Ativa um bloco (começa a bloquear sites/apps) +// open-turkey stop → Desativa um bloco (para de bloquear) +// open-turkey unlock → Desbloqueia um bloco travado via desafio de digitação +// open-turkey status → Mostra o status de todos os blocos // // COMO FUNCIONA A ATIVAÇÃO DE UM BLOCO? // ------------------------------------- @@ -36,9 +36,10 @@ package cli import ( "fmt" "os" + "time" - "github.com/brunodcdo/open-turkey/internal/blocker" "github.com/brunodcdo/open-turkey/internal/db" + "github.com/brunodcdo/open-turkey/internal/enforcer" "github.com/brunodcdo/open-turkey/internal/lock" "github.com/spf13/cobra" ) @@ -52,15 +53,17 @@ import ( // Uso: open-turkey start [--lock] [--lock-chars N] // // Exemplos: -// open-turkey start redes-sociais → ativa sem trava -// open-turkey start redes-sociais --lock → ativa com trava (300 chars padrão) -// open-turkey start redes-sociais --lock --lock-chars 500 → trava com 500 chars +// +// open-turkey start redes-sociais → ativa sem trava +// open-turkey start redes-sociais --lock → ativa com trava (300 chars padrão) +// open-turkey start redes-sociais --lock --lock-chars 500 → trava com 500 chars // // Flags: -// --lock Trava o bloco para que não possa ser desativado com "stop". -// O usuário precisará usar "unlock" com desafio de digitação. -// --lock-chars Quantidade de caracteres aleatórios do desafio de desbloqueio. -// Padrão: 300. Quanto mais, mais difícil de desbloquear. +// +// --lock Trava o bloco para que não possa ser desativado com "stop". +// O usuário precisará usar "unlock" com desafio de digitação. +// --lock-chars Quantidade de caracteres aleatórios do desafio de desbloqueio. +// Padrão: 300. Quanto mais, mais difícil de desbloquear. var startCmd = &cobra.Command{ Use: "start [bloco]", Short: "Ativar um bloco de bloqueio", @@ -127,49 +130,12 @@ var startCmd = &cobra.Command{ return err } - // --- Passo 5: Coletar TODOS os domínios e apps de TODOS os blocos ativos --- - // Precisamos de todos os domínios porque as camadas de bloqueio são globais. - // Se o bloco "redes-sociais" bloqueia facebook.com e o bloco "jogos" bloqueia - // steam.com, precisamos aplicar AMBOS no /etc/hosts, firewall, etc. - dominios, err := database.GetAllBlockedDomains() - if err != nil { - return fmt.Errorf("erro ao buscar domínios bloqueados: %w", err) - } - - apps, err := database.GetAllBlockedApps() - if err != nil { - return fmt.Errorf("erro ao buscar apps bloqueados: %w", err) - } - - // --- Passo 6: Aplicar as 4 camadas de bloqueio --- - // Cada camada é um mecanismo diferente de bloqueio que funciona - // independentemente dos outros. Juntas, tornam muito difícil burlar. - - // Camada 1: /etc/hosts — redireciona domínios para 0.0.0.0 (IP inválido). - // É a primeira linha de defesa e afeta TODOS os programas do sistema. - if err := blocker.ApplyHosts(dominios); err != nil { - return fmt.Errorf("erro ao aplicar bloqueio no /etc/hosts: %w", err) - } - - // Camada 2: iptables (firewall) — bloqueia pacotes de rede para os IPs dos sites. - // Funciona mesmo se o usuário encontrar o IP real e tentar acessar diretamente. - if err := blocker.ApplyFirewall(dominios); err != nil { - return fmt.Errorf("erro ao aplicar bloqueio no firewall: %w", err) - } - - // Camada 3: Políticas de navegador — bloqueia diretamente no Firefox/Chrome/Chromium. - // O navegador mostra uma página "Bloqueado pela política da organização". - if err := blocker.ApplyBrowserPolicies(dominios); err != nil { - return fmt.Errorf("erro ao aplicar políticas de navegador: %w", err) - } - - // Camada 4: Matar processos — encerra apps bloqueados que estejam rodando. - // Usa SIGKILL (sinal 9) para garantir que o processo morra imediatamente. - if len(apps) > 0 { - blocker.KillBlocked(apps) + // --- Passo 5: Aplicar bloqueio ou tracking conforme o limite diário --- + if err := reaplicarOuRemoverCamadas(database); err != nil { + return err } - // --- Passo 7: Mensagem de sucesso --- + // --- Passo 6: Mensagem de sucesso --- // Informamos se o bloco foi ativado com ou sem trava. if usarTrava { fmt.Printf("Bloco '%s' ativado com sucesso [TRAVADO - %d chars para desbloquear]\n", nomeBLoco, lockChars) @@ -355,16 +321,16 @@ var unlockCmd = &cobra.Command{ // // Exemplo de saída: // -// === Status do Open Turkey === +// === Status do Open Turkey === // -// Blocos ativos: -// redes-sociais [TRAVADO - 300 chars] -// jogos +// Blocos ativos: +// redes-sociais [TRAVADO - 300 chars] +// jogos // -// Blocos inativos: -// trabalho +// Blocos inativos: +// trabalho // -// Total: 3 blocos (2 ativos, 1 inativo) +// Total: 3 blocos (2 ativos, 1 inativo) // // Este comando é útil para ter uma visão geral rápida sem precisar // inspecionar cada bloco individualmente. @@ -401,6 +367,7 @@ var statusCmd = &cobra.Command{ // Cada bloco ativo vira uma string formatada com ou sem "[TRAVADO]". var blocosAtivos []string var blocosInativos []string + day := currentDayKey() for _, bloco := range blocos { // GetBlock retorna detalhes completos incluindo Active, Locked, LockChars. @@ -408,14 +375,30 @@ var statusCmd = &cobra.Command{ if err != nil { return err } + limitStatus, err := database.GetLimitStatus(bloco.Name, day) + if err != nil { + return err + } if detalhe.Active { // Se está ativo, formatamos com informação de trava (se houver). + suffix := "" if detalhe.Locked { - blocosAtivos = append(blocosAtivos, fmt.Sprintf(" %s [TRAVADO - %d chars]", detalhe.Name, detalhe.LockChars)) - } else { - blocosAtivos = append(blocosAtivos, fmt.Sprintf(" %s", detalhe.Name)) + suffix += fmt.Sprintf(" [TRAVADO - %d chars]", detalhe.LockChars) } + if limitStatus.HasDailyLimit { + used := limitStatus.UsedSecondsToday + if used > limitStatus.DailyLimitSeconds { + used = limitStatus.DailyLimitSeconds + } + remaining := limitStatus.DailyLimitSeconds - used + if remaining <= 0 { + suffix += " [LIMITE ESGOTADO]" + } else { + suffix += fmt.Sprintf(" [LIMITE %s/%s]", formatSeconds(used), formatSeconds(limitStatus.DailyLimitSeconds)) + } + } + blocosAtivos = append(blocosAtivos, fmt.Sprintf(" %s%s", detalhe.Name, suffix)) } else { blocosInativos = append(blocosInativos, fmt.Sprintf(" %s", detalhe.Name)) } @@ -463,77 +446,11 @@ var statusCmd = &cobra.Command{ // Funções auxiliares // ============================================================================ -// reaplicarOuRemoverCamadas atualiza as camadas de bloqueio após uma desativação. -// -// Esta função centraliza a lógica comum entre stopCmd e unlockCmd: -// após desativar um bloco, precisamos decidir se reaplicamos as camadas -// com os domínios restantes ou se removemos tudo. -// -// Dois cenários possíveis: -// -// 1. Ainda existem blocos ativos: reaplicamos todas as 4 camadas com os -// domínios/apps dos blocos que permaneceram ativos. Isso garante que -// os outros bloqueios continuem funcionando. -// -// 2. Nenhum bloco ativo restante: removemos todas as camadas completamente. -// O sistema volta ao estado "livre" — nenhum site ou app bloqueado. +// reaplicarOuRemoverCamadas atualiza bloqueios e tracking após mudanças na CLI. +// Blocos sem limite ou com cota esgotada são bloqueados; blocos limitados com +// saldo restante ficam disponíveis e monitorados por regras de contagem. func reaplicarOuRemoverCamadas(database *db.DB) error { - // Buscamos os domínios e apps restantes (dos blocos que ainda estão ativos). - dominios, err := database.GetAllBlockedDomains() - if err != nil { - return fmt.Errorf("erro ao buscar domínios bloqueados restantes: %w", err) - } - - apps, err := database.GetAllBlockedApps() - if err != nil { - return fmt.Errorf("erro ao buscar apps bloqueados restantes: %w", err) - } - - // Buscamos os blocos ativos para saber se ainda há algum. - blocosAtivos, err := database.GetActiveBlocks() - if err != nil { - return fmt.Errorf("erro ao buscar blocos ativos: %w", err) - } - - if len(blocosAtivos) > 0 { - // Cenário 1: Ainda há blocos ativos — reaplicar todas as camadas. - // Recriamos tudo do zero com os domínios restantes para garantir - // consistência. É mais seguro que tentar remover domínios individualmente. - - if err := blocker.ApplyHosts(dominios); err != nil { - return fmt.Errorf("erro ao reaplicar bloqueio no /etc/hosts: %w", err) - } - - if err := blocker.ApplyFirewall(dominios); err != nil { - return fmt.Errorf("erro ao reaplicar bloqueio no firewall: %w", err) - } - - if err := blocker.ApplyBrowserPolicies(dominios); err != nil { - return fmt.Errorf("erro ao reaplicar políticas de navegador: %w", err) - } - - // Matar processos dos blocos que ainda estão ativos. - if len(apps) > 0 { - blocker.KillBlocked(apps) - } - } else { - // Cenário 2: Nenhum bloco ativo — remover todas as camadas. - // O sistema volta ao estado "limpo" — sem bloqueios. - - if err := blocker.RemoveHosts(); err != nil { - return fmt.Errorf("erro ao remover bloqueio do /etc/hosts: %w", err) - } - - if err := blocker.RemoveFirewall(); err != nil { - return fmt.Errorf("erro ao remover bloqueio do firewall: %w", err) - } - - if err := blocker.RemoveBrowserPolicies(); err != nil { - return fmt.Errorf("erro ao remover políticas de navegador: %w", err) - } - } - - return nil + return enforcer.Apply(database, time.Now()) } // ============================================================================ @@ -543,10 +460,11 @@ func reaplicarOuRemoverCamadas(database *db.DB) error { // init() é chamada automaticamente pelo Go quando o pacote é importado. // Aqui registramos nossos comandos como filhos do rootCmd (comando raiz), // tornando-os disponíveis como subcomandos: -// open-turkey start ... -// open-turkey stop ... -// open-turkey unlock ... -// open-turkey status +// +// open-turkey start ... +// open-turkey stop ... +// open-turkey unlock ... +// open-turkey status // // Também configuramos as flags do startCmd aqui, pois o Cobra exige que // as flags sejam registradas antes da execução do comando. diff --git a/internal/cli/limit.go b/internal/cli/limit.go new file mode 100644 index 0000000..16cd031 --- /dev/null +++ b/internal/cli/limit.go @@ -0,0 +1,207 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/brunodcdo/open-turkey/internal/db" + "github.com/spf13/cobra" +) + +var limitCmd = &cobra.Command{ + Use: "limit", + Short: "Gerenciar limites diários automáticos", +} + +var limitSetCmd = &cobra.Command{ + Use: "set [bloco] --daily ", + Short: "Configurar limite diário de uso para um bloco", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + blockName := args[0] + daily, err := cmd.Flags().GetString("daily") + if err != nil { + return fmt.Errorf("erro ao ler flag --daily: %w", err) + } + seconds, err := parseLimitDuration(daily) + if err != nil { + return err + } + + database, err := openDB() + if err != nil { + return err + } + defer database.Close() + + detail, err := database.GetBlock(blockName) + if err != nil { + return err + } + if len(detail.Sites) == 0 { + return fmt.Errorf("limite diário automático exige pelo menos um site no bloco '%s'", blockName) + } + + if err := database.SetDailyLimit(blockName, seconds); err != nil { + return err + } + if detail.Active { + if err := reaplicarOuRemoverCamadas(database); err != nil { + return err + } + } + + fmt.Printf("Limite diário do bloco '%s' configurado para %s.\n", blockName, formatSeconds(seconds)) + return nil + }, +} + +var limitRemoveCmd = &cobra.Command{ + Use: "remove [bloco]", + Short: "Remover limite diário de um bloco", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + blockName := args[0] + + database, err := openDB() + if err != nil { + return err + } + defer database.Close() + + detail, err := database.GetBlock(blockName) + if err != nil { + return err + } + if err := database.RemoveDailyLimit(blockName); err != nil { + return err + } + if detail.Active { + if err := reaplicarOuRemoverCamadas(database); err != nil { + return err + } + } + + fmt.Printf("Limite diário do bloco '%s' removido.\n", blockName) + return nil + }, +} + +var limitStatusCmd = &cobra.Command{ + Use: "status [bloco]", + Short: "Mostrar limites diários configurados", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + database, err := openDB() + if err != nil { + return err + } + defer database.Close() + + day := currentDayKey() + if len(args) == 1 { + status, err := database.GetLimitStatus(args[0], day) + if err != nil { + return err + } + printLimitStatus(*status) + return nil + } + + statuses, err := database.ListLimitStatuses(day) + if err != nil { + return err + } + if len(statuses) == 0 { + fmt.Println("Nenhum limite diário configurado.") + return nil + } + for _, status := range statuses { + printLimitStatus(status) + } + return nil + }, +} + +func parseLimitDuration(value string) (int, error) { + if value == "" { + return 0, fmt.Errorf("informe uma duração com --daily, por exemplo: --daily 30m") + } + duration, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("duração inválida '%s': use formatos como 30m, 1h ou 1h30m", value) + } + seconds := int(duration.Seconds()) + if seconds <= 0 { + return 0, fmt.Errorf("duração deve ser maior que zero") + } + return seconds, nil +} + +func printLimitStatus(status db.LimitStatus) { + if !status.HasDailyLimit { + fmt.Printf("%s: sem limite diário configurado\n", status.BlockName) + return + } + + used := status.UsedSecondsToday + if used > status.DailyLimitSeconds { + used = status.DailyLimitSeconds + } + remaining := status.DailyLimitSeconds - used + + state := "inativo" + if status.Active { + if remaining <= 0 { + state = "bloqueado até amanhã" + } else { + state = "monitorando" + } + } + + fmt.Printf( + "%s: %s usados de %s (%s restantes) [%s]\n", + status.BlockName, + formatSeconds(used), + formatSeconds(status.DailyLimitSeconds), + formatSeconds(remaining), + state, + ) +} + +func formatSeconds(seconds int) string { + if seconds <= 0 { + return "0s" + } + + duration := time.Duration(seconds) * time.Second + hours := duration / time.Hour + duration -= hours * time.Hour + minutes := duration / time.Minute + duration -= minutes * time.Minute + secs := duration / time.Second + + result := "" + if hours > 0 { + result += fmt.Sprintf("%dh", hours) + } + if minutes > 0 { + result += fmt.Sprintf("%dm", minutes) + } + if secs > 0 || result == "" { + result += fmt.Sprintf("%ds", secs) + } + return result +} + +func currentDayKey() string { + return time.Now().Local().Format("2006-01-02") +} + +func init() { + limitSetCmd.Flags().String("daily", "", "Cota diária de uso (ex: 30m, 1h, 1h30m)") + limitCmd.AddCommand(limitSetCmd) + limitCmd.AddCommand(limitRemoveCmd) + limitCmd.AddCommand(limitStatusCmd) + rootCmd.AddCommand(limitCmd) +} diff --git a/internal/cli/limit_test.go b/internal/cli/limit_test.go new file mode 100644 index 0000000..65b8ff4 --- /dev/null +++ b/internal/cli/limit_test.go @@ -0,0 +1,44 @@ +package cli + +import "testing" + +func TestParseLimitDuration(t *testing.T) { + tests := map[string]int{ + "30m": 1800, + "1h": 3600, + "1h30m": 5400, + } + + for input, want := range tests { + got, err := parseLimitDuration(input) + if err != nil { + t.Fatalf("parseLimitDuration(%q) error = %v", input, err) + } + if got != want { + t.Fatalf("parseLimitDuration(%q) = %d, want %d", input, got, want) + } + } +} + +func TestParseLimitDurationRejectsInvalidValues(t *testing.T) { + for _, input := range []string{"", "abc", "0s", "-1m"} { + if _, err := parseLimitDuration(input); err == nil { + t.Fatalf("parseLimitDuration(%q) expected error", input) + } + } +} + +func TestFormatSeconds(t *testing.T) { + tests := map[int]string{ + 0: "0s", + 45: "45s", + 1800: "30m", + 5400: "1h30m", + } + + for input, want := range tests { + if got := formatSeconds(input); got != want { + t.Fatalf("formatSeconds(%d) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 32effb6..3644ffb 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -21,8 +21,8 @@ import ( "syscall" "time" - "github.com/brunodcdo/open-turkey/internal/blocker" "github.com/brunodcdo/open-turkey/internal/db" + "github.com/brunodcdo/open-turkey/internal/enforcer" ) // Run \u00e9 a fun\u00e7\u00e3o principal do daemon. Ela inicia o loop de fiscaliza\u00e7\u00e3o @@ -89,91 +89,9 @@ func Run() error { // enforce executa um \u00fanico ciclo de fiscaliza\u00e7\u00e3o. // -// Essa fun\u00e7\u00e3o \u00e9 o n\u00facleo do daemon. Ela verifica se cada camada de bloqueio -// est\u00e1 corretamente aplicada e, caso n\u00e3o esteja, reaplica. -// -// As camadas verificadas s\u00e3o: -// - /etc/hosts: bloqueia dom\u00ednios resolvendo para 127.0.0.1 -// - Firewall (iptables): bloqueia conex\u00f5es de rede para os dom\u00ednios -// - Pol\u00edticas de navegador: impede acesso via Chrome/Firefox policies -// - Processos: mata aplicativos bloqueados que estejam rodando -// -// Importante: se uma camada falhar, as outras ainda s\u00e3o verificadas. -// Isso garante m\u00e1xima resili\u00eancia \u2014 um erro no iptables n\u00e3o deve -// impedir o bloqueio via /etc/hosts, por exemplo. +// O enforcer contabiliza uso de blocos limitados, decide quais blocos devem +// ficar apenas monitorados e reaplica as camadas de bloqueio quando a cota acaba +// ou quando o bloco n\u00e3o tem limite diário. func enforce(database *db.DB) error { - // Buscamos todos os bloqueios ativos no banco de dados. - // Um bloqueio est\u00e1 ativo se o hor\u00e1rio atual est\u00e1 dentro do per\u00edodo configurado. - activeBlocks, err := database.GetActiveBlocks() - if err != nil { - return err - } - - // Se n\u00e3o houver bloqueios ativos, garantimos que todas as camadas - // est\u00e3o removidas (estado limpo). Isso evita que regras orf\u00e3s - // fiquem penduradas ap\u00f3s o t\u00e9rmino de um bloqueio. - if len(activeBlocks) == 0 { - // Removemos cada camada individualmente para garantir estado limpo. - blocker.RemoveHosts() - blocker.RemoveFirewall() - blocker.RemoveBrowserPolicies() - return nil - } - - // Coletamos todos os dom\u00ednios e aplicativos de todos os bloqueios ativos. - // V\u00e1rios bloqueios podem estar ativos ao mesmo tempo (ex: "redes sociais" - // e "jogos"), ent\u00e3o precisamos unificar as listas. - var domains []string - var apps []string - - for _, block := range activeBlocks { - domains = append(domains, block.Sites...) - apps = append(apps, block.Apps...) - } - - // --- Camada 1: /etc/hosts --- - // Verificamos se os dom\u00ednios j\u00e1 est\u00e3o mapeados para 127.0.0.1 no /etc/hosts. - // Se algu\u00e9m editou o arquivo manualmente para remover as entradas, reaplicamos. - if !blocker.IsHostsApplied(domains) { - log.Println("daemon: /etc/hosts desatualizado, reaplicando bloqueios") - if err := blocker.ApplyHosts(domains); err != nil { - // Logamos o erro mas continuamos para as pr\u00f3ximas camadas. - log.Printf("daemon: erro ao aplicar /etc/hosts: %v", err) - } - } - - // --- Camada 2: Firewall (iptables) --- - // Verificamos se as regras de firewall est\u00e3o presentes. - // Se algu\u00e9m executou "iptables -F" para limpar as regras, reaplicamos. - if !blocker.IsFirewallApplied() { - log.Println("daemon: regras de firewall ausentes, reaplicando") - if err := blocker.ApplyFirewall(domains); err != nil { - log.Printf("daemon: erro ao aplicar firewall: %v", err) - } - } - - // --- Camada 3: Pol\u00edticas de navegador --- - // Verificamos se as pol\u00edticas do Chrome/Firefox est\u00e3o configuradas. - // Essas pol\u00edticas impedem o acesso mesmo se o usu\u00e1rio usar DNS alternativo. - if !blocker.IsBrowserPoliciesApplied(domains) { - log.Println("daemon: pol\u00edticas de navegador desatualizadas, reaplicando") - if err := blocker.ApplyBrowserPolicies(domains); err != nil { - log.Printf("daemon: erro ao aplicar pol\u00edticas de navegador: %v", err) - } - } - - // --- Camada 4: Matar processos bloqueados --- - // Procuramos por processos de aplicativos bloqueados e os encerramos. - // Isso impede o uso de apps como jogos ou redes sociais no desktop. - if len(apps) > 0 { - killed, err := blocker.KillBlocked(apps) - if err != nil { - log.Printf("daemon: erro ao matar processos bloqueados: %v", err) - } - if killed > 0 { - log.Printf("daemon: %d processos bloqueados encerrados", killed) - } - } - - return nil + return enforcer.AccountAndApply(database, time.Now()) } diff --git a/internal/db/db.go b/internal/db/db.go index 0c8cfcb..2af5afb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -58,11 +58,26 @@ type BlockDetail struct { // ActiveBlockDetail representa um bloco que está ativo no momento. // É usado pelo daemon para saber o que bloquear. type ActiveBlockDetail struct { - BlockName string - Sites []string - Apps []string - Locked bool - LockChars int + ID int + BlockName string + Sites []string + Apps []string + Locked bool + LockChars int + HasDailyLimit bool + DailyLimitSeconds int + UsedSecondsToday int +} + +// LimitStatus resume a configuração e o consumo de limite diário de um bloco. +type LimitStatus struct { + BlockID int + BlockName string + Active bool + Locked bool + HasDailyLimit bool + DailyLimitSeconds int + UsedSecondsToday int } // -------------------------------------------------------------------------- @@ -112,6 +127,25 @@ CREATE TABLE IF NOT EXISTS active_blocks ( activated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (block_id) REFERENCES blocks(id) ON DELETE CASCADE ); + +CREATE TABLE IF NOT EXISTS daily_limits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + block_id INTEGER UNIQUE NOT NULL, + daily_seconds INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (block_id) REFERENCES blocks(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS daily_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + block_id INTEGER NOT NULL, + day TEXT NOT NULL, + used_seconds INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(block_id, day), + FOREIGN KEY (block_id) REFERENCES blocks(id) ON DELETE CASCADE +); ` // -------------------------------------------------------------------------- @@ -572,14 +606,22 @@ func (d *DB) IsBlockLocked(name string) (bool, error) { // Consultas para o daemon (bloqueios ativos) // -------------------------------------------------------------------------- -// GetActiveBlocks retorna todos os blocos ativos com seus sites e apps. +// GetActiveBlocks retorna todos os blocos ativos com seus sites, apps e limite diário. // O daemon usa isso para saber o que bloquear. -func (d *DB) GetActiveBlocks() ([]ActiveBlockDetail, error) { +func (d *DB) GetActiveBlocks(day string) ([]ActiveBlockDetail, error) { rows, err := d.conn.Query(` - SELECT b.id, b.name, ab.locked, ab.lock_chars + SELECT + b.id, + b.name, + ab.locked, + ab.lock_chars, + COALESCE(dl.daily_seconds, 0), + COALESCE(du.used_seconds, 0) FROM active_blocks ab JOIN blocks b ON b.id = ab.block_id - `) + LEFT JOIN daily_limits dl ON dl.block_id = b.id + LEFT JOIN daily_usage du ON du.block_id = b.id AND du.day = ? + `, day) if err != nil { return nil, fmt.Errorf("erro ao buscar blocos ativos: %w", err) } @@ -587,20 +629,27 @@ func (d *DB) GetActiveBlocks() ([]ActiveBlockDetail, error) { var activeBlocks []ActiveBlockDetail for rows.Next() { - var blockID int var abd ActiveBlockDetail - if err := rows.Scan(&blockID, &abd.BlockName, &abd.Locked, &abd.LockChars); err != nil { + if err := rows.Scan( + &abd.ID, + &abd.BlockName, + &abd.Locked, + &abd.LockChars, + &abd.DailyLimitSeconds, + &abd.UsedSecondsToday, + ); err != nil { return nil, fmt.Errorf("erro ao ler bloco ativo: %w", err) } + abd.HasDailyLimit = abd.DailyLimitSeconds > 0 // Para cada bloco ativo, buscamos seus sites e apps. - abd.Sites, err = d.getBlockSites(blockID) + abd.Sites, err = d.getBlockSites(abd.ID) if err != nil { return nil, err } - abd.Apps, err = d.getBlockApps(blockID) + abd.Apps, err = d.getBlockApps(abd.ID) if err != nil { return nil, err } @@ -615,6 +664,152 @@ func (d *DB) GetActiveBlocks() ([]ActiveBlockDetail, error) { return activeBlocks, nil } +// SetDailyLimit configura ou atualiza a cota diária de um bloco. +func (d *DB) SetDailyLimit(blockName string, dailySeconds int) error { + if dailySeconds <= 0 { + return fmt.Errorf("limite diário deve ser maior que zero") + } + + blockID, err := d.getBlockID(blockName) + if err != nil { + return err + } + + _, err = d.conn.Exec(` + INSERT INTO daily_limits (block_id, daily_seconds) + VALUES (?, ?) + ON CONFLICT(block_id) DO UPDATE SET + daily_seconds = excluded.daily_seconds, + updated_at = CURRENT_TIMESTAMP + `, blockID, dailySeconds) + if err != nil { + return fmt.Errorf("erro ao configurar limite diário do bloco '%s': %w", blockName, err) + } + return nil +} + +// RemoveDailyLimit remove a configuração de limite diário de um bloco. +func (d *DB) RemoveDailyLimit(blockName string) error { + blockID, err := d.getBlockID(blockName) + if err != nil { + return err + } + + result, err := d.conn.Exec("DELETE FROM daily_limits WHERE block_id = ?", blockID) + if err != nil { + return fmt.Errorf("erro ao remover limite diário do bloco '%s': %w", blockName, err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("erro ao verificar remoção do limite diário do bloco '%s': %w", blockName, err) + } + if rowsAffected == 0 { + return fmt.Errorf("bloco '%s' não tem limite diário configurado", blockName) + } + + return nil +} + +// AddDailyUsage soma segundos ao consumo de um bloco em um dia local. +func (d *DB) AddDailyUsage(blockID int, day string, seconds int) error { + if seconds <= 0 { + return nil + } + + _, err := d.conn.Exec(` + INSERT INTO daily_usage (block_id, day, used_seconds) + VALUES (?, ?, ?) + ON CONFLICT(block_id, day) DO UPDATE SET + used_seconds = used_seconds + excluded.used_seconds, + updated_at = CURRENT_TIMESTAMP + `, blockID, day, seconds) + if err != nil { + return fmt.Errorf("erro ao registrar uso diário: %w", err) + } + return nil +} + +// GetLimitStatus retorna a configuração e o consumo diário de um bloco. +func (d *DB) GetLimitStatus(blockName string, day string) (*LimitStatus, error) { + var status LimitStatus + err := d.conn.QueryRow(` + SELECT + b.id, + b.name, + CASE WHEN ab.id IS NOT NULL THEN 1 ELSE 0 END AS active, + COALESCE(ab.locked, 0) AS locked, + COALESCE(dl.daily_seconds, 0) AS daily_seconds, + COALESCE(du.used_seconds, 0) AS used_seconds + FROM blocks b + LEFT JOIN active_blocks ab ON ab.block_id = b.id + LEFT JOIN daily_limits dl ON dl.block_id = b.id + LEFT JOIN daily_usage du ON du.block_id = b.id AND du.day = ? + WHERE b.name = ? + `, day, blockName).Scan( + &status.BlockID, + &status.BlockName, + &status.Active, + &status.Locked, + &status.DailyLimitSeconds, + &status.UsedSecondsToday, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("bloco '%s' não encontrado", blockName) + } + if err != nil { + return nil, fmt.Errorf("erro ao buscar limite diário do bloco '%s': %w", blockName, err) + } + + status.HasDailyLimit = status.DailyLimitSeconds > 0 + return &status, nil +} + +// ListLimitStatuses retorna todos os blocos que têm limite diário configurado. +func (d *DB) ListLimitStatuses(day string) ([]LimitStatus, error) { + rows, err := d.conn.Query(` + SELECT + b.id, + b.name, + CASE WHEN ab.id IS NOT NULL THEN 1 ELSE 0 END AS active, + COALESCE(ab.locked, 0) AS locked, + dl.daily_seconds, + COALESCE(du.used_seconds, 0) AS used_seconds + FROM daily_limits dl + JOIN blocks b ON b.id = dl.block_id + LEFT JOIN active_blocks ab ON ab.block_id = b.id + LEFT JOIN daily_usage du ON du.block_id = b.id AND du.day = ? + ORDER BY b.name + `, day) + if err != nil { + return nil, fmt.Errorf("erro ao listar limites diários: %w", err) + } + defer rows.Close() + + var statuses []LimitStatus + for rows.Next() { + var status LimitStatus + if err := rows.Scan( + &status.BlockID, + &status.BlockName, + &status.Active, + &status.Locked, + &status.DailyLimitSeconds, + &status.UsedSecondsToday, + ); err != nil { + return nil, fmt.Errorf("erro ao ler limite diário: %w", err) + } + status.HasDailyLimit = true + statuses = append(statuses, status) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("erro ao iterar limites diários: %w", err) + } + + return statuses, nil +} + // GetAllBlockedDomains retorna todos os domínios de todos os blocos ativos. // O daemon usa isso para atualizar o /etc/hosts ou o firewall. // Retornamos uma lista "achatada" (flat) porque o daemon não precisa diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..b30e362 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,115 @@ +package db + +import ( + "path/filepath" + "testing" +) + +func openTestDB(t *testing.T) *DB { + t.Helper() + + database, err := OpenDB(filepath.Join(t.TempDir(), "open-turkey-test.db")) + if err != nil { + t.Fatalf("OpenDB() error = %v", err) + } + t.Cleanup(func() { + if err := database.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + }) + return database +} + +func createSiteBlock(t *testing.T, database *DB, name string) int { + t.Helper() + + if err := database.CreateBlock(name); err != nil { + t.Fatalf("CreateBlock() error = %v", err) + } + if err := database.AddSites(name, []string{"example.com"}); err != nil { + t.Fatalf("AddSites() error = %v", err) + } + detail, err := database.GetBlock(name) + if err != nil { + t.Fatalf("GetBlock() error = %v", err) + } + return detail.ID +} + +func TestDailyLimitStatusResetsByDay(t *testing.T) { + database := openTestDB(t) + blockID := createSiteBlock(t, database, "focus") + + if err := database.SetDailyLimit("focus", 1800); err != nil { + t.Fatalf("SetDailyLimit() error = %v", err) + } + if err := database.AddDailyUsage(blockID, "2026-06-11", 600); err != nil { + t.Fatalf("AddDailyUsage() error = %v", err) + } + + status, err := database.GetLimitStatus("focus", "2026-06-11") + if err != nil { + t.Fatalf("GetLimitStatus() error = %v", err) + } + if !status.HasDailyLimit || status.DailyLimitSeconds != 1800 || status.UsedSecondsToday != 600 { + t.Fatalf("unexpected status for first day: %+v", status) + } + + nextDay, err := database.GetLimitStatus("focus", "2026-06-12") + if err != nil { + t.Fatalf("GetLimitStatus(next day) error = %v", err) + } + if nextDay.UsedSecondsToday != 0 { + t.Fatalf("expected next day usage to reset, got %d", nextDay.UsedSecondsToday) + } +} + +func TestGetActiveBlocksIncludesDailyLimit(t *testing.T) { + database := openTestDB(t) + blockID := createSiteBlock(t, database, "news") + + if err := database.SetDailyLimit("news", 900); err != nil { + t.Fatalf("SetDailyLimit() error = %v", err) + } + if err := database.AddDailyUsage(blockID, "2026-06-11", 300); err != nil { + t.Fatalf("AddDailyUsage() error = %v", err) + } + if err := database.ActivateBlock("news", true, 300); err != nil { + t.Fatalf("ActivateBlock() error = %v", err) + } + + blocks, err := database.GetActiveBlocks("2026-06-11") + if err != nil { + t.Fatalf("GetActiveBlocks() error = %v", err) + } + if len(blocks) != 1 { + t.Fatalf("expected 1 active block, got %d", len(blocks)) + } + block := blocks[0] + if !block.HasDailyLimit || block.DailyLimitSeconds != 900 || block.UsedSecondsToday != 300 { + t.Fatalf("unexpected active block limit data: %+v", block) + } + if !block.Locked || block.LockChars != 300 { + t.Fatalf("expected lock metadata to be preserved: %+v", block) + } +} + +func TestRemoveDailyLimit(t *testing.T) { + database := openTestDB(t) + createSiteBlock(t, database, "social") + + if err := database.SetDailyLimit("social", 600); err != nil { + t.Fatalf("SetDailyLimit() error = %v", err) + } + if err := database.RemoveDailyLimit("social"); err != nil { + t.Fatalf("RemoveDailyLimit() error = %v", err) + } + + status, err := database.GetLimitStatus("social", "2026-06-11") + if err != nil { + t.Fatalf("GetLimitStatus() error = %v", err) + } + if status.HasDailyLimit { + t.Fatalf("expected removed limit, got %+v", status) + } +} diff --git a/internal/enforcer/enforcer.go b/internal/enforcer/enforcer.go new file mode 100644 index 0000000..bc7723a --- /dev/null +++ b/internal/enforcer/enforcer.go @@ -0,0 +1,173 @@ +package enforcer + +import ( + "fmt" + "time" + + "github.com/brunodcdo/open-turkey/internal/blocker" + "github.com/brunodcdo/open-turkey/internal/db" +) + +const activityWindow = 60 * time.Second + +type limitUsageState struct { + day string + activeUntil time.Time + lastAccountedAt time.Time +} + +var limitUsageStates = make(map[int]*limitUsageState) + +// AccountAndApply registra uso detectado nas chains de contagem e aplica a política atual. +func AccountAndApply(database *db.DB, now time.Time) error { + day := dayKey(now) + + counters, err := blocker.ReadLimitTrackingCounters() + if err != nil { + return fmt.Errorf("erro ao ler contadores de limite diário: %w", err) + } + + activeBlocks, err := database.GetActiveBlocks(day) + if err != nil { + return err + } + + if err := accountLimitUsage(database, activeBlocks, counters, now, day); err != nil { + return err + } + + return Apply(database, now) +} + +// Apply aplica bloqueios e tracking sem registrar novo uso. +func Apply(database *db.DB, now time.Time) error { + activeBlocks, err := database.GetActiveBlocks(dayKey(now)) + if err != nil { + return err + } + + blockedDomains, blockedApps, trackTargets := classify(activeBlocks) + return applyLayers(blockedDomains, blockedApps, trackTargets) +} + +func accountLimitUsage(database *db.DB, activeBlocks []db.ActiveBlockDetail, counters map[int]uint64, now time.Time, day string) error { + activeLimited := make(map[int]bool) + + for _, block := range activeBlocks { + if !block.HasDailyLimit { + continue + } + activeLimited[block.ID] = true + + remaining := block.DailyLimitSeconds - block.UsedSecondsToday + if remaining <= 0 { + continue + } + + state := limitUsageStates[block.ID] + if state == nil || state.day != day { + state = &limitUsageState{day: day, lastAccountedAt: now} + limitUsageStates[block.ID] = state + } + + accountUntil := now + if state.activeUntil.Before(accountUntil) { + accountUntil = state.activeUntil + } + if accountUntil.After(state.lastAccountedAt) { + seconds := int(accountUntil.Sub(state.lastAccountedAt).Seconds()) + if seconds > remaining { + seconds = remaining + } + if seconds > 0 { + if err := database.AddDailyUsage(block.ID, day, seconds); err != nil { + return err + } + remaining -= seconds + } + } + + state.lastAccountedAt = now + if counters[block.ID] > 0 && remaining > 0 { + activeUntil := now.Add(activityWindow) + if activeUntil.After(state.activeUntil) { + state.activeUntil = activeUntil + } + } + } + + for blockID := range limitUsageStates { + if !activeLimited[blockID] { + delete(limitUsageStates, blockID) + } + } + + return nil +} + +func classify(activeBlocks []db.ActiveBlockDetail) ([]string, []string, []blocker.LimitTrackTarget) { + var blockedDomains []string + var blockedApps []string + var trackTargets []blocker.LimitTrackTarget + + for _, block := range activeBlocks { + if block.HasDailyLimit && block.UsedSecondsToday < block.DailyLimitSeconds { + if len(block.Sites) > 0 { + trackTargets = append(trackTargets, blocker.LimitTrackTarget{ + BlockID: block.ID, + Domains: block.Sites, + }) + } + continue + } + + blockedDomains = append(blockedDomains, block.Sites...) + blockedApps = append(blockedApps, block.Apps...) + } + + return blockedDomains, blockedApps, trackTargets +} + +func applyLayers(blockedDomains []string, blockedApps []string, trackTargets []blocker.LimitTrackTarget) error { + if len(blockedDomains) > 0 { + if err := blocker.ApplyHosts(blockedDomains); err != nil { + return fmt.Errorf("erro ao aplicar bloqueio no /etc/hosts: %w", err) + } + if err := blocker.ApplyFirewall(blockedDomains); err != nil { + return fmt.Errorf("erro ao aplicar bloqueio no firewall: %w", err) + } + if err := blocker.ApplyBrowserPolicies(blockedDomains); err != nil { + return fmt.Errorf("erro ao aplicar políticas de navegador: %w", err) + } + } else { + if err := blocker.RemoveHosts(); err != nil { + return fmt.Errorf("erro ao remover bloqueio do /etc/hosts: %w", err) + } + if err := blocker.RemoveFirewall(); err != nil { + return fmt.Errorf("erro ao remover bloqueio do firewall: %w", err) + } + if err := blocker.RemoveBrowserPolicies(); err != nil { + return fmt.Errorf("erro ao remover políticas de navegador: %w", err) + } + } + + if len(trackTargets) > 0 { + if err := blocker.ApplyLimitTracking(trackTargets); err != nil { + return fmt.Errorf("erro ao aplicar tracking de limite diário: %w", err) + } + } else if err := blocker.RemoveLimitTracking(); err != nil { + return fmt.Errorf("erro ao remover tracking de limite diário: %w", err) + } + + if len(blockedApps) > 0 { + if _, err := blocker.KillBlocked(blockedApps); err != nil { + return fmt.Errorf("erro ao matar processos bloqueados: %w", err) + } + } + + return nil +} + +func dayKey(t time.Time) string { + return t.Local().Format("2006-01-02") +} diff --git a/internal/enforcer/enforcer_test.go b/internal/enforcer/enforcer_test.go new file mode 100644 index 0000000..64e1a3e --- /dev/null +++ b/internal/enforcer/enforcer_test.go @@ -0,0 +1,119 @@ +package enforcer + +import ( + "path/filepath" + "testing" + "time" + + "github.com/brunodcdo/open-turkey/internal/db" +) + +func openTestDB(t *testing.T) *db.DB { + t.Helper() + + database, err := db.OpenDB(filepath.Join(t.TempDir(), "open-turkey-test.db")) + if err != nil { + t.Fatalf("OpenDB() error = %v", err) + } + t.Cleanup(func() { + if err := database.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + }) + return database +} + +func createLimitedActiveBlock(t *testing.T, database *db.DB, limitSeconds int) int { + t.Helper() + + if err := database.CreateBlock("video"); err != nil { + t.Fatalf("CreateBlock() error = %v", err) + } + if err := database.AddSites("video", []string{"video.example"}); err != nil { + t.Fatalf("AddSites() error = %v", err) + } + if err := database.SetDailyLimit("video", limitSeconds); err != nil { + t.Fatalf("SetDailyLimit() error = %v", err) + } + if err := database.ActivateBlock("video", false, 0); err != nil { + t.Fatalf("ActivateBlock() error = %v", err) + } + + detail, err := database.GetBlock("video") + if err != nil { + t.Fatalf("GetBlock() error = %v", err) + } + return detail.ID +} + +func TestAccountLimitUsageCountsActiveWindow(t *testing.T) { + limitUsageStates = make(map[int]*limitUsageState) + database := openTestDB(t) + blockID := createLimitedActiveBlock(t, database, 120) + now := time.Date(2026, 6, 11, 10, 0, 0, 0, time.Local) + day := dayKey(now) + + blocks, err := database.GetActiveBlocks(day) + if err != nil { + t.Fatalf("GetActiveBlocks() error = %v", err) + } + if err := accountLimitUsage(database, blocks, map[int]uint64{blockID: 1}, now, day); err != nil { + t.Fatalf("accountLimitUsage(first) error = %v", err) + } + + blocks, err = database.GetActiveBlocks(day) + if err != nil { + t.Fatalf("GetActiveBlocks(second) error = %v", err) + } + if err := accountLimitUsage(database, blocks, nil, now.Add(5*time.Second), day); err != nil { + t.Fatalf("accountLimitUsage(second) error = %v", err) + } + + blocks, err = database.GetActiveBlocks(day) + if err != nil { + t.Fatalf("GetActiveBlocks(third) error = %v", err) + } + if err := accountLimitUsage(database, blocks, nil, now.Add(70*time.Second), day); err != nil { + t.Fatalf("accountLimitUsage(third) error = %v", err) + } + + status, err := database.GetLimitStatus("video", day) + if err != nil { + t.Fatalf("GetLimitStatus() error = %v", err) + } + if status.UsedSecondsToday != 60 { + t.Fatalf("expected 60 seconds used, got %d", status.UsedSecondsToday) + } +} + +func TestAccountLimitUsageCapsAtDailyLimit(t *testing.T) { + limitUsageStates = make(map[int]*limitUsageState) + database := openTestDB(t) + blockID := createLimitedActiveBlock(t, database, 10) + now := time.Date(2026, 6, 11, 10, 0, 0, 0, time.Local) + day := dayKey(now) + + blocks, err := database.GetActiveBlocks(day) + if err != nil { + t.Fatalf("GetActiveBlocks() error = %v", err) + } + if err := accountLimitUsage(database, blocks, map[int]uint64{blockID: 1}, now, day); err != nil { + t.Fatalf("accountLimitUsage(first) error = %v", err) + } + + blocks, err = database.GetActiveBlocks(day) + if err != nil { + t.Fatalf("GetActiveBlocks(second) error = %v", err) + } + if err := accountLimitUsage(database, blocks, nil, now.Add(20*time.Second), day); err != nil { + t.Fatalf("accountLimitUsage(second) error = %v", err) + } + + status, err := database.GetLimitStatus("video", day) + if err != nil { + t.Fatalf("GetLimitStatus() error = %v", err) + } + if status.UsedSecondsToday != 10 { + t.Fatalf("expected usage capped at 10 seconds, got %d", status.UsedSecondsToday) + } +}