-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathstaticIP
executable file
·1557 lines (1043 loc) · 70.8 KB
/
staticIP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env -S python3 -u
###############################################################################
###############################################################################
# Copyright (c) 2025, Andy Schroder
# See the file README.md for licensing information.
###############################################################################
###############################################################################
################################################################
# import modules
################################################################
from math import ceil
from copy import copy
from pathlib import Path
import sys,socket,subprocess
from os import makedirs,getcwd,symlink,geteuid
from os.path import isfile,isdir
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from ruamel.yaml import YAML
from time import time,sleep
from lndgrpc import LNDClient
from bolt11.core import decode
import requests
from requests_toolbelt.adapters.fingerprint import FingerprintAdapter
from qrcode_term import qrcode_string
from wireguard_tools import WireguardConfig,WireguardKey
from datetime import datetime,timedelta
from helpers2 import RoundAndPadToString,IndentedPrettyPrint,SetPrintWarningMessages,FullDateTimeString;SetPrintWarningMessages(True)
from threading import Thread,Event
from pystemd.systemd1 import Unit
from base64 import b64decode,urlsafe_b64encode
from textwrap import fill, indent
# use ruamel.yaml to try and get more desireable indentation of the output
# ruamel.yaml claims it is so much better than PyYAML, but it is not really that much better.
# the documentation of both is very bad. there do seem to be some indentation options in PyYAML,
# but they are hard to find in the documentation and this works, so just leaving it for now.
yaml=YAML(typ='safe',pure=True)
yaml.default_flow_style = False
yaml.indent(mapping=8, sequence=2, offset=0)
################################################################
# define constants
################################################################
NumberChecksBetweenExpectedPayments=11
OneDay=3600*24
################################################################
# assign defaults
################################################################
DefaultHost='38.45.103.1'
DefaultTrustedFingerprint='873c306a1f6a6b8f3ae439a5fbd55025edd7bb8724390f4a21bfe5c35f568b2d'
DefaultAmount=6000
ConfigFileName='Config.yaml'
TunnelStateFileName='TunnelState.yaml'
################################################################
# Only Allow One Instance to run at a time
################################################################
def OnlyAllowOneInstance(process_name): # adapted from https://stackoverflow.com/questions/788411/check-to-see-if-python-script-is-running/7758075#7758075
# Without holding a reference to our socket somewhere it gets garbage
# collected when the function exits
OnlyAllowOneInstance._lock_socket = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
try:
# The null byte (\0) means the socket is created
# in the abstract namespace instead of being created
# on the file system itself.
OnlyAllowOneInstance._lock_socket.bind('\0' + process_name)
except socket.error:
print('staticIP already running, not starting')
sys.exit()
OnlyAllowOneInstance('staticIP')
################################################################
# cleanly catch shutdown signals
################################################################
from signal import signal, SIGABRT, SIGILL, SIGINT, SIGSEGV, SIGTERM
# catch kill signals so can cleanly shut down. this is critical to properly restore state of digital outputs to turn everything off, write files back to disk, properly release network sockets, etc..
def clean(*args):
sys.exit(0)
def CatchKill():
# warning, does not catch SIGKILL.
for sig in (SIGABRT, SIGILL, SIGINT, SIGSEGV, SIGTERM):
signal(sig, clean)
CatchKill()
################################################################
# give some extra space in the output terminal
################################################################
print('')
print('')
print('')
print('')
################################################################
# make the data directory if it doesn't exist.
################################################################
TheDataFolder=str(Path.home())+'/.StaticWire/'
if not isdir(TheDataFolder):
makedirs(TheDataFolder)
MadeNewDataFolder=True
else:
MadeNewDataFolder=False
################################################################
# setup logging
################################################################
import sys,logging
from datetime import datetime
class PreciseTimeFormatterWithColorizedLevel(logging.Formatter):
COLOR_CODES = {
logging.CRITICAL: "\033[1;35m", # bright/bold magenta
logging.ERROR: "\033[1;31m", # bright/bold red
logging.WARNING: "\033[1;33m", # bright/bold yellow
logging.INFO: "\033[0;37m", # white / light gray
logging.DEBUG: "\033[1;30m" # bright/bold black / dark gray
}
RESET_CODE = "\033[0m"
converter=datetime.fromtimestamp # need to use datetime because time.strftime doesn't do microseconds, which is what is used in https://github.com/python/cpython/blob/3.11/Lib/logging/__init__.py
def formatTime(self, record, datefmt):
if datefmt is None: # logging.Formatter (?) seems to set it as None if not defined, so can't just define the default in the definition of formatTime
datefmt='%Y.%m.%d--%H.%M.%S.%f'
ct = self.converter(record.created)
return ct.strftime(datefmt)
def __init__(self, color, *args, **kwargs):
super(PreciseTimeFormatterWithColorizedLevel, self).__init__(*args, **kwargs)
self.color = color
def format(self, record, *args, **kwargs):
if (self.color == True):
record.TIMEDATECOLOR = "\033[1;37;40m" # simple example https://www.kaggle.com/discussions/general/273188
record.FUNCTIONNAMECOLOR = "\033[1;37;44m"
if (record.levelno in self.COLOR_CODES):
record.color_on = self.COLOR_CODES[record.levelno]
record.color_off = self.RESET_CODE
else:
record.color_on = ""
record.color_off = ""
record.TIMEDATECOLOR = ""
record.FUNCTIONNAMECOLOR = ""
return super(PreciseTimeFormatterWithColorizedLevel, self).format(record, *args, **kwargs)
console_log_level="info"
logfile_log_level="debug"
FormatStringTemplate='%(TIMEDATECOLOR)s%(asctime)s%(color_off)s [%(color_on)s%(levelname)8s%(color_off)s, %(FUNCTIONNAMECOLOR)s%(funcName)8.8s%(color_off)s]: %(message)s'
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
console = logging.StreamHandler(sys.stdout)
console.setLevel(console_log_level.upper())
ColorizeConsole=sys.stdout.isatty() # if directed to a tty colorize, otherwise don't (so won't get escape sequences in systemd logs for example)
console.setFormatter(PreciseTimeFormatterWithColorizedLevel(fmt=FormatStringTemplate, color=ColorizeConsole))
logfile = logging.FileHandler(TheDataFolder+"debug.log")
logfile.setLevel(logfile_log_level.upper()) # only accepts uppercase level names
logfile.setFormatter(PreciseTimeFormatterWithColorizedLevel(fmt=FormatStringTemplate, color=False))
logger.addHandler(console)
logger.addHandler(logfile)
logger.info('--------------------- startup ! ---------------------')
if MadeNewDataFolder:
# couldn't write this until there was a place to write it to, so had to remember and then write when it was possible.
logger.debug(TheDataFolder+' did not exist, so created it!')
################################################################
# read ConfigFile from home folder
################################################################
if isfile(TheDataFolder+ConfigFileName):
with open(TheDataFolder+ConfigFileName, 'r') as file:
ConfigFile=yaml.load(file)
if ConfigFile is None:
ConfigFile={}
logger.debug(TheDataFolder+ConfigFileName+' is empty!')
else:
ConfigFile={}
logger.debug(TheDataFolder+ConfigFileName+' does not exist, so creating it!')
################################################################
# use default values in the config file if they are defined
################################################################
# parameter definitions take the following priority
# 1. config file: per tunnel (TODO / NOT YET IMPLEMENTED!)
# 2. command line parameters (TODO: MUST RAISE ERROR IF (1) IS ATTEMPTED AT THE SAME TIME ?)
# 3. config file: defaults
# 4. hardcoded defaults defined above
if ConfigFile.get('DefaultRentalServer') is not None and ConfigFile['DefaultRentalServer'].get('Host') is not None:
DefaultHost=ConfigFile['DefaultRentalServer']['Host']
logger.info("using "+DefaultHost+" from the config file for the default rental server default host")
if ConfigFile.get('DefaultRentalServer') is not None and ConfigFile['DefaultRentalServer'].get('TrustedFingerprint') is not None:
DefaultTrustedFingerprint=ConfigFile['DefaultRentalServer']['TrustedFingerprint']
logger.info("using "+DefaultTrustedFingerprint+" from the config file for the default rental server default trusted fingerprint")
if ConfigFile.get('DefaultRentalServer') is not None and ConfigFile['DefaultRentalServer'].get('Amount') is not None:
DefaultAmount=ConfigFile['DefaultRentalServer']['Amount']
logger.info("using "+str(DefaultAmount)+" from the config file for the default amount of credit to add")
################################################################
# parse the command line
################################################################
parser = ArgumentParser(description="StaticWire:\n \n"+indent(fill("Rent Dedicated Public Static Internet Protocol Subnets Using Bitcoin's Lightning Network And Wireguard",width=67),' '), epilog=indent(fill("Default named argument values can be set in `"+TheDataFolder+ConfigFileName+'`, if not, an internal default is used. See `Sample-Config.yaml` for examples.',width=67), ' '),formatter_class=RawDescriptionHelpFormatter) # note the difference between RawDescriptionHelpFormatter and RawTextHelpFormatter
parser.add_argument("Action",choices=['AddCredit','GetRentalStatus','GetConf','AutoPay'], help="Action to take: `AddCredit` provides a lightning invoice to add credit to an existing tunnel rental. If there is no existing tunnel rental then a lightning invoice is provided for a new tunnel and then the new tunnel rental is started and a wireguard configuration is provided after payment is made. `GetRentalStatus` will give the current status of the tunnel so that you can check when you need to use `AddCredit` to make payments. `GetConf` gets the tunnel's wireguard configuration if you lost it after initially running `AddCredit`. `AutoPay` runs continuously and uses stored LND credentials to automatically pay to maintain the tunnel.")
parser.add_argument('--rental_server_host', default=DefaultHost,type=str,help="The default rental server host (default: %(default)s).")
parser.add_argument('--rental_server_fingerprint', default=DefaultTrustedFingerprint,type=str,help="The default rental server trusted fingerprint (default: %(default)s).")
parser.add_argument('--amount', default=DefaultAmount,type=int,help="The amount of credit that you want to add when using `AddCredit` (default: %(default)s) [sat]. If this is a new tunnel, an activation fee will be added to this credit amount in the invoiced amount.")
arguments=parser.parse_args()
################################################################
# use values from the command line (or the defaults if they were not defined)
################################################################
RentalServerDefaultConfig={}
RentalServerDefaultConfig['Host']=arguments.rental_server_host
RentalServerDefaultConfig['TrustedFingerprint']=arguments.rental_server_fingerprint
RentalServerDefaultConfig['Amount']=arguments.amount
logger.info("using "+RentalServerDefaultConfig['Host']+" for the rental server default host")
logger.info("using "+RentalServerDefaultConfig['TrustedFingerprint']+" for the rental server default trusted fingerprint")
logger.info("using "+str(RentalServerDefaultConfig['Amount'])+" for the amount of credit to add")
################################################################
# fetch wireguard key pair from config or generate a new one and save to config
################################################################
ChangesToConfigFile=False
# TODO: don't require the client to have a wireguard private key, but don't allow creating wireguard configs if missing, only allow paying.
# TODO: allow multiple tunnels to be defined
if ConfigFile.get('tunnels') is None:
ConfigFile['tunnels']=[]
Tunnel={}
#from secrets import base64,token_bytes
#Tunnel['wireguard_public_key']=base64.b64encode(token_bytes()).decode()
# generate a new private key and save it.
private_key = WireguardKey.generate()
Tunnel['wireguard_private_key'] = str(private_key)
Tunnel['wireguard_public_key'] = str(private_key.public_key())
ConfigFile['tunnels']+=[Tunnel]
ChangesToConfigFile=True
logger.info('no wireguard_private_key found in '+TheDataFolder+ConfigFileName+', so created one and overwrote any wireguard_public_key that may have existed with a new one!')
else:
try:
Tunnel=ConfigFile['tunnels'][0]
if Tunnel.get('wireguard_public_key') is None or Tunnel.get('wireguard_private_key') is None:
raise Exception
except:
raise Exception('tunnel not configured properly in config file')
logger.info('local wireguard public_key: '+Tunnel['wireguard_public_key'])
if ChangesToConfigFile:
logger.debug('writing to '+TheDataFolder+ConfigFileName)
with open(TheDataFolder+ConfigFileName, 'w') as file:
yaml.dump(ConfigFile, file)
logger.debug('finished writing to '+TheDataFolder+ConfigFileName)
else:
logger.debug('no changes to '+TheDataFolder+ConfigFileName)
################################################################
# configure https request object
################################################################
# setup a secure session that trusts only a certain fingerprint (see https://toolbelt.readthedocs.io/en/latest/adapters.html#fingerprintadapter)
# we don't want to deal with certificate authorities, they are a nuisance and they can't be trusted anyway.
# requiring a trusted fingerprint allows verify=False to be used on each request. note, https://medium.com/@jbirdvegas/python-certificate-pinning-c44e9a34ed1c suggests that
# requests_toolbelt ignores the fingerprint verification of standard validation is turned off, but tested it and this seems to not be true.
SecureSession = requests.Session()
SecureSession.mount('https://'+RentalServerDefaultConfig['Host'],FingerprintAdapter(RentalServerDefaultConfig['TrustedFingerprint'])) #RentalServerDefaultConfig['Host'] is needed here (in addition to in the .post() function) to provide a scope for the FingerprintAdapter.
################################################################
# define functions
################################################################
# TODO: make non-autopay functions do local verification of data and compare it to the server like autopay version does.
def AddCredit(amount):
response = SecureSession.post('https://'+RentalServerDefaultConfig['Host']+'/api/AddCredit/', data={'WireGuardPubKey': Tunnel['wireguard_public_key'], 'amount': amount},verify=False)
if response.status_code != 200:
print(response.status_code)
print(response.content)
raise Exception('rental server API query gave a bad status_code.')
if 'Error' not in response.json():
AddedTime=response.json()['AddedCredit']/response.json()['SellOfferTerms']['Rate']
if response.json()['InitialInvoice'] != True:
Status=response.json()['Status'] # Status is always bundled in with an invoice request so don't need to make a separate call for that too.
NewRemainingTime=Status['TimeRemaining']+AddedTime
PrintRentalStatus(Status)
print()
print('---------------------------------------------------------------------------------------------------')
print()
logger.info('adding credit for IP Networks '+str(Status['Networks']))
print()
else:
print()
print('---------------------------------------------------------------------------------------------------')
print()
logger.info('no existing rental found associated with the wireguard public key '+Tunnel['wireguard_public_key']+'. renting a new tunnel with an initial credit amount of '+RoundAndPadToString(amount,0)+' [sat]')
print()
NewRemainingTime=AddedTime
DecodedInvoice = decode(response.json()['Invoice'])
print()
logger.info(IndentedPrettyPrint(response.json(),prettyprintprepend='Terms'))
print()
print('+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++')
print()
print('new rental rate : '+RoundAndPadToString(1/response.json()['SellOfferTerms']['Rate'],0)+' [seconds/sat] , '+RoundAndPadToString(response.json()['SellOfferTerms']['Rate']*(OneDay),0)+' [sat/day], '+RoundAndPadToString(response.json()['SellOfferTerms']['Rate']*(OneDay*(365.25/12)),0)+' [sat/month]')
print('invoice expires : '+FullDateTimeString(DecodedInvoice.timestamp+DecodedInvoice.expiry_time)+' (in '+str(timedelta(seconds=ceil(DecodedInvoice.timestamp+DecodedInvoice.expiry_time-time())))+')')
print('total invoice amount : '+RoundAndPadToString(DecodedInvoice.amount/1000,0)+' [sat]')
if response.json()['InitialInvoice'] == True:
print('one time tunnel activation fee: '+RoundAndPadToString(response.json()['SellOfferTerms']['ActivationFee'],0)+' [sat]')
if response.json()['SellOfferTerms']['ActivationFee']+response.json()['AddedCredit'] != DecodedInvoice.amount/1000:
raise Exception('itemized amounts from rental server do not add up to the total invoice amount.')
print('rental credit to be added : '+RoundAndPadToString(response.json()['AddedCredit'],0)+' [sat] ('+str(timedelta(seconds=ceil(AddedTime)))+')')
print('new credit provides service until: '+FullDateTimeString(time()+NewRemainingTime)+' ('+str(timedelta(seconds=ceil(NewRemainingTime)))+')')
print()
print('---------------------------------------------------------------------------------------------------')
print()
print('lightning invoice: ')
print()
print(qrcode_string(response.json()['Invoice']))
print()
logger.debug('waiting for invoice to be paid')
while time()<(DecodedInvoice.timestamp+DecodedInvoice.expiry_time):
Status=RentalStatus()
if 'unpaid_invoice' in Status:
if Status['unpaid_invoice'] == None:
logger.info('invoice paid')
print()
print('---------------------------------------------------------------------------------------------------')
print()
PrintRentalStatus(Status)
if response.json()['InitialInvoice'] == True:
GetConf()
break
else:
logger.debug('unexpected response, trying again')
sleep(1.25)
else:
logger.info('invoice not paid before expiration.... need to request a new rental')
else:
logger.error(IndentedPrettyPrint(response.json(),prettyprintprepend='config'))
def RentalStatus(InvoiceToWaitFor=None):
response = SecureSession.post('https://'+RentalServerDefaultConfig['Host']+'/api/getstatus/', data={'WireGuardPubKey': Tunnel['wireguard_public_key'], 'InvoiceToWaitFor': InvoiceToWaitFor},verify=False)
if (response.status_code != 200) or ('Error' in response.json()):
print()
print(response.status_code)
print()
print(response.content) # should this be changed to prettyprint ?
print()
raise Exception('rental server API query gave a bad status_code or Error in the response')
return response.json()
def PrintRentalStatus(Status):
print()
logger.info(IndentedPrettyPrint(Status,prettyprintprepend='IP Rental Status'))
print()
print()
print('+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++')
print()
print('rental rate : '+RoundAndPadToString(1/Status['CurrentRate'],0)+' [seconds/sat] , '+RoundAndPadToString(Status['CurrentRate']*(OneDay),0)+' [sat/day], '+RoundAndPadToString(Status['CurrentRate']*(OneDay*(365.25/12)),0)+' [sat/month]')
print('start time : '+FullDateTimeString(Status['start_time'])+' ('+str(timedelta(seconds=ceil(time()-Status['start_time'])))+' ago)')
print('paid until : '+FullDateTimeString(time()+Status['TimeRemaining'])+' ('+str(timedelta(seconds=ceil(Status['TimeRemaining'])))+')') # trusing the rental server for this and not doing the calculation locally (although have all the data to do it), should verify this.......
print('total paid : '+RoundAndPadToString(Status['total_paid'],0)+' [sat]')
print('credit : '+RoundAndPadToString(Status['Credit'],0)+' [sat]')
def CompactRentalStatus(Status,LNDBalance=None):
# TODO: consider combining Total Payments with Total Paid and Time Remaining with Rental Server Credit
String=''
if not Status['InitialPayment']:
String+='IP Networks : '+str(Status['Networks'])+'\n'
String+='Start Time : '+FullDateTimeString(Status['start_time'])+' ('+str(timedelta(seconds=ceil(time()-Status['start_time'])))+' ago)'+'\n'
String+='Total Paid : '+RoundAndPadToString(Status['total_paid'],0)+' [sat], which includes the '+RoundAndPadToString(Status['ActivationFee'],0)+' [sat] Activation Fee'+'\n'
String+='Total Payments : '+RoundAndPadToString(Status['NumberOfPayments'],0)+'\n'
String+='Rental Rate : '+RoundAndPadToString(1/Status['CurrentRate'],0)+' [seconds/sat] , '+RoundAndPadToString(Status['CurrentRate']*(OneDay),0)+' [sat/day], '+RoundAndPadToString(Status['CurrentRate']*(OneDay*(365.25/12)),0)+' [sat/month]'+'\n'
if 'Credit' in Status: # Status is from the rental server
CurrentCredit=Status['Credit']
TimeRemaining=Status['TimeRemaining']
else: # Status is from TunnelStateFile, so need to calculate the current values
CurrentCredit=Credit(Status,time())
if Status['InitialPayment']:
TimeRemaining=None
else:
TimeRemaining=CurrentCredit/Status['CurrentRate']
if TimeRemaining is not None:
String+='Paid Until : '+FullDateTimeString(time()+TimeRemaining)+' ('+str(timedelta(seconds=ceil(TimeRemaining)))+')'+'\n'
# pad based on the larger number so both line up digits but can still left justify
TempCurrentCreditString=RoundAndPadToString(CurrentCredit,1,ShowThousandsSeparator=False)
TempCurrentCreditStringLength=len(TempCurrentCreditString)
TempCurrentLNDBalanceString=RoundAndPadToString(LNDBalance,0,ShowThousandsSeparator=False)
TempCurrentLNDBalanceStringLength=len(TempCurrentLNDBalanceString)
PaddingNeeded=max(TempCurrentCreditStringLength-2,TempCurrentLNDBalanceStringLength)
String+='Rental Server Credit : '+RoundAndPadToString(CurrentCredit,1,PaddingNeeded)+' [sat]'+'\n'
if LNDBalance is not None:
String+='LND (off chain) account balance : '+RoundAndPadToString(LNDBalance,0,PaddingNeeded)+' [sat]'+'\n'
return String[:-1] # the [:-1] skips the last character while letting have \n on the end of each line and not have to keep track if move lines around
def GetConf():
response = SecureSession.post('https://'+RentalServerDefaultConfig['Host']+'/api/getconf/', data={'WireGuardPubKey': Tunnel['wireguard_public_key']},verify=False)
if response.status_code != 200:
print(response.status_code)
print(response.content)
raise Exception('rental server API query gave a bad status_code.')
print()
print('---------------------------------------------------------------------------------------------------')
print()
logger.info(IndentedPrettyPrint(response.json(),prettyprintprepend='tunnel config info'))
if 'Error' not in response.json():
print()
print('+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++')
print()
GenerateConfig(response.json())
print()
def CheckServiceState(ServiceName,SubStateValue='running'):
try:
unit = Unit(ServiceName+'.service')
unit.load()
logger.debug(ServiceName + ' systemd service is ' + unit.Unit.LoadState.decode() + ', ' + unit.Unit.UnitFileState.decode() + ', ' + unit.Unit.ActiveState.decode() + ', and ' + unit.Unit.SubState.decode())
if unit.Unit.LoadState.decode() == 'loaded':
if unit.Unit.ActiveState.decode() == 'active':
if unit.Unit.SubState.decode() == SubStateValue:
if unit.Unit.UnitFileState.decode() == 'enabled':
logger.debug(ServiceName + ' has met the required SubState value of ' + SubStateValue + ' and it is enabled')
# although it can be active without being enabled (manually started), want to make sure it will always be on.
# there is something else, UnitFilePreset. think this might be "vendor present". not testing this though.
return True
else:
logger.debug(ServiceName + ' has met the required SubState value of ' + SubStateValue + ' but it is not enabled')
elif unit.Unit.SubState.decode() == 'dead':
logger.warning('do not understand how the ' + ServiceName + ' service can be loaded, active, and dead')
else:
logger.debug(ServiceName +' has unexpected SubState value of ' + unit.Unit.SubState.decode())
elif unit.Unit.ActiveState.decode() == 'inactive':
if unit.Unit.SubState.decode() == 'running':
logger.warning('do not understand how the ' + ServiceName + ' service can be loaded, inactive, and running')
elif unit.Unit.SubState.decode() == 'dead':
logger.debug(ServiceName + ' has a SubState value of ' + SubStateValue + ' that is valid but not acceptable')
else:
logger.warning(ServiceName +' has unexpected SubState value of ' + unit.Unit.SubState.decode())
else:
logger.warning(ServiceName + ' has unexpected ActiveState value of ' + unit.Unit.ActiveState.decode())
elif unit.Unit.LoadState.decode() == 'not-found':
logger.debug(ServiceName +' not found')
else:
logger.warning(ServiceName +' has unexpected LoadState value of ' + unit.Unit.LoadState.decode())
except:
logger.debug('can not connect to systemd via dbus')
return False
def GenerateConfig(dict_config):
dict_config['private_key']=Tunnel['wireguard_private_key']
config = WireguardConfig.from_dict(dict_config)
print('-----BEGIN WIREGUARD CONF FILE-----')
print()
print(config.to_wgconfig(wgquick_format=True))
print('-----END WIREGUARD CONF FILE-----')
print()
print()
print('wireguard conf file in a QR code (scan with something like https://f-droid.org/en/packages/com.wireguard.android/)')
print()
print(qrcode_string(config.to_wgconfig(wgquick_format=True)))
print()
print()
# now write to a file #
# use 'sw_' + 'first 12 characters of the wireguard public key' for the filename, because wg-quick uses the filename as the interface name and linux interface names can only be 15 characters long.
# also, convert the public key from base64 to urlsafe base64 so that a valid name can be ensured
WireGuardConfigBaseFileName='sw_'+urlsafe_b64encode(b64decode(Tunnel['wireguard_public_key'])).decode()[:12]
# then add the extension.
WireGuardConfigFileName=WireGuardConfigBaseFileName+'.conf'
WireGuardConfigFilePath=str(TheDataFolder+'/WireGuardConfigFiles/')
if not isdir(WireGuardConfigFilePath):
makedirs(WireGuardConfigFilePath)
logger.debug(WireGuardConfigFilePath+' did not exist, so created it!')
logger.debug('starting write config to '+WireGuardConfigFilePath+WireGuardConfigFileName)
with open(WireGuardConfigFilePath+WireGuardConfigFileName, "w") as WireGuardConfigFileHandle:
WireGuardConfigFileHandle.write(config.to_wgconfig(wgquick_format=True))
logger.info('wrote config to '+WireGuardConfigFilePath+WireGuardConfigFileName)
RootUser = (geteuid() == 0)
if RootUser:
try:
logger.debug('starting to create a symlink from '+WireGuardConfigFilePath+WireGuardConfigFileName+' to /etc/wireguard/'+WireGuardConfigFileName)
symlink(WireGuardConfigFilePath+WireGuardConfigFileName, '/etc/wireguard/'+WireGuardConfigFileName)
logger.info('created a symlink from '+WireGuardConfigFilePath+WireGuardConfigFileName+' to /etc/wireguard/'+WireGuardConfigFileName)
except PermissionError:
logger.error('root user but can not create a symlink from '+WireGuardConfigFilePath+WireGuardConfigFileName+' to /etc/wireguard/'+WireGuardConfigFileName+', permission denied')
except FileExistsError:
logger.error('root user but can not create a symlink from '+WireGuardConfigFilePath+WireGuardConfigFileName+' to /etc/wireguard/'+WireGuardConfigFileName+', file already exists')
except:
logger.error('root user but can not create a symlink from '+WireGuardConfigFilePath+WireGuardConfigFileName+' to /etc/wireguard/'+WireGuardConfigFileName+', some unknown error')
raise
else:
logger.info('not root user, not trying to create a symlink from '+WireGuardConfigFilePath+WireGuardConfigFileName+' to /etc/wireguard/'+WireGuardConfigFileName)
# now try to configure the system
if CheckServiceState('NetworkManager'):
NMInterfaceUP=False
logger.info('NetworkManager seems to be installed and running on this system.')
UFWenabled=False
if CheckServiceState('ufw',SubStateValue='exited'): # ufw seems to be a oneshot service
logger.info('and the Uncomplicated Firewall (ufw) systemd service seems to be installed and enabled on this system')
try:
with open('/etc/ufw/ufw.conf') as f:
if 'ENABLED=yes' in f.read():
UFWenabled=True
# TODO: check if running as root and use a ufw python interface to see if it is actually active.
logger.warning('and /etc/ufw/ufw.conf indicates Uncomplicated Firewall (ufw) should be enabled, but need to run `sudo ufw status verbose` to check for sure.')
else:
logger.warning('however, although the ufw systemd service is active, /etc/ufw/ufw.conf indicates Uncomplicated Firewall (ufw) should be disabled')
logger.warning('need to run `sudo ufw enable`')
except:
logger.error('but can not read /etc/ufw/ufw.conf')
if UFWenabled:
logger.info('do you want to start the wireguard tunnel and assign the dedicated public IP Networks to this system?')
print()
print()
UserInput = input(' [y/N]: ')
print()
print()
if UserInput.casefold() == 'y' or UserInput.casefold() == 'yes':
logger.info('bringing up ' + str(dict_config['addresses']) + ' using NetworkManager')
# TODO: use a Dbus connection to do this instead of subprocess. also check to see if the connection already exists first. nmcli currently just gives a warning and then disables the duplicate connection, so it "works" as is.
RunResult=subprocess.run(
[
'nmcli',
'connection',
'import',
'type',
'wireguard',
'file',
WireGuardConfigFilePath+WireGuardConfigFileName,
],
check=True,
)
NMInterfaceUP=True
logger.info('brought up ' + str(dict_config['addresses']) + ' using NetworkManager')
if not NMInterfaceUP:
logger.info('NOT bringing up ' + str(dict_config['addresses']) + ' using NetworkManager')
if not UFWenabled:
logger.warning('Uncomplicated Firewall (ufw) is not in use. You should really have a firewall in use before installing a dedicated public IP Network on this system.')
logger.warning('get Uncomplicated Firewall (ufw) running and re-run `staticIP GetConf` or if you know what you are doing, manually bring the tunnel interface up using')
logger.warning('nmcli connection import type wireguard file '+WireGuardConfigFilePath+WireGuardConfigFileName)
else:
logger.info('NetworkManager does NOT seem to be installed and running on this system.')
# second best is to use wg-quick if the root user.
if RootUser:
logger.info('It appears that staticIP is running as the root user and NetworkManager is not installed.')
logger.info('Do you want to temporarily bring the tunnel up and assign the dedicated public IP Network to this system using wg-quick?')
logger.warning('`staticIP` does not know if you have an active firewall running, so it is your responsibility to make sure there is one.')
print()
print()
UserInput = input(' [y/N]: ')
print()
print()
if UserInput.casefold() == 'y' or UserInput.casefold() == 'yes':
logger.info('bringing up ' + str(dict_config['addresses']) + ' using wg-quick')
RunResult=subprocess.run(
[
'wg-quick',
'up',
WireGuardConfigBaseFileName
],
check=True,
)
logger.info('brought up ' + str(dict_config['addresses']) + ' using wg-quick. in order to bring the interface back down, run `wg-quick down '+WireGuardConfigBaseFileName+'`.')
else:
logger.info('NOT bringing up ' + str(dict_config['addresses']) + ' using wg-quick.')
else:
logger.info('NetworkManager not found and staticIP is not running as the root user. You will need to manually bring up the tunnel interface.')
logger.info('If you know what you are doing (i.e. have proper filewall active), manually bring up the interface using')
logger.info('sudo wg-quick up '+WireGuardConfigFilePath+WireGuardConfigFileName)
logger.info('on the computer/container/VM that you want to use it with.')
################################################################
# AutoPay related functions
################################################################
def Credit(TunnelStateFile,current_time):
if not TunnelStateFile['InitialPayment']:
return TunnelStateFile['amount_paid_CurrentRate']+TunnelStateFile['Credit_PreviousRate']-(current_time-TunnelStateFile['start_time_CurrentRate'])*TunnelStateFile['CurrentRate']
else:
# this is actually -SellOfferTerms['ActivationFee'], but that will screw up the calculation of the initial ProposedPaymentSize, so just leaving as 0 and manually calculating Credit in ServerStatusMatchesLocal for the initial payment
# and setting the initial TunnelStateFile['Credit_PreviousRate']
# TODO: might want to change things so that the initial ProposedPaymentSize includes the activation fee ????
return 0
def GetSellOfferTerms():
# TODO: retry on network connection failure.
# TODO: add a command line option so that it can be manually run if desired and not just with AutoPay.
response = SecureSession.post('https://'+RentalServerDefaultConfig['Host']+'/api/GetSellOfferTerms/', verify=False)
if (response.status_code != 200) or ('Error' in response.json()):
print(response.status_code)
print(response.content)
raise Exception('rental server API query gave a bad status_code or content.')
return response.json()
def CheckSellOfferTerms(BuyOfferTerms,SellOfferTerms,TunnelStateFile):
#check to see what passes and doesn't pass. if anything doesn't pass, it doesn't pass. don't just want to use a bunch of elif statements because would like to print everything that doesn't pass, not just the first thing that doesn't pass.
Passed=True
if SellOfferTerms['Rate']>BuyOfferTerms['MaxRate']:
Passed=False
logger.error('rental server rate too high')
if SellOfferTerms['MaximumCredit']<SellOfferTerms['MinRegularPaymentSize']*2:
Passed=False
logger.error('rental server cannot have a MaximumCredit less than MinRegularPaymentSize*2')
if SellOfferTerms['MaximumCredit']<SellOfferTerms['MinimumInitialCredit']:
Passed=False
logger.error('rental server cannot have a MaximumCredit less than MinimumInitialCredit')
if TunnelStateFile['InitialPayment']:
if SellOfferTerms['ActivationFee']>BuyOfferTerms['MaxActivationFee']:
logger.error('rental server activation fee too high')
Passed=False
if SellOfferTerms['MinimumInitialCredit']>BuyOfferTerms['MaxInitialCredit']:
logger.error('rental server MinimumInitialCredit too high')
Passed=False
if SellOfferTerms['MinRegularPaymentSize']>BuyOfferTerms['MaxRegularPaymentSize']:
logger.error('rental server MinRegularPaymentSize too high')
Passed=False
if Passed:
logger.debug('all SellOfferTerms Acceptable, okay to pay')
return Passed
def GetAllowableTerms(BuyOfferTerms,SellOfferTerms,TunnelStateFile): # merge constraints of each.
BuyOfferTerms=copy(BuyOfferTerms) #don't want to modify the original
AllowableTerms={}
# before requesting an invoice with a specific amount, apply limits that don't result in a failure.
if SellOfferTerms['MinRegularPaymentSize']>BuyOfferTerms['MinRegularPaymentSize']:
AllowableTerms['MinRegularPaymentSize']=SellOfferTerms['MinRegularPaymentSize']
logger.debug('rental server MinRegularPaymentSize higher than local MinRegularPaymentSize, so using the higher amount')
else:
AllowableTerms['MinRegularPaymentSize']=BuyOfferTerms['MinRegularPaymentSize']
if BuyOfferTerms['TargetTime']*SellOfferTerms['Rate']<BuyOfferTerms['TargetCredit']:
NewTargetCredit=int(ceil(BuyOfferTerms['TargetTime']*SellOfferTerms['Rate']))
logger.debug('CurrentRate and TargetTime results an in amount ('+RoundAndPadToString(NewTargetCredit,0)+' sat) that is less than the TargetCredit ('+RoundAndPadToString(BuyOfferTerms['TargetCredit'],0)+' sat), so using the lower amount')
BuyOfferTerms['TargetCredit']=NewTargetCredit
else:
logger.debug('CurrentRate and TargetTime results in an amount greater than the TargetCredit, so not making the TargetTime')
if TunnelStateFile['InitialPayment']:
if BuyOfferTerms['MaxInitialCredit']>SellOfferTerms['MinimumInitialCredit']:
AllowableTerms['TargetCredit']=SellOfferTerms['MinimumInitialCredit']
logger.debug('rental server MinimumInitialCredit less than allowable MaxInitialCredit, so using the lower amount to minimize risk that the rental server does not ever turn the tunnel on')
else:
raise Exception('MaxInitialCredit less than rental server MinimumInitialCredit')
else:
if SellOfferTerms['MaximumCredit']<BuyOfferTerms['TargetCredit']: # rental server should never have it's MaximumCredit less than it's MinimumInitialCredit
AllowableTerms['TargetCredit']=SellOfferTerms['MaximumCredit']
logger.debug('rental server MaximumCredit less than TargetCredit, so using the lower amount')
if SellOfferTerms['MaximumCredit']<BuyOfferTerms['WarningCredit']:
logger.warning('low rental server MaximumCredit')
else:
AllowableTerms['TargetCredit']=BuyOfferTerms['TargetCredit']
if not TunnelStateFile['InitialPayment'] and AllowableTerms['TargetCredit']<AllowableTerms['MinRegularPaymentSize']*2:
# not super worried about this one right now on the initial payment because always plan to make another payment immediately. # TODO: figure out an exact minimum based on the CurrentRate and maybe 10 minutes credit and set that as the real minimum in case it is the initial payment.
logger.debug('allowable TargetCredit of '+RoundAndPadToString(AllowableTerms['TargetCredit'],0)+' sat is less than allowable MinRegularPaymentSize*2='+RoundAndPadToString(AllowableTerms['MinRegularPaymentSize']*2,0)+' sat, credit is going to go negative. setting TargetCredit to MinRegularPaymentSize*2 to avoid this issue !')
AllowableTerms['TargetCredit']=AllowableTerms['MinRegularPaymentSize']*2
if TunnelStateFile['InitialPayment']:
AllowableTerms['PaymentSize']=SellOfferTerms['MinimumInitialCredit'] # always provide the rental server minimum initial payment because plan to make another payment a soon as confirmed the tunnel is running. requires CheckSellOfferTerms() to be run before GetAllowableTerms() to make sure this is a good number here.
else:
AllowableTerms['PaymentSize']=AllowableTerms['MinRegularPaymentSize']
if AllowableTerms['PaymentSize']>BuyOfferTerms['MaxRegularPaymentSize']:
raise Exception('PaymentSize greater than MaxRegularPaymentSize.')
return AllowableTerms
def ServerStatusMatchesLocal(ServerStatus,TunnelStateFile):
# don't expect percentage error accumulation like in Distributed Charge because error shouldn't grow with time. The universe all moves forward exactly the same, so the time error will be off the about the same each time.
# this won't be the case once the cost is changed to be as a function of bandwith or total transferred data, but for now it is okay because cost is only a function of time.
MaxStartTimeError=10 # seconds
MaxCurrentTimeError=MaxStartTimeError # seconds
MaxTimeRemainingError=MaxStartTimeError*2 # seconds
MaximumCreditError=MaxTimeRemainingError*TunnelStateFile['CurrentRate'] # sat
Passed=True
if not TunnelStateFile['InitialPayment'] and (TunnelStateFile['start_time']!=ServerStatus['start_time']):
Passed=False
logger.error('agreed upon start time no longer matches')
if TunnelStateFile['InitialPayment'] and ((TunnelStateFile['current_time']-ServerStatus['start_time'])>MaxStartTimeError): # only care if the rental server thinks the start time was too far in the past
Passed=False
logger.error('start time too far off')
if (ServerStatus['current_time']-time())>MaxCurrentTimeError: # only care if the rental server thinks the start time was too far in the future
Passed=False
logger.error('current time too far off')
if TunnelStateFile['ActivationFee'] != ServerStatus['ActivationFee']:
Passed=False
logger.error('sever thinks a different ActivationFee was initially agreed upon')
if TunnelStateFile['CurrentRate'] != ServerStatus['CurrentRate']:
Passed=False
logger.error('sever thinks a different Rate was agreed upon when the last payment was made')
if TunnelStateFile['InitialPayment']: # fudge because Credit is set to 0 so that the intial ProposedPaymentSize is calculated correctly.
CurrentCredit=-TunnelStateFile['ActivationFee']
else:
CurrentCredit=Credit(TunnelStateFile,ServerStatus['current_time']) # since current_time was within tolerance, agree to use the server's time as the time so everything should match up closer
if (CurrentCredit-ServerStatus['Credit'])>MaximumCreditError:
Passed=False
logger.error('sever credit amount is too low')
if (CurrentCredit/TunnelStateFile['CurrentRate']-ServerStatus['TimeRemaining'])>MaxTimeRemainingError: # only care if the rental server thinks the time remaining is too low
Passed=False
logger.error('rental server credit remaining amount is too low')
if TunnelStateFile['total_paid'] != ServerStatus['total_paid']:
Passed=False
logger.error('rental server total paid amount is incorrect')
if TunnelStateFile['NumberOfPayments'] != ServerStatus['NumberOfPayments']:
Passed=False
logger.error('rental server total number of payments count is wrong')
return Passed
def ComputeMainSleepTimeAdder(PaymentSize,TunnelStateFile):
if TunnelStateFile['InitialPayment']:
# CurrentRate is not defined.
# won't be sleeping at all at this point anyway, so just return so the function can execute
return
else:
#note, NumberChecksBetweenExpectedPayments and OneDay are global constants so they aren't passed into the function explicitly
# define how much more time until the main code block should be triggered next once the TargetCredit is achieved.